| 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 |