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