PawTrace / backend /app /api /dogs.py
Elliott Duke
HomingPet: lost-dog reunification (FastAPI + React) with Render deploy
de1e3fc
Raw
History Blame Contribute Delete
6.03 kB
from __future__ import annotations
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from ..config import settings
from ..db import get_db
from ..models import KnownDog, Picture, User
from ..models.base import SubjectType
from ..schemas.common import Page
from ..schemas.dog import KnownDogCreate, KnownDogOut, KnownDogUpdate, PictureOut
from ..security import get_current_user
from ..services.images import ImageValidationError, process_and_store_picture
from .helpers import estimated_breeds_for, hydrate_picture, pictures_for
router = APIRouter(prefix="/dogs", tags=["dogs"])
def _get_owned_dog(db: Session, dog_id: int, user: User) -> KnownDog:
dog = db.get(KnownDog, dog_id)
if not dog:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Dog not found")
if dog.owner_id != user.id and user.role.value != "admin":
raise HTTPException(status.HTTP_403_FORBIDDEN, "Not your dog")
return dog
def _to_out(db: Session, dog: KnownDog) -> KnownDogOut:
out = KnownDogOut.model_validate(dog)
out.pictures = pictures_for(db, SubjectType.known, dog.id)
out.estimated_breeds = estimated_breeds_for(db, SubjectType.known, dog.id, limit=8)
return out
@router.post("", response_model=KnownDogOut, status_code=status.HTTP_201_CREATED)
def create_dog(
payload: KnownDogCreate,
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> KnownDogOut:
dog = KnownDog(owner_id=user.id, **payload.model_dump())
db.add(dog)
db.commit()
db.refresh(dog)
return _to_out(db, dog)
@router.get("", response_model=Page[KnownDogOut])
def list_my_dogs(
limit: int = 50,
offset: int = 0,
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Page[KnownDogOut]:
limit = max(1, min(limit, 100))
total = db.execute(
select(func.count()).select_from(KnownDog).where(KnownDog.owner_id == user.id)
).scalar_one()
dogs = db.execute(
select(KnownDog)
.where(KnownDog.owner_id == user.id)
.order_by(KnownDog.created_at.desc())
.limit(limit)
.offset(offset)
).scalars().all()
return Page(items=[_to_out(db, d) for d in dogs], total=total, limit=limit, offset=offset)
@router.get("/{dog_id}", response_model=KnownDogOut)
def get_dog(
dog_id: int,
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> KnownDogOut:
return _to_out(db, _get_owned_dog(db, dog_id, user))
@router.patch("/{dog_id}", response_model=KnownDogOut)
def update_dog(
dog_id: int,
payload: KnownDogUpdate,
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> KnownDogOut:
dog = _get_owned_dog(db, dog_id, user)
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(dog, field, value)
db.commit()
db.refresh(dog)
return _to_out(db, dog)
@router.post("/{dog_id}/mark-home", response_model=KnownDogOut)
def mark_dog_home(
dog_id: int,
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> KnownDogOut:
"""Set a dog's status back to 'home' — e.g. after a reunion, or to cancel a lost report."""
from ..models.base import KnownDogStatus
dog = _get_owned_dog(db, dog_id, user)
dog.status = KnownDogStatus.home
db.commit()
db.refresh(dog)
return _to_out(db, dog)
@router.post("/{dog_id}/photos", response_model=list[PictureOut], status_code=status.HTTP_201_CREATED)
def upload_photos(
dog_id: int,
files: list[UploadFile] = File(...),
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> list[PictureOut]:
dog = _get_owned_dog(db, dog_id, user)
existing = db.execute(
select(func.count())
.select_from(Picture)
.where(Picture.subject_type == SubjectType.known, Picture.subject_id == dog.id)
).scalar_one()
if existing + len(files) > settings.max_photos_per_dog:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
f"Max {settings.max_photos_per_dog} photos per dog",
)
out: list[PictureOut] = []
for i, f in enumerate(files):
data = f.file.read()
try:
pic = process_and_store_picture(
db,
subject_type=SubjectType.known,
subject_id=dog.id,
data=data,
is_primary=(existing == 0 and i == 0),
)
except ImageValidationError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
out.append(hydrate_picture(pic))
# If this dog has an open lost case, re-run matching now that new photos exist (spec §8 step 6).
from ..models import Case
from ..models.base import CaseStatus, CaseType
from ..services.matching import run_matching_for_case
open_case = db.execute(
select(Case)
.where(
Case.known_dog_id == dog.id,
Case.type == CaseType.lost,
Case.status.in_([CaseStatus.open, CaseStatus.matched]),
)
.order_by(Case.created_at.desc())
).scalars().first()
if open_case:
run_matching_for_case(db, open_case)
db.commit()
return out
@router.delete("/{dog_id}/photos/{img_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_photo(
dog_id: int,
img_id: int,
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> None:
dog = _get_owned_dog(db, dog_id, user)
pic = db.get(Picture, img_id)
if not pic or pic.subject_type != SubjectType.known or pic.subject_id != dog.id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Photo not found")
from ..storage import get_storage
storage = get_storage()
storage.delete(pic.file_path)
if pic.thumb_path:
storage.delete(pic.thumb_path)
db.delete(pic)
db.commit()