| 1 | from __future__ import annotations |
| 2 | |
| 3 | from fastapi import APIRouter, Depends, HTTPException, Query |
| 4 | from sqlalchemy import func, select |
| 5 | from sqlalchemy.orm import Session |
| 6 | |
| 7 | from app.api.deps import Perm, get_db, require |
| 8 | from app.core.config import settings |
| 9 | from app.models import Camera, LicensePlate, PlateRead, Sighting, User, Vehicle |
| 10 | from app.schemas import SearchQuery |
| 11 | from app.services.analytics import camera_transitions, find_cotravel, movement_summary |
| 12 | |
| 13 | router = APIRouter(tags=["search"]) |
| 14 | |
| 15 | |
| 16 | @router.get("/plates/search") |
| 17 | def plate_search(q: str = Query(..., min_length=1), partial: bool = False, |
| 18 | state: str | None = None, db: Session = Depends(get_db), |
| 19 | _: User = Depends(require(Perm.READ))): |
| 20 | norm = "".join(ch for ch in q.upper() if ch.isalnum()) |
| 21 | query = select(LicensePlate) |
| 22 | if partial: |
| 23 | query = query.where(LicensePlate.plate_norm.like(f"%{norm}%")) |
| 24 | else: |
| 25 | query = query.where(LicensePlate.plate_norm == norm) |
| 26 | if state: |
| 27 | query = query.where(LicensePlate.state == state.upper()) |
| 28 | plates = db.scalars(query.limit(100)).all() |
| 29 | return [{"plate_norm": p.plate_norm, "state": p.state, "plate_raw": p.plate_raw} for p in plates] |
| 30 | |
| 31 | |
| 32 | @router.get("/states") |
| 33 | def states_overview(db: Session = Depends(get_db), _: User = Depends(require(Perm.READ))): |
| 34 | """Per-state coverage: read count + distinct plates, joined from the plate's registration |
| 35 | state. Powers the nationwide states view and lets an analyst drill into any state.""" |
| 36 | rows = db.execute( |
| 37 | select(LicensePlate.state, func.count(PlateRead.id), func.count(func.distinct(PlateRead.plate_id))) |
| 38 | .join(PlateRead, PlateRead.plate_id == LicensePlate.id) |
| 39 | .group_by(LicensePlate.state) |
| 40 | .order_by(func.count(PlateRead.id).desc()) |
| 41 | ).all() |
| 42 | return [{"state": r[0] or "UNK", "reads": r[1], "vehicles": r[2]} for r in rows] |
| 43 | |
| 44 | |
| 45 | @router.get("/states/{state}/plates") |
| 46 | def state_plates(state: str, db: Session = Depends(get_db), _: User = Depends(require(Perm.READ))): |
| 47 | rows = db.execute( |
| 48 | select(LicensePlate.plate_norm, func.count(PlateRead.id)) |
| 49 | .join(PlateRead, PlateRead.plate_id == LicensePlate.id) |
| 50 | .where(LicensePlate.state == state.upper()) |
| 51 | .group_by(LicensePlate.plate_norm) |
| 52 | .order_by(func.count(PlateRead.id).desc()) |
| 53 | .limit(200) |
| 54 | ).all() |
| 55 | return {"state": state.upper(), "plates": [{"plate_norm": r[0], "reads": r[1]} for r in rows]} |
| 56 | |
| 57 | |
| 58 | @router.get("/plates/{plate_norm}") |
| 59 | def plate_profile(plate_norm: str, db: Session = Depends(get_db), _: User = Depends(require(Perm.READ))): |
| 60 | reads = db.scalars(select(PlateRead).where(PlateRead.plate_norm == plate_norm.upper()) |
| 61 | .order_by(PlateRead.read_ts)).all() |
| 62 | if not reads: |
| 63 | raise HTTPException(404, "no reads for plate") |
| 64 | read_dicts = _read_dicts(db, reads) |
| 65 | m = movement_summary(plate_norm.upper(), read_dicts) |
| 66 | return { |
| 67 | "plate_norm": plate_norm.upper(), |
| 68 | "total_reads": m.total_reads, |
| 69 | "first_seen": m.first_seen, "last_seen": m.last_seen, |
| 70 | "distinct_cameras": m.distinct_cameras, |
| 71 | "by_camera": m.by_camera, "by_hour": m.by_hour, |
| 72 | "reads": [{"read_ts": r["read_ts"], "camera_id": r["camera_id"], |
| 73 | "confidence": r["confidence"]} for r in read_dicts], |
| 74 | } |
| 75 | |
| 76 | |
| 77 | @router.post("/search") |
| 78 | def unified_search(qry: SearchQuery, db: Session = Depends(get_db), _: User = Depends(require(Perm.READ))): |
| 79 | q = select(PlateRead) |
| 80 | if qry.plate: |
| 81 | q = q.where(PlateRead.plate_norm == "".join(c for c in qry.plate.upper() if c.isalnum())) |
| 82 | if qry.partial_plate: |
| 83 | p = "".join(c for c in qry.partial_plate.upper() if c.isalnum()) |
| 84 | q = q.where(PlateRead.plate_norm.like(f"%{p}%")) |
| 85 | if qry.camera_id: |
| 86 | q = q.where(PlateRead.camera_id == qry.camera_id) |
| 87 | if qry.from_ts: |
| 88 | q = q.where(PlateRead.read_ts >= qry.from_ts) |
| 89 | if qry.to_ts: |
| 90 | q = q.where(PlateRead.read_ts <= qry.to_ts) |
| 91 | if qry.incident_ref: |
| 92 | q = q.where(PlateRead.incident_ref == qry.incident_ref) |
| 93 | reads = db.scalars(q.order_by(PlateRead.read_ts.desc()).limit(qry.limit)).all() |
| 94 | |
| 95 | if any([qry.make, qry.color, qry.body_type]): |
| 96 | reads = _filter_by_vehicle(db, reads, qry) |
| 97 | |
| 98 | return {"count": len(reads), |
| 99 | "results": [{"plate_norm": r.plate_norm, "read_ts": r.read_ts, |
| 100 | "camera_id": r.camera_id, "direction": r.direction, |
| 101 | "confidence": float(r.confidence) if r.confidence is not None else None, |
| 102 | "hotlist_hit": r.hotlist_hit} for r in reads]} |
| 103 | |
| 104 | |
| 105 | @router.get("/vehicles/{plate_norm}/movement") |
| 106 | def vehicle_movement(plate_norm: str, db: Session = Depends(get_db), _: User = Depends(require(Perm.READ))): |
| 107 | reads = db.scalars(select(PlateRead).where(PlateRead.plate_norm == plate_norm.upper())).all() |
| 108 | if not reads: |
| 109 | raise HTTPException(404, "no reads for plate") |
| 110 | rd = _read_dicts(db, reads) |
| 111 | cams = _camera_coords(db) |
| 112 | m = movement_summary(plate_norm.upper(), rd, cameras=cams) |
| 113 | return { |
| 114 | "plate_norm": m.plate_norm, "total_reads": m.total_reads, |
| 115 | "distinct_cameras": m.distinct_cameras, |
| 116 | "first_seen": m.first_seen, "last_seen": m.last_seen, |
| 117 | "route": [{"from": l.from_camera, "to": l.to_camera, "elapsed_s": l.elapsed_s, |
| 118 | "distance_km": l.distance_km, "speed_kmh": l.speed_kmh, |
| 119 | "implausible": l.implausible} for l in m.route], |
| 120 | "dwell": [{"camera_id": d.camera_id, "start": d.start_ts, "end": d.end_ts, |
| 121 | "duration_s": d.duration_s, "reads": d.reads} for d in m.dwell], |
| 122 | "anomalies": m.anomalies, |
| 123 | "by_hour": m.by_hour, |
| 124 | } |
| 125 | |
| 126 | |
| 127 | @router.get("/vehicles/{plate_norm}/cotravel") |
| 128 | def vehicle_cotravel(plate_norm: str, db: Session = Depends(get_db), _: User = Depends(require(Perm.READ))): |
| 129 | """Vehicles repeatedly co-located with this plate within the co-travel window - |
| 130 | candidate associates. Guarded against false positives by requiring shared sightings.""" |
| 131 | reads = db.scalars(select(PlateRead)).all() |
| 132 | by_vehicle: dict[str, list[dict]] = {} |
| 133 | for r in reads: |
| 134 | by_vehicle.setdefault(r.plate_norm, []).append({"camera_id": r.camera_id, "read_ts": r.read_ts}) |
| 135 | if plate_norm.upper() not in by_vehicle: |
| 136 | raise HTTPException(404, "no reads for plate") |
| 137 | candidates = find_cotravel(by_vehicle, plate_norm.upper(), |
| 138 | window_seconds=settings.cotravel_window_seconds, min_shared=2) |
| 139 | return {"plate_norm": plate_norm.upper(), "window_seconds": settings.cotravel_window_seconds, |
| 140 | "candidates": candidates} |
| 141 | |
| 142 | |
| 143 | @router.get("/cameras/{camera_id}/transits") |
| 144 | def camera_transits(camera_id: str, db: Session = Depends(get_db), _: User = Depends(require(Perm.READ))): |
| 145 | reads = db.scalars(select(PlateRead)).all() |
| 146 | by_vehicle: dict[str, list[dict]] = {} |
| 147 | for r in reads: |
| 148 | by_vehicle.setdefault(r.plate_norm, []).append( |
| 149 | {"camera_id": r.camera_id, "read_ts": r.read_ts}) |
| 150 | edges = [e for e in camera_transitions(by_vehicle) |
| 151 | if e["from"] == camera_id or e["to"] == camera_id] |
| 152 | return {"camera_id": camera_id, "transitions": edges} |
| 153 | |
| 154 | |
| 155 | def _read_dicts(db: Session, reads) -> list[dict]: |
| 156 | cams = _camera_coords(db) |
| 157 | out = [] |
| 158 | for r in reads: |
| 159 | c = cams.get(r.camera_id, {}) |
| 160 | out.append({"plate_norm": r.plate_norm, "read_ts": r.read_ts, "camera_id": r.camera_id, |
| 161 | "confidence": float(r.confidence) if r.confidence is not None else None, |
| 162 | "lat": r.lat if r.lat is not None else c.get("lat"), |
| 163 | "lon": r.lon if r.lon is not None else c.get("lon")}) |
| 164 | return out |
| 165 | |
| 166 | |
| 167 | def _camera_coords(db: Session) -> dict[str, dict]: |
| 168 | rows = db.execute(select(Camera.id, Camera.location_id)).all() |
| 169 | coords: dict[str, dict] = {} |
| 170 | from app.models import Location |
| 171 | locs = {l.id: l for l in db.scalars(select(Location)).all()} |
| 172 | for cam_id, loc_id in rows: |
| 173 | loc = locs.get(loc_id) |
| 174 | coords[cam_id] = {"lat": loc.lat if loc else None, "lon": loc.lon if loc else None} |
| 175 | return coords |
| 176 | |
| 177 | |
| 178 | def _filter_by_vehicle(db: Session, reads, qry: SearchQuery): |
| 179 | keep = [] |
| 180 | for r in reads: |
| 181 | veh = db.scalar(select(Vehicle).join(LicensePlate, Vehicle.primary_plate_id == LicensePlate.id) |
| 182 | .where(LicensePlate.plate_norm == r.plate_norm)) |
| 183 | if not veh: |
| 184 | continue |
| 185 | if qry.make and (veh.make or "").lower() != qry.make.lower(): |
| 186 | continue |
| 187 | if qry.color and (veh.color or "").lower() != qry.color.lower(): |
| 188 | continue |
| 189 | if qry.body_type and (veh.body_type or "").lower() != qry.body_type.lower(): |
| 190 | continue |
| 191 | keep.append(r) |
| 192 | return keep |