diff --git a/backend/migrate_add_session_period_hours.py b/backend/migrate_add_session_period_hours.py new file mode 100644 index 0000000..5cfb0dc --- /dev/null +++ b/backend/migrate_add_session_period_hours.py @@ -0,0 +1,42 @@ +""" +Migration: add period_start_hour and period_end_hour to monitoring_sessions. + +Run once: + python backend/migrate_add_session_period_hours.py + +Or inside the container: + docker exec terra-view python3 backend/migrate_add_session_period_hours.py +""" + +import sys +import os +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from backend.database import engine +from sqlalchemy import text + +def run(): + with engine.connect() as conn: + # Check which columns already exist + result = conn.execute(text("PRAGMA table_info(monitoring_sessions)")) + existing = {row[1] for row in result} + + added = [] + for col, definition in [ + ("period_start_hour", "INTEGER"), + ("period_end_hour", "INTEGER"), + ]: + if col not in existing: + conn.execute(text(f"ALTER TABLE monitoring_sessions ADD COLUMN {col} {definition}")) + added.append(col) + else: + print(f" Column '{col}' already exists — skipping.") + + conn.commit() + + if added: + print(f" Added columns: {', '.join(added)}") + print("Migration complete.") + +if __name__ == "__main__": + run() diff --git a/backend/models.py b/backend/models.py index bb0ca10..d6e156d 100644 --- a/backend/models.py +++ b/backend/models.py @@ -303,6 +303,12 @@ class MonitoringSession(Base): # weekday_day | weekday_night | weekend_day | weekend_night period_type = Column(String, nullable=True) + # Effective monitoring window (hours 0–23). Night sessions cross midnight + # (period_end_hour < period_start_hour). NULL = no filtering applied. + # e.g. Day: start=7, end=19 Night: start=19, end=7 + period_start_hour = Column(Integer, nullable=True) + period_end_hour = Column(Integer, nullable=True) + # Snapshot of device configuration at recording time session_metadata = Column(Text, nullable=True) # JSON diff --git a/backend/routers/project_locations.py b/backend/routers/project_locations.py index 0f10f0e..efa21a8 100644 --- a/backend/routers/project_locations.py +++ b/backend/routers/project_locations.py @@ -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( diff --git a/backend/routers/projects.py b/backend/routers/projects.py index 868eb11..e995293 100644 --- a/backend/routers/projects.py +++ b/backend/routers/projects.py @@ -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 diff --git a/backend/templates_config.py b/backend/templates_config.py index d1c7360..96a871f 100644 --- a/backend/templates_config.py +++ b/backend/templates_config.py @@ -73,10 +73,16 @@ def jinja_log_tail_display(s): return str(s) +def jinja_local_datetime_input(dt): + """Jinja filter: format UTC datetime as local YYYY-MM-DDTHH:MM for .""" + return format_local_datetime(dt, "%Y-%m-%dT%H:%M") + + # Register Jinja filters and globals templates.env.filters["local_datetime"] = jinja_local_datetime templates.env.filters["local_time"] = jinja_local_time templates.env.filters["local_date"] = jinja_local_date +templates.env.filters["local_datetime_input"] = jinja_local_datetime_input templates.env.filters["fromjson"] = jinja_fromjson templates.env.globals["timezone_abbr"] = jinja_timezone_abbr templates.env.globals["get_user_timezone"] = get_user_timezone diff --git a/templates/partials/projects/session_list.html b/templates/partials/projects/session_list.html index 6b8b617..2886855 100644 --- a/templates/partials/projects/session_list.html +++ b/templates/partials/projects/session_list.html @@ -5,6 +5,7 @@ {% set s = item.session %} {% set loc = item.location %} {% set unit = item.unit %} + {% set effective_range = item.effective_range %} {# Period display maps #} {% set period_labels = { @@ -49,25 +50,74 @@ Failed {% endif %} - +
{{ s.notes }}
+ + {% if effective_range %} +