from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.orm import Session from database import get_db from models.aid import Aid from models.lamp import Lamp from models.vessel import RecordingEvent from models.user import User from routers.auth import get_current_user, require_admin from routers.lamps import compute_thresholds from pydantic import BaseModel from typing import Optional from datetime import datetime import uuid router = APIRouter(prefix="/aids", tags=["aids"]) def _aid_dict(aid: Aid, lamp: Lamp | None) -> dict: """Aid → JSON, with embedded lamp + computed battery thresholds.""" base = {c.name: getattr(aid, c.name) for c in aid.__table__.columns} base["lamp"] = None base["battery_warn_v"] = None base["battery_alarm_v"] = None if lamp: th = compute_thresholds(lamp.voltage_min, lamp.voltage_max) base["lamp"] = { "id": lamp.id, "manufacturer": lamp.manufacturer, "model": lamp.model, "lamp_count": lamp.lamp_count, "voltage_min": lamp.voltage_min, "voltage_max": lamp.voltage_max, **th, } base["battery_warn_v"] = th["warn_v"] base["battery_alarm_v"] = th["alarm_v"] return base def _lamp_for(aid: Aid, db: Session) -> Lamp | None: if not aid.lamp_id: return None return db.query(Lamp).filter(Lamp.id == aid.lamp_id).first() class AidCreate(BaseModel): nombre: str numero_interno: Optional[str] = None categoria: str tipo: str tipo_ais: str = "SIN_AIS" lat_nominal: float lon_nominal: float fuente_posicion: str = "MANUAL" radio_borneo_m: float = 10.0 mmsi: Optional[str] = None caracteristica_luz: Optional[str] = None alcance_nm: Optional[float] = None class AidUpdate(BaseModel): lat_nominal: Optional[float] = None lon_nominal: Optional[float] = None puerto_responsable: Optional[str] = None empresa_responsable: Optional[str] = None caracteristica_luz: Optional[str] = None alcance_nm: Optional[float] = None radio_borneo_m: Optional[float] = None observaciones: Optional[str] = None lamp_id: Optional[str] = None # AIS link — operator-editable. Once set, AIS Type 21 with this MMSI # will update this aid's lat_actual and trigger drift/battery alerts. mmsi: Optional[str] = None tipo_ais: Optional[str] = None # Per-aid alarm thresholds (override global config when set) displacement_warn_m: Optional[float] = None displacement_alarm_m: Optional[float] = None signal_loss_min: Optional[int] = None din3_function: Optional[str] = None din4_function: Optional[str] = None # Chart link (set when aid was promoted from S-57 feature; usually # untouched after creation) source_chart: Optional[str] = None cell_id: Optional[str] = None chart_feature_id: Optional[str] = None motivo_cambio: str modificado_por: str class AidFromChartFeature(BaseModel): """Payload to create an Aid record anchored to a specific S-57 chart feature.""" cell_id: str chart_feature_id: str lat_nominal: float lon_nominal: float nombre: str tipo: str = "BOYA_ESPECIAL" # caller can refine from S-57 type categoria: str = "FLOTANTE" radio_borneo_m: float = 10.0 class LampAssign(BaseModel): lamp_id: Optional[str] = None # None → unassign @router.get("/") def list_aids(db: Session = Depends(get_db)): aids = db.query(Aid).filter(Aid.activa == True).all() return [_aid_dict(a, _lamp_for(a, db)) for a in aids] # Static paths declared BEFORE /{aid_id} so FastAPI doesn't capture # "by-chart-feature" as an aid_id. @router.get("/by-chart-feature") def aid_by_chart_feature( cell_id: str = Query(...), feature_id: str = Query(...), db: Session = Depends(get_db), ): """Return the Aid record linked to a specific S-57 chart feature, or 404.""" aid = db.query(Aid).filter( Aid.cell_id == cell_id, Aid.chart_feature_id == feature_id, Aid.activa == True, ).first() if not aid: raise HTTPException(404, "No aid linked to this chart feature") return _aid_dict(aid, _lamp_for(aid, db)) @router.get("/{aid_id}") def get_aid(aid_id: str, db: Session = Depends(get_db)): aid = db.query(Aid).filter(Aid.id == aid_id).first() if not aid: raise HTTPException(status_code=404, detail="Ayuda no encontrada") return _aid_dict(aid, _lamp_for(aid, db)) @router.patch("/{aid_id}/lamp") def assign_lamp(aid_id: str, data: LampAssign, db: Session = Depends(get_db)): aid = db.query(Aid).filter(Aid.id == aid_id).first() if not aid: raise HTTPException(404, "Ayuda no encontrada") if data.lamp_id: lamp = db.query(Lamp).filter(Lamp.id == data.lamp_id).first() if not lamp: raise HTTPException(404, "Lamp not found") aid.lamp_id = data.lamp_id db.commit(); db.refresh(aid) return _aid_dict(aid, _lamp_for(aid, db)) @router.post("/") def create_aid(data: AidCreate, db: Session = Depends(get_db)): aid = Aid(id=str(uuid.uuid4()), **data.model_dump()) db.add(aid) db.commit() db.refresh(aid) return aid @router.put("/{aid_id}") def update_aid(aid_id: str, data: AidUpdate, db: Session = Depends(get_db), current_user: User = Depends(require_admin)): aid = db.query(Aid).filter(Aid.id == aid_id).first() if not aid: raise HTTPException(status_code=404, detail="Ayuda no encontrada") # Empty-string MMSI → unassign. Reject if MMSI is already used by another aid. if data.mmsi is not None: mmsi_clean = data.mmsi.strip() or None if mmsi_clean: taken = db.query(Aid).filter( Aid.mmsi == mmsi_clean, Aid.id != aid_id ).first() if taken: raise HTTPException(409, f"MMSI {mmsi_clean} ya está asignado a '{taken.nombre}'") aid.mmsi = mmsi_clean for field in ["lat_nominal", "lon_nominal", "puerto_responsable", "empresa_responsable", "caracteristica_luz", "alcance_nm", "radio_borneo_m", "observaciones", "lamp_id", "tipo_ais", "displacement_warn_m", "displacement_alarm_m", "signal_loss_min", "din3_function", "din4_function", "source_chart", "cell_id", "chart_feature_id"]: val = getattr(data, field) if val is not None: setattr(aid, field, val) aid.motivo_cambio = data.motivo_cambio aid.modificado_por = data.modificado_por aid.modificado_en = datetime.utcnow() db.commit() db.refresh(aid) return _aid_dict(aid, _lamp_for(aid, db)) @router.post("/from-chart-feature") def create_aid_from_chart_feature( data: AidFromChartFeature, db: Session = Depends(get_db), current_user: User = Depends(require_admin), ): """Promote a S-57 chart feature into a monitored Aid record. Idempotent: if an aid already exists for (cell_id, chart_feature_id), returns it instead of creating a duplicate.""" existing = db.query(Aid).filter( Aid.cell_id == data.cell_id, Aid.chart_feature_id == data.chart_feature_id, Aid.activa == True, ).first() if existing: return _aid_dict(existing, _lamp_for(existing, db)) aid = Aid( id=str(uuid.uuid4()), nombre=data.nombre, categoria=data.categoria, tipo=data.tipo, tipo_ais="SIN_AIS", # operator can change later lat_nominal=data.lat_nominal, lon_nominal=data.lon_nominal, fuente_posicion="S57", radio_borneo_m=data.radio_borneo_m, source_chart="S57", cell_id=data.cell_id, chart_feature_id=data.chart_feature_id, modificado_por=current_user.nombre if current_user else None, motivo_cambio="Promoted from S-57 chart feature", ) db.add(aid) db.commit() db.refresh(aid) return _aid_dict(aid, _lamp_for(aid, db)) @router.get("/recordings", tags=["recordings"]) def list_recordings( db: Session = Depends(get_db), from_: str = Query(None, alias="from"), to: str = Query(None), mmsi: str = Query(None), ): q = db.query(RecordingEvent) if mmsi: q = q.filter(RecordingEvent.mmsi.ilike(f"%{mmsi}%")) if from_: try: q = q.filter(RecordingEvent.inicio >= datetime.fromisoformat(from_)) except Exception: pass if to: try: q = q.filter(RecordingEvent.inicio <= datetime.fromisoformat(to + "T23:59:59")) except Exception: pass recs = q.order_by(RecordingEvent.inicio.desc()).limit(200).all() return [ { "id": r.id, "mmsi": r.mmsi, "vessel_nombre": r.mmsi, "aid_id": r.aid_id, "aid_nombre": r.aid_id, "inicio_utc": r.inicio.isoformat() if r.inicio else None, "fin_utc": r.fin.isoformat() if r.fin else None, "distancia_min_m": round(r.distancia_min_m, 1) if r.distancia_min_m else None, "trigger": r.trigger, "cerrado": r.cerrado, } for r in recs ]