| 1 | from __future__ import annotations |
| 2 | |
| 3 | from fastapi import APIRouter, Depends, HTTPException |
| 4 | from sqlalchemy import select |
| 5 | from sqlalchemy.orm import Session |
| 6 | |
| 7 | from app.api.deps import Perm, get_db, require |
| 8 | from app.models import ( |
| 9 | Alert, AuditLog, PlateRead, Sighting, User, Watchlist, WatchlistEntry, WatchlistHit, |
| 10 | ) |
| 11 | from app.schemas import WatchlistCreate, WatchlistEntryCreate |
| 12 | from app.services.watchlist import WatchEntry, match_all |
| 13 | |
| 14 | router = APIRouter(tags=["watchlists"]) |
| 15 | |
| 16 | |
| 17 | @router.get("/watchlists") |
| 18 | def list_watchlists(db: Session = Depends(get_db), _: User = Depends(require(Perm.READ))): |
| 19 | return db.scalars(select(Watchlist).order_by(Watchlist.created_at.desc())).all() |
| 20 | |
| 21 | |
| 22 | @router.post("/watchlists", status_code=201) |
| 23 | def create_watchlist(payload: WatchlistCreate, db: Session = Depends(get_db), |
| 24 | user: User = Depends(require(Perm.MANAGE_WATCHLIST))): |
| 25 | wl = Watchlist(name=payload.name, kind=payload.kind, organization=payload.organization, |
| 26 | created_by=user.id) |
| 27 | db.add(wl) |
| 28 | db.flush() |
| 29 | db.add(AuditLog(actor_user_id=user.id, action="watchlist.create", |
| 30 | entity_type="watchlist", entity_id=wl.id)) |
| 31 | db.commit() |
| 32 | db.refresh(wl) |
| 33 | return wl |
| 34 | |
| 35 | |
| 36 | @router.post("/watchlists/{wl_id}/entries", status_code=201) |
| 37 | def add_entry(wl_id: str, payload: WatchlistEntryCreate, backfill: bool = True, |
| 38 | db: Session = Depends(get_db), user: User = Depends(require(Perm.MANAGE_WATCHLIST))): |
| 39 | if not db.get(Watchlist, wl_id): |
| 40 | raise HTTPException(404, "watchlist not found") |
| 41 | norm = payload.plate_norm |
| 42 | if norm: |
| 43 | norm = "".join(c for c in norm.upper() if c.isalnum()) |
| 44 | entry = WatchlistEntry(watchlist_id=wl_id, plate_norm=norm, |
| 45 | partial_plate=payload.partial_plate, vehicle_desc=payload.vehicle_desc, |
| 46 | reason=payload.reason, expires_at=payload.expires_at) |
| 47 | db.add(entry) |
| 48 | db.flush() |
| 49 | |
| 50 | created_hits = 0 |
| 51 | if backfill: |
| 52 | we = WatchEntry(id=entry.id, plate_norm=entry.plate_norm, |
| 53 | partial_plate=entry.partial_plate, vehicle_desc=entry.vehicle_desc) |
| 54 | reads = db.scalars(select(PlateRead)).all() |
| 55 | for r in reads: |
| 56 | if match_all([we], r.plate_norm, None): |
| 57 | s = db.scalar(select(Sighting).where(Sighting.plate_read_id == r.id)) |
| 58 | db.add(WatchlistHit(watchlist_entry_id=entry.id, plate_read_id=r.id, |
| 59 | sighting_id=s.id if s else None, matched_on="backfill")) |
| 60 | created_hits += 1 |
| 61 | db.add(AuditLog(actor_user_id=user.id, action="watchlist.add_entry", |
| 62 | entity_type="watchlist_entry", entity_id=entry.id, |
| 63 | meta={"backfill_hits": created_hits})) |
| 64 | db.commit() |
| 65 | return {"id": entry.id, "backfill_hits": created_hits} |
| 66 | |
| 67 | |
| 68 | @router.get("/watchlists/{wl_id}/entries") |
| 69 | def list_entries(wl_id: str, db: Session = Depends(get_db), _: User = Depends(require(Perm.READ))): |
| 70 | if not db.get(Watchlist, wl_id): |
| 71 | raise HTTPException(404, "watchlist not found") |
| 72 | rows = db.scalars(select(WatchlistEntry).where(WatchlistEntry.watchlist_id == wl_id) |
| 73 | .order_by(WatchlistEntry.created_at.desc())).all() |
| 74 | return [{"id": e.id, "plate_norm": e.plate_norm, "partial_plate": e.partial_plate, |
| 75 | "vehicle_desc": e.vehicle_desc, "reason": e.reason, |
| 76 | "expires_at": e.expires_at, "created_at": e.created_at} for e in rows] |
| 77 | |
| 78 | |
| 79 | @router.get("/watchlist-hits") |
| 80 | def list_hits(db: Session = Depends(get_db), _: User = Depends(require(Perm.READ))): |
| 81 | rows = db.execute( |
| 82 | select(WatchlistHit.id, WatchlistHit.matched_on, WatchlistHit.created_at, |
| 83 | PlateRead.plate_norm, PlateRead.camera_id) |
| 84 | .join(PlateRead, PlateRead.id == WatchlistHit.plate_read_id) |
| 85 | .order_by(WatchlistHit.created_at.desc()).limit(200) |
| 86 | ).all() |
| 87 | return [{"id": r[0], "matched_on": r[1], "at": r[2], "plate_norm": r[3], "camera_id": r[4]} |
| 88 | for r in rows] |
| 89 | |
| 90 | |
| 91 | @router.get("/alerts") |
| 92 | def list_alerts(status: str | None = None, db: Session = Depends(get_db), _: User = Depends(require(Perm.READ))): |
| 93 | q = select(Alert).order_by(Alert.created_at.desc()).limit(200) |
| 94 | if status: |
| 95 | q = q.where(Alert.status == status) |
| 96 | return db.scalars(q).all() |
| 97 | |
| 98 | |
| 99 | @router.patch("/alerts/{alert_id}") |
| 100 | def update_alert(alert_id: int, status: str, db: Session = Depends(get_db), |
| 101 | user: User = Depends(require(Perm.WRITE))): |
| 102 | alert = db.get(Alert, alert_id) |
| 103 | if not alert: |
| 104 | raise HTTPException(404, "alert not found") |
| 105 | if status not in {"new", "ack", "resolved"}: |
| 106 | raise HTTPException(400, "invalid status") |
| 107 | alert.status = status |
| 108 | db.add(AuditLog(actor_user_id=user.id, action="alert.update", |
| 109 | entity_type="alert", entity_id=str(alert_id), meta={"status": status})) |
| 110 | db.commit() |
| 111 | return {"id": alert_id, "status": status} |