From f89f04cd6f532e3365e33251657c1bb662c790c8 Mon Sep 17 00:00:00 2001 From: serversdown Date: Sat, 7 Mar 2026 00:16:58 +0000 Subject: [PATCH] feat: support for day time monitoring data, combined report generation now compaitible with mixed day and night types. --- backend/migrate_add_session_period_type.py | 131 +++++ backend/models.py | 8 + backend/routers/project_locations.py | 36 ++ backend/routers/projects.py | 430 +++++++++++----- templates/combined_report_wizard.html | 462 +++++++++--------- templates/partials/projects/session_list.html | 273 ++++++++--- 6 files changed, 941 insertions(+), 399 deletions(-) create mode 100644 backend/migrate_add_session_period_type.py diff --git a/backend/migrate_add_session_period_type.py b/backend/migrate_add_session_period_type.py new file mode 100644 index 0000000..386325b --- /dev/null +++ b/backend/migrate_add_session_period_type.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +""" +Migration: Add session_label and period_type columns to monitoring_sessions. + +session_label - user-editable display name, e.g. "NRL-1 Sun 2/23 Night" +period_type - one of: weekday_day | weekday_night | weekend_day | weekend_night + Auto-derived from started_at when NULL. + +Period definitions (used in report stats table): + weekday_day Mon-Fri 07:00-22:00 -> Daytime (7AM-10PM) + weekday_night Mon-Fri 22:00-07:00 -> Nighttime (10PM-7AM) + weekend_day Sat-Sun 07:00-22:00 -> Daytime (7AM-10PM) + weekend_night Sat-Sun 22:00-07:00 -> Nighttime (10PM-7AM) + +Run once inside the Docker container: + docker exec terra-view python3 backend/migrate_add_session_period_type.py +""" +from pathlib import Path +from datetime import datetime + +DB_PATH = Path("data/seismo_fleet.db") + + +def _derive_period_type(started_at_str: str) -> str | None: + """Derive period_type from a started_at ISO datetime string.""" + if not started_at_str: + return None + try: + dt = datetime.fromisoformat(started_at_str) + except ValueError: + return None + is_weekend = dt.weekday() >= 5 # 5=Sat, 6=Sun + is_night = dt.hour >= 22 or dt.hour < 7 + if is_weekend: + return "weekend_night" if is_night else "weekend_day" + else: + return "weekday_night" if is_night else "weekday_day" + + +def _build_label(started_at_str: str, location_name: str | None, period_type: str | None) -> str | None: + """Build a human-readable session label.""" + if not started_at_str: + return None + try: + dt = datetime.fromisoformat(started_at_str) + except ValueError: + return None + + day_abbr = dt.strftime("%a") # Mon, Tue, Sun, etc. + date_str = dt.strftime("%-m/%-d") # 2/23 + + period_labels = { + "weekday_day": "Day", + "weekday_night": "Night", + "weekend_day": "Day", + "weekend_night": "Night", + } + period_str = period_labels.get(period_type or "", "") + + parts = [] + if location_name: + parts.append(location_name) + parts.append(f"{day_abbr} {date_str}") + if period_str: + parts.append(period_str) + return " — ".join(parts) + + +def migrate(): + import sqlite3 + + if not DB_PATH.exists(): + print(f"Database not found at {DB_PATH}. Are you running from /home/serversdown/terra-view?") + return + + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + cur = conn.cursor() + + # 1. Add columns (idempotent) + cur.execute("PRAGMA table_info(monitoring_sessions)") + existing_cols = {row["name"] for row in cur.fetchall()} + + for col, typedef in [("session_label", "TEXT"), ("period_type", "TEXT")]: + if col not in existing_cols: + cur.execute(f"ALTER TABLE monitoring_sessions ADD COLUMN {col} {typedef}") + conn.commit() + print(f"✓ Added column {col} to monitoring_sessions") + else: + print(f"○ Column {col} already exists — skipping ALTER TABLE") + + # 2. Backfill existing rows + cur.execute( + """SELECT ms.id, ms.started_at, ms.location_id + FROM monitoring_sessions ms + WHERE ms.period_type IS NULL OR ms.session_label IS NULL""" + ) + sessions = cur.fetchall() + print(f"Backfilling {len(sessions)} session(s)...") + + updated = 0 + for row in sessions: + session_id = row["id"] + started_at = row["started_at"] + location_id = row["location_id"] + + # Look up location name + location_name = None + if location_id: + cur.execute("SELECT name FROM monitoring_locations WHERE id = ?", (location_id,)) + loc_row = cur.fetchone() + if loc_row: + location_name = loc_row["name"] + + period_type = _derive_period_type(started_at) + label = _build_label(started_at, location_name, period_type) + + cur.execute( + "UPDATE monitoring_sessions SET period_type = ?, session_label = ? WHERE id = ?", + (period_type, label, session_id), + ) + updated += 1 + + conn.commit() + conn.close() + print(f"✓ Backfilled {updated} session(s).") + print("Migration complete.") + + +if __name__ == "__main__": + migrate() diff --git a/backend/models.py b/backend/models.py index 24738c4..1c0a39d 100644 --- a/backend/models.py +++ b/backend/models.py @@ -272,6 +272,14 @@ class MonitoringSession(Base): duration_seconds = Column(Integer, nullable=True) status = Column(String, default="recording") # recording, completed, failed + # Human-readable label auto-derived from date/location, editable by user. + # e.g. "NRL-1 — Sun 2/23 — Night" + session_label = Column(String, nullable=True) + + # Period classification for report stats columns. + # weekday_day | weekday_night | weekend_day | weekend_night + period_type = Column(String, 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 adbc81a..45c1e4d 100644 --- a/backend/routers/project_locations.py +++ b/backend/routers/project_locations.py @@ -35,6 +35,37 @@ from backend.templates_config import templates router = APIRouter(prefix="/api/projects/{project_id}", tags=["project-locations"]) +# ============================================================================ +# Session period helpers +# ============================================================================ + +def _derive_period_type(dt: datetime) -> str: + """ + Classify a session start time into one of four period types. + Night = 22:00–07:00, Day = 07:00–22:00. + Weekend = Saturday (5) or Sunday (6). + """ + is_weekend = dt.weekday() >= 5 + is_night = dt.hour >= 22 or dt.hour < 7 + if is_weekend: + return "weekend_night" if is_night else "weekend_day" + return "weekday_night" if is_night else "weekday_day" + + +def _build_session_label(dt: datetime, location_name: str, period_type: str) -> str: + """Build a human-readable session label, e.g. 'NRL-1 — Sun 2/23 — Night'.""" + day_abbr = dt.strftime("%a") + date_str = f"{dt.month}/{dt.day}" + period_str = { + "weekday_day": "Day", + "weekday_night": "Night", + "weekend_day": "Day", + "weekend_night": "Night", + }.get(period_type, "") + parts = [p for p in [location_name, f"{day_abbr} {date_str}", period_str] if p] + return " — ".join(parts) + + # ============================================================================ # Monitoring Locations CRUD # ============================================================================ @@ -676,6 +707,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 + session_id = str(uuid.uuid4()) monitoring_session = MonitoringSession( id=session_id, @@ -687,6 +721,8 @@ async def upload_nrl_data( stopped_at=stopped_at, duration_seconds=duration_seconds, status="completed", + session_label=session_label, + period_type=period_type, session_metadata=json.dumps({ "source": "manual_upload", "store_name": store_name, diff --git a/backend/routers/projects.py b/backend/routers/projects.py index 5a9002f..3bf23e9 100644 --- a/backend/routers/projects.py +++ b/backend/routers/projects.py @@ -1794,6 +1794,34 @@ async def delete_session( }) +VALID_PERIOD_TYPES = {"weekday_day", "weekday_night", "weekend_day", "weekend_night"} + +@router.patch("/{project_id}/sessions/{session_id}") +async def patch_session( + project_id: str, + session_id: str, + data: dict, + db: Session = Depends(get_db), +): + """Update session_label and/or period_type on a monitoring session.""" + session = db.query(MonitoringSession).filter_by(id=session_id).first() + if not session: + raise HTTPException(status_code=404, detail="Session not found") + if session.project_id != project_id: + raise HTTPException(status_code=403, detail="Session does not belong to this project") + + if "session_label" in data: + session.session_label = str(data["session_label"]).strip() or None + if "period_type" in data: + pt = data["period_type"] + if pt and pt not in VALID_PERIOD_TYPES: + 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 + + db.commit() + return JSONResponse({"status": "success", "session_label": session.session_label, "period_type": session.period_type}) + + @router.get("/{project_id}/files/{file_id}/view-rnd", response_class=HTMLResponse) async def view_rnd_file( request: Request, @@ -3277,32 +3305,59 @@ async def combined_report_wizard( ): """Configuration page for the combined multi-location report wizard.""" from backend.models import ReportTemplate + from pathlib import Path as _Path project = db.query(Project).filter_by(id=project_id).first() if not project: raise HTTPException(status_code=404, detail="Project not found") - sessions = db.query(MonitoringSession).filter_by(project_id=project_id).all() + sessions = db.query(MonitoringSession).filter_by(project_id=project_id).order_by(MonitoringSession.started_at).all() - # Build location list with Leq file counts (no filtering) - location_file_counts: dict = {} + # Build location -> sessions list, only including sessions that have Leq files + location_sessions: dict = {} # loc_name -> list of session dicts for session in sessions: files = db.query(DataFile).filter_by(session_id=session.id).all() + has_leq = False for file in files: if not file.file_path or not file.file_path.lower().endswith('.rnd'): continue - from pathlib import Path as _Path abs_path = _Path("data") / file.file_path peek = _peek_rnd_headers(abs_path) - if not _is_leq_file(file.file_path, peek): - continue - location = db.query(MonitoringLocation).filter_by(id=session.location_id).first() if session.location_id else None - loc_name = location.name if location else f"Session {session.id[:8]}" - location_file_counts[loc_name] = location_file_counts.get(loc_name, 0) + 1 + if _is_leq_file(file.file_path, peek): + has_leq = True + break + if not has_leq: + continue + + location = db.query(MonitoringLocation).filter_by(id=session.location_id).first() if session.location_id else None + loc_name = location.name if location else f"Session {session.id[:8]}" + + if loc_name not in location_sessions: + location_sessions[loc_name] = [] + + # Build a display date and day-of-week from started_at + date_display = "" + day_of_week = "" + if session.started_at: + date_display = session.started_at.strftime("%-m/%-d/%Y") + day_of_week = session.started_at.strftime("%A") # Monday, Sunday, etc. + + location_sessions[loc_name].append({ + "session_id": session.id, + "session_label": session.session_label or "", + "date_display": date_display, + "day_of_week": day_of_week, + "started_at": session.started_at.isoformat() if session.started_at else "", + "stopped_at": session.stopped_at.isoformat() if session.stopped_at else "", + "duration_h": (session.duration_seconds // 3600) if session.duration_seconds else 0, + "duration_m": ((session.duration_seconds % 3600) // 60) if session.duration_seconds else 0, + "period_type": session.period_type or "", + "status": session.status, + }) locations = [ - {"name": name, "file_count": count} - for name, count in sorted(location_file_counts.items()) + {"name": name, "sessions": sess_list} + for name, sess_list in sorted(location_sessions.items()) ] report_templates = db.query(ReportTemplate).all() @@ -3312,10 +3367,111 @@ async def combined_report_wizard( "project": project, "project_id": project_id, "locations": locations, + "locations_json": json.dumps(locations), "report_templates": report_templates, }) +def _build_location_data_from_sessions(project_id: str, db, selected_session_ids: list) -> dict: + """ + Build per-location spreadsheet data using an explicit list of session IDs. + Only rows from those sessions are included. Per-session period_type is + stored on each row so the report can filter stats correctly. + """ + from pathlib import Path as _Path + + project = db.query(Project).filter_by(id=project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + if not selected_session_ids: + raise HTTPException(status_code=400, detail="No sessions selected.") + + # Load every requested session — one entry per (session_id, loc_name) pair. + # Keyed by session_id so overnight sessions are never split by calendar date. + session_entries: dict = {} # session_id -> {loc_name, session_label, period_type, rows[]} + + for session_id in selected_session_ids: + session = db.query(MonitoringSession).filter_by(id=session_id, project_id=project_id).first() + if not session: + continue + location = db.query(MonitoringLocation).filter_by(id=session.location_id).first() if session.location_id else None + loc_name = location.name if location else f"Session {session_id[:8]}" + + session_entries[session_id] = { + "loc_name": loc_name, + "session_label": session.session_label or "", + "period_type": session.period_type or "", + "started_at": session.started_at, + "rows": [], + } + + files = db.query(DataFile).filter_by(session_id=session_id).all() + for file in files: + if not file.file_path or not file.file_path.lower().endswith('.rnd'): + continue + abs_path = _Path("data") / file.file_path + peek = _peek_rnd_headers(abs_path) + if not _is_leq_file(file.file_path, peek): + continue + rows = _read_rnd_file_rows(file.file_path) + rows, _ = _normalize_rnd_rows(rows) + session_entries[session_id]["rows"].extend(rows) + + if not any(e["rows"] for e in session_entries.values()): + raise HTTPException(status_code=404, detail="No Leq data found in the selected sessions.") + + location_data = [] + for session_id in selected_session_ids: + entry = session_entries.get(session_id) + if not entry or not entry["rows"]: + continue + + loc_name = entry["loc_name"] + period_type = entry["period_type"] + raw_rows = sorted(entry["rows"], key=lambda r: r.get('Start Time', '')) + + spreadsheet_data = [] + for idx, row in enumerate(raw_rows, 1): + start_time_str = row.get('Start Time', '') + date_str = time_str = '' + if start_time_str: + try: + dt = datetime.strptime(start_time_str, '%Y/%m/%d %H:%M:%S') + date_str = dt.strftime('%Y-%m-%d') + time_str = dt.strftime('%H:%M') + except ValueError: + date_str = start_time_str + + lmax = row.get('Lmax(Main)', '') + ln1 = row.get('LN1(Main)', '') + ln2 = row.get('LN2(Main)', '') + + spreadsheet_data.append([ + idx, + date_str, + time_str, + lmax if lmax else '', + ln1 if ln1 else '', + ln2 if ln2 else '', + '', + period_type, # col index 7 — hidden, used by report gen for day/night bucketing + ]) + + location_data.append({ + "session_id": session_id, + "location_name": loc_name, + "session_label": entry["session_label"], + "period_type": period_type, + "started_at": entry["started_at"].isoformat() if entry["started_at"] else "", + "raw_count": len(raw_rows), + "filtered_count": len(raw_rows), + "spreadsheet_data": spreadsheet_data, + }) + + return {"project": project, "location_data": location_data} + + @router.get("/{project_id}/combined-report-preview", response_class=HTMLResponse) async def combined_report_preview( request: Request, @@ -3323,38 +3479,19 @@ async def combined_report_preview( report_title: str = Query("Background Noise Study"), project_name: str = Query(""), client_name: str = Query(""), - start_time: str = Query(""), - end_time: str = Query(""), - start_date: str = Query(""), - end_date: str = Query(""), - enabled_locations: str = Query(""), + selected_sessions: str = Query(""), # comma-separated session IDs db: Session = Depends(get_db), ): """Preview and edit combined report data before generating the Excel file.""" - enabled_list = [loc.strip() for loc in enabled_locations.split(',') if loc.strip()] if enabled_locations else None + session_ids = [s.strip() for s in selected_sessions.split(',') if s.strip()] if selected_sessions else [] - result = _build_combined_location_data( - project_id, db, - start_time=start_time, - end_time=end_time, - start_date=start_date, - end_date=end_date, - enabled_locations=enabled_list, - ) + result = _build_location_data_from_sessions(project_id, db, session_ids) project = result["project"] location_data = result["location_data"] - total_rows = sum(loc["filtered_count"] for loc in location_data) final_project_name = project_name if project_name else project.name - # Build time filter display string - time_filter_desc = "" - if start_time and end_time: - time_filter_desc = f"{start_time} – {end_time}" - elif start_time or end_time: - time_filter_desc = f"{start_time or ''} – {end_time or ''}" - return templates.TemplateResponse("combined_report_preview.html", { "request": request, "project": project, @@ -3362,11 +3499,7 @@ async def combined_report_preview( "report_title": report_title, "project_name": final_project_name, "client_name": client_name, - "start_time": start_time, - "end_time": end_time, - "start_date": start_date, - "end_date": end_date, - "time_filter_desc": time_filter_desc, + "time_filter_desc": f"{len(session_ids)} session{'s' if len(session_ids) != 1 else ''} selected", "location_data": location_data, "locations_json": json.dumps(location_data), "total_rows": total_rows, @@ -3474,13 +3607,14 @@ async def generate_combined_from_preview( b_inner = last_inner if is_last else data_inner b_right = last_right if is_last else data_right - test_num = row[0] if len(row) > 0 else row_idx + 1 - date_val = row[1] if len(row) > 1 else '' - time_val = row[2] if len(row) > 2 else '' - lmax = row[3] if len(row) > 3 else '' - ln1 = row[4] if len(row) > 4 else '' - ln2 = row[5] if len(row) > 5 else '' - comment = row[6] if len(row) > 6 else '' + test_num = row[0] if len(row) > 0 else row_idx + 1 + date_val = row[1] if len(row) > 1 else '' + time_val = row[2] if len(row) > 2 else '' + lmax = row[3] if len(row) > 3 else '' + ln1 = row[4] if len(row) > 4 else '' + ln2 = row[5] if len(row) > 5 else '' + comment = row[6] if len(row) > 6 else '' + row_period = row[7] if len(row) > 7 else '' # hidden period_type from session c = ws.cell(row=dr, column=1, value=test_num) c.font = f_data; c.alignment = center_a; c.border = b_left @@ -3505,15 +3639,8 @@ async def generate_combined_from_preview( if isinstance(ln2, (int, float)): ln2_vals.append(ln2) - if time_val and isinstance(lmax, (int, float)) and isinstance(ln1, (int, float)) and isinstance(ln2, (int, float)): - try: - try: - row_dt = datetime.strptime(str(time_val), '%H:%M') - except ValueError: - row_dt = datetime.strptime(str(time_val), '%H:%M:%S') - parsed_rows.append((row_dt, float(lmax), float(ln1), float(ln2))) - except (ValueError, TypeError): - pass + if isinstance(lmax, (int, float)) and isinstance(ln1, (int, float)) and isinstance(ln2, (int, float)): + parsed_rows.append((row_period, float(lmax), float(ln1), float(ln2))) data_end_row = data_start_row + len(day_rows) - 1 @@ -3548,44 +3675,109 @@ async def generate_combined_from_preview( ws.merge_cells(start_row=29, start_column=9, end_row=29, end_column=14) hdr_fill_tbl = PatternFill(start_color="F2F2F2", end_color="F2F2F2", fill_type="solid") - c = ws.cell(row=31, column=9, value=""); c.border = tbl_top_left; c.font = f_bold - c = ws.cell(row=31, column=10, value="Evening (7PM to 10PM)") - c.font = f_bold; c.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True) - c.border = tbl_top_mid; c.fill = hdr_fill_tbl - ws.merge_cells(start_row=31, start_column=10, end_row=31, end_column=11) - c = ws.cell(row=31, column=12, value="Nighttime (10PM to 7AM)") - c.font = f_bold; c.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True) - c.border = tbl_top_right; c.fill = hdr_fill_tbl - ws.merge_cells(start_row=31, start_column=12, end_row=31, end_column=13) - ws.row_dimensions[31].height = 15 - - evening = [(lmx, l1, l2) for dt, lmx, l1, l2 in parsed_rows if 19 <= dt.hour < 22] - nighttime = [(lmx, l1, l2) for dt, lmx, l1, l2 in parsed_rows if dt.hour >= 22 or dt.hour < 7] def _avg(vals): return round(sum(vals) / len(vals), 1) if vals else None def _max(vals): return round(max(vals), 1) if vals else None - def write_stat(row_num, label, eve_val, night_val, is_last=False): + # --- Dynamic period detection ---------------------------------------- + # Use the period_type stored on each row (from the session record). + # Rows without a period_type fall back to time-of-day detection. + # The four canonical types map to two display columns: + # Day -> "Daytime (7AM to 10PM)" + # Night -> "Nighttime (10PM to 7AM)" + PERIOD_TYPE_IS_DAY = {"weekday_day", "weekend_day"} + PERIOD_TYPE_IS_NIGHT = {"weekday_night", "weekend_night"} + + day_rows_data = [] + night_rows_data = [] + for pt, lmx, l1, l2 in parsed_rows: + if pt in PERIOD_TYPE_IS_DAY: + day_rows_data.append((lmx, l1, l2)) + elif pt in PERIOD_TYPE_IS_NIGHT: + night_rows_data.append((lmx, l1, l2)) + else: + # No period_type — fall back to time-of-day (shouldn't happen for + # new uploads, but handles legacy data gracefully) + # We can't derive from time here since parsed_rows no longer stores dt. + # Put in day as a safe default. + day_rows_data.append((lmx, l1, l2)) + + all_candidate_periods = [ + ("Daytime (7AM to 10PM)", day_rows_data), + ("Nighttime (10PM to 7AM)", night_rows_data), + ] + active_periods = [(label, rows) for label, rows in all_candidate_periods if rows] + + # If nothing at all, show both columns empty + if not active_periods: + active_periods = [("Daytime (7AM to 10PM)", []), ("Nighttime (10PM to 7AM)", [])] + + # Build header row (row 31) with one merged pair of columns per active period + # Layout: col 9 = row label, then pairs: (10,11), (12,13), (14,15) + num_periods = len(active_periods) + period_start_cols = [10 + i * 2 for i in range(num_periods)] + + # Left/right border helpers for the header row + def _hdr_border(i, n): + is_first = (i == 0) + is_last = (i == n - 1) + return Border( + left=med if is_first else thin, + right=med if is_last else thin, + top=med, + bottom=thin, + ) + def _mid_border(i, n, is_data_last=False): + is_first = (i == 0) + is_last = (i == n - 1) + b = tbl_bot_mid if is_data_last else tbl_mid_mid + return Border( + left=med if is_first else thin, + right=med if is_last else thin, + top=b.top, + bottom=b.bottom, + ) + + c = ws.cell(row=31, column=9, value=""); c.border = tbl_top_left; c.font = f_bold + ws.row_dimensions[31].height = 30 + + for i, (period_label, _) in enumerate(active_periods): + sc = period_start_cols[i] + is_last_col = (i == num_periods - 1) + c = ws.cell(row=31, column=sc, value=period_label.replace('\n', ' ')) + c.font = f_bold + c.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True) + c.border = _hdr_border(i, num_periods) + c.fill = hdr_fill_tbl + ws.merge_cells(start_row=31, start_column=sc, end_row=31, end_column=sc + 1) + + def write_stat_dynamic(row_num, row_label, period_vals_list, is_last=False): bl = tbl_bot_left if is_last else tbl_mid_left - bm = tbl_bot_mid if is_last else tbl_mid_mid - br = tbl_bot_right if is_last else tbl_mid_right - lbl = ws.cell(row=row_num, column=9, value=label) + lbl = ws.cell(row=row_num, column=9, value=row_label) lbl.font = f_data; lbl.border = bl lbl.alignment = Alignment(horizontal='left', vertical='center') - ev_str = f"{eve_val} dBA" if eve_val is not None else "" - ev = ws.cell(row=row_num, column=10, value=ev_str) - ev.font = f_bold; ev.border = bm - ev.alignment = Alignment(horizontal='center', vertical='center') - ws.merge_cells(start_row=row_num, start_column=10, end_row=row_num, end_column=11) - ni_str = f"{night_val} dBA" if night_val is not None else "" - ni = ws.cell(row=row_num, column=12, value=ni_str) - ni.font = f_bold; ni.border = br - ni.alignment = Alignment(horizontal='center', vertical='center') - ws.merge_cells(start_row=row_num, start_column=12, end_row=row_num, end_column=13) + n = len(period_vals_list) + for i, val in enumerate(period_vals_list): + sc = period_start_cols[i] + is_last_col = (i == n - 1) + val_str = f"{val} dBA" if val is not None else "" + c = ws.cell(row=row_num, column=sc, value=val_str) + c.font = f_bold + c.alignment = Alignment(horizontal='center', vertical='center') + c.border = Border( + left=med if i == 0 else thin, + right=med if is_last_col else thin, + top=tbl_bot_mid.top if is_last else tbl_mid_mid.top, + bottom=tbl_bot_mid.bottom if is_last else tbl_mid_mid.bottom, + ) + ws.merge_cells(start_row=row_num, start_column=sc, end_row=row_num, end_column=sc + 1) - write_stat(32, "LAmax", _max([v[0] for v in evening]), _max([v[0] for v in nighttime])) - write_stat(33, "LA01 Average", _avg([v[1] for v in evening]), _avg([v[1] for v in nighttime])) - write_stat(34, "LA10 Average", _avg([v[2] for v in evening]), _avg([v[2] for v in nighttime]), is_last=True) + write_stat_dynamic(32, "LAmax", + [_max([v[0] for v in rows]) for _, rows in active_periods]) + write_stat_dynamic(33, "LA01 Average", + [_avg([v[1] for v in rows]) for _, rows in active_periods]) + write_stat_dynamic(34, "LA10 Average", + [_avg([v[2] for v in rows]) for _, rows in active_periods], is_last=True) ws.sheet_properties.pageSetUpPr = PageSetupProperties(fitToPage=False) ws.page_setup.orientation = 'portrait' @@ -3624,58 +3816,58 @@ async def generate_combined_from_preview( summary_ws.cell(row=idx, column=5, value=s['ln2_avg'] or '-').border = thin_border # ---------------------------------------------------------------- - # Split each location's rows by date, collect all unique dates + # Build one workbook per session (each location entry is one session) # ---------------------------------------------------------------- - # Structure: dates_map[date_str][loc_name] = [row, ...] - dates_map: dict = {} - for loc_info in locations: - loc_name = loc_info.get("location_name", "Unknown") - rows = loc_info.get("spreadsheet_data", []) - for row in rows: - date_val = str(row[1]).strip() if len(row) > 1 else '' - if not date_val: - date_val = "Unknown Date" - dates_map.setdefault(date_val, {}).setdefault(loc_name, []).append(row) + if not locations: + raise HTTPException(status_code=400, detail="No location data provided") - if not dates_map: - raise HTTPException(status_code=400, detail="No data rows found in provided location data") - - sorted_dates = sorted(dates_map.keys()) project_name_clean = "".join(c for c in project_name if c.isalnum() or c in ('_', '-', ' ')).strip().replace(' ', '_') + final_title = f"{report_title} - {project_name}" - # ---------------------------------------------------------------- - # Build one workbook per day, zip them - # ---------------------------------------------------------------- zip_buffer = io.BytesIO() with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf: - for date_str in sorted_dates: - loc_data_for_day = dates_map[date_str] - final_title = f"{report_title} - {project_name}" + for loc_info in locations: + loc_name = loc_info.get("location_name", "Unknown") + session_label = loc_info.get("session_label", "") + period_type = loc_info.get("period_type", "") + started_at_str = loc_info.get("started_at", "") + rows = loc_info.get("spreadsheet_data", []) + if not rows: + continue + + # Re-number interval # sequentially + for i, row in enumerate(rows): + if len(row) > 0: + row[0] = i + 1 wb = openpyxl.Workbook() wb.remove(wb.active) - loc_summaries = [] - for loc_name in sorted(loc_data_for_day.keys()): - day_rows = loc_data_for_day[loc_name] - # Re-number interval # sequentially for this day - for i, row in enumerate(day_rows): - if len(row) > 0: - row[0] = i + 1 + safe_sheet = "".join(c for c in loc_name if c.isalnum() or c in (' ', '-', '_'))[:31] + ws = wb.create_sheet(title=safe_sheet) + summary = _build_location_sheet(ws, loc_name, rows, final_title) - safe_name = "".join(c for c in loc_name if c.isalnum() or c in (' ', '-', '_'))[:31] - ws = wb.create_sheet(title=safe_name) - summary = _build_location_sheet(ws, loc_name, day_rows, final_title) - loc_summaries.append(summary) + # Derive a date label for the summary sheet from started_at or first row + day_label = session_label or loc_name + if started_at_str: + try: + _dt = datetime.fromisoformat(started_at_str) + day_label = _dt.strftime('%-m/%-d/%Y') + if session_label: + day_label = session_label + except Exception: + pass - _build_summary_sheet(wb, date_str, project_name, loc_summaries) + _build_summary_sheet(wb, day_label, project_name, [summary]) xlsx_buf = io.BytesIO() wb.save(xlsx_buf) xlsx_buf.seek(0) - date_clean = date_str.replace('/', '-').replace(' ', '_') - xlsx_name = f"{project_name_clean}_{date_clean}_report.xlsx" + # Build a clean filename from label or location+date + label_clean = session_label or loc_name + label_clean = "".join(c for c in label_clean if c.isalnum() or c in (' ', '-', '_', '/')).strip().replace(' ', '_').replace('/', '-') + xlsx_name = f"{project_name_clean}_{label_clean}_report.xlsx" zf.writestr(xlsx_name, xlsx_buf.read()) zip_buffer.seek(0) diff --git a/templates/combined_report_wizard.html b/templates/combined_report_wizard.html index 8470ae9..a6c213f 100644 --- a/templates/combined_report_wizard.html +++ b/templates/combined_report_wizard.html @@ -74,105 +74,134 @@ - +
-

