| 1 | from __future__ import annotations |
| 2 | |
| 3 | from fastapi import APIRouter, Depends, HTTPException |
| 4 | from pydantic import BaseModel |
| 5 | from sqlalchemy import func, select |
| 6 | from sqlalchemy.orm import Session |
| 7 | |
| 8 | from app.api.deps import Perm, get_db, require |
| 9 | from app.models import ( |
| 10 | AuditLog, Camera, EntityPosition, LicensePlate, LiveEntity, PlateRead, User, Vehicle, |
| 11 | ) |
| 12 | from app.services.connectors import get_connector, list_connectors |
| 13 | from app.services.ingest import ingest_live_reads |
| 14 | |
| 15 | router = APIRouter(tags=["live-ingest"]) |
| 16 | |
| 17 | |
| 18 | @router.get("/satellites") |
| 19 | def satellites(db: Session = Depends(get_db), _: User = Depends(require(Perm.READ))): |
| 20 | """Live positions of real imaging satellites ('satellite cameras'), most recent snapshot.""" |
| 21 | rows = db.scalars(select(LiveEntity).where(LiveEntity.kind == "satellite_camera") |
| 22 | .order_by(LiveEntity.label)).all() |
| 23 | return {"count": len(rows), |
| 24 | "satellites": [{"identifier": s.identifier, "label": s.label, "lat": s.lat, "lon": s.lon, |
| 25 | "altitude_km": round(s.altitude_m / 1000, 1) if s.altitude_m else None, |
| 26 | "constellation": (s.attributes or {}).get("constellation"), |
| 27 | "period_min": (s.attributes or {}).get("period_min"), |
| 28 | "observed_at": s.observed_at} for s in rows]} |
| 29 | |
| 30 | |
| 31 | @router.get("/entities/{identifier}/track") |
| 32 | def entity_track(identifier: str, limit: int = 500, db: Session = Depends(get_db), |
| 33 | _: User = Depends(require(Perm.READ))): |
| 34 | """Position history (trail) for any tracked entity - e.g. a satellite camera's ground track.""" |
| 35 | rows = db.scalars(select(EntityPosition).where(EntityPosition.identifier == identifier) |
| 36 | .order_by(EntityPosition.observed_at.desc()).limit(limit)).all() |
| 37 | ent = db.scalar(select(LiveEntity).where(LiveEntity.identifier == identifier)) |
| 38 | return {"identifier": identifier, "label": ent.label if ent else None, |
| 39 | "track": [{"lat": p.lat, "lon": p.lon, "altitude_m": p.altitude_m, |
| 40 | "observed_at": p.observed_at} for p in reversed(rows)]} |
| 41 | |
| 42 | |
| 43 | @router.get("/cameras/stats") |
| 44 | def cameras_stats(db: Session = Depends(get_db), _: User = Depends(require(Perm.READ))): |
| 45 | by_op = db.execute(select(Camera.operator, func.count()).group_by(Camera.operator) |
| 46 | .order_by(func.count().desc()).limit(15)).all() |
| 47 | by_state = db.execute(select(Camera.state, func.count()).group_by(Camera.state) |
| 48 | .order_by(func.count().desc())).all() |
| 49 | return {"total": db.scalar(select(func.count()).select_from(Camera)) or 0, |
| 50 | "satellite_cameras": db.scalar(select(func.count()).select_from(LiveEntity) |
| 51 | .where(LiveEntity.kind == "satellite_camera")) or 0, |
| 52 | "by_operator": [{"operator": o or "Unknown", "count": c} for o, c in by_op], |
| 53 | "by_state": [{"state": s, "count": c} for s, c in by_state]} |
| 54 | |
| 55 | |
| 56 | @router.get("/ingest/recent") |
| 57 | def recent_reads(limit: int = 25, db: Session = Depends(get_db), _: User = Depends(require(Perm.READ))): |
| 58 | """Most-recent plate reads (by ingest time) - drives the live activity feed.""" |
| 59 | rows = db.execute( |
| 60 | select(PlateRead.plate_norm, PlateRead.read_ts, PlateRead.camera_id, |
| 61 | PlateRead.confidence, PlateRead.hotlist_hit, LicensePlate.state, Camera.name) |
| 62 | .join(LicensePlate, LicensePlate.id == PlateRead.plate_id, isouter=True) |
| 63 | .join(Camera, Camera.id == PlateRead.camera_id, isouter=True) |
| 64 | .order_by(PlateRead.id.desc()).limit(min(limit, 100)) |
| 65 | ).all() |
| 66 | return [{"plate_norm": r[0], "read_ts": r[1], "camera_id": r[2], |
| 67 | "confidence": float(r[3]) if r[3] is not None else None, |
| 68 | "hotlist_hit": r[4], "state": r[5], "camera_name": r[6]} for r in rows] |
| 69 | |
| 70 | |
| 71 | @router.get("/stats") |
| 72 | def stats(db: Session = Depends(get_db), _: User = Depends(require(Perm.READ))): |
| 73 | """Top-line counts for the dashboard - refreshed by the live feed.""" |
| 74 | return { |
| 75 | "total_reads": db.scalar(select(func.count()).select_from(PlateRead)) or 0, |
| 76 | "vehicles": db.scalar(select(func.count()).select_from(Vehicle)) or 0, |
| 77 | "cameras": db.scalar(select(func.count()).select_from(Camera)) or 0, |
| 78 | "states": db.scalar(select(func.count(func.distinct(LicensePlate.state)))) or 0, |
| 79 | "hotlist_reads": db.scalar(select(func.count()).select_from(PlateRead).where(PlateRead.hotlist_hit.is_(True))) or 0, |
| 80 | } |
| 81 | |
| 82 | |
| 83 | class StreamBatch(BaseModel): |
| 84 | source_id: str | None = None |
| 85 | label: str = "stream" |
| 86 | reads: list[dict] |
| 87 | |
| 88 | |
| 89 | @router.post("/ingest/stream") |
| 90 | def ingest_stream(batch: StreamBatch, db: Session = Depends(get_db), |
| 91 | user: User = Depends(require(Perm.INGEST))): |
| 92 | """Real-time ingest: push a batch of read objects (no file) straight into the pipeline - |
| 93 | normalized, deduplicated, and evaluated against watchlists inline. Suitable for a camera |
| 94 | edge device or message-bus consumer posting reads as they happen.""" |
| 95 | if not batch.reads: |
| 96 | raise HTTPException(400, "no reads provided") |
| 97 | if len(batch.reads) > 5000: |
| 98 | raise HTTPException(413, "batch too large (max 5000 reads)") |
| 99 | summary = ingest_live_reads(db, batch.source_id, batch.reads, batch.label, created_by=user.id) |
| 100 | db.add(AuditLog(actor_user_id=user.id, action="ingest.stream", |
| 101 | entity_type="import", entity_id=summary.get("import_id"), |
| 102 | meta={"label": batch.label, "count": len(batch.reads)})) |
| 103 | db.commit() |
| 104 | return summary |
| 105 | |
| 106 | |
| 107 | @router.get("/connectors") |
| 108 | def connectors(_: User = Depends(require(Perm.READ))): |
| 109 | return list_connectors() |
| 110 | |
| 111 | |
| 112 | class ConnectorRun(BaseModel): |
| 113 | connector: str |
| 114 | config: dict |
| 115 | source_id: str | None = None |
| 116 | |
| 117 | |
| 118 | @router.post("/connectors/run") |
| 119 | def run_connector(req: ConnectorRun, db: Session = Depends(get_db), |
| 120 | user: User = Depends(require(Perm.INGEST))): |
| 121 | """Invoke a connector to pull reads from an external source and ingest them now.""" |
| 122 | conn = get_connector(req.connector) |
| 123 | if not conn: |
| 124 | raise HTTPException(404, f"unknown connector '{req.connector}'") |
| 125 | try: |
| 126 | reads = conn.fetch(req.config) |
| 127 | except Exception as e: |
| 128 | raise HTTPException(502, f"connector fetch failed: {e}") |
| 129 | if not reads: |
| 130 | return {"fetched": 0, "inserted": 0, "note": "connector returned no reads"} |
| 131 | summary = ingest_live_reads(db, req.source_id, reads, f"connector:{req.connector}", |
| 132 | created_by=user.id) |
| 133 | db.add(AuditLog(actor_user_id=user.id, action="connector.run", |
| 134 | entity_type="import", entity_id=summary.get("import_id"), |
| 135 | meta={"connector": req.connector, "fetched": len(reads)})) |
| 136 | db.commit() |
| 137 | return {"fetched": len(reads), **summary} |