Initial commit — multi-tenant filtering, port constraints, chart bbox

This commit is contained in:
2026-05-04 22:41:09 -04:00
parent c3b07be67e
commit fcf1d2787a
1102 changed files with 7353 additions and 1166 deletions
+233 -24
View File
@@ -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")