"""Thin admin slice (spec §2, §11): view pending matches, flag spam, close cases.""" from __future__ import annotations from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import select from sqlalchemy.orm import Session from ..db import get_db from ..models import Case, Match, User from ..models.base import CaseStatus, CaseType, MatchStatus, SubjectType from ..schemas.case import CaseOut, MatchOut from ..security import require_admin from ..services import datasets as ds from .helpers import pictures_for from .hydrate import build_match_out router = APIRouter(prefix="/admin", tags=["admin"]) @router.get("/matches/pending", response_model=list[MatchOut]) def pending_matches( _: User = Depends(require_admin), db: Session = Depends(get_db), ) -> list[MatchOut]: matches = db.execute( select(Match).where(Match.status == MatchStatus.pending).order_by(Match.created_at.desc()) ).scalars().all() return [build_match_out(db, m) for m in matches] @router.get("/cases") def all_cases( kind: str | None = None, status: str | None = None, limit: int = 30, offset: int = 0, _: User = Depends(require_admin), db: Session = Depends(get_db), ) -> dict: """All cases (admin), hydrated with dog + owner/finder + match count; paginated + filterable.""" return ds.list_all_cases( db, case_type=kind, status=status, limit=max(1, min(limit, 100)), offset=max(0, offset) ) @router.get("/owners") def all_owners( q: str | None = None, limit: int = 30, offset: int = 0, _: User = Depends(require_admin), db: Session = Depends(get_db), ) -> dict: """Owners (newest first) and the dogs they own; optional name/email search (admin).""" return ds.list_owners(db, q=q, limit=max(1, min(limit, 100)), offset=max(0, offset)) @router.get("/owners/{user_id}") def owner_detail( user_id: int, _: User = Depends(require_admin), db: Session = Depends(get_db), ) -> dict: """One owner with all their dogs (photos-ready) and the cases they reported.""" data = ds.owner_detail(db, user_id) if data is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Owner not found") return data @router.get("/cases/{case_id}") def case_detail( case_id: int, _: User = Depends(require_admin), db: Session = Depends(get_db), ) -> dict: """A case with its subject dog (all photos) and its persisted matches (each candidate + photos).""" case = db.get(Case, case_id) if not case: raise HTTPException(status.HTTP_404_NOT_FOUND, "Case not found") dog = None if case.known_dog_id is not None: st, did = SubjectType.known, case.known_dog_id elif case.unknown_dog_id is not None: st, did = SubjectType.unknown, case.unknown_dog_id else: st = did = None if st is not None: dog = { "profile": ds.dog_profile(db, st, did), "photos": [p.model_dump() for p in pictures_for(db, st, did)], } matches = db.execute( select(Match).where(Match.case_id == case_id).order_by(Match.rank) ).scalars().all() return { "case": ds._case_row(db, case), "dog": dog, "matches": [build_match_out(db, m) for m in matches], } @router.post("/cases/{case_id}/run-match", response_model=list[MatchOut]) def run_case_match( case_id: int, _: User = Depends(require_admin), db: Session = Depends(get_db), ) -> list[MatchOut]: """Run PRODUCTION matching for a case (persists ranked Match rows) and return them.""" case = db.get(Case, case_id) if not case: raise HTTPException(status.HTTP_404_NOT_FOUND, "Case not found") from ..services.matching import run_matching_for_case matches = run_matching_for_case(db, case) db.commit() return [build_match_out(db, m) for m in matches] @router.delete("/cases/{case_id}") def delete_case( case_id: int, _: User = Depends(require_admin), db: Session = Depends(get_db), ) -> dict: """Delete a single case + its matches (temporary admin convenience for re-demoing).""" if not db.get(Case, case_id): raise HTTPException(status.HTTP_404_NOT_FOUND, "Case not found") return {"deleted": ds.delete_case(db, case_id)} @router.delete("/owners/{user_id}") def delete_person( user_id: int, admin: User = Depends(require_admin), db: Session = Depends(get_db), ) -> dict: """Delete a person + their dogs/cases/matches (temporary admin convenience). Admins are refused.""" from ..models.base import UserRole person = db.get(User, user_id) if not person: raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found") if person.role == UserRole.admin: raise HTTPException(status.HTTP_400_BAD_REQUEST, "Refusing to delete an admin user") return {"deleted": ds.delete_person(db, user_id)} @router.delete("/dogs/{kind}/{dog_id}") def delete_dog( kind: str, dog_id: int, _: User = Depends(require_admin), db: Session = Depends(get_db), ) -> dict: """Delete a single dog + its pictures/embeddings/cases/matches (temporary admin convenience).""" from ..models import KnownDog, UnknownDog if kind not in ("known", "unknown"): raise HTTPException(status.HTTP_400_BAD_REQUEST, "kind must be 'known' or 'unknown'") st = SubjectType.known if kind == "known" else SubjectType.unknown exists = db.get(KnownDog if kind == "known" else UnknownDog, dog_id) if not exists: raise HTTPException(status.HTTP_404_NOT_FOUND, "Dog not found") return {"deleted": ds.delete_dog(db, st, dog_id)} @router.post("/dogs/{kind}/{dog_id}/status") def set_dog_status( kind: str, dog_id: int, payload: dict, _: User = Depends(require_admin), db: Session = Depends(get_db), ) -> dict: """Admin: set a dog's status (e.g. a found dog 'at_shelter' vs 'reunited').""" from ..models import KnownDog, UnknownDog from ..models.base import KnownDogStatus, UnknownDogStatus if kind not in ("known", "unknown"): raise HTTPException(status.HTTP_400_BAD_REQUEST, "kind must be 'known' or 'unknown'") new_status = (payload or {}).get("status") enum_cls = KnownDogStatus if kind == "known" else UnknownDogStatus valid = [s.value for s in enum_cls] if new_status not in valid: raise HTTPException( status.HTTP_400_BAD_REQUEST, f"status must be one of {valid}" ) dog = db.get(KnownDog if kind == "known" else UnknownDog, dog_id) if not dog: raise HTTPException(status.HTTP_404_NOT_FOUND, "Dog not found") dog.status = enum_cls(new_status) db.commit() st = SubjectType.known if kind == "known" else SubjectType.unknown return {"profile": ds.dog_profile(db, st, dog_id)} @router.post("/cases/{case_id}/close", response_model=CaseOut) def close_case( case_id: int, _: User = Depends(require_admin), 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") case.status = CaseStatus.closed db.commit() db.refresh(case) return CaseOut.model_validate(case) @router.post("/reset-demo") def reset_demo( _: User = Depends(require_admin), db: Session = Depends(get_db), ) -> dict: """Reset the demo to its 'nothing solved yet' state: revert every match/case resolution and put matched dogs back to lost / found-not-matched. Dogs that were never lost (the intentionally-'home' ones, which have no lost case) are left home. Does not delete any dogs added during the demo.""" from ..models import KnownDog, UnknownDog from ..models.base import KnownDogStatus, UnknownDogStatus n_matches = 0 for m in db.execute(select(Match)).scalars(): if m.status != MatchStatus.pending or m.reviewed or m.reviewed_by is not None: m.status = MatchStatus.pending m.reviewed = False m.reviewed_by = None n_matches += 1 # Only undo confirm's effect (open/matched -> resolved). 'matched' is a normal pipeline state # (set whenever matching finds candidates, regardless of review) and 'closed' is a separate manual # admin action -- neither should be touched here. A resolved case goes back to 'matched' if it # still owns pending matches (it will, since they were just reset above), else 'open'. n_cases = 0 for c in db.execute(select(Case).where(Case.status == CaseStatus.resolved)).scalars(): has_matches = db.execute( select(Match.id).where(Match.case_id == c.id).limit(1) ).first() is not None c.status = CaseStatus.matched if has_matches else CaseStatus.open n_cases += 1 # Known dogs that have a lost case -> back to 'lost'. (Never-lost 'home' dogs have no lost case, # so they're skipped and stay home.) lost_known_ids = set(db.execute( select(Case.known_dog_id).where(Case.type == CaseType.lost, Case.known_dog_id.isnot(None)) ).scalars()) n_known = 0 if lost_known_ids: for d in db.execute(select(KnownDog).where(KnownDog.id.in_(lost_known_ids))).scalars(): if d.status != KnownDogStatus.lost: d.status = KnownDogStatus.lost n_known += 1 # Found dogs -> not-matched: 'at_shelter' if a shelter is recorded, else 'pending'. n_found = 0 for d in db.execute(select(UnknownDog)).scalars(): want = UnknownDogStatus.at_shelter if d.current_location_detail else UnknownDogStatus.pending if d.status != want: d.status = want n_found += 1 db.commit() return {"matches_reset": n_matches, "cases_reopened": n_cases, "known_relost": n_known, "found_reset": n_found} @router.post("/matches/{match_id}/flag-spam", response_model=MatchOut) def flag_spam( match_id: int, _: User = Depends(require_admin), db: Session = Depends(get_db), ) -> MatchOut: match = db.get(Match, match_id) if not match: raise HTTPException(status.HTTP_404_NOT_FOUND, "Match not found") match.status = MatchStatus.rejected match.reviewed = True db.commit() db.refresh(match) return build_match_out(db, match)