● Zion Boggan
repos/Sightline/backend/app/api/routes/intel.py
zionboggan.com ↗
179 lines · python
History for this file →
1
"""Omni-search and person/detection intelligence. One query box that searches across plates,
2
persons (by name/alias), vehicles (make/model/color/body), detections (clothing/articles), and
3
cameras (operator/name) - plus type-specific faceted endpoints and a paginated camera grid."""
4
from __future__ import annotations
5
 
6
from fastapi import APIRouter, Depends, HTTPException, Query
7
from sqlalchemy import String, cast, func, or_, select
8
from sqlalchemy.orm import Session
9
 
10
from app.api.deps import Perm, get_db, require
11
from app.models import (
12
    Camera, Detection, LicensePlate, Location, Person, PlateRead, Sighting, User, Vehicle,
13
)
14
 
15
router = APIRouter(tags=["intel"])
16
 
17
 
18
def _norm_plate(q: str) -> str:
19
    return "".join(ch for ch in q.upper() if ch.isalnum())
20
 
21
 
22
@router.get("/search/omni")
23
def omni_search(q: str = Query(..., min_length=1), limit: int = 8,
24
                db: Session = Depends(get_db), _: User = Depends(require(Perm.READ))):
25
    """Search anything: a plate, a person's name, a vehicle make/model/color, an article of
26
    clothing, or a camera. Returns grouped, ranked results."""
27
    like = f"%{q}%"
28
    np = _norm_plate(q)
29
 
30
    plates = db.execute(
31
        select(LicensePlate.plate_norm, LicensePlate.state, func.count(PlateRead.id))
32
        .join(PlateRead, PlateRead.plate_id == LicensePlate.id, isouter=True)
33
        .where(LicensePlate.plate_norm.like(f"%{np}%") if np else False)
34
        .group_by(LicensePlate.plate_norm, LicensePlate.state)
35
        .order_by(func.count(PlateRead.id).desc()).limit(limit)
36
    ).all() if np else []
37
 
38
    persons = db.scalars(
39
        select(Person).where(or_(Person.full_name.ilike(like),
40
                                 cast(Person.aliases, String).ilike(like)))
41
        .limit(limit)
42
    ).all()
43
 
44
    vehicles = db.execute(
45
        select(Vehicle, LicensePlate.plate_norm)
46
        .join(LicensePlate, Vehicle.primary_plate_id == LicensePlate.id, isouter=True)
47
        .where(or_(Vehicle.make.ilike(like), Vehicle.model.ilike(like),
48
                   Vehicle.color.ilike(like), Vehicle.body_type.ilike(like)))
49
        .limit(limit)
50
    ).all()
51
 
52
    detections = db.scalars(
53
        select(Detection).where(or_(
54
            Detection.clothing_top.ilike(like), Detection.clothing_bottom.ilike(like),
55
            Detection.description.ilike(like),
56
            cast(Detection.articles, String).ilike(like)))
57
        .order_by(Detection.read_ts.desc()).limit(limit)
58
    ).all()
59
 
60
    cameras = db.scalars(
61
        select(Camera).where(or_(Camera.operator.ilike(like), Camera.name.ilike(like),
62
                                 Camera.state.ilike(q)))
63
        .limit(limit)
64
    ).all()
65
 
66
    return {
67
        "query": q,
68
        "plates": [{"plate_norm": p[0], "state": p[1], "reads": p[2]} for p in plates],
69
        "persons": [{"id": p.id, "full_name": p.full_name, "aliases": p.aliases,
70
                     "threat_level": p.threat_level, "wanted": p.wanted,
71
                     "associated_plate": p.associated_plate} for p in persons],
72
        "vehicles": [{"plate_norm": v[1], "make": v[0].make, "model": v[0].model,
73
                      "color": v[0].color, "body_type": v[0].body_type} for v in vehicles],
74
        "detections": [{"id": d.id, "clothing_top": d.clothing_top, "clothing_bottom": d.clothing_bottom,
75
                        "articles": d.articles, "description": d.description,
76
                        "plate_norm": d.plate_norm, "read_ts": d.read_ts} for d in detections],
77
        "cameras": [{"id": c.id, "name": c.name, "operator": c.operator, "state": c.state} for c in cameras],
78
    }
79
 
80
 
81
@router.get("/persons/search")
82
def persons_search(q: str = Query(..., min_length=1), db: Session = Depends(get_db),
83
                   _: User = Depends(require(Perm.READ))):
84
    like = f"%{q}%"
85
    rows = db.scalars(select(Person).where(Person.full_name.ilike(like)).limit(50)).all()
86
    return [{"id": p.id, "full_name": p.full_name, "aliases": p.aliases,
87
             "threat_level": p.threat_level, "wanted": p.wanted,
88
             "associated_plate": p.associated_plate} for p in rows]
89
 
90
 
91
@router.get("/persons/{person_id}")
92
def person_profile(person_id: str, db: Session = Depends(get_db), _: User = Depends(require(Perm.READ))):
93
    p = db.get(Person, person_id)
94
    if not p:
95
        raise HTTPException(404, "person not found")
96
    dets = db.scalars(select(Detection).where(Detection.person_id == person_id)
97
                      .order_by(Detection.read_ts.desc()).limit(100)).all()
