File size: 10,324 Bytes
de1e3fc 7f48c4d de1e3fc 7f48c4d fb71747 7f48c4d fb71747 7f48c4d fb71747 7f48c4d fb71747 7f48c4d de1e3fc | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 | """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)
|