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