Time Filter

-

Applied to all locations. Leave blank to include all data.

- - -
- - - - -
- - -
-
- - -
-
- - -
-
- - -
- -
-
- - -
-
- - -
-
-
-
- - -
-
-
-

Locations to Include

-

- {{ locations|length }} of {{ locations|length }} selected -

-
+
+

Monitoring Sessions

- - + +
+

+ 0 session(s) selected — each selected session becomes one sheet in the ZIP. + Change the period type per session to control how stats are bucketed (Day vs Night). +

{% if locations %} -
{% for loc in locations %} - + {% set loc_name = loc.name %} + {% set sessions = loc.sessions %} +
+ + + +
+ + + +
+ {% for s in sessions %} + {% set pt_colors = { + 'weekday_day': 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300', + 'weekday_night': 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-300', + 'weekend_day': 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300', + 'weekend_night': 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300', + } %} + {% set pt_labels = { + 'weekday_day': 'Weekday Day', + 'weekday_night': 'Weekday Night', + 'weekend_day': 'Weekend Day', + 'weekend_night': 'Weekend Night', + } %} +
+ + + + +
+
+ + {{ s.day_of_week }} {{ s.date_display }} + + {% if s.session_label %} + {{ s.session_label }} + {% endif %} + {% if s.status == 'recording' %} + + Recording + + {% endif %} +
+
+ {% if s.started_at %} + {{ s.started_at }} + {% endif %} + {% if s.duration_h is not none %} + {{ s.duration_h }}h {{ s.duration_m }}m + {% endif %} +
+
+ + +
+ + +
+
+ {% endfor %} +
+
{% endfor %} -
{% else %} -
-

