cfd94f905a
- Restrict CORS to localhost origins (was allow_origins=[*])
- Require valid JWT on WebSocket /ws (anonymous no longer gets admin view)
- Fix path traversal in delete_cell(): resolve() + parent check
- Validate cell_id format in /charts/download-noaa/{cell_id}
- Exclude charts/ and Cartas/ from git (keep US1GC09M world overview)
- Add NOAA ENC Portal external link in charts catalog tab
- Untrack __pycache__/, .db, .claude/ session files
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
262 lines
9.1 KiB
Python
262 lines
9.1 KiB
Python
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
|
|
]
|