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