● Zion Boggan
repos/Sightline/backend/app/api/routes/flock.py
zionboggan.com ↗
145 lines · python
History for this file →
1
"""Owned Flock ALPR device API: register a user-owned Flock Safety unit and ingest from the
2
surfaces such a device actually exposes (no public API): snapshot images, an RTSP stream,
3
and Flock-style CSV/JSON export files.
4
 
5
  POST /flock/devices                  register the owned device (Source + Camera + Location)
6
  GET  /flock/devices                  list registered owned devices
7
  POST /flock/devices/{id}/image       multipart snapshot -> recognize + ingest reads
8
  POST /flock/devices/{id}/rtsp        {rtsp_url, seconds} -> sample stream + ingest reads
9
  POST /flock/devices/{id}/import      multipart CSV/JSON export -> parse + ingest reads
10
"""
11
from __future__ import annotations
12
 
13
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
14
from pydantic import BaseModel
15
from sqlalchemy import select
16
from sqlalchemy.orm import Session
17
 
18
from app.api.deps import Perm, get_db, require
19
from app.models import AuditLog, Camera, Location, User
20
from app.services.flock_connector import (
21
    LIVE_SOURCE, ingest_flock_export, ingest_flock_image, ingest_flock_rtsp,
22
    register_flock_device,
23
)
24
 
25
router = APIRouter(prefix="/flock", tags=["flock"])
26
 
27
 
28
def _device_dict(db: Session, cam: Camera) -> dict:
29
    loc = db.get(Location, cam.location_id) if cam.location_id else None
30
    return {"id": cam.id, "external_camera_id": cam.external_camera_id, "name": cam.name,
31
            "operator": cam.operator, "state": cam.state, "has_live_feed": cam.has_live_feed,
32
            "image_url": cam.image_url, "live_source": cam.live_source,
33
            "lat": loc.lat if loc else None, "lon": loc.lon if loc else None,
34
            "created_at": cam.created_at}
35
 
36
 
37
def _get_device(db: Session, device_id: str) -> Camera:
38
    cam = db.get(Camera, device_id)
39
    if cam is None or cam.live_source != LIVE_SOURCE:
40
        raise HTTPException(404, "owned flock device not found")
41
    return cam
42
 
43
 
44
class DeviceReq(BaseModel):
45
    name: str
46
    lat: float
47
    lon: float
48
    snapshot_url: str | None = None
49
 
50
 
51
@router.post("/devices", status_code=201)
52
def register_device(req: DeviceReq, db: Session = Depends(get_db),
53
                    user: User = Depends(require(Perm.WRITE))):
54
    """Register a consented, user-owned Flock unit as a Source + Camera."""
55
    cam = register_flock_device(db, req.name, req.lat, req.lon, snapshot_url=req.snapshot_url)
56
    db.add(AuditLog(actor_user_id=user.id, action="flock.register_device",
57
                    entity_type="camera", entity_id=cam.id,
58
                    meta={"name": req.name, "snapshot_url": req.snapshot_url}))
59
    db.commit()
60
    db.refresh(cam)
61
    return _device_dict(db, cam)
62
 
63
 
64
@router.get("/devices")
65
def list_devices(db: Session = Depends(get_db), _: User = Depends(require(Perm.READ))):
66
    rows = db.scalars(select(Camera).where(Camera.live_source == LIVE_SOURCE)
67
                      .order_by(Camera.created_at.desc())).all()
68
    return [_device_dict(db, c) for c in rows]
69
 
70
 
71
@router.post("/devices/{device_id}/image")
72
async def ingest_image(
73
    device_id: str,
74
    file: UploadFile = File(...),
75
    min_conf: float = Form(default=0.4),
76
    db: Session = Depends(get_db),
77
    user: User = Depends(require(Perm.INGEST)),
78
):
79
    """Recognize plates in a snapshot from the owned unit and create real reads."""
80
    cam = _get_device(db, device_id)
81
    data = await file.read()
82
    if not data:
83
        raise HTTPException(400, "empty image")
84
    try:
85
        result = ingest_flock_image(db, cam, data, created_by=user.id, min_conf=min_conf)
86
    except Exception as e:
87
        raise HTTPException(422, f"recognition failed: {e}")
88
    db.add(AuditLog(actor_user_id=user.id, action="flock.image_ingest",
89
                    entity_type="camera", entity_id=cam.id,
90
                    meta={"recognized": result.get("recognized", 0),
91
                          "import_id": result.get("import_id")}))
92
    db.commit()
93
    return result
94
 
95
 
96
class RtspReq(BaseModel):
97
    rtsp_url: str
98
    seconds: float = 15.0
99
    sample_fps: float = 2.0
100
    min_conf: float = 0.4
101
 
102
 
103
@router.post("/devices/{device_id}/rtsp")
104
def ingest_rtsp(device_id: str, req: RtspReq, db: Session = Depends(get_db),
105
                user: User = Depends(require(Perm.INGEST))):
106
    """Sample the owned unit's RTSP stream for a bounded window and ingest plate tracks."""
107
    cam = _get_device(db, device_id)
108
    if req.seconds > 120:
109
        raise HTTPException(400, "capture window capped at 120s")
110
    try:
111
        result = ingest_flock_rtsp(db, cam, req.rtsp_url, seconds=req.seconds,
112
                                   created_by=user.id, sample_fps=req.sample_fps,
113
                                   min_conf=req.min_conf)
114
    except Exception as e:
115
        raise HTTPException(502, f"stream capture failed: {e}")
116
    db.add(AuditLog(actor_user_id=user.id, action="flock.rtsp_ingest",
117
                    entity_type="camera", entity_id=cam.id,
118
                    meta={"plates": len(result.get("plates", [])), "seconds": req.seconds}))
119
    db.commit()
120
    return result
121
 
122
 
123
@router.post("/devices/{device_id}/import")
124
async def ingest_export(
125
    device_id: str,
126
    file: UploadFile = File(...),
127
    db: Session = Depends(get_db),
128
    user: User = Depends(require(Perm.INGEST)),
129
):
130
    """Ingest a Flock-style CSV/JSON export file from the owned unit."""
131
    cam = _get_device(db, device_id)
132
    data = await file.read()
133
    if not data:
134
        raise HTTPException(400, "empty file")
135
    try:
136
        result = ingest_flock_export(db, cam, data, file.filename or "export.csv",
137
                                     created_by=user.id, content_type=file.content_type)
138
    except Exception as e:
139
        raise HTTPException(422, f"export ingest failed: {e}")
140
    db.add(AuditLog(actor_user_id=user.id, action="flock.export_ingest",
141
                    entity_type="camera", entity_id=cam.id,
142
                    meta={"filename": file.filename, "inserted": result.get("inserted", 0),
143
                          "import_id": result.get("import_id")}))
144
    db.commit()
145
    return result