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:
2026-03-27 21:52:52 +00:00
parent e8e155556a
commit 95fedca8c9
10 changed files with 1066 additions and 84 deletions

View File

@@ -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(

View File

@@ -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 (MonSun 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 (023 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 023 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:0018: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:0006: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