| """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 |
|
|
| |
| |
| |
| |
| 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 |
|
|
| |
| |
| 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 |
|
|
| |
| 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) |
|
|