PawTrace / backend /app /api /cases.py
Elliott Duke
Next version: reset button, +250 distractors, 5 named pairs, breed-widen, texture
7f48c4d
Raw
History Blame Contribute Delete
13.3 kB
from __future__ import annotations
from datetime import date
from fastapi import (
APIRouter,
Depends,
File,
Form,
HTTPException,
Request,
UploadFile,
status,
)
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from ..config import settings
from ..db import get_db
from ..models import Case, KnownDog, UnknownDog, User
from ..models.base import (
CaseStatus,
CaseType,
KnownDogStatus,
SubjectType,
UnknownDogStatus,
)
from ..schemas.case import (
CaseOut,
CaseUpdate,
FoundReportResponse,
LostCaseCreate,
MatchOut,
)
from ..schemas.common import Page
from ..security import get_current_user, get_current_user_optional
from ..services.images import ImageValidationError, process_and_store_picture
from ..services.matching import (
notify_owner_of_strong_match,
rematch_open_lost_cases_against,
run_matching_for_case,
)
from .helpers import rate_limit
from .hydrate import build_match_out
from .shelters import nearby_shelters
router = APIRouter(prefix="/cases", tags=["cases"])
def _first_radius() -> int:
return settings.radius_levels[0] if settings.radius_levels else 0
def _next_radius(current: int) -> int | None:
levels = settings.radius_levels
if current in levels:
idx = levels.index(current)
if idx + 1 < len(levels):
return levels[idx + 1]
return None
def _reject_if_resolved(case: Case) -> None:
"""A resolved/closed case already has a confirmed match — block further matching."""
if case.status in (CaseStatus.resolved, CaseStatus.closed):
raise HTTPException(
status.HTTP_400_BAD_REQUEST, "This case is resolved; matching is closed."
)
def _owns_case(case: Case, user: User | None) -> bool:
if user is None:
return False
if user.role.value == "admin":
return True
return case.person_id == user.id
# ----------------------------- Lost (owner) -----------------------------
@router.post("/lost", response_model=FoundReportResponse, status_code=status.HTTP_201_CREATED)
def create_lost_case(
payload: LostCaseCreate,
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> FoundReportResponse:
if payload.known_dog_id:
dog = db.get(KnownDog, payload.known_dog_id)
if not dog or (dog.owner_id != user.id and user.role.value != "admin"):
raise HTTPException(status.HTTP_404_NOT_FOUND, "Dog not found")
else:
if not payload.new_dog_name:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"Provide known_dog_id or new_dog_name to register a dog inline.",
)
dog = KnownDog(
owner_id=user.id,
name=payload.new_dog_name,
description=payload.new_dog_description or "",
)
db.add(dog)
db.flush()
dog.status = KnownDogStatus.lost
dog.last_known_zip = payload.event_zip
case = Case(
person_id=user.id,
known_dog_id=dog.id,
type=CaseType.lost,
event_zip=payload.event_zip,
event_date=payload.event_date,
search_radius_miles=_first_radius(),
notes=payload.notes,
status=CaseStatus.open,
)
db.add(case)
db.flush()
matches = run_matching_for_case(db, case)
db.commit()
db.refresh(case)
return FoundReportResponse(
case=CaseOut.model_validate(case),
matches=[build_match_out(db, m) for m in matches],
)
# ------------------------- Found (anon ok) -------------------------
# A found report means the reporter HAS the dog — either in their own custody (status "pending") or
# dropped at a shelter/vet (status "at_shelter"). There is no "sighted" (seen-but-not-caught) flow.
@router.post("/found", response_model=FoundReportResponse, status_code=status.HTTP_201_CREATED)
def create_found_case(
request: Request,
event_zip: str = Form(...),
event_date: date = Form(...),
description: str = Form(""),
est_age: str | None = Form(None),
other_info: str | None = Form(None),
current_zip: str | None = Form(None),
current_location: str | None = Form(None),
current_location_detail: str | None = Form(None),
finder_name: str | None = Form(None),
finder_email: str | None = Form(None),
finder_phone: str | None = Form(None),
files: list[UploadFile] = File(...),
user: User | None = Depends(get_current_user_optional),
db: Session = Depends(get_db),
) -> FoundReportResponse:
rate_limit(request, key_prefix="report")
# Contact required when reporting anonymously (spec §7.4, §11).
if user is None and not (finder_email or finder_phone):
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"Anonymous reports must include finder_email or finder_phone.",
)
if not files:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "At least one photo is required.")
if len(files) > settings.max_photos_per_dog:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
f"Max {settings.max_photos_per_dog} photos per report.",
)
# Dropped at a shelter/vet -> at_shelter; otherwise the finder has custody -> pending.
initial_status = (
UnknownDogStatus.at_shelter if current_location_detail else UnknownDogStatus.pending
)
unknown = UnknownDog(
description=description or "",
est_age=est_age,
other_info=other_info,
current_zip=current_zip or event_zip,
current_location_detail=current_location_detail,
status=initial_status,
)
db.add(unknown)
db.flush()
for i, f in enumerate(files):
data = f.file.read()
try:
process_and_store_picture(
db,
subject_type=SubjectType.unknown,
subject_id=unknown.id,
data=data,
is_primary=(i == 0),
)
except ImageValidationError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
case = Case(
person_id=user.id if user else None,
finder_name=finder_name or (user.name if user else None),
finder_email=finder_email or (user.email if user else None),
finder_phone=finder_phone,
unknown_dog_id=unknown.id,
type=CaseType.found,
event_zip=event_zip,
event_date=event_date,
current_location=current_location,
search_radius_miles=_first_radius(),
status=CaseStatus.open,
)
db.add(case)
db.flush()
# Match against the known lost pool, notify owners of strong matches.
matches = run_matching_for_case(db, case)
notify_owner_of_strong_match(db, case, matches)
# Also let this new found dog refresh any open lost cases.
rematch_open_lost_cases_against(db, unknown.id)
db.commit()
db.refresh(case)
return FoundReportResponse(
case=CaseOut.model_validate(case),
matches=[build_match_out(db, m) for m in matches],
vet_guidance=nearby_shelters(current_zip or event_zip),
)
# ----------------------------- Read / update -----------------------------
@router.get("", response_model=Page[CaseOut])
def list_my_cases(
limit: int = 50,
offset: int = 0,
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Page[CaseOut]:
limit = max(1, min(limit, 100))
base = select(Case).where(Case.person_id == user.id)
total = db.execute(
select(func.count()).select_from(Case).where(Case.person_id == user.id)
).scalar_one()
cases = db.execute(
base.order_by(Case.created_at.desc()).limit(limit).offset(offset)
).scalars().all()
return Page(
items=[CaseOut.model_validate(c) for c in cases],
total=total,
limit=limit,
offset=offset,
)
@router.get("/{case_id}", response_model=CaseOut)
def get_case(
case_id: int,
user: User | None = Depends(get_current_user_optional),
db: Session = Depends(get_db),
) -> CaseOut:
case = db.get(Case, case_id)
if not case:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Case not found")
if not _owns_case(case, user):
raise HTTPException(status.HTTP_403_FORBIDDEN, "Not your case")
return CaseOut.model_validate(case)
@router.get("/{case_id}/matches", response_model=list[MatchOut])
def get_case_matches(
case_id: int,
user: User | None = Depends(get_current_user_optional),
db: Session = Depends(get_db),
) -> list[MatchOut]:
case = db.get(Case, case_id)
if not case:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Case not found")
if not _owns_case(case, user):
raise HTTPException(status.HTTP_403_FORBIDDEN, "Not your case")
from ..models import Match
matches = db.execute(
select(Match).where(Match.case_id == case_id).order_by(Match.rank)
).scalars().all()
return [build_match_out(db, m) for m in matches]
@router.get("/{case_id}/dog")
def get_case_dog(
case_id: int,
user: User | None = Depends(get_current_user_optional),
db: Session = Depends(get_db),
) -> dict:
"""The case's own dog (kind + all photos) — for comparing against candidate matches."""
case = db.get(Case, case_id)
if not case:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Case not found")
if not _owns_case(case, user):
raise HTTPException(status.HTTP_403_FORBIDDEN, "Not your case")
from .helpers import pictures_for
if case.known_dog_id is not None:
st, did, kind = SubjectType.known, case.known_dog_id, "known"
elif case.unknown_dog_id is not None:
st, did, kind = SubjectType.unknown, case.unknown_dog_id, "unknown"
else:
return {"kind": None, "photos": []}
return {"kind": kind, "photos": [p.model_dump() for p in pictures_for(db, st, did)]}
@router.post("/{case_id}/rematch", response_model=FoundReportResponse)
def rematch_case(
case_id: int,
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> FoundReportResponse:
"""Re-run matching for a case at its current search radius (no widening). Owner/admin only."""
case = db.get(Case, case_id)
if not case:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Case not found")
if not _owns_case(case, user):
raise HTTPException(status.HTTP_403_FORBIDDEN, "Not your case")
_reject_if_resolved(case)
matches = run_matching_for_case(db, case)
db.commit()
db.refresh(case)
return FoundReportResponse(
case=CaseOut.model_validate(case),
matches=[build_match_out(db, m) for m in matches],
)
@router.post("/{case_id}/widen", response_model=FoundReportResponse)
def widen_case(
case_id: int,
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> FoundReportResponse:
case = db.get(Case, case_id)
if not case:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Case not found")
if not _owns_case(case, user):
raise HTTPException(status.HTTP_403_FORBIDDEN, "Not your case")
_reject_if_resolved(case)
nxt = _next_radius(case.search_radius_miles)
if nxt is None:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Search is already at the widest level.")
case.search_radius_miles = nxt
matches = run_matching_for_case(db, case)
db.commit()
db.refresh(case)
return FoundReportResponse(
case=CaseOut.model_validate(case),
matches=[build_match_out(db, m) for m in matches],
)
@router.post("/{case_id}/widen-breed", response_model=FoundReportResponse)
def widen_breed_case(
case_id: int,
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> FoundReportResponse:
"""Re-run matching with the estimated-breed gate dropped (current radius kept). For when the right
match is being filtered out by breed rather than distance. Owner/admin only."""
case = db.get(Case, case_id)
if not case:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Case not found")
if not _owns_case(case, user):
raise HTTPException(status.HTTP_403_FORBIDDEN, "Not your case")
_reject_if_resolved(case)
matches = run_matching_for_case(db, case, drop_breed_gate=True)
db.commit()
db.refresh(case)
return FoundReportResponse(
case=CaseOut.model_validate(case),
matches=[build_match_out(db, m) for m in matches],
)
@router.patch("/{case_id}", response_model=CaseOut)
def update_case(
case_id: int,
payload: CaseUpdate,
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> CaseOut:
case = db.get(Case, case_id)
if not case:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Case not found")
if not _owns_case(case, user):
raise HTTPException(status.HTTP_403_FORBIDDEN, "Not your case")
if payload.notes is not None:
case.notes = payload.notes
if payload.close:
case.status = CaseStatus.closed
db.commit()
db.refresh(case)
return CaseOut.model_validate(case)