803 lines
24 KiB
Python
803 lines
24 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,
|
|
MonitoringLocation,
|
|
UnitAssignment,
|
|
RosterUnit,
|
|
MonitoringSession,
|
|
DataFile,
|
|
)
|
|
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 monitoring sessions
|
|
session_count = db.query(MonitoringSession).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 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()
|
|
|
|
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 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("/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
|
|
location = db.query(MonitoringLocation).filter_by(
|
|
id=location_id, project_id=project_id
|
|
).first()
|
|
if not location:
|
|
raise HTTPException(status_code=404, detail="Location not found")
|
|
|
|
# --- Step 1: Normalize to (filename, bytes) list ---
|
|
file_entries: list[tuple[str, bytes]] = []
|
|
|
|
if len(files) == 1 and files[0].filename.lower().endswith(".zip"):
|
|
raw = await files[0].read()
|
|
try:
|
|
with zipfile.ZipFile(io.BytesIO(raw)) as zf:
|
|
for info in zf.infolist():
|
|
if info.is_dir():
|
|
continue
|
|
name = Path(info.filename).name # strip folder path
|
|
if not name:
|
|
continue
|
|
file_entries.append((name, zf.read(info)))
|
|
except zipfile.BadZipFile:
|
|
raise HTTPException(status_code=400, detail="Uploaded file is not a valid ZIP archive.")
|
|
else:
|
|
for uf in files:
|
|
data = await uf.read()
|
|
file_entries.append((uf.filename, data))
|
|
|
|
if not file_entries:
|
|
raise HTTPException(status_code=400, detail="No usable files found in upload.")
|
|
|
|
# --- Step 2: Find and parse .rnh metadata ---
|
|
rnh_meta = {}
|
|
for fname, fbytes in file_entries:
|
|
if fname.lower().endswith(".rnh"):
|
|
rnh_meta = _parse_rnh(fbytes)
|
|
break
|
|
|
|
started_at = _parse_rnh_datetime(rnh_meta.get("start_time_str")) or datetime.utcnow()
|
|
stopped_at = _parse_rnh_datetime(rnh_meta.get("stop_time_str"))
|
|
duration_seconds = None
|
|
if started_at and stopped_at:
|
|
duration_seconds = int((stopped_at - started_at).total_seconds())
|
|
|
|
store_name = rnh_meta.get("store_name", "")
|
|
serial_number = rnh_meta.get("serial_number", "")
|
|
index_number = rnh_meta.get("index_number", "")
|
|
|
|
# --- Step 3: Create MonitoringSession ---
|
|
session_id = str(uuid.uuid4())
|
|
monitoring_session = MonitoringSession(
|
|
id=session_id,
|
|
project_id=project_id,
|
|
location_id=location_id,
|
|
unit_id=None,
|
|
session_type="sound",
|
|
started_at=started_at,
|
|
stopped_at=stopped_at,
|
|
duration_seconds=duration_seconds,
|
|
status="completed",
|
|
session_metadata=json.dumps({
|
|
"source": "manual_upload",
|
|
"store_name": store_name,
|
|
"serial_number": serial_number,
|
|
"index_number": index_number,
|
|
}),
|
|
)
|
|
db.add(monitoring_session)
|
|
db.commit()
|
|
db.refresh(monitoring_session)
|
|
|
|
# --- Step 4: Write files to disk and create DataFile records ---
|
|
output_dir = Path("data/Projects") / project_id / session_id
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
leq_count = 0
|
|
lp_count = 0
|
|
metadata_count = 0
|
|
files_imported = 0
|
|
|
|
for fname, fbytes in file_entries:
|
|
file_type = _classify_file(fname)
|
|
fname_lower = fname.lower()
|
|
|
|
# Track counts for summary
|
|
if fname_lower.endswith(".rnd"):
|
|
if "_leq_" in fname_lower:
|
|
leq_count += 1
|
|
elif "_lp" in fname_lower:
|
|
lp_count += 1
|
|
elif fname_lower.endswith(".rnh"):
|
|
metadata_count += 1
|
|
|
|
# Write to disk
|
|
dest = output_dir / fname
|
|
dest.write_bytes(fbytes)
|
|
|
|
# Compute checksum
|
|
checksum = hashlib.sha256(fbytes).hexdigest()
|
|
|
|
# Store relative path from data/ dir
|
|
rel_path = str(dest.relative_to("data"))
|
|
|
|
data_file = DataFile(
|
|
id=str(uuid.uuid4()),
|
|
session_id=session_id,
|
|
file_path=rel_path,
|
|
file_type=file_type,
|
|
file_size_bytes=len(fbytes),
|
|
downloaded_at=datetime.utcnow(),
|
|
checksum=checksum,
|
|
file_metadata=json.dumps({
|
|
"source": "manual_upload",
|
|
"original_filename": fname,
|
|
"store_name": store_name,
|
|
}),
|
|
)
|
|
db.add(data_file)
|
|
files_imported += 1
|
|
|
|
db.commit()
|
|
|
|
return {
|
|
"success": True,
|
|
"session_id": session_id,
|
|
"files_imported": files_imported,
|
|
"leq_files": leq_count,
|
|
"lp_files": lp_count,
|
|
"metadata_files": metadata_count,
|
|
"store_name": store_name,
|
|
"started_at": started_at.isoformat() if started_at else None,
|
|
"stopped_at": stopped_at.isoformat() if stopped_at else None,
|
|
}
|
|
|
|
|
|
# ============================================================================
|
|
# 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.
|
|
"""
|
|
import os
|
|
import httpx
|
|
|
|
# Find the assigned unit
|
|
assignment = db.query(UnitAssignment).filter(
|
|
and_(
|
|
UnitAssignment.location_id == location_id,
|
|
UnitAssignment.status == "active",
|
|
)
|
|
).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,
|
|
})
|