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 <noreply@anthropic.com>
This commit is contained in:
2026-02-24 19:54:40 +00:00
parent da4e5f66c5
commit 7516bbea70
16 changed files with 509 additions and 123 deletions

View File

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