Initial commit — multi-tenant filtering, port constraints, chart bbox
This commit is contained in:
Binary file not shown.
Binary file not shown.
+233
-24
@@ -16,18 +16,20 @@ load_dotenv(dotenv_path=os.path.join(os.path.dirname(__file__), '..', '.env'))
|
||||
|
||||
from database import engine, SessionLocal, ensure_column
|
||||
from models.aid import Aid
|
||||
from models.vessel import Vessel, VesselTrack, RecordingEvent
|
||||
from models.vessel import Vessel, VesselTrack, AtonTrack, RecordingEvent
|
||||
from models.lamp import Lamp
|
||||
from models.contact import Contact, AlertReport
|
||||
from models.org import Port, Company, BuoyOwnership
|
||||
import models.aid
|
||||
import models.vessel
|
||||
import models.user
|
||||
import models.lamp
|
||||
import models.contact
|
||||
import models.org
|
||||
from routers import aids
|
||||
from routers.auth import seed_users, get_current_user
|
||||
from routers.auth import seed_users, get_current_user, require_admin
|
||||
from models.user import User
|
||||
from fastapi import Depends
|
||||
from fastapi import Depends, Query as _Query
|
||||
from database import get_db
|
||||
from sqlalchemy.orm import Session
|
||||
from routers import auth as auth_router
|
||||
@@ -35,6 +37,8 @@ from routers import charts as charts_router
|
||||
from routers import equipment as equipment_router
|
||||
from routers import lamps as lamps_router
|
||||
from routers import contacts as contacts_router
|
||||
from routers import tracks as tracks_router
|
||||
from routers import org as org_router
|
||||
from services.ais_simulator import run_simulator, MIAMI_AIDS
|
||||
from services.alert_engine import evaluate_vessel, evaluate_aid_movement, aid_alert_state
|
||||
from services.gps_reader import GPSReader
|
||||
@@ -50,11 +54,25 @@ models.vessel.Base.metadata.create_all(bind=engine)
|
||||
models.user.Base.metadata.create_all(bind=engine)
|
||||
models.lamp.Base.metadata.create_all(bind=engine)
|
||||
models.contact.Base.metadata.create_all(bind=engine)
|
||||
models.org.Base.metadata.create_all(bind=engine)
|
||||
# Additive migrations for columns added after first install
|
||||
ensure_column("aids", "lamp_id", "TEXT")
|
||||
ensure_column("aids", "lamp_id", "TEXT")
|
||||
ensure_column("users", "prefs_json", "TEXT")
|
||||
ensure_column("users", "company_id", "TEXT")
|
||||
|
||||
# Each entry: {"ws": WebSocket, "company_id": str|None}
|
||||
# company_id=None means superadmin/admin — sees ALL traffic.
|
||||
connected_clients: list[dict] = []
|
||||
|
||||
# Ownership cache: company_id → set of MMSI strings the company owns.
|
||||
# Rebuilt at startup and refreshed via POST /org/refresh (or when ownership changes).
|
||||
_ownership_cache: dict[str, set] = {} # company_id → {mmsi, ...}
|
||||
_aid_ownership_cache: dict[str, set] = {} # company_id → {aid_id, ...}
|
||||
|
||||
# Track throttle: last persisted point per MMSI
|
||||
_vessel_track_last: dict[str, dict] = {} # mmsi → {ts, lat, lon}
|
||||
_aton_track_last: dict[str, dict] = {} # mmsi → {ts, lat, lon}
|
||||
|
||||
connected_clients: list[WebSocket] = []
|
||||
# Source of truth for runtime config — mutated via POST /settings.
|
||||
config = settings_store.SETTINGS
|
||||
|
||||
@@ -69,6 +87,47 @@ _battery_alert_state: dict[str, str | None] = {}
|
||||
vessels_state: dict = {}
|
||||
aids_state: dict = {}
|
||||
|
||||
def _build_ownership_cache(db):
|
||||
"""Rebuild the in-memory ownership maps (MMSI and aid_id) from the DB."""
|
||||
_ownership_cache.clear()
|
||||
_aid_ownership_cache.clear()
|
||||
rows = db.query(BuoyOwnership).all()
|
||||
for row in rows:
|
||||
if row.mmsi:
|
||||
_ownership_cache.setdefault(row.company_id, set()).add(row.mmsi)
|
||||
if row.aid_id:
|
||||
_aid_ownership_cache.setdefault(row.company_id, set()).add(row.aid_id)
|
||||
|
||||
|
||||
def seed_ports():
|
||||
"""Ensure canonical Colombian ports exist in the DB."""
|
||||
PORTS = [
|
||||
{"id": "port-barranquilla", "name": "Barranquilla",
|
||||
"center_lat": 11.0041, "center_lon": -74.8070, "default_zoom": 12.0,
|
||||
"chart_name": None},
|
||||
{"id": "port-cartagena", "name": "Cartagena",
|
||||
"center_lat": 10.3997, "center_lon": -75.5144, "default_zoom": 12.0,
|
||||
"chart_name": "BAHÍA_DE_CARTAGENA"},
|
||||
{"id": "port-santamarta", "name": "Santa Marta",
|
||||
"center_lat": 11.2408, "center_lon": -74.2110, "default_zoom": 12.0,
|
||||
"chart_name": None},
|
||||
{"id": "port-buenaventura", "name": "Buenaventura",
|
||||
"center_lat": 3.8800, "center_lon": -77.0311, "default_zoom": 12.0,
|
||||
"chart_name": None},
|
||||
{"id": "port-tumaco", "name": "Tumaco",
|
||||
"center_lat": 1.8189, "center_lon": -78.7619, "default_zoom": 12.0,
|
||||
"chart_name": None},
|
||||
]
|
||||
db = SessionLocal()
|
||||
try:
|
||||
for p in PORTS:
|
||||
if not db.query(Port).filter(Port.id == p["id"]).first():
|
||||
db.add(Port(**p))
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def seed_contacts():
|
||||
"""Pre-load the operator company (INSM) as an OWNER contact so the
|
||||
REPORT flow has someone to notify out of the box."""
|
||||
@@ -113,16 +172,47 @@ def seed_aids():
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
async def broadcast(message: dict):
|
||||
def _client_may_see(client: dict,
|
||||
owned_mmsi: str | None = None,
|
||||
owned_aid_id: str | None = None) -> bool:
|
||||
"""Return True if this client is allowed to receive this message.
|
||||
|
||||
- owned_mmsi → vessel/AtoN traffic filtered by MMSI ownership
|
||||
- owned_aid_id → aid-position traffic filtered by aid_id ownership
|
||||
- neither → system/alert message: always delivered
|
||||
"""
|
||||
cid = client.get("company_id")
|
||||
if cid is None:
|
||||
return True # admin/superadmin: sees all
|
||||
if owned_mmsi is not None:
|
||||
return owned_mmsi in _ownership_cache.get(cid, set())
|
||||
if owned_aid_id is not None:
|
||||
return owned_aid_id in _aid_ownership_cache.get(cid, set())
|
||||
return True # non-filtered message
|
||||
|
||||
|
||||
async def broadcast(message: dict,
|
||||
owned_mmsi: str | None = None,
|
||||
owned_aid_id: str | None = None):
|
||||
"""
|
||||
Send *message* to connected WebSocket clients.
|
||||
|
||||
owned_mmsi → filter vessel/AtoN traffic by MMSI (company users see only their own)
|
||||
owned_aid_id → filter aid-position updates by aid_id
|
||||
Admins (company_id=None) always receive everything.
|
||||
"""
|
||||
data = json.dumps(message)
|
||||
dead = []
|
||||
for ws in connected_clients:
|
||||
for client in connected_clients:
|
||||
if not _client_may_see(client, owned_mmsi, owned_aid_id):
|
||||
continue
|
||||
try:
|
||||
await ws.send_text(data)
|
||||
await client["ws"].send_text(data)
|
||||
except Exception:
|
||||
dead.append(ws)
|
||||
for ws in dead:
|
||||
connected_clients.remove(ws)
|
||||
dead.append(client)
|
||||
for c in dead:
|
||||
if c in connected_clients:
|
||||
connected_clients.remove(c)
|
||||
|
||||
async def _persist_recording(db, alert: dict):
|
||||
"""Save or close a RecordingEvent row when auto-recording triggers."""
|
||||
@@ -157,23 +247,39 @@ async def process_message(msg: dict):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
if msg["type"] == "vessel":
|
||||
vessels_state[msg["mmsi"]] = msg
|
||||
mmsi = msg["mmsi"]
|
||||
vessels_state[mmsi] = msg
|
||||
aids_list = list(aids_state.values())
|
||||
alerts = evaluate_vessel(msg, aids_list, config)
|
||||
await broadcast(msg)
|
||||
await broadcast(msg) # vessels = public traffic, no filter
|
||||
for alert in alerts:
|
||||
await broadcast({"type": "alert", **alert})
|
||||
if alert["tipo"] in ("GRABACION_INICIADA", "GRABACION_FINALIZADA"):
|
||||
await _persist_recording(db, alert)
|
||||
|
||||
# ── Auto-persist VesselTrack (DVR) ───────────────────────────────
|
||||
lat, lon = msg.get("lat"), msg.get("lon")
|
||||
if lat is not None and lon is not None:
|
||||
now = datetime.utcnow()
|
||||
prev = _vessel_track_last.get(mmsi)
|
||||
elapsed = (now - prev["ts"]).total_seconds() if prev else 9999
|
||||
if elapsed >= 10:
|
||||
db.add(VesselTrack(
|
||||
mmsi=mmsi, timestamp=now,
|
||||
lat=lat, lon=lon,
|
||||
sog=msg.get("sog"), cog=msg.get("cog"),
|
||||
heading=msg.get("heading"),
|
||||
))
|
||||
db.commit()
|
||||
_vessel_track_last[mmsi] = {"ts": now, "lat": lat, "lon": lon}
|
||||
|
||||
elif msg["type"] == "aton":
|
||||
# Real AIS Type 21 / Type 8 from hardware receiver
|
||||
entry = process_aton_message(msg)
|
||||
if entry:
|
||||
aton_state[entry["mmsi"]] = entry
|
||||
await broadcast({"type": "aton", **entry})
|
||||
|
||||
mmsi = entry["mmsi"]
|
||||
aton_state[mmsi] = entry
|
||||
await broadcast({"type": "aton", **entry}, owned_mmsi=mmsi)
|
||||
|
||||
# Auto-upsert Aid row on first sight (Type 21 carries name + position).
|
||||
# The user must then assign a lamp via the right panel.
|
||||
@@ -243,6 +349,23 @@ async def process_message(msg: dict):
|
||||
})
|
||||
_battery_alert_state[mmsi] = new_state
|
||||
|
||||
# ── Auto-persist AtonTrack (DVR) ─────────────────────────────
|
||||
at_lat = entry.get("lat")
|
||||
at_lon = entry.get("lon")
|
||||
if at_lat is not None and at_lon is not None:
|
||||
now = datetime.utcnow()
|
||||
prev = _aton_track_last.get(mmsi)
|
||||
elapsed = (now - prev["ts"]).total_seconds() if prev else 9999
|
||||
if elapsed >= 30: # AtoN updates less frequent → 30s threshold
|
||||
db.add(AtonTrack(
|
||||
mmsi=mmsi, timestamp=now,
|
||||
lat=at_lat, lon=at_lon,
|
||||
voltage_v=entry.get("voltage_v"),
|
||||
off_position=entry.get("off_position"),
|
||||
))
|
||||
db.commit()
|
||||
_aton_track_last[mmsi] = {"ts": now, "lat": at_lat, "lon": at_lon}
|
||||
|
||||
elif msg["type"] == "aid_position":
|
||||
aid_id = msg["id"]
|
||||
if aid_id in aids_state:
|
||||
@@ -258,7 +381,7 @@ async def process_message(msg: dict):
|
||||
aid.desplazamiento_m = msg["desplazamiento_m"]
|
||||
aid.en_posicion = msg["en_posicion"]
|
||||
db.commit()
|
||||
await broadcast(msg)
|
||||
await broadcast(msg) # aid positions = public, no filter
|
||||
for alert in alert_list:
|
||||
await broadcast({"type": "alert", **alert})
|
||||
finally:
|
||||
@@ -296,6 +419,10 @@ async def lifespan(app: FastAPI):
|
||||
seed_aids()
|
||||
seed_users()
|
||||
seed_contacts()
|
||||
seed_ports()
|
||||
db = SessionLocal()
|
||||
_build_ownership_cache(db)
|
||||
db.close()
|
||||
db = SessionLocal()
|
||||
for aid in db.query(Aid).all():
|
||||
aids_state[aid.id] = {
|
||||
@@ -337,9 +464,34 @@ app.include_router(charts_router.router)
|
||||
app.include_router(equipment_router.router)
|
||||
app.include_router(lamps_router.router)
|
||||
app.include_router(contacts_router.router)
|
||||
app.include_router(tracks_router.router)
|
||||
app.include_router(org_router.router)
|
||||
|
||||
@app.post("/org/refresh")
|
||||
async def refresh_ownership(
|
||||
_user: User = Depends(require_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Rebuild the in-memory MMSI ownership cache from DB. Call after assigning/removing buoys."""
|
||||
_build_ownership_cache(db)
|
||||
return {"ok": True, "companies": len(_ownership_cache),
|
||||
"total_mmsis": sum(len(v) for v in _ownership_cache.values())}
|
||||
|
||||
|
||||
def _require_recording_permission(user: User):
|
||||
"""ADMIN, SUPERADMIN, or CLIENT_ADMIN may start/stop recordings."""
|
||||
from models.user import Role
|
||||
if user.role not in (Role.ADMIN, Role.SUPERADMIN, Role.CLIENT_ADMIN):
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=403,
|
||||
detail="Only ADMIN or CLIENT_ADMIN may control recordings")
|
||||
|
||||
@app.post("/recordings/start/{mmsi}")
|
||||
async def manual_start_recording(mmsi: str, aid_id: str = "MANUAL"):
|
||||
async def manual_start_recording(
|
||||
mmsi: str, aid_id: str = "MANUAL",
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
_require_recording_permission(current_user)
|
||||
from services.alert_engine import active_recordings
|
||||
from models.vessel import RecordingEvent
|
||||
import uuid as _uuid
|
||||
@@ -368,10 +520,14 @@ async def manual_start_recording(mmsi: str, aid_id: str = "MANUAL"):
|
||||
return {"ok": True}
|
||||
|
||||
@app.post("/recordings/stop/{mmsi}")
|
||||
async def manual_stop_recording(mmsi: str, aid_id: str = "MANUAL"):
|
||||
async def manual_stop_recording(
|
||||
mmsi: str, aid_id: str = "MANUAL",
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
from services.alert_engine import active_recordings
|
||||
from models.vessel import RecordingEvent
|
||||
from sqlalchemy import and_
|
||||
_require_recording_permission(current_user)
|
||||
key = f"{mmsi}_{aid_id}"
|
||||
rec_mem = active_recordings.pop(key, None)
|
||||
min_dist = round(rec_mem["min_dist"], 1) if rec_mem else None
|
||||
@@ -562,10 +718,63 @@ async def ais_stop():
|
||||
|
||||
|
||||
@app.websocket("/ws")
|
||||
async def websocket_endpoint(ws: WebSocket):
|
||||
async def websocket_endpoint(
|
||||
ws: WebSocket,
|
||||
token: str | None = _Query(default=None),
|
||||
):
|
||||
await ws.accept()
|
||||
connected_clients.append(ws)
|
||||
await ws.send_text(json.dumps({"type": "init", "aids": list(aids_state.values()), "vessels": list(vessels_state.values()), "atons": list(aton_state.values())}))
|
||||
|
||||
# ── Resolve company_id from optional JWT token ────────────────────────────
|
||||
company_id = None
|
||||
if token:
|
||||
try:
|
||||
from jose import jwt as _jwt, JWTError as _JWTError
|
||||
from routers.auth import SECRET_KEY, ALGORITHM
|
||||
payload = _jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
username = payload.get("sub")
|
||||
if username:
|
||||
_db = SessionLocal()
|
||||
try:
|
||||
_u = _db.query(User).filter(User.username == username,
|
||||
User.activo == True).first()
|
||||
if _u:
|
||||
company_id = getattr(_u, "company_id", None)
|
||||
finally:
|
||||
_db.close()
|
||||
except Exception:
|
||||
pass # invalid/expired token → treat as anonymous (admin-level view)
|
||||
|
||||
client = {"ws": ws, "company_id": company_id}
|
||||
connected_clients.append(client)
|
||||
|
||||
# ── Build filtered init snapshot ──────────────────────────────────────────
|
||||
# company users see ONLY their company's aids/vessels/atons
|
||||
# admins (company_id=None) see everything
|
||||
owned_mmsis = _ownership_cache.get(company_id) if company_id else None
|
||||
owned_aid_ids= _aid_ownership_cache.get(company_id) if company_id else None
|
||||
|
||||
# aids = official positions → visible to ALL users (public nav info)
|
||||
# vessels= ship AIS traffic → visible to ALL users
|
||||
# atons = live AIS AtoN msgs → filtered by company MMSI ownership
|
||||
init_aids = list(aids_state.values())
|
||||
init_vessels = list(vessels_state.values())
|
||||
|
||||
if company_id is None:
|
||||
# Admin / superadmin → all AtoNs
|
||||
init_atons = list(aton_state.values())
|
||||
else:
|
||||
# Client role: get their owned MMSIs (empty set = no buoys assigned = sees nothing)
|
||||
client_mmsis = _ownership_cache.get(company_id, set())
|
||||
init_atons = [a for a in aton_state.values()
|
||||
if a.get("mmsi") in client_mmsis]
|
||||
|
||||
await ws.send_text(json.dumps({
|
||||
"type": "init",
|
||||
"aids": init_aids,
|
||||
"vessels": init_vessels,
|
||||
"atons": init_atons,
|
||||
}))
|
||||
|
||||
# Re-emit any active aid alerts so new clients see current state
|
||||
from datetime import datetime as _dt
|
||||
for aid_id, state in aid_alert_state.items():
|
||||
@@ -584,7 +793,7 @@ async def websocket_endpoint(ws: WebSocket):
|
||||
while True:
|
||||
await ws.receive_text()
|
||||
except WebSocketDisconnect:
|
||||
if ws in connected_clients:
|
||||
connected_clients.remove(ws)
|
||||
if client in connected_clients:
|
||||
connected_clients.remove(client)
|
||||
|
||||
app.mount("/", StaticFiles(directory=os.path.join(os.path.dirname(__file__), '..', 'frontend'), html=True), name="frontend")
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,52 @@
|
||||
"""
|
||||
Organization models: Port, Company, BuoyOwnership
|
||||
Used for multi-client access control and per-port default views.
|
||||
"""
|
||||
from sqlalchemy import Column, String, Float, Boolean, DateTime, Text
|
||||
from sqlalchemy.sql import func
|
||||
from database import Base
|
||||
|
||||
|
||||
class Port(Base):
|
||||
"""Geographic port / region served by this deployment."""
|
||||
__tablename__ = "ports"
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
name = Column(String, nullable=False)
|
||||
country = Column(String, default="Colombia")
|
||||
center_lat = Column(Float, nullable=True)
|
||||
center_lon = Column(Float, nullable=True)
|
||||
default_zoom = Column(Float, default=12.0)
|
||||
chart_name = Column(String, nullable=True) # folder under charts/, e.g. "BAHÍA_DE_CARTAGENA"
|
||||
activo = Column(Boolean, default=True)
|
||||
creado_en = Column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
class Company(Base):
|
||||
"""Buoy-owner company (client). Belongs to a home port."""
|
||||
__tablename__ = "companies"
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
name = Column(String, nullable=False)
|
||||
port_id = Column(String, nullable=True) # FK → ports.id
|
||||
contact_email = Column(String, nullable=True)
|
||||
contact_phone = Column(String, nullable=True)
|
||||
activa = Column(Boolean, default=True)
|
||||
notas = Column(Text, nullable=True)
|
||||
creado_en = Column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
class BuoyOwnership(Base):
|
||||
"""
|
||||
Which company owns (and monitors) a given Aid/MMSI.
|
||||
A company user can see AIS/ATON real-time data only for buoys
|
||||
listed in this table under their company_id.
|
||||
"""
|
||||
__tablename__ = "buoy_ownership"
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
company_id = Column(String, nullable=False) # FK → companies.id
|
||||
aid_id = Column(String, nullable=True) # FK → aids.id (nullable if aid not yet in DB)
|
||||
mmsi = Column(String, nullable=True) # direct MMSI reference
|
||||
notas = Column(Text, nullable=True)
|
||||
creado_en = Column(DateTime, server_default=func.now())
|
||||
@@ -4,9 +4,10 @@ from database import Base
|
||||
import enum
|
||||
|
||||
class Role(str, enum.Enum):
|
||||
SUPERADMIN = "SUPERADMIN"
|
||||
ADMIN = "ADMIN"
|
||||
USER = "USER"
|
||||
SUPERADMIN = "SUPERADMIN"
|
||||
ADMIN = "ADMIN"
|
||||
CLIENT_ADMIN = "CLIENT_ADMIN" # company-scoped: can start/stop recordings for own aids
|
||||
USER = "USER" # company-scoped: read-only
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
@@ -17,6 +18,7 @@ class User(Base):
|
||||
email = Column(String, unique=True, nullable=True)
|
||||
hashed_pw = Column(String, nullable=False)
|
||||
role = Column(String, default="USER")
|
||||
company_id = Column(String, nullable=True)
|
||||
activo = Column(Boolean, default=True)
|
||||
creado_en = Column(DateTime, server_default=func.now())
|
||||
ultimo_login = Column(DateTime, nullable=True)
|
||||
|
||||
@@ -2,6 +2,7 @@ from sqlalchemy import Column, String, Float, DateTime, Boolean, Integer
|
||||
from sqlalchemy.sql import func
|
||||
from database import Base
|
||||
|
||||
|
||||
class Vessel(Base):
|
||||
__tablename__ = "vessels"
|
||||
|
||||
@@ -44,5 +45,18 @@ class RecordingEvent(Base):
|
||||
inicio = Column(DateTime, nullable=False)
|
||||
fin = Column(DateTime, nullable=True)
|
||||
distancia_min_m = Column(Float, nullable=True)
|
||||
trigger = Column(String) # PROXIMIDAD | PROYECCION
|
||||
trigger = Column(String) # PROXIMIDAD | PROYECCION | MANUAL
|
||||
cerrado = Column(Boolean, default=False)
|
||||
|
||||
|
||||
class AtonTrack(Base):
|
||||
"""Continuous position history for AIS AtoN (Type 21) devices."""
|
||||
__tablename__ = "aton_tracks"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
mmsi = Column(String, nullable=False, index=True)
|
||||
timestamp = Column(DateTime, server_default=func.now(), index=True)
|
||||
lat = Column(Float, nullable=False)
|
||||
lon = Column(Float, nullable=False)
|
||||
voltage_v = Column(Float, nullable=True)
|
||||
off_position = Column(Boolean, nullable=True)
|
||||
|
||||
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
|
||||
],
|
||||
}
|
||||
Binary file not shown.
@@ -78,6 +78,269 @@ def auto_region(cell_id: str) -> str:
|
||||
if prefix in REGION_B_PREFIXES: return "B"
|
||||
return "B"
|
||||
|
||||
|
||||
# ── CSV → features (DIMAR / custom CSV charts) ────────────────────────────────
|
||||
# Mapping from DIMAR CSV feat_type to canonical S-57 layer name.
|
||||
_CSV_FEAT_TYPE_MAP = {
|
||||
"BOYSPEC": "BOYSPP", # DIMAR uses BOYSPEC; IHO S-57 is BOYSPP
|
||||
}
|
||||
|
||||
# Feature categories per layer
|
||||
_CSV_CATEGORY_MAP = {
|
||||
"BOYLAT": "buoy", "BOYCAR": "buoy", "BOYISD": "buoy",
|
||||
"BOYSPP": "buoy", "BOYSAW": "buoy",
|
||||
"BCNLAT": "beacon","BCNCAR": "beacon",
|
||||
"LIGHTS": "light", "LNDMRK": "landmark",
|
||||
}
|
||||
|
||||
|
||||
def _csv_num(v):
|
||||
if v is None or str(v).strip() == "":
|
||||
return None
|
||||
try:
|
||||
return float(v)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _csv_int(v):
|
||||
n = _csv_num(v)
|
||||
return int(n) if n is not None else None
|
||||
|
||||
|
||||
def _csv_fmt(v):
|
||||
"""Format float: drop trailing .0 for whole numbers."""
|
||||
try:
|
||||
f = float(v)
|
||||
return str(int(f)) if f == int(f) else str(f)
|
||||
except Exception:
|
||||
return str(v)
|
||||
|
||||
|
||||
def _csv_light_desc(litchr, siggrp, colour, sigper, valnmr) -> str:
|
||||
"""Build compact light description from CSV fields (e.g. 'Fl G 3s 3M')."""
|
||||
parts = []
|
||||
lc_int = _csv_int(litchr)
|
||||
lc = LITCHR.get(lc_int, str(lc_int)) if lc_int is not None else ""
|
||||
sg = _csv_int(siggrp)
|
||||
if sg is not None:
|
||||
lc = f"{lc}({sg})"
|
||||
col_int = _csv_int(colour)
|
||||
col_str = COLOUR.get(col_int, "") if col_int is not None else ""
|
||||
if lc:
|
||||
parts.append(f"{lc} {col_str}".strip())
|
||||
sp = _csv_num(sigper)
|
||||
if sp is not None:
|
||||
parts.append(f"{_csv_fmt(sp)}s")
|
||||
rng = _csv_num(valnmr)
|
||||
if rng is not None:
|
||||
parts.append(f"{_csv_fmt(rng)}M")
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def _csv_infer_catcam(siggrp: str, name: str) -> str | None:
|
||||
"""
|
||||
Cardinal buoy quadrant from SIGGRP (reliable) or DIMAR naming convention.
|
||||
Q(9)+LFl=W Q(6)+LFl=S Q(3)=E Q=N
|
||||
DIMAR name suffixes: SS/VS→S SN/VN→N SE→E SO→W
|
||||
"""
|
||||
sg = _csv_int(siggrp)
|
||||
if sg is not None:
|
||||
if sg == 9: return "W"
|
||||
if sg == 6: return "S"
|
||||
if sg == 3: return "E"
|
||||
return "N"
|
||||
n = (name or "").upper()
|
||||
if any(x in n for x in (" SS", "(SS)", "VS")): return "S"
|
||||
if any(x in n for x in (" SE", "(SE)")): return "E"
|
||||
if any(x in n for x in (" SO", "(SO)")): return "W"
|
||||
return "N"
|
||||
|
||||
|
||||
def _csv_infer_catlam(colour: str) -> int | None:
|
||||
"""IALA B (Americas): green(4)=port=1, red(3)=stbd=2."""
|
||||
c = _csv_int(colour)
|
||||
if c == 4: return 1
|
||||
if c == 3: return 2
|
||||
return None
|
||||
|
||||
|
||||
def _parse_csvs_to_features(csv_dir: Path) -> list[dict]:
|
||||
"""
|
||||
Read navigation-aid CSV files from csv_dir and return GeoJSON features.
|
||||
|
||||
Each CSV must have columns: OBJNAM, lon, lat, feat_type, LITCHR, SIGGRP,
|
||||
COLOUR, SIGPER, VALNMR, HEIGHT, ORIENT, INFORM.
|
||||
feat_type values: BOYLAT, BOYCAR, BOYISD, BOYSPEC/BOYSPP, BCNLAT, LIGHTS,
|
||||
LNDMRK.
|
||||
|
||||
This function is the primary path for DIMAR/custom charts created with
|
||||
QGISS57Converter; it preserves all light attributes (LITCHR, SIGPER, etc.)
|
||||
that the GDAL S-57 driver may drop during the .000 round-trip.
|
||||
"""
|
||||
import csv as _csv_mod
|
||||
features: list[dict] = []
|
||||
for csv_file in sorted(csv_dir.glob("*.csv")):
|
||||
with open(csv_file, newline="", encoding="utf-8") as fh:
|
||||
reader = _csv_mod.DictReader(fh)
|
||||
for row in reader:
|
||||
feat_type = (row.get("feat_type") or csv_file.stem).strip()
|
||||
layer = _CSV_FEAT_TYPE_MAP.get(feat_type, feat_type)
|
||||
if not layer:
|
||||
continue
|
||||
lon = _csv_num(row.get("lon"))
|
||||
lat = _csv_num(row.get("lat"))
|
||||
if lon is None or lat is None:
|
||||
continue
|
||||
|
||||
category = _CSV_CATEGORY_MAP.get(layer, "buoy")
|
||||
litchr = row.get("LITCHR", "").strip()
|
||||
siggrp = row.get("SIGGRP", "").strip()
|
||||
colour = row.get("COLOUR", "").strip()
|
||||
sigper = row.get("SIGPER", "").strip()
|
||||
valnmr = row.get("VALNMR", "").strip()
|
||||
height = row.get("HEIGHT", "").strip()
|
||||
orient_r = row.get("ORIENT", "").strip()
|
||||
name = row.get("OBJNAM", "").strip()
|
||||
inform = row.get("INFORM", "").strip()
|
||||
|
||||
col_int = _csv_int(colour)
|
||||
colours = [col_int] if col_int is not None else []
|
||||
light_d = _csv_light_desc(litchr, siggrp, colour, sigper, valnmr)
|
||||
|
||||
props: dict = {
|
||||
"layer": layer,
|
||||
"category": category,
|
||||
"name": name or None,
|
||||
"info": inform or None,
|
||||
"light_desc": light_d or None,
|
||||
"range_nm": _csv_num(valnmr),
|
||||
"height_m": _csv_num(height),
|
||||
"colours": colours,
|
||||
"colour_code": col_int,
|
||||
}
|
||||
if layer in ("BOYCAR", "BCNCAR"):
|
||||
props["catcam"] = _csv_infer_catcam(siggrp, name)
|
||||
if layer in ("BOYLAT", "BCNLAT"):
|
||||
props["catlam"] = _csv_infer_catlam(colour)
|
||||
orient_val = _csv_num(orient_r)
|
||||
if orient_val is not None:
|
||||
props["orient"] = orient_val
|
||||
props["aid_type"] = classify(layer, props)
|
||||
# Remove None values
|
||||
props = {k: v for k, v in props.items() if v is not None}
|
||||
features.append({
|
||||
"type": "Feature",
|
||||
"geometry": {"type": "Point", "coordinates": [lon, lat]},
|
||||
"properties": props,
|
||||
})
|
||||
return features
|
||||
|
||||
|
||||
def install_from_csv_dir(csv_dir: Path, cell_id: str) -> str:
|
||||
"""
|
||||
Create or update an AidsMonitoring chart cell from a directory of CSV files.
|
||||
|
||||
This is the preferred install pathway for custom (DIMAR) charts because it
|
||||
preserves all S-57 attribute codes (LITCHR, SIGGRP, etc.) without loss.
|
||||
|
||||
csv_dir — directory containing *.csv files (BOYLAT.csv, BOYCAR.csv, etc.)
|
||||
cell_id — chart cell identifier (e.g. 'BAHIA_DE_CARTAGENA')
|
||||
"""
|
||||
cell_id = cell_id.upper()
|
||||
cell_dir = CHARTS_DIR / cell_id
|
||||
cell_dir.mkdir(exist_ok=True)
|
||||
|
||||
features = _parse_csvs_to_features(csv_dir)
|
||||
with open(cell_dir / "features.geojson", "w", encoding="utf-8") as f:
|
||||
json.dump({"type": "FeatureCollection", "features": features}, f,
|
||||
ensure_ascii=False)
|
||||
log.info("CSV chart %s → %d features", cell_id, len(features))
|
||||
|
||||
# Empty auxiliary caches so the frontend doesn't show stale data
|
||||
for fname in ("depths.geojson", "land.geojson", "hazards.geojson", "zones.geojson"):
|
||||
p = cell_dir / fname
|
||||
if not p.exists():
|
||||
with open(p, "w") as f:
|
||||
json.dump({"type": "FeatureCollection", "features": []}, f)
|
||||
|
||||
# Update meta.json
|
||||
bbox = None
|
||||
if features:
|
||||
lons = [ft["geometry"]["coordinates"][0] for ft in features]
|
||||
lats = [ft["geometry"]["coordinates"][1] for ft in features]
|
||||
bbox = [min(lons), min(lats), max(lons), max(lats)]
|
||||
meta = get_meta(cell_id)
|
||||
meta["feature_count"] = len(features)
|
||||
meta["bbox"] = bbox
|
||||
meta.setdefault("region", auto_region(cell_id))
|
||||
_meta_path(cell_dir).write_text(json.dumps(meta))
|
||||
return cell_id
|
||||
|
||||
|
||||
def install_from_csv_zip(zip_path: Path) -> list[str]:
|
||||
"""
|
||||
Install one or more CSV chart cells from a ZIP archive.
|
||||
|
||||
Expected ZIP layout (any of these is accepted):
|
||||
Option A — single cell (cell_id inferred from folder name or ZIP name):
|
||||
BOYLAT.csv
|
||||
BOYCAR.csv
|
||||
...
|
||||
Option B — cell_id in subfolder:
|
||||
BAHIA_DE_CARTAGENA/BOYLAT.csv
|
||||
BAHIA_DE_CARTAGENA/BOYCAR.csv
|
||||
Option C — meta.json declares cell_id:
|
||||
meta.json → {"cell_id": "BAHIA_DE_CARTAGENA"}
|
||||
BOYLAT.csv
|
||||
...
|
||||
"""
|
||||
import csv as _csv_mod
|
||||
installed: list[str] = []
|
||||
with zipfile.ZipFile(zip_path) as z:
|
||||
namelist = z.namelist()
|
||||
|
||||
# Collect all CSV files grouped by subfolder
|
||||
csv_files = [n for n in namelist if n.lower().endswith(".csv")]
|
||||
if not csv_files:
|
||||
raise ValueError("No CSV files found in ZIP")
|
||||
|
||||
# Check for meta.json
|
||||
meta_cell_id: str | None = None
|
||||
if "meta.json" in namelist:
|
||||
try:
|
||||
meta_cell_id = json.loads(z.read("meta.json")).get("cell_id")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Group by directory prefix
|
||||
import collections
|
||||
groups: dict[str, list[str]] = collections.defaultdict(list)
|
||||
for name in csv_files:
|
||||
prefix = str(Path(name).parent)
|
||||
groups[prefix].append(name)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_root:
|
||||
tmp_root_p = Path(tmp_root)
|
||||
for prefix, members in groups.items():
|
||||
# Determine cell_id
|
||||
if meta_cell_id:
|
||||
cid = meta_cell_id
|
||||
elif prefix and prefix != ".":
|
||||
cid = Path(prefix).name
|
||||
else:
|
||||
cid = Path(zip_path).stem # e.g. BAHIA_DE_CARTAGENA
|
||||
# Extract CSVs to temp dir
|
||||
tmp_csv = tmp_root_p / cid
|
||||
tmp_csv.mkdir(exist_ok=True)
|
||||
for member in members:
|
||||
data = z.read(member)
|
||||
(tmp_csv / Path(member).name).write_bytes(data)
|
||||
# Install
|
||||
result_id = install_from_csv_dir(tmp_csv, cid)
|
||||
installed.append(result_id)
|
||||
return installed
|
||||
|
||||
def _meta_path(cell_dir: Path) -> Path:
|
||||
return cell_dir / "meta.json"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user