269 lines
9.9 KiB
Python
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
|
|
],
|
|
}
|