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)