● Zion Boggan
repos/Sightline/backend/app/api/routes/live.py
zionboggan.com ↗
137 lines · python
History for this file →
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}