| 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_current_user, get_db, require |
| 8 | from app.models import ( |
| 9 | AuditLog, EntityRelationship, Investigation, Note, PlateRead, Sighting, User, |
| 10 | ) |
| 11 | from app.schemas import InvestigationCreate, InvestigationOut, NoteCreate |
| 12 | |
| 13 | router = APIRouter(prefix="/investigations", tags=["investigations"]) |
| 14 | |
| 15 | |
| 16 | @router.get("", response_model=list[InvestigationOut]) |
| 17 | def list_investigations(status: str | None = None, db: Session = Depends(get_db), |
| 18 | _: User = Depends(require(Perm.READ))): |
| 19 | q = select(Investigation).order_by(Investigation.created_at.desc()) |
| 20 | if status: |
| 21 | q = q.where(Investigation.status == status) |
| 22 | return db.scalars(q).all() |
| 23 | |
| 24 | |
| 25 | @router.post("", response_model=InvestigationOut, status_code=201) |
| 26 | def create_investigation(payload: InvestigationCreate, db: Session = Depends(get_db), |
| 27 | user: User = Depends(require(Perm.WRITE))): |
| 28 | inv = Investigation(title=payload.title, summary=payload.summary, |
| 29 | priority=payload.priority, organization=payload.organization, |
| 30 | lead_user_id=user.id, status="open") |
| 31 | db.add(inv) |
| 32 | db.flush() |
| 33 | db.add(AuditLog(actor_user_id=user.id, action="investigation.create", |
| 34 | entity_type="investigation", entity_id=inv.id)) |
| 35 | db.commit() |
| 36 | db.refresh(inv) |
| 37 | return inv |
| 38 | |
| 39 | |
| 40 | @router.get("/{inv_id}", response_model=InvestigationOut) |
| 41 | def get_investigation(inv_id: str, db: Session = Depends(get_db), _: User = Depends(require(Perm.READ))): |
| 42 | inv = db.get(Investigation, inv_id) |
| 43 | if not inv: |
| 44 | raise HTTPException(404, "investigation not found") |
| 45 | return inv |
| 46 | |
| 47 | |
| 48 | @router.post("/{inv_id}/sightings") |
| 49 | def attach_sightings(inv_id: str, plate_norm: str, db: Session = Depends(get_db), |
| 50 | user: User = Depends(require(Perm.WRITE))): |
| 51 | """Attach all sightings of a plate to this investigation.""" |
| 52 | if not db.get(Investigation, inv_id): |
| 53 | raise HTTPException(404, "investigation not found") |
| 54 | reads = db.scalars(select(PlateRead).where(PlateRead.plate_norm == plate_norm)).all() |
| 55 | attached = 0 |
| 56 | for r in reads: |
| 57 | s = db.scalar(select(Sighting).where(Sighting.plate_read_id == r.id)) |
| 58 | if s and s.investigation_id != inv_id: |
| 59 | s.investigation_id = inv_id |
| 60 | attached += 1 |
| 61 | db.add(AuditLog(actor_user_id=user.id, action="investigation.attach_sightings", |
| 62 | entity_type="investigation", entity_id=inv_id, |
| 63 | meta={"plate_norm": plate_norm, "attached": attached})) |
| 64 | db.commit() |
| 65 | return {"attached": attached, "plate_norm": plate_norm} |
| 66 | |
| 67 | |
| 68 | @router.post("/{inv_id}/notes") |
| 69 | def add_note(inv_id: str, payload: NoteCreate, db: Session = Depends(get_db), |
| 70 | user: User = Depends(require(Perm.WRITE))): |
| 71 | if not db.get(Investigation, inv_id): |
| 72 | raise HTTPException(404, "investigation not found") |
| 73 | note = Note(investigation_id=inv_id, author_id=user.id, body=payload.body, pinned=payload.pinned) |
| 74 | db.add(note) |
| 75 | db.commit() |
| 76 | return {"id": note.id} |
| 77 | |
| 78 | |
| 79 | @router.get("/{inv_id}/timeline") |
| 80 | def timeline(inv_id: str, db: Session = Depends(get_db), _: User = Depends(require(Perm.READ))): |
| 81 | rows = db.execute( |
| 82 | select(PlateRead.plate_norm, PlateRead.read_ts, PlateRead.camera_id, |
| 83 | PlateRead.direction, PlateRead.confidence) |
| 84 | .join(Sighting, Sighting.plate_read_id == PlateRead.id) |
| 85 | .where(Sighting.investigation_id == inv_id) |
| 86 | .order_by(PlateRead.read_ts) |
| 87 | ).all() |
| 88 | return [{"plate_norm": r[0], "read_ts": r[1], "camera_id": r[2], |
| 89 | "direction": r[3], "confidence": float(r[4]) if r[4] is not None else None} |
| 90 | for r in rows] |
| 91 | |
| 92 | |
| 93 | @router.get("/{inv_id}/graph") |
| 94 | def entity_graph(inv_id: str, db: Session = Depends(get_db), _: User = Depends(require(Perm.READ))): |
| 95 | """Build a node-link graph from the investigation's sightings: |
| 96 | plate -> camera edges, plus any stored entity_relationships touching those nodes.""" |
| 97 | rows = db.execute( |
| 98 | select(PlateRead.plate_norm, PlateRead.camera_id) |
| 99 | .join(Sighting, Sighting.plate_read_id == PlateRead.id) |
| 100 | .where(Sighting.investigation_id == inv_id) |
| 101 | ).all() |
| 102 | nodes: dict[str, dict] = {} |
| 103 | edges: list[dict] = [] |
| 104 | seen_edges: set = set() |
| 105 | for plate, cam in rows: |
| 106 | if plate: |
| 107 | nodes[f"plate:{plate}"] = {"id": f"plate:{plate}", "type": "plate", "label": plate} |
| 108 | if cam: |
| 109 | nodes[f"camera:{cam}"] = {"id": f"camera:{cam}", "type": "camera", "label": cam} |
| 110 | if plate and cam: |
| 111 | key = (plate, cam) |
| 112 | if key not in seen_edges: |
| 113 | seen_edges.add(key) |
| 114 | edges.append({"source": f"plate:{plate}", "target": f"camera:{cam}", |
| 115 | "relation": "seen_at"}) |
| 116 | rels = db.scalars(select(EntityRelationship)).all() |
| 117 | for rel in rels: |
| 118 | s, t = f"{rel.src_type}:{rel.src_id}", f"{rel.dst_type}:{rel.dst_id}" |
| 119 | if s in nodes or t in nodes: |
| 120 | nodes.setdefault(s, {"id": s, "type": rel.src_type, "label": rel.src_id}) |
| 121 | nodes.setdefault(t, {"id": t, "type": rel.dst_type, "label": rel.dst_id}) |
| 122 | edges.append({"source": s, "target": t, "relation": rel.relation}) |
| 123 | return {"nodes": list(nodes.values()), "edges": edges} |