feat: monitoring session improvements — UTC fix, period hours, calendar, session detail
- Fix UTC display bug: upload_nrl_data now wraps RNH datetimes with
local_to_utc() before storing, matching patch_session behavior.
Period type and label are derived from local time before conversion.
- Add period_start_hour / period_end_hour to MonitoringSession model
(nullable integers 0–23). Migration: migrate_add_session_period_hours.py
- Update patch_session to accept and store period_start_hour / period_end_hour.
Response now includes both fields.
- Update get_project_sessions to compute "Effective: M/D H:MM AM → M/D H:MM AM"
string from period hours and pass it to session_list.html.
- Rework period edit UI in session_list.html: clicking the period badge now
opens an inline editor with period type selector + start/end hour inputs.
Selecting a period type pre-fills default hours (Day: 7–19, Night: 19–7).
- Wire period hours into _build_location_data_from_sessions: uses
period_start/end_hour when set, falls back to hardcoded defaults.
- RND viewer: inject SESSION_PERIOD_START/END_HOUR from template context.
renderTable() dims rows outside the period window (opacity-40) with a
tooltip; shows "(N in period window)" in the row count.
- New session detail page at /api/projects/{id}/sessions/{id}/detail:
shows breadcrumb, files list with View/Download/Report actions,
editable session info form (label, period type, hours, times).
- Add local_datetime_input Jinja filter for datetime-local input values.
- Monthly calendar view: new get_sessions_calendar endpoint returns
sessions_calendar.html partial; added below sessions list in detail.html.
Color-coded per NRL with legend, HTMX prev/next navigation, session dots
link to detail page.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -31,6 +31,7 @@ from backend.models import (
|
||||
DataFile,
|
||||
)
|
||||
from backend.templates_config import templates
|
||||
from backend.utils.timezone import local_to_utc
|
||||
|
||||
router = APIRouter(prefix="/api/projects/{project_id}", tags=["project-locations"])
|
||||
|
||||
@@ -824,8 +825,15 @@ async def upload_nrl_data(
|
||||
rnh_meta = _parse_rnh(fbytes)
|
||||
break
|
||||
|
||||
started_at = _parse_rnh_datetime(rnh_meta.get("start_time_str")) or datetime.utcnow()
|
||||
stopped_at = _parse_rnh_datetime(rnh_meta.get("stop_time_str"))
|
||||
# RNH files store local time (no UTC offset). Use local values for period
|
||||
# classification / label generation, then convert to UTC for DB storage so
|
||||
# the local_datetime Jinja filter displays the correct time.
|
||||
started_at_local = _parse_rnh_datetime(rnh_meta.get("start_time_str")) or datetime.utcnow()
|
||||
stopped_at_local = _parse_rnh_datetime(rnh_meta.get("stop_time_str"))
|
||||
|
||||
started_at = local_to_utc(started_at_local)
|
||||
stopped_at = local_to_utc(stopped_at_local) if stopped_at_local else None
|
||||
|
||||
duration_seconds = None
|
||||
if started_at and stopped_at:
|
||||
duration_seconds = int((stopped_at - started_at).total_seconds())
|
||||
@@ -835,8 +843,9 @@ async def upload_nrl_data(
|
||||
index_number = rnh_meta.get("index_number", "")
|
||||
|
||||
# --- Step 3: Create MonitoringSession ---
|
||||
period_type = _derive_period_type(started_at) if started_at else None
|
||||
session_label = _build_session_label(started_at, location.name, period_type) if started_at else None
|
||||
# Use local times for period/label so classification reflects the clock at the site.
|
||||
period_type = _derive_period_type(started_at_local) if started_at_local else None
|
||||
session_label = _build_session_label(started_at_local, location.name, period_type) if started_at_local else None
|
||||
|
||||
session_id = str(uuid.uuid4())
|
||||
monitoring_session = MonitoringSession(
|
||||
|
||||
@@ -1143,7 +1143,7 @@ async def get_project_sessions(
|
||||
|
||||
sessions = query.order_by(MonitoringSession.started_at.desc()).all()
|
||||
|
||||
# Enrich with unit and location details
|
||||
# Enrich with unit, location, and effective time window details
|
||||
sessions_data = []
|
||||
for session in sessions:
|
||||
unit = None
|
||||
@@ -1154,10 +1154,34 @@ async def get_project_sessions(
|
||||
if session.location_id:
|
||||
location = db.query(MonitoringLocation).filter_by(id=session.location_id).first()
|
||||
|
||||
# Compute "Effective: date time → date time" string when period hours are set
|
||||
effective_range = None
|
||||
if session.period_start_hour is not None and session.period_end_hour is not None and session.started_at:
|
||||
from datetime import date as _date
|
||||
local_start = utc_to_local(session.started_at)
|
||||
start_day = local_start.date()
|
||||
sh = session.period_start_hour
|
||||
eh = session.period_end_hour
|
||||
|
||||
def _fmt_h(h):
|
||||
ampm = "AM" if h < 12 else "PM"
|
||||
h12 = h % 12 or 12
|
||||
return f"{h12}:00 {ampm}"
|
||||
|
||||
start_str = f"{start_day.month}/{start_day.day} {_fmt_h(sh)}"
|
||||
if eh > sh: # same calendar day
|
||||
end_day = start_day
|
||||
else: # crosses midnight
|
||||
from datetime import timedelta as _td
|
||||
end_day = start_day + _td(days=1)
|
||||
end_str = f"{end_day.month}/{end_day.day} {_fmt_h(eh)}"
|
||||
effective_range = f"{start_str} → {end_str}"
|
||||
|
||||
sessions_data.append({
|
||||
"session": session,
|
||||
"unit": unit,
|
||||
"location": location,
|
||||
"effective_range": effective_range,
|
||||
})
|
||||
|
||||
return templates.TemplateResponse("partials/projects/session_list.html", {
|
||||
@@ -1167,6 +1191,125 @@ async def get_project_sessions(
|
||||
})
|
||||
|
||||
|
||||
@router.get("/{project_id}/sessions-calendar", response_class=HTMLResponse)
|
||||
async def get_sessions_calendar(
|
||||
project_id: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
month: Optional[int] = Query(None),
|
||||
year: Optional[int] = Query(None),
|
||||
):
|
||||
"""
|
||||
Monthly calendar view of monitoring sessions.
|
||||
Color-coded by NRL location. Returns HTML partial.
|
||||
"""
|
||||
from calendar import monthrange
|
||||
from datetime import date as _date, timedelta as _td
|
||||
|
||||
# Default to current month
|
||||
now_local = utc_to_local(datetime.utcnow())
|
||||
if not year:
|
||||
year = now_local.year
|
||||
if not month:
|
||||
month = now_local.month
|
||||
|
||||
# Clamp month to valid range
|
||||
month = max(1, min(12, month))
|
||||
|
||||
# Load all sessions for this project
|
||||
sessions = db.query(MonitoringSession).filter_by(project_id=project_id).all()
|
||||
|
||||
# Build location -> color map (deterministic)
|
||||
PALETTE = [
|
||||
"#f97316", "#3b82f6", "#10b981", "#8b5cf6",
|
||||
"#ec4899", "#14b8a6", "#f59e0b", "#6366f1",
|
||||
"#ef4444", "#84cc16",
|
||||
]
|
||||
loc_ids = sorted({s.location_id for s in sessions if s.location_id})
|
||||
loc_color = {lid: PALETTE[i % len(PALETTE)] for i, lid in enumerate(loc_ids)}
|
||||
|
||||
# Load location names
|
||||
loc_names = {}
|
||||
for lid in loc_ids:
|
||||
loc = db.query(MonitoringLocation).filter_by(id=lid).first()
|
||||
if loc:
|
||||
loc_names[lid] = loc.name
|
||||
|
||||
# Build day -> list of session dots
|
||||
# day key: date object
|
||||
day_sessions: dict = {}
|
||||
for s in sessions:
|
||||
if not s.started_at:
|
||||
continue
|
||||
local_start = utc_to_local(s.started_at)
|
||||
d = local_start.date()
|
||||
if d.year == year and d.month == month:
|
||||
if d not in day_sessions:
|
||||
day_sessions[d] = []
|
||||
day_sessions[d].append({
|
||||
"session_id": s.id,
|
||||
"label": s.session_label or f"Session {s.id[:8]}",
|
||||
"location_id": s.location_id,
|
||||
"location_name": loc_names.get(s.location_id, "Unknown"),
|
||||
"color": loc_color.get(s.location_id, "#9ca3af"),
|
||||
"status": s.status,
|
||||
"period_type": s.period_type,
|
||||
})
|
||||
|
||||
# Build calendar grid (Mon–Sun weeks)
|
||||
first_day = _date(year, month, 1)
|
||||
last_day = _date(year, month, monthrange(year, month)[1])
|
||||
# Start on Monday before first_day
|
||||
grid_start = first_day - _td(days=first_day.weekday())
|
||||
# End on Sunday after last_day
|
||||
days_after = 6 - last_day.weekday()
|
||||
grid_end = last_day + _td(days=days_after)
|
||||
|
||||
weeks = []
|
||||
cur = grid_start
|
||||
while cur <= grid_end:
|
||||
week = []
|
||||
for _ in range(7):
|
||||
week.append({
|
||||
"date": cur,
|
||||
"in_month": cur.month == month,
|
||||
"is_today": cur == now_local.date(),
|
||||
"sessions": day_sessions.get(cur, []),
|
||||
})
|
||||
cur += _td(days=1)
|
||||
weeks.append(week)
|
||||
|
||||
# Prev/next month navigation
|
||||
prev_month = month - 1 if month > 1 else 12
|
||||
prev_year = year if month > 1 else year - 1
|
||||
next_month = month + 1 if month < 12 else 1
|
||||
next_year = year if month < 12 else year + 1
|
||||
|
||||
import calendar as _cal
|
||||
month_name = _cal.month_name[month]
|
||||
|
||||
# Legend: only locations that have sessions this month
|
||||
used_lids = {s["location_id"] for day in day_sessions.values() for s in day}
|
||||
legend = [
|
||||
{"location_id": lid, "name": loc_names.get(lid, lid[:8]), "color": loc_color[lid]}
|
||||
for lid in loc_ids if lid in used_lids
|
||||
]
|
||||
|
||||
return templates.TemplateResponse("partials/projects/sessions_calendar.html", {
|
||||
"request": request,
|
||||
"project_id": project_id,
|
||||
"weeks": weeks,
|
||||
"month": month,
|
||||
"year": year,
|
||||
"month_name": month_name,
|
||||
"prev_month": prev_month,
|
||||
"prev_year": prev_year,
|
||||
"next_month": next_month,
|
||||
"next_year": next_year,
|
||||
"legend": legend,
|
||||
})
|
||||
|
||||
|
||||
@router.get("/{project_id}/ftp-browser", response_class=HTMLResponse)
|
||||
async def get_ftp_browser(
|
||||
project_id: str,
|
||||
@@ -1903,8 +2046,98 @@ async def patch_session(
|
||||
raise HTTPException(status_code=400, detail=f"Invalid period_type. Must be one of: {', '.join(sorted(VALID_PERIOD_TYPES))}")
|
||||
session.period_type = pt or None
|
||||
|
||||
# Configurable period window (0–23 integers; null = no filter)
|
||||
for field in ("period_start_hour", "period_end_hour"):
|
||||
if field in data:
|
||||
val = data[field]
|
||||
if val is None or val == "":
|
||||
setattr(session, field, None)
|
||||
else:
|
||||
try:
|
||||
h = int(val)
|
||||
if not (0 <= h <= 23):
|
||||
raise ValueError
|
||||
setattr(session, field, h)
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(status_code=400, detail=f"{field} must be an integer 0–23 or null")
|
||||
|
||||
db.commit()
|
||||
return JSONResponse({"status": "success", "session_label": session.session_label, "period_type": session.period_type})
|
||||
return JSONResponse({
|
||||
"status": "success",
|
||||
"session_label": session.session_label,
|
||||
"period_type": session.period_type,
|
||||
"period_start_hour": session.period_start_hour,
|
||||
"period_end_hour": session.period_end_hour,
|
||||
})
|
||||
|
||||
|
||||
@router.get("/{project_id}/sessions/{session_id}/detail", response_class=HTMLResponse)
|
||||
async def view_session_detail(
|
||||
request: Request,
|
||||
project_id: str,
|
||||
session_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Session detail page: shows files, editable session info, data preview, and report actions.
|
||||
"""
|
||||
from backend.models import DataFile
|
||||
from pathlib import Path
|
||||
|
||||
project = db.query(Project).filter_by(id=project_id).first()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
_require_sound_project(project)
|
||||
|
||||
session = db.query(MonitoringSession).filter_by(id=session_id, project_id=project_id).first()
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
|
||||
location = db.query(MonitoringLocation).filter_by(id=session.location_id).first() if session.location_id else None
|
||||
unit = db.query(RosterUnit).filter_by(id=session.unit_id).first() if session.unit_id else None
|
||||
|
||||
# Load all data files for this session
|
||||
files = db.query(DataFile).filter_by(session_id=session_id).order_by(DataFile.created_at).all()
|
||||
|
||||
# Compute effective time range string for display
|
||||
effective_range = None
|
||||
if session.period_start_hour is not None and session.period_end_hour is not None and session.started_at:
|
||||
local_start = utc_to_local(session.started_at)
|
||||
start_day = local_start.date()
|
||||
sh = session.period_start_hour
|
||||
eh = session.period_end_hour
|
||||
def _fmt_h(h):
|
||||
ampm = "AM" if h < 12 else "PM"
|
||||
h12 = h % 12 or 12
|
||||
return f"{h12}:00 {ampm}"
|
||||
start_str = f"{start_day.month}/{start_day.day} {_fmt_h(sh)}"
|
||||
if eh > sh:
|
||||
end_day = start_day
|
||||
else:
|
||||
from datetime import timedelta as _td
|
||||
end_day = start_day + _td(days=1)
|
||||
end_str = f"{end_day.month}/{end_day.day} {_fmt_h(eh)}"
|
||||
effective_range = f"{start_str} → {end_str}"
|
||||
|
||||
# Parse session_metadata if present
|
||||
session_meta = {}
|
||||
if session.session_metadata:
|
||||
try:
|
||||
session_meta = json.loads(session.session_metadata)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return templates.TemplateResponse("session_detail.html", {
|
||||
"request": request,
|
||||
"project": project,
|
||||
"project_id": project_id,
|
||||
"session": session,
|
||||
"location": location,
|
||||
"unit": unit,
|
||||
"files": files,
|
||||
"effective_range": effective_range,
|
||||
"session_meta": session_meta,
|
||||
})
|
||||
|
||||
|
||||
@router.get("/{project_id}/files/{file_id}/view-rnd", response_class=HTMLResponse)
|
||||
@@ -1971,6 +2204,8 @@ async def view_rnd_file(
|
||||
"metadata": metadata,
|
||||
"filename": file_path.name,
|
||||
"is_leq": _is_leq_file(str(file_record.file_path), _peek_rnd_headers(file_path)),
|
||||
"period_start_hour": session.period_start_hour,
|
||||
"period_end_hour": session.period_end_hour,
|
||||
})
|
||||
|
||||
|
||||
@@ -2080,6 +2315,8 @@ async def get_rnd_data(
|
||||
"summary": summary,
|
||||
"headers": summary["headers"],
|
||||
"data": rows,
|
||||
"period_start_hour": session.period_start_hour,
|
||||
"period_end_hour": session.period_end_hour,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
@@ -3511,6 +3748,8 @@ def _build_location_data_from_sessions(project_id: str, db, selected_session_ids
|
||||
"loc_name": loc_name,
|
||||
"session_label": session.session_label or "",
|
||||
"period_type": session.period_type or "",
|
||||
"period_start_hour": session.period_start_hour,
|
||||
"period_end_hour": session.period_end_hour,
|
||||
"started_at": session.started_at,
|
||||
"rows": [],
|
||||
}
|
||||
@@ -3552,25 +3791,36 @@ def _build_location_data_from_sessions(project_id: str, db, selected_session_ids
|
||||
pass
|
||||
parsed.append((dt, row))
|
||||
|
||||
# Determine which rows to keep based on period_type
|
||||
is_day_session = period_type in ('weekday_day', 'weekend_day')
|
||||
# Determine effective hour window.
|
||||
# Prefer per-session period_start/end_hour; fall back to hardcoded defaults.
|
||||
sh = entry.get("period_start_hour") # e.g. 7 for Day, 19 for Night
|
||||
eh = entry.get("period_end_hour") # e.g. 19 for Day, 7 for Night
|
||||
if sh is None or eh is None:
|
||||
# Legacy defaults based on period_type
|
||||
is_day_session = period_type in ('weekday_day', 'weekend_day')
|
||||
sh = 7 if is_day_session else 19
|
||||
eh = 19 if is_day_session else 7
|
||||
else:
|
||||
is_day_session = eh > sh # crosses midnight when end < start
|
||||
|
||||
target_date = None
|
||||
if is_day_session:
|
||||
# Day: 07:00–18:59 only, restricted to the LAST calendar date that has daytime rows
|
||||
# Day-style: start_h <= hour < end_h, restricted to the LAST calendar date
|
||||
in_window = lambda h: sh <= h < eh
|
||||
daytime_dates = sorted({
|
||||
dt.date() for dt, row in parsed
|
||||
if dt and 7 <= dt.hour < 19
|
||||
dt.date() for dt, row in parsed if dt and in_window(dt.hour)
|
||||
})
|
||||
target_date = daytime_dates[-1] if daytime_dates else None
|
||||
filtered = [
|
||||
(dt, row) for dt, row in parsed
|
||||
if dt and dt.date() == target_date and 7 <= dt.hour < 19
|
||||
if dt and dt.date() == target_date and in_window(dt.hour)
|
||||
]
|
||||
else:
|
||||
# Night: 19:00–06:59, spanning both calendar days — no date restriction
|
||||
# Night-style: hour >= start_h OR hour < end_h (crosses midnight)
|
||||
in_window = lambda h: h >= sh or h < eh
|
||||
filtered = [
|
||||
(dt, row) for dt, row in parsed
|
||||
if dt and (dt.hour >= 19 or dt.hour < 7)
|
||||
if dt and in_window(dt.hour)
|
||||
]
|
||||
|
||||
# Fall back to all rows if filtering removed everything
|
||||
|
||||
Reference in New Issue
Block a user