From 7516bbea70590fc9c2f1db57a696d3971cac60b6 Mon Sep 17 00:00:00 2001 From: serversdown Date: Tue, 24 Feb 2026 19:54:40 +0000 Subject: [PATCH] feat: add manual SD card data upload for offline NRLs; rename RecordingSession to MonitoringSession - Add POST /api/projects/{project_id}/nrl/{location_id}/upload-data endpoint accepting a ZIP or multi-file select of .rnd/.rnh files from an SD card. Parses .rnh metadata for session start/stop times, serial number, and store name. Creates a MonitoringSession (no unit assignment required) and DataFile records for each measurement file. - Add Upload Data button and collapsible upload panel to the NRL detail Data Files tab, with inline success/error feedback and automatic file list refresh via HTMX after import. - Rename RecordingSession -> MonitoringSession throughout the codebase (models.py, projects.py, project_locations.py, scheduler.py, roster_rename.py, main.py, init_projects_db.py, scripts/rename_unit.py). DB table renamed from recording_sessions to monitoring_sessions; old indexes dropped and recreated. - Update all template UI copy from Recording Sessions to Monitoring Sessions (nrl_detail, projects/detail, session_list, schedule_oneoff, roster). - Add backend/migrate_rename_recording_to_monitoring_sessions.py for applying the table rename on production databases before deploying this build. Co-Authored-By: Claude Sonnet 4.6 --- backend/init_projects_db.py | 2 +- backend/main.py | 19 +- ...rename_recording_to_monitoring_sessions.py | 54 ++++ backend/models.py | 12 +- backend/routers/project_locations.py | 259 ++++++++++++++++-- backend/routers/projects.py | 96 +++---- backend/routers/roster_rename.py | 8 +- backend/services/scheduler.py | 22 +- scripts/rename_unit.py | 6 +- templates/nrl_detail.html | 101 ++++++- templates/partials/projects/file_list.html | 4 +- .../partials/projects/schedule_oneoff.html | 2 +- templates/partials/projects/session_list.html | 6 +- templates/projects/detail.html | 20 +- templates/projects/overview.html | 19 +- templates/roster.html | 2 +- 16 files changed, 509 insertions(+), 123 deletions(-) create mode 100644 backend/migrate_rename_recording_to_monitoring_sessions.py diff --git a/backend/init_projects_db.py b/backend/init_projects_db.py index 68802c7..4b239c6 100644 --- a/backend/init_projects_db.py +++ b/backend/init_projects_db.py @@ -18,7 +18,7 @@ from backend.models import ( MonitoringLocation, UnitAssignment, ScheduledAction, - RecordingSession, + MonitoringSession, DataFile, ) from datetime import datetime diff --git a/backend/main.py b/backend/main.py index 07f0fc9..777ff48 100644 --- a/backend/main.py +++ b/backend/main.py @@ -312,7 +312,7 @@ async def nrl_detail_page( db: Session = Depends(get_db) ): """NRL (Noise Recording Location) detail page with tabs""" - from backend.models import Project, MonitoringLocation, UnitAssignment, RosterUnit, RecordingSession, DataFile + from backend.models import Project, MonitoringLocation, UnitAssignment, RosterUnit, MonitoringSession, DataFile from sqlalchemy import and_ # Get project @@ -348,23 +348,24 @@ async def nrl_detail_page( assigned_unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first() # Get session count - session_count = db.query(RecordingSession).filter_by(location_id=location_id).count() + session_count = db.query(MonitoringSession).filter_by(location_id=location_id).count() # Get file count (DataFile links to session, not directly to location) file_count = db.query(DataFile).join( - RecordingSession, - DataFile.session_id == RecordingSession.id - ).filter(RecordingSession.location_id == location_id).count() + MonitoringSession, + DataFile.session_id == MonitoringSession.id + ).filter(MonitoringSession.location_id == location_id).count() # Check for active session - active_session = db.query(RecordingSession).filter( + active_session = db.query(MonitoringSession).filter( and_( - RecordingSession.location_id == location_id, - RecordingSession.status == "recording" + MonitoringSession.location_id == location_id, + MonitoringSession.status == "recording" ) ).first() - return templates.TemplateResponse("nrl_detail.html", { + template = "vibration_location_detail.html" if location.location_type == "vibration" else "nrl_detail.html" + return templates.TemplateResponse(template, { "request": request, "project_id": project_id, "location_id": location_id, diff --git a/backend/migrate_rename_recording_to_monitoring_sessions.py b/backend/migrate_rename_recording_to_monitoring_sessions.py new file mode 100644 index 0000000..475ed67 --- /dev/null +++ b/backend/migrate_rename_recording_to_monitoring_sessions.py @@ -0,0 +1,54 @@ +""" +Migration: Rename recording_sessions table to monitoring_sessions + +Renames the table and updates the model name from RecordingSession to MonitoringSession. +Run once per database: python backend/migrate_rename_recording_to_monitoring_sessions.py +""" + +import sqlite3 +import sys +from pathlib import Path + + +def migrate(db_path: str): + """Run the migration.""" + print(f"Migrating database: {db_path}") + + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + try: + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='recording_sessions'") + if not cursor.fetchone(): + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='monitoring_sessions'") + if cursor.fetchone(): + print("monitoring_sessions table already exists. Skipping migration.") + else: + print("recording_sessions table does not exist. Skipping migration.") + return + + print("Renaming recording_sessions -> monitoring_sessions...") + cursor.execute("ALTER TABLE recording_sessions RENAME TO monitoring_sessions") + + conn.commit() + print("Migration completed successfully!") + + except Exception as e: + print(f"Migration failed: {e}") + conn.rollback() + raise + finally: + conn.close() + + +if __name__ == "__main__": + db_path = "./data/terra-view.db" + + if len(sys.argv) > 1: + db_path = sys.argv[1] + + if not Path(db_path).exists(): + print(f"Database not found: {db_path}") + sys.exit(1) + + migrate(db_path) diff --git a/backend/models.py b/backend/models.py index 5f8eb99..4dc6244 100644 --- a/backend/models.py +++ b/backend/models.py @@ -245,17 +245,17 @@ class ScheduledAction(Base): created_at = Column(DateTime, default=datetime.utcnow) -class RecordingSession(Base): +class MonitoringSession(Base): """ - Recording sessions: tracks actual monitoring sessions. - Created when recording starts, updated when it stops. + Monitoring sessions: tracks actual monitoring sessions. + Created when monitoring starts, updated when it stops. """ - __tablename__ = "recording_sessions" + __tablename__ = "monitoring_sessions" id = Column(String, primary_key=True, index=True) # UUID project_id = Column(String, nullable=False, index=True) # FK to Project.id location_id = Column(String, nullable=False, index=True) # FK to MonitoringLocation.id - unit_id = Column(String, nullable=False, index=True) # FK to RosterUnit.id + unit_id = Column(String, nullable=True, index=True) # FK to RosterUnit.id (nullable for offline uploads) session_type = Column(String, nullable=False) # sound | vibration started_at = Column(DateTime, nullable=False) @@ -278,7 +278,7 @@ class DataFile(Base): __tablename__ = "data_files" id = Column(String, primary_key=True, index=True) # UUID - session_id = Column(String, nullable=False, index=True) # FK to RecordingSession.id + session_id = Column(String, nullable=False, index=True) # FK to MonitoringSession.id file_path = Column(String, nullable=False) # Relative to data/Projects/ file_type = Column(String, nullable=False) # wav, csv, mseed, json diff --git a/backend/routers/project_locations.py b/backend/routers/project_locations.py index 54d36b1..28b4127 100644 --- a/backend/routers/project_locations.py +++ b/backend/routers/project_locations.py @@ -14,6 +14,12 @@ from typing import Optional import uuid import json +from fastapi import UploadFile, File +import zipfile +import hashlib +import io +from pathlib import Path + from backend.database import get_db from backend.models import ( Project, @@ -21,7 +27,8 @@ from backend.models import ( MonitoringLocation, UnitAssignment, RosterUnit, - RecordingSession, + MonitoringSession, + DataFile, ) from backend.templates_config import templates @@ -70,8 +77,8 @@ async def get_project_locations( if assignment: assigned_unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first() - # Count recording sessions - session_count = db.query(RecordingSession).filter_by( + # Count monitoring sessions + session_count = db.query(MonitoringSession).filter_by( location_id=location.id ).count() @@ -370,19 +377,19 @@ async def unassign_unit( if not assignment: raise HTTPException(status_code=404, detail="Assignment not found") - # Check if there are active recording sessions - active_sessions = db.query(RecordingSession).filter( + # Check if there are active monitoring sessions + active_sessions = db.query(MonitoringSession).filter( and_( - RecordingSession.location_id == assignment.location_id, - RecordingSession.unit_id == assignment.unit_id, - RecordingSession.status == "recording", + MonitoringSession.location_id == assignment.location_id, + MonitoringSession.unit_id == assignment.unit_id, + MonitoringSession.status == "recording", ) ).count() if active_sessions > 0: raise HTTPException( status_code=400, - detail="Cannot unassign unit with active recording sessions. Stop recording first.", + detail="Cannot unassign unit with active monitoring sessions. Stop monitoring first.", ) assignment.status = "completed" @@ -451,14 +458,12 @@ async def get_nrl_sessions( db: Session = Depends(get_db), ): """ - Get recording sessions for a specific NRL. + Get monitoring sessions for a specific NRL. Returns HTML partial with session list. """ - from backend.models import RecordingSession, RosterUnit - - sessions = db.query(RecordingSession).filter_by( + sessions = db.query(MonitoringSession).filter_by( location_id=location_id - ).order_by(RecordingSession.started_at.desc()).all() + ).order_by(MonitoringSession.started_at.desc()).all() # Enrich with unit details sessions_data = [] @@ -491,14 +496,12 @@ async def get_nrl_files( Get data files for a specific NRL. Returns HTML partial with file list. """ - from backend.models import DataFile, RecordingSession - - # Join DataFile with RecordingSession to filter by location_id + # Join DataFile with MonitoringSession to filter by location_id files = db.query(DataFile).join( - RecordingSession, - DataFile.session_id == RecordingSession.id + MonitoringSession, + DataFile.session_id == MonitoringSession.id ).filter( - RecordingSession.location_id == location_id + MonitoringSession.location_id == location_id ).order_by(DataFile.created_at.desc()).all() # Enrich with session details @@ -506,7 +509,7 @@ async def get_nrl_files( for file in files: session = None if file.session_id: - session = db.query(RecordingSession).filter_by(id=file.session_id).first() + session = db.query(MonitoringSession).filter_by(id=file.session_id).first() files_data.append({ "file": file, @@ -519,3 +522,217 @@ async def get_nrl_files( "location_id": location_id, "files": files_data, }) + + +# ============================================================================ +# Manual SD Card Data Upload +# ============================================================================ + +def _parse_rnh(content: bytes) -> dict: + """ + Parse a Rion .rnh metadata file (INI-style with [Section] headers). + Returns a dict of key metadata fields. + """ + result = {} + try: + text = content.decode("utf-8", errors="replace") + for line in text.splitlines(): + line = line.strip() + if not line or line.startswith("["): + continue + if "," in line: + key, _, value = line.partition(",") + key = key.strip() + value = value.strip() + if key == "Serial Number": + result["serial_number"] = value + elif key == "Store Name": + result["store_name"] = value + elif key == "Index Number": + result["index_number"] = value + elif key == "Measurement Start Time": + result["start_time_str"] = value + elif key == "Measurement Stop Time": + result["stop_time_str"] = value + elif key == "Total Measurement Time": + result["total_time_str"] = value + except Exception: + pass + return result + + +def _parse_rnh_datetime(s: str): + """Parse RNH datetime string: '2026/02/17 19:00:19' -> datetime""" + from datetime import datetime + if not s: + return None + try: + return datetime.strptime(s.strip(), "%Y/%m/%d %H:%M:%S") + except Exception: + return None + + +def _classify_file(filename: str) -> str: + """Classify a file by name into a DataFile file_type.""" + name = filename.lower() + if name.endswith(".rnh"): + return "log" + if name.endswith(".rnd"): + return "measurement" + if name.endswith(".zip"): + return "archive" + return "data" + + +@router.post("/nrl/{location_id}/upload-data") +async def upload_nrl_data( + project_id: str, + location_id: str, + db: Session = Depends(get_db), + files: list[UploadFile] = File(...), +): + """ + Manually upload SD card data for an offline NRL. + + Accepts either: + - A single .zip file (the Auto_#### folder zipped) — auto-extracted + - Multiple .rnd / .rnh files selected directly from the SD card folder + + Creates a MonitoringSession from .rnh metadata and DataFile records + for each measurement file. No unit assignment required. + """ + from datetime import datetime + + # Verify project and location exist + location = db.query(MonitoringLocation).filter_by( + id=location_id, project_id=project_id + ).first() + if not location: + raise HTTPException(status_code=404, detail="Location not found") + + # --- Step 1: Normalize to (filename, bytes) list --- + file_entries: list[tuple[str, bytes]] = [] + + if len(files) == 1 and files[0].filename.lower().endswith(".zip"): + raw = await files[0].read() + try: + with zipfile.ZipFile(io.BytesIO(raw)) as zf: + for info in zf.infolist(): + if info.is_dir(): + continue + name = Path(info.filename).name # strip folder path + if not name: + continue + file_entries.append((name, zf.read(info))) + except zipfile.BadZipFile: + raise HTTPException(status_code=400, detail="Uploaded file is not a valid ZIP archive.") + else: + for uf in files: + data = await uf.read() + file_entries.append((uf.filename, data)) + + if not file_entries: + raise HTTPException(status_code=400, detail="No usable files found in upload.") + + # --- Step 2: Find and parse .rnh metadata --- + rnh_meta = {} + for fname, fbytes in file_entries: + if fname.lower().endswith(".rnh"): + 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")) + duration_seconds = None + if started_at and stopped_at: + duration_seconds = int((stopped_at - started_at).total_seconds()) + + store_name = rnh_meta.get("store_name", "") + serial_number = rnh_meta.get("serial_number", "") + index_number = rnh_meta.get("index_number", "") + + # --- Step 3: Create MonitoringSession --- + session_id = str(uuid.uuid4()) + monitoring_session = MonitoringSession( + id=session_id, + project_id=project_id, + location_id=location_id, + unit_id=None, + session_type="sound", + started_at=started_at, + stopped_at=stopped_at, + duration_seconds=duration_seconds, + status="completed", + session_metadata=json.dumps({ + "source": "manual_upload", + "store_name": store_name, + "serial_number": serial_number, + "index_number": index_number, + }), + ) + db.add(monitoring_session) + db.commit() + db.refresh(monitoring_session) + + # --- Step 4: Write files to disk and create DataFile records --- + output_dir = Path("data/Projects") / project_id / session_id + output_dir.mkdir(parents=True, exist_ok=True) + + leq_count = 0 + lp_count = 0 + metadata_count = 0 + files_imported = 0 + + for fname, fbytes in file_entries: + file_type = _classify_file(fname) + fname_lower = fname.lower() + + # Track counts for summary + if fname_lower.endswith(".rnd"): + if "_leq_" in fname_lower: + leq_count += 1 + elif "_lp" in fname_lower: + lp_count += 1 + elif fname_lower.endswith(".rnh"): + metadata_count += 1 + + # Write to disk + dest = output_dir / fname + dest.write_bytes(fbytes) + + # Compute checksum + checksum = hashlib.sha256(fbytes).hexdigest() + + # Store relative path from data/ dir + rel_path = str(dest.relative_to("data")) + + data_file = DataFile( + id=str(uuid.uuid4()), + session_id=session_id, + file_path=rel_path, + file_type=file_type, + file_size_bytes=len(fbytes), + downloaded_at=datetime.utcnow(), + checksum=checksum, + file_metadata=json.dumps({ + "source": "manual_upload", + "original_filename": fname, + "store_name": store_name, + }), + ) + db.add(data_file) + files_imported += 1 + + db.commit() + + return { + "success": True, + "session_id": session_id, + "files_imported": files_imported, + "leq_files": leq_count, + "lp_files": lp_count, + "metadata_files": metadata_count, + "store_name": store_name, + "started_at": started_at.isoformat() if started_at else None, + "stopped_at": stopped_at.isoformat() if stopped_at else None, + } diff --git a/backend/routers/projects.py b/backend/routers/projects.py index 1316a79..4d5ea24 100644 --- a/backend/routers/projects.py +++ b/backend/routers/projects.py @@ -28,7 +28,7 @@ from backend.models import ( ProjectType, MonitoringLocation, UnitAssignment, - RecordingSession, + MonitoringSession, ScheduledAction, RecurringSchedule, RosterUnit, @@ -89,10 +89,10 @@ async def get_projects_list( ).scalar() # Count active sessions - active_session_count = db.query(func.count(RecordingSession.id)).filter( + active_session_count = db.query(func.count(MonitoringSession.id)).filter( and_( - RecordingSession.project_id == project.id, - RecordingSession.status == "recording", + MonitoringSession.project_id == project.id, + MonitoringSession.status == "recording", ) ).scalar() @@ -135,7 +135,7 @@ async def get_projects_stats(request: Request, db: Session = Depends(get_db)): ).scalar() # Count active recording sessions - active_sessions = db.query(func.count(RecordingSession.id)).filter_by( + active_sessions = db.query(func.count(MonitoringSession.id)).filter_by( status="recording" ).scalar() @@ -410,7 +410,7 @@ async def permanently_delete_project(project_id: str, db: Session = Depends(get_ # Delete related data db.query(RecurringSchedule).filter_by(project_id=project_id).delete() db.query(ScheduledAction).filter_by(project_id=project_id).delete() - db.query(RecordingSession).filter_by(project_id=project_id).delete() + db.query(MonitoringSession).filter_by(project_id=project_id).delete() db.query(UnitAssignment).filter_by(project_id=project_id).delete() db.query(MonitoringLocation).filter_by(project_id=project_id).delete() db.delete(project) @@ -501,18 +501,18 @@ async def get_project_dashboard( }) # Get active recording sessions - active_sessions = db.query(RecordingSession).filter( + active_sessions = db.query(MonitoringSession).filter( and_( - RecordingSession.project_id == project_id, - RecordingSession.status == "recording", + MonitoringSession.project_id == project_id, + MonitoringSession.status == "recording", ) ).all() # Get completed sessions count - completed_sessions_count = db.query(func.count(RecordingSession.id)).filter( + completed_sessions_count = db.query(func.count(MonitoringSession.id)).filter( and_( - RecordingSession.project_id == project_id, - RecordingSession.status == "completed", + MonitoringSession.project_id == project_id, + MonitoringSession.status == "completed", ) ).scalar() @@ -591,26 +591,26 @@ async def get_project_units( location = db.query(MonitoringLocation).filter_by(id=assignment.location_id).first() # Count sessions for this assignment - session_count = db.query(func.count(RecordingSession.id)).filter_by( + session_count = db.query(func.count(MonitoringSession.id)).filter_by( location_id=assignment.location_id, unit_id=assignment.unit_id, ).scalar() # Count files from sessions file_count = db.query(func.count(DataFile.id)).join( - RecordingSession, - DataFile.session_id == RecordingSession.id + MonitoringSession, + DataFile.session_id == MonitoringSession.id ).filter( - RecordingSession.location_id == assignment.location_id, - RecordingSession.unit_id == assignment.unit_id, + MonitoringSession.location_id == assignment.location_id, + MonitoringSession.unit_id == assignment.unit_id, ).scalar() # Check if currently recording - active_session = db.query(RecordingSession).filter( + active_session = db.query(MonitoringSession).filter( and_( - RecordingSession.location_id == assignment.location_id, - RecordingSession.unit_id == assignment.unit_id, - RecordingSession.status == "recording", + MonitoringSession.location_id == assignment.location_id, + MonitoringSession.unit_id == assignment.unit_id, + MonitoringSession.status == "recording", ) ).first() @@ -797,13 +797,13 @@ async def get_project_sessions( Returns HTML partial with session list. Optional status filter: recording, completed, paused, failed """ - query = db.query(RecordingSession).filter_by(project_id=project_id) + query = db.query(MonitoringSession).filter_by(project_id=project_id) # Filter by status if provided if status: - query = query.filter(RecordingSession.status == status) + query = query.filter(MonitoringSession.status == status) - sessions = query.order_by(RecordingSession.started_at.desc()).all() + sessions = query.order_by(MonitoringSession.started_at.desc()).all() # Enrich with unit and location details sessions_data = [] @@ -895,18 +895,18 @@ async def ftp_download_to_server( raise HTTPException(status_code=400, detail="Missing unit_id or remote_path") # Get or create active session for this location/unit - session = db.query(RecordingSession).filter( + session = db.query(MonitoringSession).filter( and_( - RecordingSession.project_id == project_id, - RecordingSession.location_id == location_id, - RecordingSession.unit_id == unit_id, - RecordingSession.status.in_(["recording", "paused"]) + MonitoringSession.project_id == project_id, + MonitoringSession.location_id == location_id, + MonitoringSession.unit_id == unit_id, + MonitoringSession.status.in_(["recording", "paused"]) ) ).first() # If no active session, create one if not session: - session = RecordingSession( + session = MonitoringSession( id=str(uuid.uuid4()), project_id=project_id, location_id=location_id, @@ -1060,18 +1060,18 @@ async def ftp_download_folder_to_server( raise HTTPException(status_code=400, detail="Missing unit_id or remote_path") # Get or create active session for this location/unit - session = db.query(RecordingSession).filter( + session = db.query(MonitoringSession).filter( and_( - RecordingSession.project_id == project_id, - RecordingSession.location_id == location_id, - RecordingSession.unit_id == unit_id, - RecordingSession.status.in_(["recording", "paused"]) + MonitoringSession.project_id == project_id, + MonitoringSession.location_id == location_id, + MonitoringSession.unit_id == unit_id, + MonitoringSession.status.in_(["recording", "paused"]) ) ).first() # If no active session, create one if not session: - session = RecordingSession( + session = MonitoringSession( id=str(uuid.uuid4()), project_id=project_id, location_id=location_id, @@ -1231,9 +1231,9 @@ async def get_unified_files( import json # Get all sessions for this project - sessions = db.query(RecordingSession).filter_by( + sessions = db.query(MonitoringSession).filter_by( project_id=project_id - ).order_by(RecordingSession.started_at.desc()).all() + ).order_by(MonitoringSession.started_at.desc()).all() sessions_data = [] for session in sessions: @@ -1310,7 +1310,7 @@ async def download_project_file( raise HTTPException(status_code=404, detail="File not found") # Verify file belongs to this project - session = db.query(RecordingSession).filter_by(id=file_record.session_id).first() + session = db.query(MonitoringSession).filter_by(id=file_record.session_id).first() if not session or session.project_id != project_id: raise HTTPException(status_code=403, detail="File does not belong to this project") @@ -1344,7 +1344,7 @@ async def download_session_files( import zipfile # Verify session belongs to this project - session = db.query(RecordingSession).filter_by(id=session_id).first() + 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: @@ -1412,7 +1412,7 @@ async def delete_project_file( raise HTTPException(status_code=404, detail="File not found") # Verify file belongs to this project - session = db.query(RecordingSession).filter_by(id=file_record.session_id).first() + session = db.query(MonitoringSession).filter_by(id=file_record.session_id).first() if not session or session.project_id != project_id: raise HTTPException(status_code=403, detail="File does not belong to this project") @@ -1442,7 +1442,7 @@ async def delete_session( from pathlib import Path # Verify session belongs to this project - session = db.query(RecordingSession).filter_by(id=session_id).first() + 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: @@ -1491,7 +1491,7 @@ async def view_rnd_file( raise HTTPException(status_code=404, detail="File not found") # Verify file belongs to this project - session = db.query(RecordingSession).filter_by(id=file_record.session_id).first() + session = db.query(MonitoringSession).filter_by(id=file_record.session_id).first() if not session or session.project_id != project_id: raise HTTPException(status_code=403, detail="File does not belong to this project") @@ -1557,7 +1557,7 @@ async def get_rnd_data( raise HTTPException(status_code=404, detail="File not found") # Verify file belongs to this project - session = db.query(RecordingSession).filter_by(id=file_record.session_id).first() + session = db.query(MonitoringSession).filter_by(id=file_record.session_id).first() if not session or session.project_id != project_id: raise HTTPException(status_code=403, detail="File does not belong to this project") @@ -1695,7 +1695,7 @@ async def generate_excel_report( raise HTTPException(status_code=404, detail="File not found") # Verify file belongs to this project - session = db.query(RecordingSession).filter_by(id=file_record.session_id).first() + session = db.query(MonitoringSession).filter_by(id=file_record.session_id).first() if not session or session.project_id != project_id: raise HTTPException(status_code=403, detail="File does not belong to this project") @@ -2101,7 +2101,7 @@ async def preview_report_data( raise HTTPException(status_code=404, detail="File not found") # Verify file belongs to this project - session = db.query(RecordingSession).filter_by(id=file_record.session_id).first() + session = db.query(MonitoringSession).filter_by(id=file_record.session_id).first() if not session or session.project_id != project_id: raise HTTPException(status_code=403, detail="File does not belong to this project") @@ -2309,7 +2309,7 @@ async def generate_report_from_preview( if not file_record: raise HTTPException(status_code=404, detail="File not found") - session = db.query(RecordingSession).filter_by(id=file_record.session_id).first() + session = db.query(MonitoringSession).filter_by(id=file_record.session_id).first() if not session or session.project_id != project_id: raise HTTPException(status_code=403, detail="File does not belong to this project") @@ -2471,7 +2471,7 @@ async def generate_combined_excel_report( raise HTTPException(status_code=404, detail="Project not found") # Get all sessions with measurement files - sessions = db.query(RecordingSession).filter_by(project_id=project_id).all() + sessions = db.query(MonitoringSession).filter_by(project_id=project_id).all() # Collect all Leq RND files grouped by location # Only include files with '_Leq_' in the path (15-minute averaged data) diff --git a/backend/routers/roster_rename.py b/backend/routers/roster_rename.py index c99082d..c8dbf19 100644 --- a/backend/routers/roster_rename.py +++ b/backend/routers/roster_rename.py @@ -92,15 +92,15 @@ async def rename_unit( except Exception as e: logger.warning(f"Could not update unit_assignments: {e}") - # Update recording_sessions table (if exists) + # Update monitoring_sessions table (if exists) try: - from backend.models import RecordingSession - db.query(RecordingSession).filter(RecordingSession.unit_id == old_id).update( + from backend.models import MonitoringSession + db.query(MonitoringSession).filter(MonitoringSession.unit_id == old_id).update( {"unit_id": new_id}, synchronize_session=False ) except Exception as e: - logger.warning(f"Could not update recording_sessions: {e}") + logger.warning(f"Could not update monitoring_sessions: {e}") # Commit all changes db.commit() diff --git a/backend/services/scheduler.py b/backend/services/scheduler.py index 8e40e67..b782280 100644 --- a/backend/services/scheduler.py +++ b/backend/services/scheduler.py @@ -21,7 +21,7 @@ from sqlalchemy.orm import Session from sqlalchemy import and_ from backend.database import SessionLocal -from backend.models import ScheduledAction, RecordingSession, MonitoringLocation, Project, RecurringSchedule +from backend.models import ScheduledAction, MonitoringSession, MonitoringLocation, Project, RecurringSchedule from backend.services.device_controller import get_device_controller, DeviceControllerError from backend.services.alert_service import get_alert_service import uuid @@ -272,7 +272,7 @@ class SchedulerService: ) # Create recording session - session = RecordingSession( + session = MonitoringSession( id=str(uuid.uuid4()), project_id=action.project_id, location_id=action.location_id, @@ -336,11 +336,11 @@ class SchedulerService: ) # Find and update the active recording session - active_session = db.query(RecordingSession).filter( + active_session = db.query(MonitoringSession).filter( and_( - RecordingSession.location_id == action.location_id, - RecordingSession.unit_id == unit_id, - RecordingSession.status == "recording", + MonitoringSession.location_id == action.location_id, + MonitoringSession.unit_id == unit_id, + MonitoringSession.status == "recording", ) ).first() @@ -617,11 +617,11 @@ class SchedulerService: result["steps"]["download"] = {"success": False, "error": "Project or location not found"} # Close out the old recording session - active_session = db.query(RecordingSession).filter( + active_session = db.query(MonitoringSession).filter( and_( - RecordingSession.location_id == action.location_id, - RecordingSession.unit_id == unit_id, - RecordingSession.status == "recording", + MonitoringSession.location_id == action.location_id, + MonitoringSession.unit_id == unit_id, + MonitoringSession.status == "recording", ) ).first() @@ -648,7 +648,7 @@ class SchedulerService: result["steps"]["start"] = {"success": True, "response": cycle_response} # Create new recording session - new_session = RecordingSession( + new_session = MonitoringSession( id=str(uuid.uuid4()), project_id=action.project_id, location_id=action.location_id, diff --git a/scripts/rename_unit.py b/scripts/rename_unit.py index 915e7fd..68d4dc6 100644 --- a/scripts/rename_unit.py +++ b/scripts/rename_unit.py @@ -90,14 +90,14 @@ def rename_unit(old_id: str, new_id: str): except Exception: pass # Table may not exist - # Update recording_sessions table (if exists) + # Update monitoring_sessions table (if exists) try: result = session.execute( - text("UPDATE recording_sessions SET unit_id = :new_id WHERE unit_id = :old_id"), + text("UPDATE monitoring_sessions SET unit_id = :new_id WHERE unit_id = :old_id"), {"new_id": new_id, "old_id": old_id} ) if result.rowcount > 0: - print(f" ✓ Updated recording_sessions ({result.rowcount} rows)") + print(f" ✓ Updated monitoring_sessions ({result.rowcount} rows)") except Exception: pass # Table may not exist diff --git a/templates/nrl_detail.html b/templates/nrl_detail.html index 470a93f..b702944 100644 --- a/templates/nrl_detail.html +++ b/templates/nrl_detail.html @@ -80,7 +80,7 @@ + + + + + @@ -559,5 +591,64 @@ document.getElementById('assign-modal')?.addEventListener('click', function(e) { closeAssignModal(); } }); + +// ── Upload Data ───────────────────────────────────────────────────────────── + +function toggleUploadPanel() { + const panel = document.getElementById('upload-panel'); + const status = document.getElementById('upload-status'); + panel.classList.toggle('hidden'); + // Reset status when reopening + if (!panel.classList.contains('hidden')) { + status.textContent = ''; + status.className = 'text-sm hidden'; + document.getElementById('upload-input').value = ''; + } +} + +async function submitUpload() { + const input = document.getElementById('upload-input'); + const status = document.getElementById('upload-status'); + + if (!input.files.length) { + alert('Please select files to upload.'); + return; + } + + const formData = new FormData(); + for (const file of input.files) { + formData.append('files', file); + } + + status.textContent = 'Uploading\u2026'; + status.className = 'text-sm text-gray-500'; + + try { + const response = await fetch( + `/api/projects/${projectId}/nrl/${locationId}/upload-data`, + { method: 'POST', body: formData } + ); + const data = await response.json(); + + if (response.ok) { + const parts = [`Imported ${data.files_imported} file${data.files_imported !== 1 ? 's' : ''}`]; + if (data.leq_files || data.lp_files) { + parts.push(`(${data.leq_files} Leq, ${data.lp_files} Lp)`); + } + if (data.store_name) parts.push(`\u2014 ${data.store_name}`); + status.textContent = parts.join(' '); + status.className = 'text-sm text-green-600 dark:text-green-400'; + input.value = ''; + // Refresh the file list + htmx.trigger(document.getElementById('data-files-list'), 'load'); + } else { + status.textContent = `Error: ${data.detail || 'Upload failed'}`; + status.className = 'text-sm text-red-600 dark:text-red-400'; + } + } catch (err) { + status.textContent = `Error: ${err.message}`; + status.className = 'text-sm text-red-600 dark:text-red-400'; + } +} {% endblock %} diff --git a/templates/partials/projects/file_list.html b/templates/partials/projects/file_list.html index 979a8ed..3b5b382 100644 --- a/templates/partials/projects/file_list.html +++ b/templates/partials/projects/file_list.html @@ -151,9 +151,9 @@ -

