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