Elliott Duke
Next version: reset button, +250 distractors, 5 named pairs, breed-widen, texture
7f48c4d | 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) ----------------------------- | |
| 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. | |
| 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 ----------------------------- | |
| 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, | |
| ) | |
| 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) | |
| 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] | |
| 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)]} | |
| 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], | |
| ) | |
| 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], | |
| ) | |
| 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], | |
| ) | |
| 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) | |