No files downloaded yet

+

No data files yet

- Files will appear here once they are downloaded from the sound level meter + Files appear here after an FTP download from a connected meter, or after uploading SD card data manually.

{% endif %} diff --git a/templates/partials/projects/schedule_oneoff.html b/templates/partials/projects/schedule_oneoff.html index c3e4e4d..4b8dba4 100644 --- a/templates/partials/projects/schedule_oneoff.html +++ b/templates/partials/projects/schedule_oneoff.html @@ -5,7 +5,7 @@

One-Off Recording

- Schedule a single recording session with a specific start and end time. + Schedule a single monitoring session with a specific start and end time. Duration can be between 15 minutes and 24 hours.

diff --git a/templates/partials/projects/session_list.html b/templates/partials/projects/session_list.html index 51af907..957f431 100644 --- a/templates/partials/projects/session_list.html +++ b/templates/partials/projects/session_list.html @@ -1,4 +1,4 @@ - + {% if sessions %}
{% for item in sessions %} @@ -87,7 +87,7 @@ -

No recording sessions yet

+

No monitoring sessions yet

Schedule a session to get started

{% endif %} @@ -99,7 +99,7 @@ function viewSession(sessionId) { } function stopRecording(sessionId) { - if (!confirm('Stop this recording session?')) return; + if (!confirm('Stop this monitoring session?')) return; // TODO: Implement stop recording API call alert('Stop recording API coming soon for session: ' + sessionId); diff --git a/templates/projects/detail.html b/templates/projects/detail.html index 8fcb927..dc33a09 100644 --- a/templates/projects/detail.html +++ b/templates/projects/detail.html @@ -53,7 +53,7 @@