punimtag/backend/api/people.py
tanyar09 09ee8712aa feat: extend people search to first/middle/last/maiden names and fix port binding issue
- Backend: Updated list_people_with_faces to search by first_name, middle_name, last_name, and maiden_name
- Frontend: Updated Modify page search UI and API client to support extended search
- Frontend: Updated Help page documentation for new search capabilities
- Infrastructure: Added start-api.sh wrapper script to prevent port 8000 binding conflicts
- Infrastructure: Updated PM2 config with improved kill_timeout and restart_delay settings
2026-02-05 16:57:47 +00:00

333 lines
12 KiB
Python

"""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, or_
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 first, middle, last, 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 first_name, middle_name, 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 first_name, middle_name, last_name, and maiden_name
search_term = last_name.lower()
query = query.filter(
or_(
func.lower(Person.first_name).contains(search_term),
func.lower(Person.middle_name).contains(search_term),
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"]
try:
identified_count, updated_count = accept_auto_match_matches(
db, person_id, request.face_ids, user_id=user_id
)
except ValueError as e:
if "not found" in str(e).lower():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(e)
)
raise
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)}",
)