Initial commit — multi-tenant filtering, port constraints, chart bbox
This commit is contained in:
@@ -0,0 +1,268 @@
|
||||
"""
|
||||
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
|
||||
],
|
||||
}
|
||||
Reference in New Issue
Block a user