- Moved Jinja2 template setup to a shared configuration file (templates_config.py) for consistent usage across routers. - Introduced timezone utilities in a new module (timezone.py) to handle UTC to local time conversions and formatting. - Updated all relevant routers to use the new shared template configuration and timezone filters. - Enhanced templates to utilize local time formatting for various datetime fields, improving user experience with timezone awareness.
522 lines
15 KiB
Python
522 lines
15 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 backend.database import get_db
|
|
from backend.models import (
|
|
Project,
|
|
ProjectType,
|
|
MonitoringLocation,
|
|
UnitAssignment,
|
|
RosterUnit,
|
|
RecordingSession,
|
|
)
|
|
from backend.templates_config import templates
|
|
|
|
router = APIRouter(prefix="/api/projects/{project_id}", tags=["project-locations"])
|
|
|
|
|
|
# ============================================================================
|
|
# 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.get("/locations-json")
|
|
async def get_project_locations_json(
|
|
project_id: str,
|
|
db: Session = Depends(get_db),
|
|
location_type: Optional[str] = Query(None),
|
|
):
|
|
"""
|
|
Get all monitoring locations for a project as JSON.
|
|
Used by the schedule modal to populate location dropdown.
|
|
"""
|
|
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)
|
|
|
|
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,
|
|
}
|
|
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_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 = "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
|
|
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 = "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
|
|
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 == "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 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,
|
|
})
|