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