c48c6e5bca
The safety check that refuses to delete assignments with real recording
history referenced MonitoringSession.start_time, but the actual column
is MonitoringSession.started_at. Every DELETE call to /assignments/{id}
crashed with AttributeError before doing anything.
Now uses started_at correctly. Verified end-to-end on dev.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1727 lines
57 KiB
Python
1727 lines
57 KiB
Python
"""
|
||
Project Locations Router
|
||
|
||
Handles monitoring locations (NRLs for sound, monitoring points for vibration)
|
||
and unit assignments within projects.
|
||
"""
|
||
|
||
from fastapi import APIRouter, Request, Depends, HTTPException, Query
|
||
from fastapi.responses import HTMLResponse, JSONResponse
|
||
from sqlalchemy.orm import Session
|
||
from sqlalchemy import and_, or_
|
||
from datetime import datetime
|
||
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,
|
||
ProjectType,
|
||
ProjectModule,
|
||
MonitoringLocation,
|
||
UnitAssignment,
|
||
RosterUnit,
|
||
MonitoringSession,
|
||
DataFile,
|
||
UnitHistory,
|
||
ScheduledAction,
|
||
)
|
||
from backend.templates_config import templates
|
||
from backend.utils.timezone import local_to_utc
|
||
|
||
router = APIRouter(prefix="/api/projects/{project_id}", tags=["project-locations"])
|
||
|
||
|
||
# ── Audit log helper ──────────────────────────────────────────────────────────
|
||
# Mirrors record_history() in roster_edit.py. Kept local to avoid cross-router
|
||
# imports. The four assignment endpoints below use this to write UnitHistory
|
||
# rows that the unit-detail deployment timeline (Phase 4) renders.
|
||
|
||
def _record_assignment_history(
|
||
db: Session,
|
||
unit_id: str,
|
||
change_type: str,
|
||
*,
|
||
old_value: Optional[str] = None,
|
||
new_value: Optional[str] = None,
|
||
notes: Optional[str] = None,
|
||
) -> None:
|
||
"""Append a UnitHistory row for an assignment-lifecycle event.
|
||
|
||
change_type values used:
|
||
- assignment_created — unit assigned to a location (new assignment)
|
||
- assignment_ended — unit unassigned / removed (assigned_until set)
|
||
- assignment_swapped — unit replaced by another at the same location
|
||
- assignment_updated — assignment dates / notes edited via PATCH
|
||
|
||
Caller is responsible for db.commit().
|
||
"""
|
||
db.add(UnitHistory(
|
||
unit_id=unit_id,
|
||
change_type=change_type,
|
||
field_name="unit_assignment",
|
||
old_value=old_value,
|
||
new_value=new_value,
|
||
changed_at=datetime.utcnow(),
|
||
source="manual",
|
||
notes=notes,
|
||
))
|
||
|
||
|
||
# ============================================================================
|
||
# Shared helpers
|
||
# ============================================================================
|
||
|
||
def _require_module(project, module_type: str, db: Session) -> None:
|
||
"""Raise 400 if the project does not have the given module enabled."""
|
||
if not project:
|
||
raise HTTPException(status_code=404, detail="Project not found.")
|
||
exists = db.query(ProjectModule).filter_by(
|
||
project_id=project.id, module_type=module_type, enabled=True
|
||
).first()
|
||
if not exists:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"This project does not have the {module_type.replace('_', ' ').title()} module enabled.",
|
||
)
|
||
|
||
|
||
# ============================================================================
|
||
# 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'.
|
||
Uses started_at date as-is; user can correct period_type in the wizard.
|
||
"""
|
||
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
|
||
# ============================================================================
|
||
|
||
@router.get("/locations", response_class=HTMLResponse)
|
||
async def get_project_locations(
|
||
project_id: str,
|
||
request: Request,
|
||
db: Session = Depends(get_db),
|
||
location_type: Optional[str] = Query(None),
|
||
):
|
||
"""
|
||
Get all monitoring locations for a project.
|
||
Returns HTML partial with location list, split into active + removed.
|
||
"""
|
||
project = db.query(Project).filter_by(id=project_id).first()
|
||
if not project:
|
||
raise HTTPException(status_code=404, detail="Project not found")
|
||
|
||
query = db.query(MonitoringLocation).filter_by(project_id=project_id)
|
||
|
||
# Filter by type if provided
|
||
if location_type:
|
||
query = query.filter_by(location_type=location_type)
|
||
|
||
locations = query.order_by(MonitoringLocation.name).all()
|
||
|
||
# Enrich with assignment info, splitting active vs removed.
|
||
active_data: list = []
|
||
removed_data: list = []
|
||
for location in locations:
|
||
# Get active assignment (active = assigned_until IS NULL). For
|
||
# removed locations this will normally be None because the
|
||
# /remove cascade closes them, but check anyway for resilience
|
||
# against legacy data.
|
||
assignment = db.query(UnitAssignment).filter(
|
||
and_(
|
||
UnitAssignment.location_id == location.id,
|
||
UnitAssignment.assigned_until == None,
|
||
)
|
||
).first()
|
||
|
||
assigned_unit = None
|
||
if assignment:
|
||
assigned_unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first()
|
||
|
||
# Count monitoring sessions
|
||
session_count = db.query(MonitoringSession).filter_by(
|
||
location_id=location.id
|
||
).count()
|
||
|
||
item = {
|
||
"location": location,
|
||
"assignment": assignment,
|
||
"assigned_unit": assigned_unit,
|
||
"session_count": session_count,
|
||
}
|
||
if location.removed_at is None:
|
||
active_data.append(item)
|
||
else:
|
||
removed_data.append(item)
|
||
|
||
return templates.TemplateResponse("partials/projects/location_list.html", {
|
||
"request": request,
|
||
"project": project,
|
||
"locations": active_data, # back-compat alias
|
||
"active_locations": active_data,
|
||
"removed_locations": removed_data,
|
||
})
|
||
|
||
|
||
@router.get("/locations-json")
|
||
async def get_project_locations_json(
|
||
project_id: str,
|
||
db: Session = Depends(get_db),
|
||
location_type: Optional[str] = Query(None),
|
||
include_removed: bool = Query(False),
|
||
):
|
||
"""
|
||
Get all monitoring locations for a project as JSON.
|
||
Used by the schedule modal to populate location dropdown.
|
||
|
||
Removed locations are filtered out by default (you can't schedule
|
||
a new action at a removed location). Pass `include_removed=true`
|
||
to get them too — useful for historical / reporting views.
|
||
"""
|
||
project = db.query(Project).filter_by(id=project_id).first()
|
||
if not project:
|
||
raise HTTPException(status_code=404, detail="Project not found")
|
||
|
||
query = db.query(MonitoringLocation).filter_by(project_id=project_id)
|
||
|
||
if location_type:
|
||
query = query.filter_by(location_type=location_type)
|
||
|
||
if not include_removed:
|
||
query = query.filter(MonitoringLocation.removed_at == None) # noqa: E711
|
||
|
||
locations = query.order_by(MonitoringLocation.name).all()
|
||
|
||
return [
|
||
{
|
||
"id": loc.id,
|
||
"name": loc.name,
|
||
"location_type": loc.location_type,
|
||
"description": loc.description,
|
||
"address": loc.address,
|
||
"coordinates": loc.coordinates,
|
||
"removed_at": loc.removed_at.isoformat() if loc.removed_at else None,
|
||
"removal_reason": loc.removal_reason,
|
||
}
|
||
for loc in locations
|
||
]
|
||
|
||
|
||
@router.post("/locations/create")
|
||
async def create_location(
|
||
project_id: str,
|
||
request: Request,
|
||
db: Session = Depends(get_db),
|
||
):
|
||
"""
|
||
Create a new monitoring location within a project.
|
||
"""
|
||
project = db.query(Project).filter_by(id=project_id).first()
|
||
if not project:
|
||
raise HTTPException(status_code=404, detail="Project not found")
|
||
|
||
form_data = await request.form()
|
||
|
||
location = MonitoringLocation(
|
||
id=str(uuid.uuid4()),
|
||
project_id=project_id,
|
||
location_type=form_data.get("location_type"),
|
||
name=form_data.get("name"),
|
||
description=form_data.get("description"),
|
||
coordinates=form_data.get("coordinates"),
|
||
address=form_data.get("address"),
|
||
location_metadata=form_data.get("location_metadata"), # JSON string
|
||
)
|
||
|
||
db.add(location)
|
||
db.commit()
|
||
db.refresh(location)
|
||
|
||
return JSONResponse({
|
||
"success": True,
|
||
"location_id": location.id,
|
||
"message": f"Location '{location.name}' created successfully",
|
||
})
|
||
|
||
|
||
@router.put("/locations/{location_id}")
|
||
async def update_location(
|
||
project_id: str,
|
||
location_id: str,
|
||
request: Request,
|
||
db: Session = Depends(get_db),
|
||
):
|
||
"""
|
||
Update a monitoring location.
|
||
"""
|
||
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")
|
||
|
||
data = await request.json()
|
||
|
||
# Update fields if provided
|
||
if "name" in data:
|
||
location.name = data["name"]
|
||
if "description" in data:
|
||
location.description = data["description"]
|
||
if "location_type" in data:
|
||
location.location_type = data["location_type"]
|
||
if "coordinates" in data:
|
||
location.coordinates = data["coordinates"]
|
||
if "address" in data:
|
||
location.address = data["address"]
|
||
if "location_metadata" in data:
|
||
location.location_metadata = data["location_metadata"]
|
||
|
||
location.updated_at = datetime.utcnow()
|
||
|
||
db.commit()
|
||
|
||
return {"success": True, "message": "Location updated successfully"}
|
||
|
||
|
||
@router.delete("/locations/{location_id}")
|
||
async def delete_location(
|
||
project_id: str,
|
||
location_id: str,
|
||
db: Session = Depends(get_db),
|
||
):
|
||
"""
|
||
Delete a monitoring location.
|
||
"""
|
||
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")
|
||
|
||
# Check if location has active assignments (active = assigned_until IS NULL)
|
||
active_assignments = db.query(UnitAssignment).filter(
|
||
and_(
|
||
UnitAssignment.location_id == location_id,
|
||
UnitAssignment.assigned_until == None,
|
||
)
|
||
).count()
|
||
|
||
if active_assignments > 0:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="Cannot delete location with active unit assignments. Unassign units first.",
|
||
)
|
||
|
||
db.delete(location)
|
||
db.commit()
|
||
|
||
return {"success": True, "message": "Location deleted successfully"}
|
||
|
||
|
||
@router.post("/locations/{location_id}/remove")
|
||
async def remove_location(
|
||
project_id: str,
|
||
location_id: str,
|
||
request: Request,
|
||
db: Session = Depends(get_db),
|
||
):
|
||
"""
|
||
Soft-remove a monitoring location — mark it as no longer actively
|
||
monitored without destroying it.
|
||
|
||
Use case: a client drops a location from scope mid-project, but the
|
||
historical events recorded there should remain attributed. Deleting
|
||
would orphan those events; this preserves them.
|
||
|
||
Cascading side-effects:
|
||
1. All active UnitAssignment rows at this location are closed
|
||
(assigned_until = effective_date, status = "completed").
|
||
Units become available for other deployments.
|
||
2. All pending ScheduledAction rows at this location are cancelled
|
||
(execution_status = "cancelled").
|
||
3. Historical events stay attributed (attribution is window-based;
|
||
events with timestamp < effective_date still match the
|
||
now-closed assignment windows).
|
||
|
||
Accepts JSON body:
|
||
- effective_date: ISO datetime (optional, defaults to now)
|
||
- reason: operator note (optional)
|
||
"""
|
||
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")
|
||
|
||
if location.removed_at is not None:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"Location is already removed (as of {location.removed_at.isoformat()}).",
|
||
)
|
||
|
||
# Body is optional — POST with no body is fine and means "remove now,
|
||
# no reason given."
|
||
try:
|
||
payload = await request.json()
|
||
except Exception:
|
||
payload = {}
|
||
|
||
# Effective date: accept "YYYY-MM-DDTHH:MM" from datetime-local inputs or
|
||
# full ISO. Defaults to now if absent/empty.
|
||
raw_eff = payload.get("effective_date")
|
||
if raw_eff:
|
||
try:
|
||
effective_date = datetime.fromisoformat(raw_eff)
|
||
except (TypeError, ValueError):
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"Invalid effective_date: {raw_eff!r}",
|
||
)
|
||
else:
|
||
effective_date = datetime.utcnow()
|
||
|
||
reason = (payload.get("reason") or "").strip() or None
|
||
|
||
# 1. Close active assignments at this location.
|
||
active_assignments = db.query(UnitAssignment).filter(
|
||
and_(
|
||
UnitAssignment.location_id == location_id,
|
||
UnitAssignment.assigned_until == None, # noqa: E711 — SQL NULL
|
||
)
|
||
).all()
|
||
|
||
for a in active_assignments:
|
||
a.status = "completed"
|
||
a.assigned_until = effective_date
|
||
_record_assignment_history(
|
||
db,
|
||
unit_id=a.unit_id,
|
||
change_type="assignment_ended",
|
||
old_value=location.name,
|
||
new_value="location removed",
|
||
notes=f"Location '{location.name}' marked as removed"
|
||
+ (f" — {reason}" if reason else ""),
|
||
)
|
||
|
||
# 2. Cancel pending scheduled actions at this location.
|
||
pending_actions = db.query(ScheduledAction).filter(
|
||
and_(
|
||
ScheduledAction.location_id == location_id,
|
||
ScheduledAction.execution_status == "pending",
|
||
ScheduledAction.scheduled_time >= effective_date,
|
||
)
|
||
).all()
|
||
|
||
for sa in pending_actions:
|
||
sa.execution_status = "cancelled"
|
||
sa.error_message = (
|
||
f"Cancelled: location '{location.name}' marked as removed"
|
||
+ (f" — {reason}" if reason else "")
|
||
)
|
||
|
||
# 3. Mark the location itself as removed.
|
||
location.removed_at = effective_date
|
||
location.removal_reason = reason
|
||
location.updated_at = datetime.utcnow()
|
||
|
||
db.commit()
|
||
|
||
return {
|
||
"success": True,
|
||
"message": f"Location '{location.name}' marked as removed",
|
||
"effective_date": effective_date.isoformat(),
|
||
"assignments_closed": len(active_assignments),
|
||
"actions_cancelled": len(pending_actions),
|
||
}
|
||
|
||
|
||
@router.post("/locations/{location_id}/restore")
|
||
async def restore_location(
|
||
project_id: str,
|
||
location_id: str,
|
||
db: Session = Depends(get_db),
|
||
):
|
||
"""
|
||
Restore a previously-removed monitoring location to active.
|
||
|
||
Clears `removed_at` and `removal_reason`. Does NOT automatically
|
||
re-open the assignments or scheduled actions that were closed when
|
||
the location was removed — those stay closed and the operator can
|
||
create new ones if they want to resume monitoring.
|
||
"""
|
||
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")
|
||
|
||
if location.removed_at is None:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="Location is already active.",
|
||
)
|
||
|
||
location.removed_at = None
|
||
location.removal_reason = None
|
||
location.updated_at = datetime.utcnow()
|
||
|
||
db.commit()
|
||
|
||
return {
|
||
"success": True,
|
||
"message": f"Location '{location.name}' restored to active",
|
||
}
|
||
|
||
|
||
# ============================================================================
|
||
# Unit Assignments
|
||
# ============================================================================
|
||
|
||
@router.get("/assignments", response_class=HTMLResponse)
|
||
async def get_project_assignments(
|
||
project_id: str,
|
||
request: Request,
|
||
db: Session = Depends(get_db),
|
||
status: Optional[str] = Query("active"),
|
||
):
|
||
"""
|
||
Get all unit assignments for a project.
|
||
Returns HTML partial with assignment list.
|
||
"""
|
||
query = db.query(UnitAssignment).filter_by(project_id=project_id)
|
||
|
||
if status:
|
||
query = query.filter_by(status=status)
|
||
|
||
assignments = query.order_by(UnitAssignment.assigned_at.desc()).all()
|
||
|
||
# Enrich with unit and location details
|
||
assignments_data = []
|
||
for assignment in assignments:
|
||
unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first()
|
||
location = db.query(MonitoringLocation).filter_by(id=assignment.location_id).first()
|
||
|
||
assignments_data.append({
|
||
"assignment": assignment,
|
||
"unit": unit,
|
||
"location": location,
|
||
})
|
||
|
||
return templates.TemplateResponse("partials/projects/assignment_list.html", {
|
||
"request": request,
|
||
"project_id": project_id,
|
||
"assignments": assignments_data,
|
||
})
|
||
|
||
|
||
@router.post("/locations/{location_id}/assign")
|
||
async def assign_unit_to_location(
|
||
project_id: str,
|
||
location_id: str,
|
||
request: Request,
|
||
db: Session = Depends(get_db),
|
||
):
|
||
"""
|
||
Assign a unit to a monitoring location.
|
||
"""
|
||
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")
|
||
|
||
form_data = await request.form()
|
||
unit_id = form_data.get("unit_id")
|
||
|
||
# Verify unit exists and matches location type
|
||
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
|
||
if not unit:
|
||
raise HTTPException(status_code=404, detail="Unit not found")
|
||
|
||
# Check device type matches location type
|
||
expected_device_type = "slm" if location.location_type == "sound" else "seismograph"
|
||
if unit.device_type != expected_device_type:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"Unit type '{unit.device_type}' does not match location type '{location.location_type}'",
|
||
)
|
||
|
||
# Check if location already has an active assignment (active = assigned_until IS NULL)
|
||
existing_assignment = db.query(UnitAssignment).filter(
|
||
and_(
|
||
UnitAssignment.location_id == location_id,
|
||
UnitAssignment.assigned_until == None,
|
||
)
|
||
).first()
|
||
|
||
if existing_assignment:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"Location already has an active unit assignment ({existing_assignment.unit_id}). Use swap to replace it.",
|
||
)
|
||
|
||
# Create new assignment
|
||
assigned_until_str = form_data.get("assigned_until")
|
||
assigned_until = datetime.fromisoformat(assigned_until_str) if assigned_until_str else None
|
||
|
||
assignment = UnitAssignment(
|
||
id=str(uuid.uuid4()),
|
||
unit_id=unit_id,
|
||
location_id=location_id,
|
||
project_id=project_id,
|
||
device_type=unit.device_type,
|
||
assigned_until=assigned_until,
|
||
status="active",
|
||
notes=form_data.get("notes"),
|
||
)
|
||
|
||
db.add(assignment)
|
||
_record_assignment_history(
|
||
db,
|
||
unit_id=unit_id,
|
||
change_type="assignment_created",
|
||
new_value=f"{location.name} (project: {location.project_id})",
|
||
notes=form_data.get("notes"),
|
||
)
|
||
db.commit()
|
||
db.refresh(assignment)
|
||
|
||
return JSONResponse({
|
||
"success": True,
|
||
"assignment_id": assignment.id,
|
||
"message": f"Unit '{unit_id}' assigned to '{location.name}'",
|
||
})
|
||
|
||
|
||
@router.post("/assignments/{assignment_id}/unassign")
|
||
async def unassign_unit(
|
||
project_id: str,
|
||
assignment_id: str,
|
||
db: Session = Depends(get_db),
|
||
):
|
||
"""
|
||
Unassign a unit from a location.
|
||
"""
|
||
assignment = db.query(UnitAssignment).filter_by(
|
||
id=assignment_id,
|
||
project_id=project_id,
|
||
).first()
|
||
|
||
if not assignment:
|
||
raise HTTPException(status_code=404, detail="Assignment not found")
|
||
|
||
# Check if there are active monitoring sessions
|
||
active_sessions = db.query(MonitoringSession).filter(
|
||
and_(
|
||
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 monitoring sessions. Stop monitoring first.",
|
||
)
|
||
|
||
assignment.status = "completed"
|
||
assignment.assigned_until = datetime.utcnow()
|
||
|
||
location = db.query(MonitoringLocation).filter_by(id=assignment.location_id).first()
|
||
_record_assignment_history(
|
||
db,
|
||
unit_id=assignment.unit_id,
|
||
change_type="assignment_ended",
|
||
old_value=location.name if location else assignment.location_id,
|
||
new_value="unassigned",
|
||
)
|
||
|
||
db.commit()
|
||
|
||
return {"success": True, "message": "Unit unassigned successfully"}
|
||
|
||
|
||
@router.patch("/assignments/{assignment_id}")
|
||
async def update_assignment(
|
||
project_id: str,
|
||
assignment_id: str,
|
||
request: Request,
|
||
db: Session = Depends(get_db),
|
||
):
|
||
"""
|
||
Update an assignment's date window and/or notes.
|
||
|
||
Common use case: backdate a deployment so events emitted before the
|
||
operator created the assignment in terra-view (e.g. a unit that was
|
||
physically deployed in December but only recorded in the system today)
|
||
get correctly attributed to the location.
|
||
|
||
Accepts JSON body with optional fields:
|
||
- assigned_at: ISO datetime (or empty string to leave unchanged)
|
||
- assigned_until: ISO datetime, or null/"" to mark indefinite (active)
|
||
- notes: string
|
||
|
||
Sets `status` to "active" when assigned_until is cleared, "completed"
|
||
when it's set in the past.
|
||
"""
|
||
assignment = db.query(UnitAssignment).filter_by(
|
||
id=assignment_id,
|
||
project_id=project_id,
|
||
).first()
|
||
|
||
if not assignment:
|
||
raise HTTPException(status_code=404, detail="Assignment not found")
|
||
|
||
try:
|
||
payload = await request.json()
|
||
except Exception:
|
||
raise HTTPException(status_code=400, detail="Invalid JSON body")
|
||
|
||
# Parse new values (None = unchanged, explicit None/"" for assigned_until = clear)
|
||
new_assigned_at = assignment.assigned_at
|
||
new_assigned_until = assignment.assigned_until
|
||
new_notes = assignment.notes
|
||
|
||
if "assigned_at" in payload:
|
||
raw = payload["assigned_at"]
|
||
if raw is None or raw == "":
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="assigned_at is required; cannot be cleared.",
|
||
)
|
||
try:
|
||
# Accept "YYYY-MM-DDTHH:MM" from datetime-local inputs or full ISO.
|
||
new_assigned_at = datetime.fromisoformat(raw)
|
||
except (TypeError, ValueError):
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"Invalid assigned_at datetime: {raw!r}",
|
||
)
|
||
|
||
if "assigned_until" in payload:
|
||
raw = payload["assigned_until"]
|
||
if raw is None or raw == "":
|
||
new_assigned_until = None
|
||
else:
|
||
try:
|
||
new_assigned_until = datetime.fromisoformat(raw)
|
||
except (TypeError, ValueError):
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"Invalid assigned_until datetime: {raw!r}",
|
||
)
|
||
|
||
if "notes" in payload:
|
||
raw = payload["notes"]
|
||
new_notes = (raw or "").strip() or None
|
||
|
||
# Validation: end must be after start if both set.
|
||
if new_assigned_until is not None and new_assigned_until <= new_assigned_at:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="assigned_until must be after assigned_at.",
|
||
)
|
||
|
||
# Sanity: reject creating an overlap with another assignment of the SAME
|
||
# unit at the SAME location. Different units at the same location can
|
||
# legitimately overlap during a swap window (rare but valid).
|
||
new_end_for_overlap = new_assigned_until or datetime.utcnow()
|
||
overlapping = (
|
||
db.query(UnitAssignment)
|
||
.filter(UnitAssignment.location_id == assignment.location_id)
|
||
.filter(UnitAssignment.unit_id == assignment.unit_id)
|
||
.filter(UnitAssignment.id != assignment.id)
|
||
.all()
|
||
)
|
||
for other in overlapping:
|
||
other_start = other.assigned_at
|
||
other_end = other.assigned_until or datetime.utcnow()
|
||
if new_assigned_at < other_end and new_end_for_overlap > other_start:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=(
|
||
f"This window overlaps with another assignment for the "
|
||
f"same unit ({other.assigned_at:%Y-%m-%d} → "
|
||
f"{other.assigned_until and other.assigned_until.strftime('%Y-%m-%d') or 'present'})."
|
||
),
|
||
)
|
||
|
||
# Capture change description for audit log BEFORE mutating.
|
||
old_start = assignment.assigned_at.isoformat() if assignment.assigned_at else None
|
||
old_end = assignment.assigned_until.isoformat() if assignment.assigned_until else "active"
|
||
new_start = new_assigned_at.isoformat() if new_assigned_at else None
|
||
new_end = new_assigned_until.isoformat() if new_assigned_until else "active"
|
||
|
||
# Apply.
|
||
assignment.assigned_at = new_assigned_at
|
||
assignment.assigned_until = new_assigned_until
|
||
assignment.notes = new_notes
|
||
assignment.status = "completed" if new_assigned_until is not None else "active"
|
||
|
||
if old_start != new_start or old_end != new_end:
|
||
_record_assignment_history(
|
||
db,
|
||
unit_id=assignment.unit_id,
|
||
change_type="assignment_updated",
|
||
old_value=f"{old_start} → {old_end}",
|
||
new_value=f"{new_start} → {new_end}",
|
||
notes=new_notes,
|
||
)
|
||
|
||
db.commit()
|
||
db.refresh(assignment)
|
||
|
||
return {
|
||
"success": True,
|
||
"assignment": {
|
||
"id": assignment.id,
|
||
"unit_id": assignment.unit_id,
|
||
"location_id": assignment.location_id,
|
||
"assigned_at": assignment.assigned_at.isoformat() if assignment.assigned_at else None,
|
||
"assigned_until": assignment.assigned_until.isoformat() if assignment.assigned_until else None,
|
||
"status": assignment.status,
|
||
"notes": assignment.notes,
|
||
},
|
||
}
|
||
|
||
|
||
@router.delete("/assignments/{assignment_id}")
|
||
async def delete_assignment(
|
||
project_id: str,
|
||
assignment_id: str,
|
||
db: Session = Depends(get_db),
|
||
):
|
||
"""
|
||
Hard-delete an assignment record.
|
||
|
||
Use case: operator clicked Assign by mistake (or 8 times in a row) and
|
||
wants the bogus records gone — not just closed with an `assigned_until`
|
||
timestamp. The standard close-via-unassign path is for legitimate
|
||
deployments that ended; this is for mis-clicks that never actually
|
||
happened.
|
||
|
||
Safety:
|
||
- Refuses if any MonitoringSession exists for the same (unit, location)
|
||
within this assignment's window — that suggests the deployment was
|
||
real, and the operator should use unassign instead.
|
||
- Refuses if the assignment is the ONLY active assignment for a unit
|
||
currently shown as deployed AND a recording session is in progress.
|
||
|
||
Audit:
|
||
- Records UnitHistory `assignment_deleted` so the unit's deployment
|
||
timeline shows the deletion happened (even though the row itself
|
||
is gone).
|
||
"""
|
||
assignment = db.query(UnitAssignment).filter_by(
|
||
id=assignment_id,
|
||
project_id=project_id,
|
||
).first()
|
||
|
||
if not assignment:
|
||
raise HTTPException(status_code=404, detail="Assignment not found")
|
||
|
||
# Safety: is there a real recording history for this (unit, location)
|
||
# within the assignment's time window? If so, this isn't a mis-click —
|
||
# the operator should close it via unassign, not delete it.
|
||
window_start = assignment.assigned_at
|
||
window_end = assignment.assigned_until or datetime.utcnow()
|
||
real_sessions = db.query(MonitoringSession).filter(
|
||
and_(
|
||
MonitoringSession.location_id == assignment.location_id,
|
||
MonitoringSession.unit_id == assignment.unit_id,
|
||
MonitoringSession.started_at >= window_start,
|
||
MonitoringSession.started_at <= window_end,
|
||
)
|
||
).count()
|
||
|
||
if real_sessions > 0:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=(
|
||
f"Cannot delete this assignment — {real_sessions} monitoring "
|
||
f"session(s) were recorded under it. Use Unassign to close "
|
||
f"the window instead, which preserves the audit trail."
|
||
),
|
||
)
|
||
|
||
# Resolve location name for audit log before deletion.
|
||
location = db.query(MonitoringLocation).filter_by(
|
||
id=assignment.location_id
|
||
).first()
|
||
location_label = location.name if location else assignment.location_id
|
||
|
||
_record_assignment_history(
|
||
db,
|
||
unit_id=assignment.unit_id,
|
||
change_type="assignment_deleted",
|
||
old_value=f"{location_label} ({assignment.assigned_at:%Y-%m-%d} → "
|
||
f"{assignment.assigned_until and assignment.assigned_until.strftime('%Y-%m-%d') or 'active'})",
|
||
new_value="deleted",
|
||
notes=(
|
||
"Assignment row removed — created in error or accidental duplicate."
|
||
),
|
||
)
|
||
|
||
db.delete(assignment)
|
||
db.commit()
|
||
|
||
return {
|
||
"success": True,
|
||
"message": "Assignment deleted.",
|
||
}
|
||
|
||
|
||
@router.post("/assignments/merge")
|
||
async def merge_assignments(
|
||
project_id: str,
|
||
request: Request,
|
||
db: Session = Depends(get_db),
|
||
):
|
||
"""
|
||
Merge multiple consecutive UnitAssignment rows for the same (unit, location)
|
||
into a single record spanning their combined window.
|
||
|
||
Use case: a unit's deployment timeline shows 3 stacked rows for the
|
||
same location because the assignment was closed-and-reopened (e.g. via
|
||
location remove + restore) or because metadata-backfill auto-created
|
||
a retroactive window adjacent to a manual one. Operator sees three
|
||
rows but they represent one continuous deployment.
|
||
|
||
Body JSON:
|
||
{ "assignment_ids": ["<uuid>", "<uuid>", ...] }
|
||
|
||
Validation:
|
||
- All assignments must belong to project_id
|
||
- All must share the same unit_id AND location_id
|
||
- At least 2 ids must be provided
|
||
|
||
Merge rules:
|
||
- Keeps the EARLIEST-starting assignment as the surviving row
|
||
- assigned_at = min(assigned_at across all)
|
||
- assigned_until = max(assigned_until), or NULL if any input was active
|
||
- status = "active" if any input was active, else "completed"
|
||
- source = source of the earliest record (preserves original ingest provenance)
|
||
- notes = earliest's notes + "Merged N records (<sources>)"
|
||
- Other records are DELETED
|
||
- One UnitHistory `assignment_merged` row is written for audit
|
||
|
||
No tolerance check — the operator is asking for the merge, so we trust
|
||
the intent. The UI can pre-filter to only offer "consecutive" merges
|
||
if it wants.
|
||
"""
|
||
try:
|
||
payload = await request.json()
|
||
except Exception:
|
||
raise HTTPException(status_code=400, detail="Invalid JSON body")
|
||
|
||
ids = payload.get("assignment_ids") or []
|
||
if not isinstance(ids, list) or len(ids) < 2:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="Need at least 2 assignment_ids to merge.",
|
||
)
|
||
|
||
assignments = (
|
||
db.query(UnitAssignment)
|
||
.filter(UnitAssignment.project_id == project_id)
|
||
.filter(UnitAssignment.id.in_(ids))
|
||
.all()
|
||
)
|
||
|
||
if len(assignments) != len(set(ids)):
|
||
raise HTTPException(
|
||
status_code=404,
|
||
detail=f"Some assignments not found (got {len(assignments)} of {len(set(ids))}).",
|
||
)
|
||
|
||
unit_ids = {a.unit_id for a in assignments}
|
||
loc_ids = {a.location_id for a in assignments}
|
||
if len(unit_ids) > 1 or len(loc_ids) > 1:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="Can only merge assignments that share the same unit and location.",
|
||
)
|
||
|
||
# Order chronologically.
|
||
assignments.sort(key=lambda a: a.assigned_at)
|
||
earliest = assignments[0]
|
||
others = assignments[1:]
|
||
|
||
# Compute merged window.
|
||
any_active = any(a.assigned_until is None for a in assignments)
|
||
if any_active:
|
||
merged_until = None
|
||
else:
|
||
merged_until = max(a.assigned_until for a in assignments)
|
||
|
||
# Build a brief audit-style note describing what got merged.
|
||
bits = []
|
||
for a in assignments:
|
||
win = (
|
||
f"{a.assigned_at:%Y-%m-%d}"
|
||
f"→{(a.assigned_until and a.assigned_until.strftime('%Y-%m-%d')) or 'active'}"
|
||
)
|
||
bits.append(f"{win} [{a.source}]")
|
||
merge_note_suffix = f"Merged {len(assignments)} records: " + " + ".join(bits)
|
||
new_notes = (earliest.notes + " • " + merge_note_suffix) if earliest.notes else merge_note_suffix
|
||
|
||
# Resolve names for the audit log before mutating.
|
||
location = db.query(MonitoringLocation).filter_by(id=earliest.location_id).first()
|
||
location_label = location.name if location else earliest.location_id
|
||
|
||
# Mutate the survivor.
|
||
earliest.assigned_at = min(a.assigned_at for a in assignments)
|
||
earliest.assigned_until = merged_until
|
||
earliest.status = "active" if any_active else "completed"
|
||
earliest.notes = new_notes
|
||
|
||
# Delete the rest.
|
||
deleted_ids = [a.id for a in others]
|
||
for a in others:
|
||
db.delete(a)
|
||
|
||
_record_assignment_history(
|
||
db,
|
||
unit_id=earliest.unit_id,
|
||
change_type="assignment_merged",
|
||
old_value=f"{len(assignments)} rows at {location_label}",
|
||
new_value=(
|
||
f"1 row {earliest.assigned_at:%Y-%m-%d}"
|
||
f"→{(merged_until and merged_until.strftime('%Y-%m-%d')) or 'active'}"
|
||
),
|
||
notes=merge_note_suffix,
|
||
)
|
||
|
||
db.commit()
|
||
db.refresh(earliest)
|
||
|
||
return {
|
||
"success": True,
|
||
"message": f"Merged {len(assignments)} assignments into one.",
|
||
"kept_id": earliest.id,
|
||
"deleted_ids": deleted_ids,
|
||
"merged_window": {
|
||
"assigned_at": earliest.assigned_at.isoformat(),
|
||
"assigned_until": earliest.assigned_until.isoformat() if earliest.assigned_until else None,
|
||
},
|
||
}
|
||
|
||
|
||
@router.post("/locations/{location_id}/swap")
|
||
async def swap_unit_on_location(
|
||
project_id: str,
|
||
location_id: str,
|
||
request: Request,
|
||
db: Session = Depends(get_db),
|
||
):
|
||
"""
|
||
Swap the unit assigned to a vibration monitoring location.
|
||
Ends the current active assignment (if any), creates a new one,
|
||
and optionally updates modem pairing on the seismograph.
|
||
Works for first-time assignments too (no current assignment = just create).
|
||
"""
|
||
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")
|
||
|
||
form_data = await request.form()
|
||
unit_id = form_data.get("unit_id")
|
||
modem_id = form_data.get("modem_id") or None
|
||
notes = form_data.get("notes") or None
|
||
|
||
if not unit_id:
|
||
raise HTTPException(status_code=400, detail="unit_id is required")
|
||
|
||
# Validate new unit
|
||
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
|
||
if not unit:
|
||
raise HTTPException(status_code=404, detail="Unit not found")
|
||
|
||
expected_device_type = "slm" if location.location_type == "sound" else "seismograph"
|
||
if unit.device_type != expected_device_type:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"Unit type '{unit.device_type}' does not match location type '{location.location_type}'",
|
||
)
|
||
|
||
# End current active assignment if one exists (active = assigned_until IS NULL)
|
||
current = db.query(UnitAssignment).filter(
|
||
and_(
|
||
UnitAssignment.location_id == location_id,
|
||
UnitAssignment.assigned_until == None,
|
||
)
|
||
).first()
|
||
if current:
|
||
current.assigned_until = datetime.utcnow()
|
||
current.status = "completed"
|
||
# If the swap is replacing a different unit, that unit's deployment ended.
|
||
if current.unit_id != unit_id:
|
||
_record_assignment_history(
|
||
db,
|
||
unit_id=current.unit_id,
|
||
change_type="assignment_swapped",
|
||
old_value=location.name,
|
||
new_value=f"swapped out → {unit_id}",
|
||
notes=notes,
|
||
)
|
||
|
||
# Create new assignment
|
||
new_assignment = UnitAssignment(
|
||
id=str(uuid.uuid4()),
|
||
unit_id=unit_id,
|
||
location_id=location_id,
|
||
project_id=project_id,
|
||
device_type=unit.device_type,
|
||
assigned_until=None,
|
||
status="active",
|
||
notes=notes,
|
||
)
|
||
db.add(new_assignment)
|
||
_record_assignment_history(
|
||
db,
|
||
unit_id=unit_id,
|
||
change_type="assignment_swapped" if (current and current.unit_id != unit_id) else "assignment_created",
|
||
new_value=f"{location.name} (project: {location.project_id})",
|
||
notes=notes,
|
||
)
|
||
|
||
# Update modem pairing on the seismograph if modem provided
|
||
if modem_id:
|
||
modem = db.query(RosterUnit).filter_by(id=modem_id, device_type="modem").first()
|
||
if not modem:
|
||
raise HTTPException(status_code=404, detail=f"Modem '{modem_id}' not found")
|
||
unit.deployed_with_modem_id = modem_id
|
||
modem.deployed_with_unit_id = unit_id
|
||
else:
|
||
# Clear modem pairing if not provided
|
||
unit.deployed_with_modem_id = None
|
||
|
||
db.commit()
|
||
|
||
return JSONResponse({
|
||
"success": True,
|
||
"assignment_id": new_assignment.id,
|
||
"message": f"Unit '{unit_id}' assigned to '{location.name}'" + (f" with modem '{modem_id}'" if modem_id else ""),
|
||
})
|
||
|
||
|
||
# ============================================================================
|
||
# Available Units for Assignment
|
||
# ============================================================================
|
||
|
||
@router.get("/available-modems", response_class=JSONResponse)
|
||
async def get_available_modems(
|
||
project_id: str,
|
||
db: Session = Depends(get_db),
|
||
):
|
||
"""
|
||
Get all deployed, non-retired modems for the modem assignment dropdown.
|
||
"""
|
||
modems = db.query(RosterUnit).filter(
|
||
and_(
|
||
RosterUnit.device_type == "modem",
|
||
RosterUnit.deployed == True,
|
||
RosterUnit.retired == False,
|
||
)
|
||
).order_by(RosterUnit.id).all()
|
||
|
||
return [
|
||
{
|
||
"id": m.id,
|
||
"hardware_model": m.hardware_model,
|
||
"ip_address": m.ip_address,
|
||
}
|
||
for m in modems
|
||
]
|
||
|
||
|
||
@router.get("/available-units", response_class=JSONResponse)
|
||
async def get_available_units(
|
||
project_id: str,
|
||
location_type: str = Query(...),
|
||
db: Session = Depends(get_db),
|
||
):
|
||
"""
|
||
Get list of available units for assignment to a location.
|
||
Filters by device type matching the location type.
|
||
"""
|
||
# Determine required device type
|
||
required_device_type = "slm" if location_type == "sound" else "seismograph"
|
||
|
||
# Get all units of the required type that are deployed and not retired
|
||
all_units = db.query(RosterUnit).filter(
|
||
and_(
|
||
RosterUnit.device_type == required_device_type,
|
||
RosterUnit.deployed == True,
|
||
RosterUnit.retired == False,
|
||
)
|
||
).all()
|
||
|
||
# Filter out units that already have active assignments (active = assigned_until IS NULL)
|
||
assigned_unit_ids = db.query(UnitAssignment.unit_id).filter(
|
||
UnitAssignment.assigned_until == None
|
||
).distinct().all()
|
||
assigned_unit_ids = [uid[0] for uid in assigned_unit_ids]
|
||
|
||
available_units = [
|
||
{
|
||
"id": unit.id,
|
||
"device_type": unit.device_type,
|
||
"location": unit.address or unit.location,
|
||
"model": unit.slm_model if unit.device_type == "slm" else unit.unit_type,
|
||
}
|
||
for unit in all_units
|
||
if unit.id not in assigned_unit_ids
|
||
]
|
||
|
||
return available_units
|
||
|
||
|
||
# ============================================================================
|
||
# NRL-specific endpoints for detail page
|
||
# ============================================================================
|
||
|
||
@router.get("/nrl/{location_id}/sessions", response_class=HTMLResponse)
|
||
async def get_nrl_sessions(
|
||
project_id: str,
|
||
location_id: str,
|
||
request: Request,
|
||
db: Session = Depends(get_db),
|
||
):
|
||
"""
|
||
Get monitoring sessions for a specific NRL.
|
||
Returns HTML partial with session list.
|
||
"""
|
||
sessions = db.query(MonitoringSession).filter_by(
|
||
location_id=location_id
|
||
).order_by(MonitoringSession.started_at.desc()).all()
|
||
|
||
# Enrich with unit details
|
||
sessions_data = []
|
||
for session in sessions:
|
||
unit = None
|
||
if session.unit_id:
|
||
unit = db.query(RosterUnit).filter_by(id=session.unit_id).first()
|
||
|
||
sessions_data.append({
|
||
"session": session,
|
||
"unit": unit,
|
||
})
|
||
|
||
return templates.TemplateResponse("partials/projects/session_list.html", {
|
||
"request": request,
|
||
"project_id": project_id,
|
||
"location_id": location_id,
|
||
"sessions": sessions_data,
|
||
})
|
||
|
||
|
||
@router.get("/vibration_summary", response_class=HTMLResponse)
|
||
async def get_project_vibration_summary(
|
||
project_id: str,
|
||
request: Request,
|
||
from_dt: Optional[datetime] = Query(None),
|
||
to_dt: Optional[datetime] = Query(None),
|
||
db: Session = Depends(get_db),
|
||
):
|
||
"""
|
||
Render a small HTML partial summarising vibration-event activity
|
||
across every vibration MonitoringLocation in the project.
|
||
|
||
Returned to the Vibration tab of the project detail page via HTMX.
|
||
Fans out concurrently across all locations (which in turn fan out
|
||
across each location's UnitAssignment windows). Total queries to
|
||
SFM = sum of assignments across the project.
|
||
|
||
404 if the project doesn't exist. Empty-state partial if the
|
||
project has no vibration locations.
|
||
"""
|
||
project = db.query(Project).filter_by(id=project_id).first()
|
||
if not project:
|
||
raise HTTPException(status_code=404, detail="Project not found.")
|
||
|
||
from backend.services.sfm_events import vibration_summary_for_project
|
||
|
||
summary = await vibration_summary_for_project(
|
||
db, project_id, from_dt=from_dt, to_dt=to_dt
|
||
)
|
||
|
||
return templates.TemplateResponse(
|
||
"partials/projects/vibration_summary.html",
|
||
{
|
||
"request": request,
|
||
"project_id": project_id,
|
||
"summary": summary,
|
||
},
|
||
)
|
||
|
||
|
||
@router.get("/locations/{location_id}/events", response_class=JSONResponse)
|
||
async def get_location_events(
|
||
project_id: str,
|
||
location_id: str,
|
||
from_dt: Optional[datetime] = Query(None),
|
||
to_dt: Optional[datetime] = Query(None),
|
||
false_trigger: Optional[bool] = Query(None),
|
||
limit: int = Query(500, ge=1, le=5000),
|
||
db: Session = Depends(get_db),
|
||
):
|
||
"""
|
||
Return SFM events recorded at this monitoring location.
|
||
|
||
Fans out the location's UnitAssignment rows (every seismograph ever
|
||
assigned to this location, active + closed), queries SFM /db/events
|
||
for each (serial, time-window) pair concurrently, and unions the
|
||
results.
|
||
|
||
Sound (SLM) locations return an empty payload — SFM events are
|
||
seismograph-only.
|
||
"""
|
||
location = db.query(MonitoringLocation).filter_by(id=location_id).first()
|
||
if not location:
|
||
raise HTTPException(status_code=404, detail="Location not found.")
|
||
if location.project_id != project_id:
|
||
raise HTTPException(
|
||
status_code=404,
|
||
detail="Location does not belong to this project.",
|
||
)
|
||
|
||
# SLM locations don't have SFM events — return an empty payload rather
|
||
# than 404 so the frontend can render an empty state gracefully.
|
||
if location.location_type != "vibration":
|
||
return {
|
||
"events": [],
|
||
"count": 0,
|
||
"stats": {
|
||
"event_count": 0,
|
||
"peak_pvs": None,
|
||
"peak_pvs_at": None,
|
||
"peak_pvs_serial": None,
|
||
"last_event": None,
|
||
"false_trigger_count": 0,
|
||
},
|
||
"assignments_used": [],
|
||
"location_type": location.location_type,
|
||
}
|
||
|
||
from backend.services.sfm_events import events_for_location
|
||
|
||
result = await events_for_location(
|
||
db,
|
||
location_id,
|
||
from_dt=from_dt,
|
||
to_dt=to_dt,
|
||
false_trigger=false_trigger,
|
||
limit=limit,
|
||
)
|
||
result["location_type"] = location.location_type
|
||
return result
|
||
|
||
|
||
@router.get("/nrl/{location_id}/files", response_class=HTMLResponse)
|
||
async def get_nrl_files(
|
||
project_id: str,
|
||
location_id: str,
|
||
request: Request,
|
||
db: Session = Depends(get_db),
|
||
):
|
||
"""
|
||
Get data files for a specific NRL.
|
||
Returns HTML partial with file list.
|
||
"""
|
||
# Join DataFile with MonitoringSession to filter by location_id
|
||
files = db.query(DataFile).join(
|
||
MonitoringSession,
|
||
DataFile.session_id == MonitoringSession.id
|
||
).filter(
|
||
MonitoringSession.location_id == location_id
|
||
).order_by(DataFile.created_at.desc()).all()
|
||
|
||
# Enrich with session details
|
||
files_data = []
|
||
for file in files:
|
||
session = None
|
||
if file.session_id:
|
||
session = db.query(MonitoringSession).filter_by(id=file.session_id).first()
|
||
|
||
files_data.append({
|
||
"file": file,
|
||
"session": session,
|
||
})
|
||
|
||
return templates.TemplateResponse("partials/projects/file_list.html", {
|
||
"request": request,
|
||
"project_id": project_id,
|
||
"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
|
||
project = db.query(Project).filter_by(id=project_id).first()
|
||
_require_module(project, "sound_monitoring", db)
|
||
|
||
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 1b: Filter to only relevant files ---
|
||
# Keep: .rnh (metadata) and measurement .rnd files
|
||
# NL-43 generates two .rnd types: _Leq_ (15-min averages, wanted) and _Lp_ (1-sec granular, skip)
|
||
# AU2 (NL-23/older Rion) generates a single Au2_####.rnd per session — always keep those
|
||
# Drop: _Lp_ .rnd, .xlsx, .mp3, and anything else
|
||
def _is_wanted(fname: str) -> bool:
|
||
n = fname.lower()
|
||
if n.endswith(".rnh"):
|
||
return True
|
||
if n.endswith(".rnd"):
|
||
if "_leq_" in n: # NL-43 Leq file
|
||
return True
|
||
if n.startswith("au2_"): # AU2 format (NL-23) — always Leq equivalent
|
||
return True
|
||
if "_lp" not in n and "_leq_" not in n:
|
||
# Unknown .rnd format — include it so we don't silently drop data
|
||
return True
|
||
return False
|
||
|
||
file_entries = [(fname, fbytes) for fname, fbytes in file_entries if _is_wanted(fname)]
|
||
|
||
if not file_entries:
|
||
raise HTTPException(status_code=400, detail="No usable .rnd or .rnh files found. Expected NL-43 _Leq_ files or AU2 format .rnd files.")
|
||
|
||
# --- 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
|
||
|
||
# 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())
|
||
|
||
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 ---
|
||
# 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(
|
||
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_label=session_label,
|
||
period_type=period_type,
|
||
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,
|
||
}
|
||
|
||
|
||
# ============================================================================
|
||
# NRL Live Status (connected NRLs only)
|
||
# ============================================================================
|
||
|
||
@router.get("/nrl/{location_id}/live-status", response_class=HTMLResponse)
|
||
async def get_nrl_live_status(
|
||
project_id: str,
|
||
location_id: str,
|
||
request: Request,
|
||
db: Session = Depends(get_db),
|
||
):
|
||
"""
|
||
Fetch cached status from SLMM for the unit assigned to this NRL and
|
||
return a compact HTML status card. Used in the NRL overview tab for
|
||
connected NRLs. Gracefully shows an offline message if SLMM is unreachable.
|
||
Sound Monitoring projects only.
|
||
"""
|
||
import os
|
||
import httpx
|
||
|
||
_require_module(db.query(Project).filter_by(id=project_id).first(), "sound_monitoring", db)
|
||
|
||
# Find the assigned unit (active = assigned_until IS NULL)
|
||
assignment = db.query(UnitAssignment).filter(
|
||
and_(
|
||
UnitAssignment.location_id == location_id,
|
||
UnitAssignment.assigned_until == None,
|
||
)
|
||
).first()
|
||
|
||
if not assignment:
|
||
return templates.TemplateResponse("partials/projects/nrl_live_status.html", {
|
||
"request": request,
|
||
"status": None,
|
||
"error": "No unit assigned",
|
||
})
|
||
|
||
unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first()
|
||
if not unit:
|
||
return templates.TemplateResponse("partials/projects/nrl_live_status.html", {
|
||
"request": request,
|
||
"status": None,
|
||
"error": "Assigned unit not found",
|
||
})
|
||
|
||
slmm_base = os.getenv("SLMM_BASE_URL", "http://localhost:8100")
|
||
status_data = None
|
||
error_msg = None
|
||
|
||
try:
|
||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||
resp = await client.get(f"{slmm_base}/api/nl43/{unit.id}/status")
|
||
if resp.status_code == 200:
|
||
status_data = resp.json()
|
||
else:
|
||
error_msg = f"SLMM returned {resp.status_code}"
|
||
except Exception as e:
|
||
error_msg = "SLMM unreachable"
|
||
|
||
return templates.TemplateResponse("partials/projects/nrl_live_status.html", {
|
||
"request": request,
|
||
"unit": unit,
|
||
"status": status_data,
|
||
"error": error_msg,
|
||
})
|