PawTrace / backend /app /api /admin.py
Elliott Duke
Next version: fixed reset button, verified dog image pairs
fb71747
Raw
History Blame Contribute Delete
10.3 kB
"""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)