"""People management endpoints.""" from __future__ import annotations from datetime import datetime from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, Query, Response, status from sqlalchemy import func from sqlalchemy.orm import Session from backend.db.session import get_db from backend.db.models import Person, Face, PersonEncoding, PhotoPersonLinkage, Photo from backend.api.auth import get_current_user_with_id from backend.schemas.people import ( PeopleListResponse, PersonCreateRequest, PersonResponse, PersonUpdateRequest, PersonWithFacesResponse, PeopleWithFacesListResponse, ) from backend.schemas.faces import PersonFacesResponse, PersonFaceItem, AcceptMatchesRequest, IdentifyFaceResponse from backend.services.face_service import accept_auto_match_matches router = APIRouter(prefix="/people", tags=["people"]) @router.get("", response_model=PeopleListResponse) def list_people( last_name: str | None = Query(None, description="Filter by last name (case-insensitive)"), db: Session = Depends(get_db), ) -> PeopleListResponse: """List all people sorted by last_name, first_name. Optionally filter by last_name if provided (case-insensitive search). """ query = db.query(Person) if last_name: # Case-insensitive search on last_name query = query.filter(func.lower(Person.last_name).contains(func.lower(last_name))) people = query.order_by(Person.last_name.asc(), Person.first_name.asc()).all() items = [PersonResponse.model_validate(p) for p in people] return PeopleListResponse(items=items, total=len(items)) @router.get("/with-faces", response_model=PeopleWithFacesListResponse) def list_people_with_faces( last_name: str | None = Query(None, description="Filter by last name or maiden name (case-insensitive)"), db: Session = Depends(get_db), ) -> PeopleWithFacesListResponse: """List all people with face counts and video counts, sorted by last_name, first_name. Optionally filter by last_name or maiden_name if provided (case-insensitive search). Returns all people, including those with zero faces or videos. """ # Query people with face counts using LEFT OUTER JOIN to include people with no faces query = ( db.query( Person, func.count(Face.id.distinct()).label('face_count') ) .outerjoin(Face, Person.id == Face.person_id) .group_by(Person.id) ) if last_name: # Case-insensitive search on both last_name and maiden_name search_term = last_name.lower() query = query.filter( (func.lower(Person.last_name).contains(search_term)) | ((Person.maiden_name.isnot(None)) & (func.lower(Person.maiden_name).contains(search_term))) ) results = query.order_by(Person.last_name.asc(), Person.first_name.asc()).all() # Get video counts separately for each person person_ids = [person.id for person, _ in results] video_counts = {} if person_ids: video_count_query = ( db.query( PhotoPersonLinkage.person_id, func.count(PhotoPersonLinkage.id).label('video_count') ) .join(Photo, PhotoPersonLinkage.photo_id == Photo.id) .filter( PhotoPersonLinkage.person_id.in_(person_ids), Photo.media_type == "video" ) .group_by(PhotoPersonLinkage.person_id) ) for person_id, video_count in video_count_query.all(): video_counts[person_id] = video_count items = [ PersonWithFacesResponse( id=person.id, first_name=person.first_name, last_name=person.last_name, middle_name=person.middle_name, maiden_name=person.maiden_name, date_of_birth=person.date_of_birth, face_count=face_count or 0, # Convert None to 0 for people with no faces video_count=video_counts.get(person.id, 0), # Get video count or default to 0 ) for person, face_count in results ] return PeopleWithFacesListResponse(items=items, total=len(items)) @router.post("", response_model=PersonResponse, status_code=status.HTTP_201_CREATED) def create_person(request: PersonCreateRequest, db: Session = Depends(get_db)) -> PersonResponse: """Create a new person.""" first_name = request.first_name.strip() last_name = request.last_name.strip() middle_name = request.middle_name.strip() if request.middle_name else None maiden_name = request.maiden_name.strip() if request.maiden_name else None # Explicitly set created_date to ensure it's a valid datetime object person = Person( first_name=first_name, last_name=last_name, middle_name=middle_name, maiden_name=maiden_name, date_of_birth=request.date_of_birth, created_date=datetime.utcnow(), ) db.add(person) try: db.commit() except Exception as e: db.rollback() raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) db.refresh(person) return PersonResponse.model_validate(person) @router.get("/{person_id}", response_model=PersonResponse) def get_person(person_id: int, db: Session = Depends(get_db)) -> PersonResponse: """Get person by ID.""" person = db.query(Person).filter(Person.id == person_id).first() if not person: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Person {person_id} not found") return PersonResponse.model_validate(person) @router.put("/{person_id}", response_model=PersonResponse) def update_person( person_id: int, request: PersonUpdateRequest, db: Session = Depends(get_db), ) -> PersonResponse: """Update person information.""" person = db.query(Person).filter(Person.id == person_id).first() if not person: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Person {person_id} not found") # Update fields person.first_name = request.first_name.strip() person.last_name = request.last_name.strip() person.middle_name = request.middle_name.strip() if request.middle_name else None person.maiden_name = request.maiden_name.strip() if request.maiden_name else None person.date_of_birth = request.date_of_birth try: db.commit() db.refresh(person) except Exception as e: db.rollback() raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) return PersonResponse.model_validate(person) @router.get("/{person_id}/faces", response_model=PersonFacesResponse) def get_person_faces(person_id: int, db: Session = Depends(get_db)) -> PersonFacesResponse: """Get all faces for a specific person.""" person = db.query(Person).filter(Person.id == person_id).first() if not person: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Person {person_id} not found") from backend.db.models import Photo faces = ( db.query(Face) .join(Photo, Face.photo_id == Photo.id) .filter(Face.person_id == person_id) .order_by(Photo.filename) .all() ) items = [ PersonFaceItem( id=face.id, photo_id=face.photo_id, photo_path=face.photo.path, photo_filename=face.photo.filename, location=face.location, face_confidence=float(face.face_confidence), quality_score=float(face.quality_score), detector_backend=face.detector_backend, model_name=face.model_name, ) for face in faces ] return PersonFacesResponse(person_id=person_id, items=items, total=len(items)) @router.get("/{person_id}/videos") def get_person_videos(person_id: int, db: Session = Depends(get_db)) -> dict: """Get all videos linked to a specific person.""" person = db.query(Person).filter(Person.id == person_id).first() if not person: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Person {person_id} not found") # Get all video linkages for this person linkages = ( db.query(PhotoPersonLinkage, Photo) .join(Photo, PhotoPersonLinkage.photo_id == Photo.id) .filter( PhotoPersonLinkage.person_id == person_id, Photo.media_type == "video" ) .order_by(Photo.filename) .all() ) items = [ { "id": photo.id, "filename": photo.filename, "path": photo.path, "date_taken": photo.date_taken.isoformat() if photo.date_taken else None, "date_added": photo.date_added.isoformat() if photo.date_added else None, "linkage_id": linkage.id, } for linkage, photo in linkages ] return { "person_id": person_id, "items": items, "total": len(items), } @router.post("/{person_id}/accept-matches", response_model=IdentifyFaceResponse) def accept_matches( person_id: int, request: AcceptMatchesRequest, current_user: Annotated[dict, Depends(get_current_user_with_id)], db: Session = Depends(get_db), ) -> IdentifyFaceResponse: """Accept auto-match matches for a person. Matches desktop auto-match save workflow exactly: 1. Identifies selected faces with this person 2. Inserts person_encodings for each identified face 3. Updates person encodings (removes old, adds current) Tracks which user identified the faces. """ from backend.api.auth import get_current_user_with_id user_id = current_user["user_id"] identified_count, updated_count = accept_auto_match_matches( db, person_id, request.face_ids, user_id=user_id ) return IdentifyFaceResponse( identified_face_ids=request.face_ids, person_id=person_id, created_person=False, ) @router.delete("/{person_id}") def delete_person(person_id: int, db: Session = Depends(get_db)) -> Response: """Delete a person and all their linkages. This will: 1. Delete all person_encodings for this person 2. Unlink all faces (set person_id to NULL) 3. Delete all video linkages (PhotoPersonLinkage records) 4. Delete the person record """ person = db.query(Person).filter(Person.id == person_id).first() if not person: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Person {person_id} not found", ) try: # Delete all person_encodings for this person db.query(PersonEncoding).filter(PersonEncoding.person_id == person_id).delete(synchronize_session=False) # Unlink all faces (set person_id to NULL) db.query(Face).filter(Face.person_id == person_id).update( {"person_id": None}, synchronize_session=False ) # Delete all video linkages (PhotoPersonLinkage records) db.query(PhotoPersonLinkage).filter(PhotoPersonLinkage.person_id == person_id).delete(synchronize_session=False) # Delete the person record db.delete(person) db.commit() return Response(status_code=status.HTTP_204_NO_CONTENT) except Exception as e: db.rollback() raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to delete person: {str(e)}", )