Initial commit — multi-tenant filtering, port constraints, chart bbox
This commit is contained in:
Binary file not shown.
Binary file not shown.
+27
-8
@@ -100,11 +100,12 @@ class TokenResponse(BaseModel):
|
||||
role: str
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
username: str
|
||||
nombre: str
|
||||
email: Optional[str] = None
|
||||
password: str
|
||||
role: str = "USER"
|
||||
username: str
|
||||
nombre: str
|
||||
email: Optional[str] = None
|
||||
password: str
|
||||
role: str = "USER"
|
||||
company_id: Optional[str] = None
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
def login(form: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
|
||||
@@ -121,11 +122,25 @@ def login(form: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get
|
||||
|
||||
@router.get("/me")
|
||||
def me(user: User = Depends(get_current_user)):
|
||||
return {"username": user.username, "nombre": user.nombre, "role": user.role}
|
||||
return {
|
||||
"username": user.username,
|
||||
"nombre": user.nombre,
|
||||
"role": user.role,
|
||||
"company_id": getattr(user, "company_id", None),
|
||||
}
|
||||
|
||||
@router.get("/users", dependencies=[Depends(require_superadmin)])
|
||||
def list_users(db: Session = Depends(get_db)):
|
||||
return db.query(User).all()
|
||||
users = db.query(User).all()
|
||||
return [
|
||||
{
|
||||
"id": u.id, "username": u.username, "nombre": u.nombre,
|
||||
"email": u.email, "role": u.role, "activo": u.activo,
|
||||
"company_id": getattr(u, "company_id", None),
|
||||
"ultimo_login": u.ultimo_login.isoformat() if u.ultimo_login else None,
|
||||
}
|
||||
for u in users
|
||||
]
|
||||
|
||||
@router.post("/users", dependencies=[Depends(require_superadmin)])
|
||||
def create_user(data: UserCreate, db: Session = Depends(get_db)):
|
||||
@@ -138,10 +153,12 @@ def create_user(data: UserCreate, db: Session = Depends(get_db)):
|
||||
email=data.email,
|
||||
hashed_pw=hash_password(data.password),
|
||||
role=data.role,
|
||||
company_id=data.company_id,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
return {"ok": True, "username": user.username, "role": user.role}
|
||||
return {"ok": True, "username": user.username, "role": user.role,
|
||||
"company_id": user.company_id}
|
||||
|
||||
@router.put("/users/{username}", dependencies=[Depends(require_superadmin)])
|
||||
def update_user(username: str, data: dict, db: Session = Depends(get_db)):
|
||||
@@ -158,6 +175,8 @@ def update_user(username: str, data: dict, db: Session = Depends(get_db)):
|
||||
user.activo = data["activo"]
|
||||
if "password" in data and data["password"]:
|
||||
user.hashed_pw = hash_password(data["password"])
|
||||
if "company_id" in data:
|
||||
user.company_id = data["company_id"] or None
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
+110
-3
@@ -17,6 +17,7 @@ from services.chart_manager import (
|
||||
delete_cell, get_all_features, get_all_depths,
|
||||
get_all_land, get_all_hazards, get_all_zones,
|
||||
CHARTS_DIR, set_meta, get_region,
|
||||
install_from_csv_zip, install_from_csv_dir,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/charts", tags=["charts"])
|
||||
@@ -210,9 +211,28 @@ async def download_noaa(cell_id: str):
|
||||
return {"installed": installed}
|
||||
|
||||
|
||||
def _zip_contains_csvs(zip_path: Path) -> bool:
|
||||
"""Return True if the ZIP has *.csv files but no *.000 ENC files."""
|
||||
import zipfile as _zf
|
||||
with _zf.ZipFile(zip_path) as z:
|
||||
names = z.namelist()
|
||||
has_csv = any(n.lower().endswith(".csv") for n in names)
|
||||
has_enc = any(n.upper().endswith(".000") for n in names)
|
||||
return has_csv and not has_enc
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
async def upload_chart(file: UploadFile = File(...)):
|
||||
suffix = Path(file.filename).suffix.lower()
|
||||
"""
|
||||
Universal chart upload.
|
||||
|
||||
Accepts:
|
||||
• .000 — single S-57 ENC cell
|
||||
• .zip — either a NOAA ENC zip (contains .000) OR a CSV-based custom
|
||||
chart zip (contains *.csv, no .000). The ZIP auto-detection
|
||||
determines which parser is used.
|
||||
"""
|
||||
suffix = Path(file.filename or "").suffix.lower()
|
||||
if suffix not in (".zip", ".000"):
|
||||
raise HTTPException(400, "Only .zip or .000 files accepted")
|
||||
|
||||
@@ -223,8 +243,13 @@ async def upload_chart(file: UploadFile = File(...)):
|
||||
|
||||
try:
|
||||
if suffix == ".zip":
|
||||
installed = await asyncio.get_event_loop().run_in_executor(
|
||||
None, install_from_zip, tmp_path)
|
||||
# Auto-detect: CSV zip vs ENC zip
|
||||
if _zip_contains_csvs(tmp_path):
|
||||
installed = await asyncio.get_event_loop().run_in_executor(
|
||||
None, install_from_csv_zip, tmp_path)
|
||||
else:
|
||||
installed = await asyncio.get_event_loop().run_in_executor(
|
||||
None, install_from_zip, tmp_path)
|
||||
else:
|
||||
orig_name = Path(file.filename).stem.upper() if file.filename else None
|
||||
cell_id = await asyncio.get_event_loop().run_in_executor(
|
||||
@@ -238,6 +263,58 @@ async def upload_chart(file: UploadFile = File(...)):
|
||||
return {"installed": installed}
|
||||
|
||||
|
||||
@router.post("/upload-csv")
|
||||
async def upload_csv_chart(file: UploadFile = File(...),
|
||||
cell_id: str | None = None):
|
||||
"""
|
||||
Upload a ZIP archive containing CSV navigation-aid files to create a
|
||||
custom chart cell. Use this when your source data is in DIMAR / custom
|
||||
CSV format rather than S-57 .000.
|
||||
|
||||
The cell_id query parameter overrides the inferred name from the ZIP.
|
||||
|
||||
Workflow:
|
||||
1. Edit BOYLAT.csv, BOYCAR.csv, BOYSPEC.csv, etc. in your local
|
||||
capas_ctg/ folder.
|
||||
2. Zip the entire folder.
|
||||
3. POST the zip here (optionally with ?cell_id=BAHIA_DE_CARTAGENA).
|
||||
4. AidsMonitoring reads the CSVs directly, preserving all light
|
||||
attributes (LITCHR, SIGPER, VALNMR …) without GDAL round-trip loss.
|
||||
"""
|
||||
if not (file.filename or "").lower().endswith(".zip"):
|
||||
raise HTTPException(400, "Only .zip files accepted for CSV upload")
|
||||
|
||||
data = await file.read()
|
||||
with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as tmp:
|
||||
tmp.write(data)
|
||||
tmp_path = Path(tmp.name)
|
||||
|
||||
try:
|
||||
if cell_id:
|
||||
# Extract to temp dir then install with explicit cell_id
|
||||
import zipfile as _zf
|
||||
import tempfile as _tf
|
||||
with _tf.TemporaryDirectory() as td:
|
||||
td_p = Path(td)
|
||||
with _zf.ZipFile(tmp_path) as z:
|
||||
for member in z.namelist():
|
||||
if member.lower().endswith(".csv"):
|
||||
data_bytes = z.read(member)
|
||||
(td_p / Path(member).name).write_bytes(data_bytes)
|
||||
installed_id = await asyncio.get_event_loop().run_in_executor(
|
||||
None, install_from_csv_dir, td_p, cell_id)
|
||||
installed = [installed_id]
|
||||
else:
|
||||
installed = await asyncio.get_event_loop().run_in_executor(
|
||||
None, install_from_csv_zip, tmp_path)
|
||||
except Exception as e:
|
||||
raise HTTPException(500, str(e))
|
||||
finally:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
|
||||
return {"installed": installed}
|
||||
|
||||
|
||||
@router.delete("/cells/{cell_id}")
|
||||
def remove_cell(cell_id: str):
|
||||
delete_cell(cell_id)
|
||||
@@ -305,6 +382,36 @@ async def rebuild_cache():
|
||||
return {"rebuilt": rebuilt}
|
||||
|
||||
|
||||
@router.post("/cells/{cell_id}/rebuild-from-csv")
|
||||
async def rebuild_cell_from_csv(cell_id: str, file: UploadFile = File(...)):
|
||||
"""
|
||||
Update an existing cell's features.geojson by re-uploading its CSV zip.
|
||||
Equivalent to DELETE + upload-csv but preserves meta.json settings
|
||||
(e.g. region override).
|
||||
"""
|
||||
if not (file.filename or "").lower().endswith(".zip"):
|
||||
raise HTTPException(400, "Only .zip files accepted")
|
||||
data = await file.read()
|
||||
with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as tmp:
|
||||
tmp.write(data)
|
||||
tmp_path = Path(tmp.name)
|
||||
try:
|
||||
import zipfile as _zf, tempfile as _tf
|
||||
with _tf.TemporaryDirectory() as td:
|
||||
td_p = Path(td)
|
||||
with _zf.ZipFile(tmp_path) as z:
|
||||
for member in z.namelist():
|
||||
if member.lower().endswith(".csv"):
|
||||
(td_p / Path(member).name).write_bytes(z.read(member))
|
||||
installed_id = await asyncio.get_event_loop().run_in_executor(
|
||||
None, install_from_csv_dir, td_p, cell_id)
|
||||
except Exception as e:
|
||||
raise HTTPException(500, str(e))
|
||||
finally:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
return {"rebuilt": installed_id}
|
||||
|
||||
|
||||
@router.post("/cells/{cell_id}/rebuild")
|
||||
async def rebuild_cell(cell_id: str):
|
||||
"""Re-parse a single ENC cell and regenerate its feature cache."""
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
"""
|
||||
Organization CRUD: Ports, Companies, BuoyOwnership.
|
||||
|
||||
GET /org/ports → list ports
|
||||
POST /org/ports → create port (admin)
|
||||
PUT /org/ports/{id} → update port (admin)
|
||||
|
||||
GET /org/companies → list companies
|
||||
POST /org/companies → create company (admin)
|
||||
PUT /org/companies/{id} → update company (admin)
|
||||
|
||||
GET /org/companies/{company_id}/buoys → list owned buoys
|
||||
POST /org/companies/{company_id}/buoys → assign buoy to company (admin)
|
||||
DELETE /org/companies/{company_id}/buoys/{id} → remove ownership (admin)
|
||||
|
||||
GET /org/me/company → current user's company + port (for homepage)
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
import uuid
|
||||
import os
|
||||
import json
|
||||
|
||||
from database import get_db
|
||||
from models.org import Port, Company, BuoyOwnership
|
||||
from models.user import User
|
||||
from models.aid import Aid
|
||||
from routers.auth import get_current_user, require_admin
|
||||
|
||||
# Charts directory — one level above the backend package
|
||||
_CHARTS_DIR = os.path.normpath(
|
||||
os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', '..', 'charts')
|
||||
)
|
||||
|
||||
def _read_chart_bbox(chart_name: str) -> list | None:
|
||||
"""Return [west, south, east, north] from the chart's meta.json, or None."""
|
||||
if not chart_name:
|
||||
return None
|
||||
meta = os.path.join(_CHARTS_DIR, chart_name, 'meta.json')
|
||||
try:
|
||||
with open(meta, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
bbox = data.get('bbox')
|
||||
if bbox and len(bbox) == 4:
|
||||
return bbox # [minLon, minLat, maxLon, maxLat]
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
router = APIRouter(prefix="/org", tags=["org"])
|
||||
|
||||
|
||||
# ── Ports ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/ports")
|
||||
def list_ports(db: Session = Depends(get_db)):
|
||||
return [_port_dict(p) for p in db.query(Port).filter(Port.activo == True).all()]
|
||||
|
||||
|
||||
@router.post("/ports", dependencies=[Depends(require_admin)])
|
||||
def create_port(data: dict, db: Session = Depends(get_db)):
|
||||
if not data.get("name"):
|
||||
raise HTTPException(400, "name is required")
|
||||
port = Port(
|
||||
id=str(uuid.uuid4()),
|
||||
name=data["name"],
|
||||
country=data.get("country", "Colombia"),
|
||||
center_lat=data.get("center_lat"),
|
||||
center_lon=data.get("center_lon"),
|
||||
default_zoom=data.get("default_zoom", 12.0),
|
||||
chart_name=data.get("chart_name"),
|
||||
)
|
||||
db.add(port); db.commit()
|
||||
return _port_dict(port)
|
||||
|
||||
|
||||
@router.put("/ports/{port_id}", dependencies=[Depends(require_admin)])
|
||||
def update_port(port_id: str, data: dict, db: Session = Depends(get_db)):
|
||||
port = db.query(Port).filter(Port.id == port_id).first()
|
||||
if not port:
|
||||
raise HTTPException(404, "Port not found")
|
||||
for field in ("name", "country", "center_lat", "center_lon",
|
||||
"default_zoom", "chart_name", "activo"):
|
||||
if field in data:
|
||||
setattr(port, field, data[field])
|
||||
db.commit()
|
||||
return _port_dict(port)
|
||||
|
||||
|
||||
def _port_dict(p: Port) -> dict:
|
||||
return {
|
||||
"id": p.id, "name": p.name, "country": p.country,
|
||||
"center_lat": p.center_lat, "center_lon": p.center_lon,
|
||||
"default_zoom": p.default_zoom, "chart_name": p.chart_name,
|
||||
"chart_bbox": _read_chart_bbox(p.chart_name), # [W,S,E,N] or null
|
||||
"activo": p.activo,
|
||||
}
|
||||
|
||||
|
||||
# ── Companies ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/companies")
|
||||
def list_companies(db: Session = Depends(get_db)):
|
||||
return [_company_dict(c) for c in db.query(Company).all()]
|
||||
|
||||
|
||||
@router.post("/companies", dependencies=[Depends(require_admin)])
|
||||
def create_company(data: dict, db: Session = Depends(get_db)):
|
||||
if not data.get("name"):
|
||||
raise HTTPException(400, "name is required")
|
||||
company = Company(
|
||||
id=str(uuid.uuid4()),
|
||||
name=data["name"],
|
||||
port_id=data.get("port_id"),
|
||||
contact_email=data.get("contact_email"),
|
||||
contact_phone=data.get("contact_phone"),
|
||||
notas=data.get("notas"),
|
||||
)
|
||||
db.add(company); db.commit()
|
||||
return _company_dict(company)
|
||||
|
||||
|
||||
@router.put("/companies/{company_id}", dependencies=[Depends(require_admin)])
|
||||
def update_company(company_id: str, data: dict, db: Session = Depends(get_db)):
|
||||
company = db.query(Company).filter(Company.id == company_id).first()
|
||||
if not company:
|
||||
raise HTTPException(404, "Company not found")
|
||||
for field in ("name", "port_id", "contact_email", "contact_phone", "notas", "activa"):
|
||||
if field in data:
|
||||
setattr(company, field, data[field])
|
||||
db.commit()
|
||||
return _company_dict(company)
|
||||
|
||||
|
||||
def _company_dict(c: Company) -> dict:
|
||||
return {
|
||||
"id": c.id, "name": c.name, "port_id": c.port_id,
|
||||
"contact_email": c.contact_email, "contact_phone": c.contact_phone,
|
||||
"activa": c.activa, "notas": c.notas,
|
||||
}
|
||||
|
||||
|
||||
# ── Buoy Ownership ────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/companies/{company_id}/buoys")
|
||||
def list_company_buoys(company_id: str, db: Session = Depends(get_db)):
|
||||
rows = db.query(BuoyOwnership).filter(BuoyOwnership.company_id == company_id).all()
|
||||
result = []
|
||||
for r in rows:
|
||||
entry = {"id": r.id, "company_id": r.company_id,
|
||||
"aid_id": r.aid_id, "mmsi": r.mmsi, "notas": r.notas}
|
||||
# Enrich with aid name if available
|
||||
if r.aid_id:
|
||||
aid = db.query(Aid).filter(Aid.id == r.aid_id).first()
|
||||
if aid:
|
||||
entry["aid_nombre"] = aid.nombre
|
||||
entry["mmsi"] = entry["mmsi"] or aid.mmsi
|
||||
result.append(entry)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/companies/{company_id}/buoys", dependencies=[Depends(require_admin)])
|
||||
def assign_buoy(company_id: str, data: dict, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Assign a buoy to a company. Provide either aid_id or mmsi (or both).
|
||||
If aid_id is given, mmsi is auto-filled from the Aid row.
|
||||
"""
|
||||
company = db.query(Company).filter(Company.id == company_id).first()
|
||||
if not company:
|
||||
raise HTTPException(404, "Company not found")
|
||||
|
||||
aid_id = data.get("aid_id")
|
||||
mmsi = data.get("mmsi")
|
||||
|
||||
if not aid_id and not mmsi:
|
||||
raise HTTPException(400, "Provide aid_id or mmsi")
|
||||
|
||||
if aid_id:
|
||||
aid = db.query(Aid).filter(Aid.id == aid_id).first()
|
||||
if not aid:
|
||||
raise HTTPException(404, "Aid not found")
|
||||
mmsi = mmsi or aid.mmsi # fill from aid if not explicitly given
|
||||
|
||||
# Prevent duplicate
|
||||
existing = db.query(BuoyOwnership).filter(
|
||||
BuoyOwnership.company_id == company_id,
|
||||
BuoyOwnership.aid_id == aid_id,
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(409, "Already assigned")
|
||||
|
||||
row = BuoyOwnership(
|
||||
id=str(uuid.uuid4()),
|
||||
company_id=company_id,
|
||||
aid_id=aid_id,
|
||||
mmsi=mmsi,
|
||||
notas=data.get("notas"),
|
||||
)
|
||||
db.add(row); db.commit()
|
||||
return {"ok": True, "id": row.id, "mmsi": mmsi}
|
||||
|
||||
|
||||
@router.delete("/companies/{company_id}/buoys/{ownership_id}",
|
||||
dependencies=[Depends(require_admin)])
|
||||
def remove_ownership(company_id: str, ownership_id: str, db: Session = Depends(get_db)):
|
||||
row = db.query(BuoyOwnership).filter(
|
||||
BuoyOwnership.id == ownership_id,
|
||||
BuoyOwnership.company_id == company_id,
|
||||
).first()
|
||||
if not row:
|
||||
raise HTTPException(404, "Ownership record not found")
|
||||
db.delete(row); db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── Current user's company / home port ───────────────────────────────────────
|
||||
|
||||
@router.get("/me/company")
|
||||
def my_company(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Returns the company and port associated with the logged-in user.
|
||||
Used by the frontend to set the default map view on login.
|
||||
"""
|
||||
company_id = getattr(current_user, "company_id", None)
|
||||
if not company_id:
|
||||
return {"company": None, "port": None}
|
||||
|
||||
company = db.query(Company).filter(Company.id == company_id).first()
|
||||
port = db.query(Port).filter(Port.id == company.port_id).first() \
|
||||
if company and company.port_id else None
|
||||
|
||||
return {
|
||||
"company": _company_dict(company) if company else None,
|
||||
"port": _port_dict(port) if port else None,
|
||||
}
|
||||
@@ -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