No Leq measurement files found in this project.

-

Upload RND files with '_Leq_' in the filename to generate reports.

+
+ + + +

No monitoring sessions found.

+

Upload data files to create sessions first.

{% endif %}
- Cancel @@ -191,180 +220,173 @@
diff --git a/templates/partials/projects/session_list.html b/templates/partials/projects/session_list.html index 957f431..6b8b617 100644 --- a/templates/partials/projects/session_list.html +++ b/templates/partials/projects/session_list.html @@ -1,79 +1,149 @@ {% if sessions %} -
+
{% for item in sessions %} -
-
+ {% set s = item.session %} + {% set loc = item.location %} + {% set unit = item.unit %} + + {# Period display maps #} + {% set period_labels = { + 'weekday_day': 'Weekday Day', + 'weekday_night': 'Weekday Night', + 'weekend_day': 'Weekend Day', + 'weekend_night': 'Weekend Night', + } %} + {% set period_colors = { + 'weekday_day': 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300', + 'weekday_night': 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-300', + 'weekend_day': 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300', + 'weekend_night': 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300', + } %} + +
+
-
-

- Session {{ item.session.id[:8] }}... -

- {% if item.session.status == 'recording' %} - - - Recording - - {% elif item.session.status == 'completed' %} - - Completed - - {% elif item.session.status == 'paused' %} - - Paused - - {% elif item.session.status == 'failed' %} - - Failed + + +
+ + {{ s.session_label or ('Session ' + s.id[:8] + '…') }} + + + + {% if s.status == 'recording' %} + + Recording + {% elif s.status == 'completed' %} + Completed + {% elif s.status == 'failed' %} + Failed {% endif %} + + +
+ + +
-
- {% if item.unit %} -
- Unit: - - {{ item.unit.id }} - + +
+ {% if loc %} +
+ + + + + {{ loc.name }}
{% endif %} -
- Started: - {{ item.session.started_at|local_datetime if item.session.started_at else 'N/A' }} -
- - {% if item.session.stopped_at %} -
- Ended: - {{ item.session.stopped_at|local_datetime }} + {% if s.started_at %} +
+ + + + {{ s.started_at|local_datetime }}
{% endif %} - {% if item.session.duration_seconds %} -
- Duration: - {{ (item.session.duration_seconds // 3600) }}h {{ ((item.session.duration_seconds % 3600) // 60) }}m + {% if s.stopped_at %} +
+ + + + Ended {{ s.stopped_at|local_datetime }} +
+ {% endif %} + + {% if s.duration_seconds %} +
+ + + + {{ (s.duration_seconds // 3600) }}h {{ ((s.duration_seconds % 3600) // 60) }}m +
+ {% endif %} + + {% if unit %} +
+ + + + {{ unit.id }} +
+ {% endif %} + + {% if s.device_model %} +
+ + + + {{ s.device_model }}
{% endif %}
- {% if item.session.notes %} -

- {{ item.session.notes }} -

+ {% if s.notes %} +

{{ s.notes }}

{% endif %}
-
- {% if item.session.status == 'recording' %} - +
+ {% if s.status == 'recording' %} + {% endif %} - @@ -84,24 +154,107 @@
{% else %}
- + -

No monitoring sessions yet

-

Schedule a session to get started

+

No monitoring sessions yet

+

Upload data to create sessions

{% endif %}