feat: Add Faces Maintenance page and API for managing face items
This commit introduces a new Faces Maintenance page in the frontend, allowing users to view, sort, and delete face items based on quality and person information. The API has been updated to include endpoints for retrieving and deleting faces, enhancing the management capabilities of the application. Additionally, new data models and schemas for maintenance face items have been added to support these features. Documentation has been updated to reflect these changes.
This commit is contained in:
parent
20f1a4207f
commit
f7accb925d
@ -10,6 +10,7 @@ import Identify from './pages/Identify'
|
||||
import AutoMatch from './pages/AutoMatch'
|
||||
import Modify from './pages/Modify'
|
||||
import Tags from './pages/Tags'
|
||||
import FacesMaintenance from './pages/FacesMaintenance'
|
||||
import Settings from './pages/Settings'
|
||||
import Layout from './components/Layout'
|
||||
|
||||
@ -41,6 +42,7 @@ function AppRoutes() {
|
||||
<Route path="auto-match" element={<AutoMatch />} />
|
||||
<Route path="modify" element={<Modify />} />
|
||||
<Route path="tags" element={<Tags />} />
|
||||
<Route path="faces-maintenance" element={<FacesMaintenance />} />
|
||||
<Route path="settings" element={<Settings />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
@ -136,6 +136,31 @@ export interface AcceptMatchesRequest {
|
||||
face_ids: number[]
|
||||
}
|
||||
|
||||
export interface MaintenanceFaceItem {
|
||||
id: number
|
||||
photo_id: number
|
||||
photo_path: string
|
||||
photo_filename: string
|
||||
quality_score: number
|
||||
person_id: number | null
|
||||
person_name: string | null
|
||||
}
|
||||
|
||||
export interface MaintenanceFacesResponse {
|
||||
items: MaintenanceFaceItem[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface DeleteFacesRequest {
|
||||
face_ids: number[]
|
||||
}
|
||||
|
||||
export interface DeleteFacesResponse {
|
||||
deleted_face_ids: number[]
|
||||
count: number
|
||||
message: string
|
||||
}
|
||||
|
||||
export const facesApi = {
|
||||
/**
|
||||
* Start face processing job
|
||||
@ -189,6 +214,21 @@ export const facesApi = {
|
||||
const response = await apiClient.post<AutoMatchResponse>('/api/v1/faces/auto-match', request)
|
||||
return response.data
|
||||
},
|
||||
getMaintenanceFaces: async (params: {
|
||||
page?: number
|
||||
page_size?: number
|
||||
min_quality?: number
|
||||
max_quality?: number
|
||||
}): Promise<MaintenanceFacesResponse> => {
|
||||
const response = await apiClient.get<MaintenanceFacesResponse>('/api/v1/faces/maintenance', {
|
||||
params,
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
deleteFaces: async (request: DeleteFacesRequest): Promise<DeleteFacesResponse> => {
|
||||
const response = await apiClient.post<DeleteFacesResponse>('/api/v1/faces/delete', request)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
export default facesApi
|
||||
|
||||
@ -14,6 +14,7 @@ export default function Layout() {
|
||||
{ path: '/auto-match', label: 'Auto-Match', icon: '🤖' },
|
||||
{ path: '/modify', label: 'Modify', icon: '✏️' },
|
||||
{ path: '/tags', label: 'Tags', icon: '🏷️' },
|
||||
{ path: '/faces-maintenance', label: 'Faces Maintenance', icon: '🔧' },
|
||||
{ path: '/settings', label: 'Settings', icon: '⚙️' },
|
||||
]
|
||||
|
||||
|
||||
343
frontend/src/pages/FacesMaintenance.tsx
Normal file
343
frontend/src/pages/FacesMaintenance.tsx
Normal file
@ -0,0 +1,343 @@
|
||||
import { useEffect, useState, useMemo } from 'react'
|
||||
import facesApi, { MaintenanceFaceItem } from '../api/faces'
|
||||
import { apiClient } from '../api/client'
|
||||
|
||||
type SortColumn = 'person_name' | 'quality'
|
||||
type SortDir = 'asc' | 'desc'
|
||||
|
||||
export default function FacesMaintenance() {
|
||||
const [faces, setFaces] = useState<MaintenanceFaceItem[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [pageSize, setPageSize] = useState(50)
|
||||
const [minQuality, setMinQuality] = useState(0.0)
|
||||
const [maxQuality, setMaxQuality] = useState(1.0)
|
||||
const [selectedFaces, setSelectedFaces] = useState<Set<number>>(new Set())
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [sortColumn, setSortColumn] = useState<SortColumn | null>(null)
|
||||
const [sortDir, setSortDir] = useState<SortDir>('asc')
|
||||
|
||||
const loadFaces = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await facesApi.getMaintenanceFaces({
|
||||
page: 1,
|
||||
page_size: pageSize,
|
||||
min_quality: minQuality,
|
||||
max_quality: maxQuality,
|
||||
})
|
||||
setFaces(res.items)
|
||||
setTotal(res.total)
|
||||
setSelectedFaces(new Set()) // Clear selection when reloading
|
||||
} catch (error) {
|
||||
console.error('Error loading faces:', error)
|
||||
alert('Error loading faces. Please try again.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadFaces()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pageSize, minQuality, maxQuality])
|
||||
|
||||
const toggleSelection = (faceId: number) => {
|
||||
setSelectedFaces(prev => {
|
||||
const newSet = new Set(prev)
|
||||
if (newSet.has(faceId)) {
|
||||
newSet.delete(faceId)
|
||||
} else {
|
||||
newSet.add(faceId)
|
||||
}
|
||||
return newSet
|
||||
})
|
||||
}
|
||||
|
||||
const selectAll = () => {
|
||||
setSelectedFaces(new Set(sortedFaces.map(f => f.id)))
|
||||
}
|
||||
|
||||
const unselectAll = () => {
|
||||
setSelectedFaces(new Set())
|
||||
}
|
||||
|
||||
const handleSort = (column: SortColumn) => {
|
||||
if (sortColumn === column) {
|
||||
setSortDir(sortDir === 'asc' ? 'desc' : 'asc')
|
||||
} else {
|
||||
setSortColumn(column)
|
||||
setSortDir('asc')
|
||||
}
|
||||
}
|
||||
|
||||
const sortedFaces = useMemo(() => {
|
||||
if (!sortColumn) return faces
|
||||
|
||||
return [...faces].sort((a, b) => {
|
||||
let aVal: any
|
||||
let bVal: any
|
||||
|
||||
switch (sortColumn) {
|
||||
case 'person_name':
|
||||
aVal = a.person_name || 'Unidentified'
|
||||
bVal = b.person_name || 'Unidentified'
|
||||
break
|
||||
case 'quality':
|
||||
aVal = a.quality_score
|
||||
bVal = b.quality_score
|
||||
break
|
||||
}
|
||||
|
||||
if (typeof aVal === 'string') {
|
||||
aVal = aVal.toLowerCase()
|
||||
bVal = bVal.toLowerCase()
|
||||
}
|
||||
|
||||
if (aVal < bVal) return sortDir === 'asc' ? -1 : 1
|
||||
if (aVal > bVal) return sortDir === 'asc' ? 1 : -1
|
||||
return 0
|
||||
})
|
||||
}, [faces, sortColumn, sortDir])
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (selectedFaces.size === 0) {
|
||||
alert('Please select at least one face to delete.')
|
||||
return
|
||||
}
|
||||
setShowDeleteConfirm(true)
|
||||
}
|
||||
|
||||
const confirmDelete = async () => {
|
||||
setShowDeleteConfirm(false)
|
||||
setDeleting(true)
|
||||
try {
|
||||
await facesApi.deleteFaces({
|
||||
face_ids: Array.from(selectedFaces),
|
||||
})
|
||||
// Reload faces after deletion
|
||||
await loadFaces()
|
||||
alert(`Successfully deleted ${selectedFaces.size} face(s)`)
|
||||
} catch (error) {
|
||||
console.error('Error deleting faces:', error)
|
||||
alert('Error deleting faces. Please try again.')
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">Faces Maintenance</h1>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="bg-white rounded-lg shadow mb-4 p-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{/* Quality Range Selector */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Quality Range
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={minQuality}
|
||||
onChange={(e) => setMinQuality(parseFloat(e.target.value))}
|
||||
className="flex-1"
|
||||
/>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={maxQuality}
|
||||
onChange={(e) => setMaxQuality(parseFloat(e.target.value))}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>Min: {(minQuality * 100).toFixed(0)}%</span>
|
||||
<span>Max: {(maxQuality * 100).toFixed(0)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Batch Size */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Batch Size
|
||||
</label>
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => setPageSize(parseInt(e.target.value))}
|
||||
className="block w-full border rounded px-2 py-1 text-sm"
|
||||
>
|
||||
{[25, 50, 100, 200, 500, 1000, 1500, 2000].map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-end gap-2">
|
||||
<button
|
||||
onClick={selectAll}
|
||||
disabled={faces.length === 0}
|
||||
className="px-3 py-2 text-sm border rounded hover:bg-gray-50 disabled:bg-gray-100 disabled:text-gray-400"
|
||||
>
|
||||
Select All
|
||||
</button>
|
||||
<button
|
||||
onClick={unselectAll}
|
||||
disabled={selectedFaces.size === 0}
|
||||
className="px-3 py-2 text-sm border rounded hover:bg-gray-50 disabled:bg-gray-100 disabled:text-gray-400"
|
||||
>
|
||||
Unselect All
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={selectedFaces.size === 0 || deleting}
|
||||
className="px-3 py-2 text-sm bg-red-600 text-white rounded hover:bg-red-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
|
||||
>
|
||||
{deleting ? 'Deleting...' : 'Delete Selected'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="mb-4">
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
Total: {total} face(s)
|
||||
</span>
|
||||
{selectedFaces.size > 0 && (
|
||||
<span className="ml-4 text-sm text-gray-600">
|
||||
Selected: {selectedFaces.size} face(s)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-8 text-gray-500">Loading faces...</div>
|
||||
) : sortedFaces.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">No faces found</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="text-left p-2 w-12">☑</th>
|
||||
<th className="text-left p-2 w-24">Thumbnail</th>
|
||||
<th
|
||||
className="text-left p-2 cursor-pointer hover:bg-gray-50"
|
||||
onClick={() => handleSort('person_name')}
|
||||
>
|
||||
Person Name {sortColumn === 'person_name' && (sortDir === 'asc' ? '↑' : '↓')}
|
||||
</th>
|
||||
<th className="text-left p-2">File Path</th>
|
||||
<th
|
||||
className="text-left p-2 cursor-pointer hover:bg-gray-50"
|
||||
onClick={() => handleSort('quality')}
|
||||
>
|
||||
Quality {sortColumn === 'quality' && (sortDir === 'asc' ? '↑' : '↓')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedFaces.map((face) => (
|
||||
<tr key={face.id} className="border-b hover:bg-gray-50">
|
||||
<td className="p-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedFaces.has(face.id)}
|
||||
onChange={() => toggleSelection(face.id)}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<div
|
||||
className="w-20 h-20 bg-gray-100 rounded overflow-hidden flex items-center justify-center relative group cursor-pointer"
|
||||
onClick={() => {
|
||||
const photoUrl = `${apiClient.defaults.baseURL}/api/v1/photos/${face.photo_id}/image`
|
||||
window.open(photoUrl, '_blank')
|
||||
}}
|
||||
title="Click to open full photo"
|
||||
>
|
||||
<img
|
||||
src={`${apiClient.defaults.baseURL}/api/v1/faces/${face.id}/crop`}
|
||||
alt={`Face ${face.id}`}
|
||||
className="max-w-full max-h-full object-contain pointer-events-none"
|
||||
crossOrigin="anonymous"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement
|
||||
target.style.display = 'none'
|
||||
const parent = target.parentElement
|
||||
if (parent && !parent.querySelector('.error-fallback')) {
|
||||
const fallback = document.createElement('div')
|
||||
fallback.className = 'text-gray-400 text-xs error-fallback'
|
||||
fallback.textContent = `#${face.id}`
|
||||
parent.appendChild(fallback)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-10 transition-opacity pointer-events-none" />
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
{face.person_name || (
|
||||
<span className="text-gray-400 italic">Unidentified</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<span className="text-blue-600" title={face.photo_path}>
|
||||
{face.photo_path}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
{(face.quality_score * 100).toFixed(1)}%
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
{showDeleteConfirm && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-md p-6">
|
||||
<h3 className="text-lg font-bold mb-4">Confirm Delete</h3>
|
||||
<p className="text-gray-700 mb-6">
|
||||
Are you sure you want to delete {selectedFaces.size} face(s) from
|
||||
the database? This action cannot be undone.
|
||||
</p>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
className="px-4 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={confirmDelete}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -31,9 +31,13 @@ from src.web.schemas.faces import (
|
||||
AutoMatchPersonItem,
|
||||
AutoMatchFaceItem,
|
||||
AcceptMatchesRequest,
|
||||
MaintenanceFacesResponse,
|
||||
MaintenanceFaceItem,
|
||||
DeleteFacesRequest,
|
||||
DeleteFacesResponse,
|
||||
)
|
||||
from src.web.schemas.people import PersonCreateRequest, PersonResponse
|
||||
from src.web.db.models import Face, Person, PersonEncoding
|
||||
from src.web.db.models import Face, Person, PersonEncoding, Photo
|
||||
from src.web.services.face_service import (
|
||||
list_unidentified_faces,
|
||||
find_similar_faces,
|
||||
@ -688,4 +692,151 @@ def auto_match_faces(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/maintenance", response_model=MaintenanceFacesResponse)
|
||||
def list_all_faces(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=2000),
|
||||
min_quality: float = Query(0.0, ge=0.0, le=1.0),
|
||||
max_quality: float = Query(1.0, ge=0.0, le=1.0),
|
||||
db: Session = Depends(get_db),
|
||||
) -> MaintenanceFacesResponse:
|
||||
"""List all faces with person info and file path for maintenance.
|
||||
|
||||
Returns all faces (both identified and unidentified) with their associated
|
||||
person information (if identified) and photo file path.
|
||||
"""
|
||||
# Build query with quality filter
|
||||
query = (
|
||||
db.query(Face, Photo, Person)
|
||||
.join(Photo, Face.photo_id == Photo.id)
|
||||
.outerjoin(Person, Face.person_id == Person.id)
|
||||
.filter(Face.quality_score >= min_quality)
|
||||
.filter(Face.quality_score <= max_quality)
|
||||
)
|
||||
|
||||
# Get total count
|
||||
total = query.count()
|
||||
|
||||
# Apply pagination
|
||||
offset = (page - 1) * page_size
|
||||
results = query.order_by(Face.id.desc()).offset(offset).limit(page_size).all()
|
||||
|
||||
# Build response items
|
||||
items = []
|
||||
for face, photo, person in results:
|
||||
person_name = None
|
||||
if person:
|
||||
# Build full name
|
||||
name_parts = []
|
||||
if person.first_name:
|
||||
name_parts.append(person.first_name)
|
||||
if person.middle_name:
|
||||
name_parts.append(person.middle_name)
|
||||
if person.last_name:
|
||||
name_parts.append(person.last_name)
|
||||
if person.maiden_name:
|
||||
name_parts.append(f"({person.maiden_name})")
|
||||
person_name = " ".join(name_parts) if name_parts else None
|
||||
|
||||
items.append(
|
||||
MaintenanceFaceItem(
|
||||
id=face.id,
|
||||
photo_id=face.photo_id,
|
||||
photo_path=photo.path,
|
||||
photo_filename=photo.filename,
|
||||
quality_score=float(face.quality_score),
|
||||
person_id=face.person_id,
|
||||
person_name=person_name,
|
||||
)
|
||||
)
|
||||
|
||||
return MaintenanceFacesResponse(items=items, total=total)
|
||||
|
||||
|
||||
@router.post("/delete", response_model=DeleteFacesResponse)
|
||||
def delete_faces(
|
||||
request: DeleteFacesRequest,
|
||||
db: Session = Depends(get_db),
|
||||
) -> DeleteFacesResponse:
|
||||
"""Delete multiple faces from the database.
|
||||
|
||||
This permanently removes faces and their associated encodings.
|
||||
Also removes person_encodings associated with these faces.
|
||||
|
||||
If a face is identified (has a person_id), we check if that person will be
|
||||
left without any faces after deletion. If so, the person is also deleted
|
||||
from the database.
|
||||
"""
|
||||
if not request.face_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="face_ids list cannot be empty",
|
||||
)
|
||||
|
||||
# Validate all faces exist
|
||||
faces = db.query(Face).filter(Face.id.in_(request.face_ids)).all()
|
||||
found_ids = {f.id for f in faces}
|
||||
missing_ids = set(request.face_ids) - found_ids
|
||||
|
||||
if missing_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Faces not found: {sorted(missing_ids)}",
|
||||
)
|
||||
|
||||
# Collect person_ids that will be affected (before deletion)
|
||||
# Only include faces that are identified (have a person_id)
|
||||
affected_person_ids = {f.person_id for f in faces if f.person_id is not None}
|
||||
|
||||
# Delete associated person_encodings for these faces
|
||||
db.query(PersonEncoding).filter(PersonEncoding.face_id.in_(request.face_ids)).delete(synchronize_session=False)
|
||||
|
||||
# Delete the faces
|
||||
db.query(Face).filter(Face.id.in_(request.face_ids)).delete(synchronize_session=False)
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to delete faces: {str(e)}",
|
||||
)
|
||||
|
||||
# After committing, check which people have no faces left and delete them
|
||||
# This ensures that if a person was identified by the deleted faces and has
|
||||
# no other faces remaining, the person is also removed from the database
|
||||
deleted_person_ids = []
|
||||
if affected_person_ids:
|
||||
for person_id in affected_person_ids:
|
||||
# Check if person has any faces left after deletion
|
||||
face_count = db.query(func.count(Face.id)).filter(Face.person_id == person_id).scalar()
|
||||
if face_count == 0:
|
||||
# Person has no faces left, delete them
|
||||
person = db.query(Person).filter(Person.id == person_id).first()
|
||||
if person:
|
||||
db.delete(person)
|
||||
deleted_person_ids.append(person_id)
|
||||
|
||||
if deleted_person_ids:
|
||||
try:
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to delete people with no faces: {str(e)}",
|
||||
)
|
||||
|
||||
message = f"Successfully deleted {len(request.face_ids)} face(s)"
|
||||
if deleted_person_ids:
|
||||
message += f" and deleted {len(deleted_person_ids)} person(s) with no faces"
|
||||
|
||||
return DeleteFacesResponse(
|
||||
deleted_face_ids=request.face_ids,
|
||||
count=len(request.face_ids),
|
||||
message=message,
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
@ -268,3 +268,44 @@ class AcceptMatchesRequest(BaseModel):
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
face_ids: list[int] = Field(..., min_items=0, description="Face IDs to identify with this person")
|
||||
|
||||
|
||||
class MaintenanceFaceItem(BaseModel):
|
||||
"""Face item for maintenance view with person info and file path."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
id: int
|
||||
photo_id: int
|
||||
photo_path: str
|
||||
photo_filename: str
|
||||
quality_score: float
|
||||
person_id: Optional[int] = None
|
||||
person_name: Optional[str] = None # Full name if identified
|
||||
|
||||
|
||||
class MaintenanceFacesResponse(BaseModel):
|
||||
"""Response containing all faces for maintenance."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
items: list[MaintenanceFaceItem]
|
||||
total: int
|
||||
|
||||
|
||||
class DeleteFacesRequest(BaseModel):
|
||||
"""Request to delete multiple faces."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
face_ids: list[int] = Field(..., min_items=1, description="Face IDs to delete")
|
||||
|
||||
|
||||
class DeleteFacesResponse(BaseModel):
|
||||
"""Response after deleting faces."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
deleted_face_ids: list[int]
|
||||
count: int
|
||||
message: str
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user