Files

269 lines
9.9 KiB
Python

"""
Track history endpoints — DVR replay for vessels and AtoNs.
GET /tracks/vessels → list MMSIs that have track data
GET /tracks/vessels/{mmsi} → track points for one vessel (?from=&to=&limit=)
GET /tracks/atons → list AtoN MMSIs with track data
GET /tracks/atons/{mmsi} → track points for one AtoN (?from=&to=&limit=)
GET /recordings → list RecordingEvents (?mmsi=&open=)
GET /recordings/{event_id}/track → vessel track for that event window
Access control:
- ADMIN / SUPERADMIN : see ALL recordings
- USER with company : see ONLY recordings where aid_id belongs to their company's buoys
- Anonymous : see ALL (legacy / unauthenticated clients)
"""
from fastapi import APIRouter, Depends, Query, HTTPException, Header
from sqlalchemy.orm import Session
from sqlalchemy import distinct
from typing import Optional
from datetime import datetime
from database import get_db
from models.vessel import VesselTrack, AtonTrack, RecordingEvent
from models.org import BuoyOwnership
from models.user import User, Role
router = APIRouter(tags=["tracks"])
def _optional_user(
authorization: Optional[str] = Header(default=None),
db: Session = Depends(get_db),
) -> Optional[User]:
"""Resolve JWT token from Authorization header if present. Returns None if absent/invalid."""
if not authorization or not authorization.startswith("Bearer "):
return None
token = authorization.split(" ", 1)[1]
try:
from jose import jwt as _jwt, JWTError
from routers.auth import SECRET_KEY, ALGORITHM
payload = _jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username = payload.get("sub")
if not username:
return None
return db.query(User).filter(User.username == username, User.activo == True).first()
except Exception:
return None
def _company_aid_ids(company_id: str, db: Session) -> Optional[set]:
"""Return the set of aid_ids owned by this company, or None if unrestricted."""
rows = db.query(BuoyOwnership).filter(
BuoyOwnership.company_id == company_id,
BuoyOwnership.aid_id.isnot(None),
).all()
return {r.aid_id for r in rows} if rows is not None else set()
# ── Vessel tracks ─────────────────────────────────────────────────────────────
@router.get("/tracks/vessels")
def list_tracked_vessels(db: Session = Depends(get_db)):
"""Return distinct MMSIs that have at least one VesselTrack row."""
rows = db.query(distinct(VesselTrack.mmsi)).all()
return [r[0] for r in rows]
@router.get("/tracks/vessels/{mmsi}")
def get_vessel_track(
mmsi: str,
from_dt: Optional[str] = Query(None, alias="from"),
to_dt: Optional[str] = Query(None, alias="to"),
limit: int = Query(10_000, le=100_000),
db: Session = Depends(get_db),
):
"""
Return time-ordered track points for *mmsi*.
Optionally filter by ISO-8601 timestamps: ?from=2026-05-01T00:00:00&to=2026-05-02T00:00:00
"""
q = db.query(VesselTrack).filter(VesselTrack.mmsi == mmsi)
if from_dt:
try:
q = q.filter(VesselTrack.timestamp >= datetime.fromisoformat(from_dt))
except ValueError:
raise HTTPException(400, "Invalid 'from' datetime (use ISO-8601)")
if to_dt:
try:
q = q.filter(VesselTrack.timestamp <= datetime.fromisoformat(to_dt))
except ValueError:
raise HTTPException(400, "Invalid 'to' datetime (use ISO-8601)")
rows = q.order_by(VesselTrack.timestamp.asc()).limit(limit).all()
return [
{
"mmsi": r.mmsi,
"ts": r.timestamp.isoformat() if r.timestamp else None,
"lat": r.lat,
"lon": r.lon,
"sog": r.sog,
"cog": r.cog,
"heading": r.heading,
}
for r in rows
]
# ── AtoN tracks ───────────────────────────────────────────────────────────────
@router.get("/tracks/atons")
def list_tracked_atons(db: Session = Depends(get_db)):
"""Return distinct MMSIs that have at least one AtonTrack row."""
rows = db.query(distinct(AtonTrack.mmsi)).all()
return [r[0] for r in rows]
@router.get("/tracks/atons/{mmsi}")
def get_aton_track(
mmsi: str,
from_dt: Optional[str] = Query(None, alias="from"),
to_dt: Optional[str] = Query(None, alias="to"),
limit: int = Query(10_000, le=100_000),
db: Session = Depends(get_db),
):
"""Time-ordered position history for an AIS AtoN (Type 21)."""
q = db.query(AtonTrack).filter(AtonTrack.mmsi == mmsi)
if from_dt:
try:
q = q.filter(AtonTrack.timestamp >= datetime.fromisoformat(from_dt))
except ValueError:
raise HTTPException(400, "Invalid 'from' datetime")
if to_dt:
try:
q = q.filter(AtonTrack.timestamp <= datetime.fromisoformat(to_dt))
except ValueError:
raise HTTPException(400, "Invalid 'to' datetime")
rows = q.order_by(AtonTrack.timestamp.asc()).limit(limit).all()
return [
{
"mmsi": r.mmsi,
"ts": r.timestamp.isoformat() if r.timestamp else None,
"lat": r.lat,
"lon": r.lon,
"voltage_v": r.voltage_v,
"off_position": r.off_position,
}
for r in rows
]
# ── Recording events ──────────────────────────────────────────────────────────
@router.get("/recordings")
def list_recordings(
mmsi: Optional[str] = Query(None),
open_: Optional[bool] = Query(None, alias="open"),
from_dt: Optional[str] = Query(None, alias="from"),
to_dt: Optional[str] = Query(None, alias="to"),
limit: int = Query(200, le=2000),
db: Session = Depends(get_db),
caller: Optional[User] = Depends(_optional_user),
):
"""
List RecordingEvents (most-recent first).
Supports ?mmsi=, ?open=true, ?from=YYYY-MM-DD, ?to=YYYY-MM-DD
Access control:
ADMIN/SUPERADMIN → all recordings
USER with company → only recordings for their company's aids
No token → all recordings (legacy)
"""
from models.vessel import Vessel
from models.aid import Aid
# ── Company filter for USER role ─────────────────────────────────────────
allowed_aid_ids: Optional[set] = None
if caller and caller.role == Role.USER:
cid = getattr(caller, "company_id", None)
if cid:
allowed_aid_ids = _company_aid_ids(cid, db)
q = db.query(RecordingEvent)
# Restrict to owned aids if caller is a company user
if allowed_aid_ids is not None:
q = q.filter(RecordingEvent.aid_id.in_(allowed_aid_ids))
if mmsi:
q = q.filter(RecordingEvent.mmsi == mmsi)
if open_ is not None:
q = q.filter(RecordingEvent.cerrado == (not open_))
if from_dt:
try:
q = q.filter(RecordingEvent.inicio >= datetime.fromisoformat(from_dt))
except ValueError:
pass
if to_dt:
try:
# Include the full end date day
to_end = datetime.fromisoformat(to_dt).replace(hour=23, minute=59, second=59)
q = q.filter(RecordingEvent.inicio <= to_end)
except ValueError:
pass
events = q.order_by(RecordingEvent.inicio.desc()).limit(limit).all()
# Enrich with vessel / aid names in one pass
mmsis = {e.mmsi for e in events}
aid_ids = {e.aid_id for e in events}
vessels = {v.mmsi: v.nombre for v in db.query(Vessel).filter(Vessel.mmsi.in_(mmsis)).all()}
aids = {a.id: a.nombre for a in db.query(Aid).filter(Aid.id.in_(aid_ids)).all()}
return [
{
"id": e.id,
"mmsi": e.mmsi,
"vessel_nombre": vessels.get(e.mmsi),
"aid_id": e.aid_id,
"aid_nombre": aids.get(e.aid_id),
"inicio_utc": e.inicio.isoformat() if e.inicio else None,
"fin_utc": e.fin.isoformat() if e.fin else None,
"distancia_min_m": e.distancia_min_m,
"trigger": e.trigger,
"cerrado": e.cerrado,
}
for e in events
]
@router.get("/recordings/{event_id}/track")
def get_recording_track(event_id: str, db: Session = Depends(get_db)):
"""
Return the VesselTrack points that fall within the time window of a
specific RecordingEvent — ready for DVR replay.
"""
event = db.query(RecordingEvent).filter(RecordingEvent.id == event_id).first()
if not event:
raise HTTPException(404, "Recording event not found")
q = (
db.query(VesselTrack)
.filter(VesselTrack.mmsi == event.mmsi)
.filter(VesselTrack.timestamp >= event.inicio)
)
if event.fin:
q = q.filter(VesselTrack.timestamp <= event.fin)
rows = q.order_by(VesselTrack.timestamp.asc()).all()
return {
"event": {
"id": event.id,
"mmsi": event.mmsi,
"aid_id": event.aid_id,
"inicio": event.inicio.isoformat(),
"fin": event.fin.isoformat() if event.fin else None,
"trigger": event.trigger,
},
"track": [
{
"ts": r.timestamp.isoformat() if r.timestamp else None,
"lat": r.lat,
"lon": r.lon,
"sog": r.sog,
"cog": r.cog,
"heading": r.heading,
}
for r in rows
],
}