98
    return {
99
        "id": p.id, "full_name": p.full_name, "aliases": p.aliases, "dob": p.dob, "sex": p.sex,
100
        "height_cm": p.height_cm, "build": p.build, "hair": p.hair, "eyes": p.eyes,
101
        "distinguishing_marks": p.distinguishing_marks, "associated_plate": p.associated_plate,
102
        "threat_level": p.threat_level, "wanted": p.wanted, "notes": p.notes,
103
        "detections": [{"id": d.id, "read_ts": d.read_ts, "camera_id": d.camera_id,
104
                        "clothing_top": d.clothing_top, "clothing_bottom": d.clothing_bottom,
105
                        "articles": d.articles, "occupant_role": d.occupant_role,
106
                        "description": d.description, "plate_norm": d.plate_norm} for d in dets],
107
    }
108
 
109
 
110
@router.get("/detections/search")
111
def detections_search(clothing: str | None = None, article: str | None = None,
112
                      q: str | None = None, db: Session = Depends(get_db),
113
                      _: User = Depends(require(Perm.READ))):
114
    """Search detections by clothing color, article of clothing, or free text."""
115
    query = select(Detection)
116
    if clothing:
117
        query = query.where(or_(Detection.clothing_top.ilike(f"%{clothing}%"),
118
                                Detection.clothing_bottom.ilike(f"%{clothing}%")))
119
    if article:
120
        query = query.where(cast(Detection.articles, String).ilike(f"%{article}%"))
121
    if q:
122
        query = query.where(Detection.description.ilike(f"%{q}%"))
123
    rows = db.scalars(query.order_by(Detection.read_ts.desc()).limit(100)).all()
124
    return [{"id": d.id, "person_id": d.person_id, "read_ts": d.read_ts, "camera_id": d.camera_id,
125
             "clothing_top": d.clothing_top, "clothing_bottom": d.clothing_bottom,
126
             "articles": d.articles, "occupant_role": d.occupant_role,
127
             "description": d.description, "plate_norm": d.plate_norm} for d in rows]
128
 
129
 
130
@router.get("/vehicles/search")
131
def vehicles_search(make: str | None = None, model: str | None = None, color: str | None = None,
132
                    body_type: str | None = None, db: Session = Depends(get_db),
133
                    _: User = Depends(require(Perm.READ))):
134
    query = select(Vehicle, LicensePlate.plate_norm, LicensePlate.state).join(
135
        LicensePlate, Vehicle.primary_plate_id == LicensePlate.id, isouter=True)
136
    if make:
137
        query = query.where(Vehicle.make.ilike(f"%{make}%"))
138
    if model:
139
        query = query.where(Vehicle.model.ilike(f"%{model}%"))
140
    if color:
141
        query = query.where(Vehicle.color.ilike(f"%{color}%"))
142
    if body_type:
143
        query = query.where(Vehicle.body_type.ilike(f"%{body_type}%"))
144
    rows = db.execute(query.limit(200)).all()
145
    return [{"plate_norm": r[1], "state": r[2], "make": r[0].make, "model": r[0].model,
146
             "color": r[0].color, "body_type": r[0].body_type, "year": r[0].year} for r in rows]
147
 
148
 
149
@router.get("/cameras/grid")
150
def cameras_grid(state: str | None = None, operator: str | None = None, q: str | None = None,
151
                 limit: int = 60, offset: int = 0, db: Session = Depends(get_db),
152
                 _: User = Depends(require(Perm.READ))):
153
    """Paginated, filterable camera grid."""
154
    base = select(Camera)
155
    if state:
156
        base = base.where(Camera.state == state.upper())
157
    if operator:
158
        base = base.where(Camera.operator.ilike(f"%{operator}%"))
159
    if q:
160
        base = base.where(or_(Camera.name.ilike(f"%{q}%"), Camera.operator.ilike(f"%{q}%")))
161
    total = db.scalar(select(func.count()).select_from(base.subquery()))
162
    cams = db.scalars(base.order_by(Camera.state, Camera.name).limit(min(limit, 200)).offset(offset)).all()
163
    out = []
164
    for c in cams:
165
        loc = db.get(Location, c.location_id) if c.location_id else None
166
        reads = db.scalar(select(func.count()).select_from(PlateRead).where(PlateRead.camera_id == c.id)) or 0
167
        out.append({"id": c.id, "name": c.name, "operator": c.operator, "state": c.state,
168
                    "zone": c.zone, "direction_facing": c.direction_facing,
169
                    "lat": loc.lat if loc else None, "lon": loc.lon if loc else None,
170
                    "reads": reads, "has_live_feed": bool(c.has_live_feed),
171
                    "snapshot_url": f"/api/v1/cameras/{c.id}/snapshot" if c.has_live_feed else None})
172
    return {"total": total, "limit": limit, "offset": offset, "cameras": out}
173
 
174
 
175
@router.get("/cameras/operators")
176
def camera_operators(db: Session = Depends(get_db), _: User = Depends(require(Perm.READ))):
177
    rows = db.execute(select(Camera.operator, func.count()).group_by(Camera.operator)
178
                      .order_by(func.count().desc())).all()
179
    return [{"operator": r[0] or "Unknown", "count": r[1]} for r in rows]