""" 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.templating import Jinja2Templates 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 backend.database import get_db from backend.models import ( Project, ProjectType, MonitoringLocation, UnitAssignment, RosterUnit, RecordingSession, ) router = APIRouter(prefix="/api/projects/{project_id}", tags=["project-locations"]) templates = Jinja2Templates(directory="templates") # ============================================================================ # 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. """ 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 locations_data = [] for location in locations: # Get active assignment assignment = db.query(UnitAssignment).filter( and_( UnitAssignment.location_id == location.id, UnitAssignment.status == "active", ) ).first() assigned_unit = None if assignment: assigned_unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first() # Count recording sessions session_count = db.query(RecordingSession).filter_by( location_id=location.id ).count() locations_data.append({ "location": location, "assignment": assignment, "assigned_unit": assigned_unit, "session_count": session_count, }) return templates.TemplateResponse("partials/projects/location_list.html", { "request": request, "project": project, "locations": locations_data, }) @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_assignments = db.query(UnitAssignment).filter( and_( UnitAssignment.location_id == location_id, UnitAssignment.status == "active", ) ).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"} # ============================================================================ # 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 = "sound_level_meter" 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 existing_assignment = db.query(UnitAssignment).filter( and_( UnitAssignment.location_id == location_id, UnitAssignment.status == "active", ) ).first() if existing_assignment: raise HTTPException( status_code=400, detail=f"Location already has an active unit assignment ({existing_assignment.unit_id}). Unassign first.", ) # 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) 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 recording sessions active_sessions = db.query(RecordingSession).filter( and_( RecordingSession.location_id == assignment.location_id, RecordingSession.unit_id == assignment.unit_id, RecordingSession.status == "recording", ) ).count() if active_sessions > 0: raise HTTPException( status_code=400, detail="Cannot unassign unit with active recording sessions. Stop recording first.", ) assignment.status = "completed" assignment.assigned_until = datetime.utcnow() db.commit() return {"success": True, "message": "Unit unassigned successfully"} # ============================================================================ # Available Units for Assignment # ============================================================================ @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 = "sound_level_meter" 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 assigned_unit_ids = db.query(UnitAssignment.unit_id).filter( UnitAssignment.status == "active" ).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 == "sound_level_meter" 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 recording sessions for a specific NRL. Returns HTML partial with session list. """ from backend.models import RecordingSession, RosterUnit sessions = db.query(RecordingSession).filter_by( location_id=location_id ).order_by(RecordingSession.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("/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. """ from backend.models import DataFile, RecordingSession # Join DataFile with RecordingSession to filter by location_id files = db.query(DataFile).join( RecordingSession, DataFile.session_id == RecordingSession.id ).filter( RecordingSession.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(RecordingSession).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, })