feat: Add video count to person API and frontend for enhanced media management

This commit introduces a new `video_count` field in the `PersonWithFaces` interface and updates the API to return video counts alongside face counts. The frontend has been modified to display video counts in the people list and includes functionality for selecting and unmatching videos. Additionally, the layout has been enhanced to support resizing of the people panel, improving user experience when managing faces and videos. Documentation has been updated to reflect these changes.
This commit is contained in:
tanyar09 2025-12-02 16:03:15 -05:00
parent 9d40f9772e
commit e0e5aae2ff
11 changed files with 327 additions and 162 deletions

View File

@ -47,3 +47,4 @@ module.exports = {
},
}

View File

@ -16,6 +16,7 @@ export interface PeopleListResponse {
export interface PersonWithFaces extends Person {
face_count: number
video_count: number
}
export interface PeopleWithFacesListResponse {

View File

@ -34,3 +34,4 @@ export const rolePermissionsApi = {
},
}

View File

@ -120,3 +120,4 @@ export const videosApi = {
export default videosApi

View File

@ -1,6 +1,7 @@
import { useEffect, useState, useRef, useCallback } from 'react'
import peopleApi, { PersonWithFaces, PersonFaceItem, PersonVideoItem, PersonUpdateRequest } from '../api/people'
import facesApi from '../api/faces'
import videosApi from '../api/videos'
interface EditDialogProps {
person: PersonWithFaces
@ -150,13 +151,21 @@ export default function Modify() {
const [selectedPersonName, setSelectedPersonName] = useState('')
const [faces, setFaces] = useState<PersonFaceItem[]>([])
const [videos, setVideos] = useState<PersonVideoItem[]>([])
const [unmatchedFaces, setUnmatchedFaces] = useState<Set<number>>(new Set())
const [unmatchedByPerson, setUnmatchedByPerson] = useState<Record<number, Set<number>>>({})
const [unmatchConfirmDialog, setUnmatchConfirmDialog] = useState<{
type: 'face' | 'video'
single: boolean
faceId?: number
videoId?: number
count?: number
} | null>(null)
const [selectedFaces, setSelectedFaces] = useState<Set<number>>(new Set())
const [selectedVideos, setSelectedVideos] = useState<Set<number>>(new Set())
const [editDialogPerson, setEditDialogPerson] = useState<PersonWithFaces | null>(null)
const [deleteDialogPerson, setDeleteDialogPerson] = useState<PersonWithFaces | null>(null)
const [facesExpanded, setFacesExpanded] = useState(true)
const [videosExpanded, setVideosExpanded] = useState(false)
const [peoplePanelWidth, setPeoplePanelWidth] = useState(450) // Default width in pixels (will be set to 50% on mount)
const [isResizing, setIsResizing] = useState(false)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
@ -192,9 +201,7 @@ export default function Modify() {
setBusy(true)
setError(null)
const res = await peopleApi.getFaces(personId)
// Filter out unmatched faces (show only matched faces)
const visibleFaces = res.items.filter((f) => !unmatchedFaces.has(f.id))
setFaces(visibleFaces)
setFaces(res.items)
} catch (err: any) {
// If person not found (404), clear selection instead of showing error
// This can happen if person was deleted after selection
@ -211,7 +218,7 @@ export default function Modify() {
} finally {
setBusy(false)
}
}, [unmatchedFaces])
}, [])
// Load videos for a person
const loadPersonVideos = useCallback(async (personId: number) => {
@ -231,13 +238,57 @@ export default function Modify() {
loadPeople()
}, [loadPeople])
// Initialize panel width to 50% of container
useEffect(() => {
const container = document.querySelector('[data-modify-container]') as HTMLElement
if (container) {
const containerWidth = container.getBoundingClientRect().width
const initialWidth = Math.max(450, containerWidth * 0.5) // At least 450px, or 50% of container
setPeoplePanelWidth(initialWidth)
}
}, [])
// Handle resize
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (!isResizing) return
const container = document.querySelector('[data-modify-container]') as HTMLElement
if (!container) return
const containerRect = container.getBoundingClientRect()
const newWidth = e.clientX - containerRect.left
const minWidth = 450 // Minimum width for People panel (current size)
if (newWidth >= minWidth) {
setPeoplePanelWidth(newWidth)
}
}
const handleMouseUp = () => {
setIsResizing(false)
}
if (isResizing) {
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
}
return () => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
}, [isResizing])
// Reload faces and videos when person changes
useEffect(() => {
if (selectedPersonId) {
loadPersonFaces(selectedPersonId)
loadPersonVideos(selectedPersonId)
// Clear selected faces when person changes
// Clear selected faces and videos when person changes
setSelectedFaces(new Set())
setSelectedVideos(new Set())
} else {
setFaces([])
setVideos([])
@ -319,33 +370,6 @@ export default function Modify() {
}
}
const handleUnmatchFace = async (faceId: number) => {
// Add to unmatched set (temporary, not persisted until save)
const newUnmatched = new Set(unmatchedFaces)
newUnmatched.add(faceId)
setUnmatchedFaces(newUnmatched)
// Track by person
if (selectedPersonId) {
const newByPerson = { ...unmatchedByPerson }
if (!newByPerson[selectedPersonId]) {
newByPerson[selectedPersonId] = new Set()
}
newByPerson[selectedPersonId].add(faceId)
setUnmatchedByPerson(newByPerson)
}
// Remove from selected faces
const newSelected = new Set(selectedFaces)
newSelected.delete(faceId)
setSelectedFaces(newSelected)
// Immediately refresh display to hide unmatched face
if (selectedPersonId) {
await loadPersonFaces(selectedPersonId)
}
}
const handleToggleFaceSelection = (faceId: number) => {
const newSelected = new Set(selectedFaces)
if (newSelected.has(faceId)) {
@ -357,7 +381,7 @@ export default function Modify() {
}
const handleSelectAll = () => {
const allFaceIds = new Set(visibleFaces.map(f => f.id))
const allFaceIds = new Set(faces.map(f => f.id))
setSelectedFaces(allFaceIds)
}
@ -365,38 +389,38 @@ export default function Modify() {
setSelectedFaces(new Set())
}
const handleBulkUnmatch = async () => {
const handleBulkUnmatch = () => {
if (selectedFaces.size === 0) return
setUnmatchConfirmDialog({
type: 'face',
single: false,
count: selectedFaces.size,
})
}
const confirmBulkUnmatchFaces = async () => {
if (!unmatchConfirmDialog || !selectedPersonId || selectedFaces.size === 0) return
try {
setBusy(true)
setError(null)
setUnmatchConfirmDialog(null)
// Add all selected faces to unmatched set
const newUnmatched = new Set(unmatchedFaces)
// Batch unmatch all selected faces
const faceIds = Array.from(selectedFaces)
faceIds.forEach(id => newUnmatched.add(id))
setUnmatchedFaces(newUnmatched)
// Track by person
if (selectedPersonId) {
const newByPerson = { ...unmatchedByPerson }
if (!newByPerson[selectedPersonId]) {
newByPerson[selectedPersonId] = new Set()
}
faceIds.forEach(id => newByPerson[selectedPersonId].add(id))
setUnmatchedByPerson(newByPerson)
}
await facesApi.batchUnmatch({ face_ids: faceIds })
// Clear selected faces
setSelectedFaces(new Set())
// Immediately refresh display to hide unmatched faces
if (selectedPersonId) {
await loadPersonFaces(selectedPersonId)
}
// Reload people list to update face counts
const peopleRes = await peopleApi.listWithFaces(lastNameFilter || undefined)
setPeople(peopleRes.items)
setSuccess(`Marked ${faceIds.length} face(s) for unmatching`)
// Reload faces
await loadPersonFaces(selectedPersonId)
setSuccess(`Successfully unlinked ${faceIds.length} face(s)`)
setTimeout(() => setSuccess(null), 3000)
} catch (err: any) {
setError(err.response?.data?.detail || err.message || 'Failed to unmatch faces')
@ -405,93 +429,96 @@ export default function Modify() {
}
}
const handleUndoChanges = () => {
if (!selectedPersonId) return
const personFaces = unmatchedByPerson[selectedPersonId]
if (!personFaces || personFaces.size === 0) return
// Remove faces for current person from unmatched sets
const newUnmatched = new Set(unmatchedFaces)
for (const faceId of personFaces) {
newUnmatched.delete(faceId)
}
setUnmatchedFaces(newUnmatched)
const newByPerson = { ...unmatchedByPerson }
delete newByPerson[selectedPersonId]
setUnmatchedByPerson(newByPerson)
// Clear selected faces
setSelectedFaces(new Set())
// Reload faces to show restored faces
if (selectedPersonId) {
loadPersonFaces(selectedPersonId)
}
setSuccess(`Undid changes for ${personFaces.size} face(s)`)
setTimeout(() => setSuccess(null), 3000)
const handleUnmatchVideo = (videoId: number) => {
setUnmatchConfirmDialog({
type: 'video',
single: true,
videoId,
})
}
const handleSaveChanges = async () => {
if (unmatchedFaces.size === 0) return
const confirmUnmatchVideo = async () => {
if (!unmatchConfirmDialog || !selectedPersonId || !unmatchConfirmDialog.videoId) return
try {
setBusy(true)
setError(null)
setUnmatchConfirmDialog(null)
// Batch unmatch all faces
const faceIds = Array.from(unmatchedFaces)
await facesApi.batchUnmatch({ face_ids: faceIds })
// Unmatch the single video
await videosApi.removePerson(unmatchConfirmDialog.videoId, selectedPersonId)
// Clear unmatched sets
setUnmatchedFaces(new Set())
setUnmatchedByPerson({})
setSelectedFaces(new Set())
// Reload people list first to update face counts and check if person still exists
// Reload people list
const peopleRes = await peopleApi.listWithFaces(lastNameFilter || undefined)
// Check if selected person still exists BEFORE updating state
// This prevents useEffect from triggering unnecessary requests
const currentSelectedId = selectedPersonId
const personStillExists = currentSelectedId
? peopleRes.items.some(p => p.id === currentSelectedId)
: false
// Clear selection immediately if person was deleted (before state update)
if (currentSelectedId && !personStillExists) {
setSelectedPersonId(null)
setSelectedPersonName('')
setFaces([])
setError(null)
}
// Update people list
setPeople(peopleRes.items)
// Reload faces only if person still exists
if (currentSelectedId && personStillExists) {
await loadPersonFaces(currentSelectedId)
}
// Reload videos
await loadPersonVideos(selectedPersonId)
setSuccess(`Successfully unlinked ${faceIds.length} face(s)`)
setSuccess('Successfully unlinked video')
setTimeout(() => setSuccess(null), 3000)
} catch (err: any) {
setError(err.response?.data?.detail || err.message || 'Failed to save changes')
setError(err.response?.data?.detail || err.message || 'Failed to unmatch video')
} finally {
setBusy(false)
}
}
const visibleFaces = faces.filter((f) => !unmatchedFaces.has(f.id))
const currentPersonHasUnmatched = selectedPersonId
? Boolean(unmatchedByPerson[selectedPersonId]?.size)
: false
const handleToggleVideoSelection = (videoId: number) => {
const newSelected = new Set(selectedVideos)
if (newSelected.has(videoId)) {
newSelected.delete(videoId)
} else {
newSelected.add(videoId)
}
setSelectedVideos(newSelected)
}
const handleBulkUnmatchVideos = () => {
if (selectedVideos.size === 0) return
setUnmatchConfirmDialog({
type: 'video',
single: false,
count: selectedVideos.size,
})
}
const confirmBulkUnmatchVideos = async () => {
if (!unmatchConfirmDialog || !selectedPersonId || selectedVideos.size === 0) return
try {
setBusy(true)
setError(null)
setUnmatchConfirmDialog(null)
// Unmatch all selected videos
const videoIds = Array.from(selectedVideos)
for (const videoId of videoIds) {
await videosApi.removePerson(videoId, selectedPersonId)
}
// Clear selected videos
setSelectedVideos(new Set())
// Reload people list
const peopleRes = await peopleApi.listWithFaces(lastNameFilter || undefined)
setPeople(peopleRes.items)
// Reload videos
await loadPersonVideos(selectedPersonId)
setSuccess(`Successfully unlinked ${videoIds.length} video(s)`)
setTimeout(() => setSuccess(null), 3000)
} catch (err: any) {
setError(err.response?.data?.detail || err.message || 'Failed to unmatch videos')
} finally {
setBusy(false)
}
}
return (
<div className="flex flex-col" style={{ height: 'calc(100vh - 3rem - 2rem)', overflow: 'hidden' }}>
<div className="flex flex-col" data-modify-container style={{ height: 'calc(100vh - 3rem - 2rem)', overflow: 'hidden' }}>
{error && (
<div className="p-4 bg-red-50 border border-red-200 rounded text-red-700 flex-shrink-0">
@ -505,9 +532,9 @@ export default function Modify() {
</div>
)}
<div className="grid grid-cols-3 gap-6 flex-1" style={{ minHeight: 0, overflow: 'hidden' }}>
<div className="flex flex-1" style={{ minHeight: 0, overflow: 'hidden' }}>
{/* Left panel: People list */}
<div className="col-span-1" style={{ minHeight: 0, display: 'flex', flexDirection: 'column' }}>
<div style={{ width: `${peoplePanelWidth}px`, minWidth: '450px', minHeight: 0, display: 'flex', flexDirection: 'column', flexShrink: 0 }}>
<div className="bg-white rounded-lg shadow p-4 h-full flex flex-col" style={{ minHeight: 0, overflow: 'hidden' }}>
<h2 className="text-lg font-semibold mb-4 flex-shrink-0">People</h2>
@ -570,7 +597,7 @@ export default function Modify() {
onClick={() => handlePersonClick(person)}
className="flex-1"
>
{name} ({person.face_count})
{name} ({person.face_count})({person.video_count})
</div>
<button
onClick={(e) => {
@ -592,8 +619,31 @@ export default function Modify() {
</div>
</div>
{/* Resize handle */}
<div
onMouseDown={() => setIsResizing(true)}
style={{
width: '4px',
cursor: 'col-resize',
backgroundColor: '#e5e7eb',
flexShrink: 0,
position: 'relative',
}}
className="hover:bg-gray-400 transition-colors"
>
<div
style={{
position: 'absolute',
left: '-2px',
right: '-2px',
top: 0,
bottom: 0,
}}
/>
</div>
{/* Right panel: Faces and Videos split horizontally */}
<div className="col-span-2" style={{ minHeight: 0, display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
<div style={{ flex: 1, minWidth: 0, minHeight: 0, display: 'flex', flexDirection: 'column', gap: '1.5rem', marginLeft: '1.5rem' }}>
{/* Top section: Faces grid */}
<div className={facesExpanded ? "flex-1" : ""} style={{ minHeight: 0, display: 'flex', flexDirection: 'column', flexShrink: facesExpanded ? 1 : 0 }}>
<div className={`bg-white rounded-lg shadow p-4 flex flex-col ${facesExpanded ? 'h-full' : ''}`} style={{ minHeight: 0, overflow: 'hidden' }}>
@ -607,7 +657,7 @@ export default function Modify() {
</button>
{selectedPersonId && (
<div className="flex gap-2">
{visibleFaces.length > 0 && (
{faces.length > 0 && (
<>
<button
onClick={handleSelectAll}
@ -632,20 +682,6 @@ export default function Modify() {
</button>
</>
)}
<button
onClick={handleUndoChanges}
disabled={!currentPersonHasUnmatched || busy}
className="px-3 py-1 text-sm bg-gray-100 text-gray-700 rounded hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
Undo changes
</button>
<button
onClick={handleSaveChanges}
disabled={unmatchedFaces.size === 0 || busy}
className="px-3 py-1 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
💾 Save changes
</button>
</div>
)}
</div>
@ -654,20 +690,16 @@ export default function Modify() {
<>
{selectedPersonId ? (
<div className="flex-1" style={{ minHeight: 0, overflowY: 'auto' }}>
{busy && visibleFaces.length === 0 ? (
{busy && faces.length === 0 ? (
<div className="text-center text-gray-500 py-8">Loading faces...</div>
) : visibleFaces.length === 0 ? (
<div className="text-center text-gray-500 py-8">
{faces.length === 0
? 'No faces found for this person'
: 'All faces unmatched'}
</div>
) : faces.length === 0 ? (
<div className="text-center text-gray-500 py-8">No faces found for this person</div>
) : (
<div
ref={gridRef}
className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4"
>
{visibleFaces.map((face) => (
{faces.map((face) => (
<div key={face.id} className="flex flex-col items-center">
<div className="w-20 h-20 mb-2">
<img
@ -692,7 +724,7 @@ export default function Modify() {
className="rounded"
disabled={busy}
/>
<span className="text-xs text-gray-700">Unmatch</span>
<span className="text-xs text-gray-700">Select</span>
</label>
</div>
))}
@ -722,6 +754,38 @@ export default function Modify() {
<span className="text-lg font-semibold">Videos</span>
<span>{videosExpanded ? '▼' : '▶'}</span>
</button>
{selectedPersonId && videosExpanded && (
<div className="flex gap-2">
{videos.length > 0 && (
<>
<button
onClick={() => {
const allVideoIds = new Set(videos.map(v => v.id))
setSelectedVideos(allVideoIds)
}}
disabled={busy}
className="px-3 py-1 text-sm bg-gray-100 text-gray-700 rounded hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
Select All
</button>
<button
onClick={() => setSelectedVideos(new Set())}
disabled={busy || selectedVideos.size === 0}
className="px-3 py-1 text-sm bg-gray-100 text-gray-700 rounded hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
Unselect All
</button>
<button
onClick={handleBulkUnmatchVideos}
disabled={busy || selectedVideos.size === 0}
className="px-3 py-1 text-sm bg-red-600 text-white rounded hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Unmatch Selected
</button>
</>
)}
</div>
)}
</div>
{videosExpanded && (
<>
@ -734,19 +798,44 @@ export default function Modify() {
{videos.map((video) => (
<div
key={video.id}
className="p-3 border border-gray-200 rounded hover:bg-gray-50 cursor-pointer"
onClick={() => {
// Open video in new window or navigate to video page
window.open(`/api/v1/videos/${video.id}/thumbnail`, '_blank')
}}
title="Click to view video"
className="p-3 border border-gray-200 rounded hover:bg-gray-50"
>
<div className="font-medium text-sm truncate">{video.filename}</div>
{video.date_taken && (
<div className="text-xs text-gray-500 mt-1">
{new Date(video.date_taken).toLocaleDateString()}
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={selectedVideos.has(video.id)}
onChange={() => handleToggleVideoSelection(video.id)}
className="rounded"
disabled={busy}
onClick={(e) => e.stopPropagation()}
/>
<div
className="flex-1 cursor-pointer"
onClick={() => {
// Open video in new window or navigate to video page
window.open(`/api/v1/videos/${video.id}/thumbnail`, '_blank')
}}
title="Click to view video"
>
<div className="font-medium text-sm truncate">{video.filename}</div>
{video.date_taken && (
<div className="text-xs text-gray-500 mt-1">
{new Date(video.date_taken).toLocaleDateString()}
</div>
)}
</div>
)}
<button
onClick={(e) => {
e.stopPropagation()
handleUnmatchVideo(video.id)
}}
className="px-2 py-1 text-xs bg-red-600 text-white rounded hover:bg-red-700 disabled:opacity-50"
disabled={busy}
title="Unmatch video"
>
Unmatch
</button>
</div>
</div>
))}
</div>
@ -814,6 +903,53 @@ export default function Modify() {
</div>
</div>
)}
{/* Unmatch confirmation dialog */}
{unmatchConfirmDialog && (
<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">
<h2 className="text-xl font-bold mb-4 text-red-600">Confirm Unmatch</h2>
<p className="mb-4">
{unmatchConfirmDialog.single
? `Are you sure you want to unmatch this ${unmatchConfirmDialog.type === 'face' ? 'face' : 'video'} from ${selectedPersonName || 'this person'}?`
: `Are you sure you want to unmatch ${unmatchConfirmDialog.count} ${unmatchConfirmDialog.type === 'face' ? 'face(s)' : 'video(s)'} from ${selectedPersonName || 'this person'}?`}
</p>
<p className="mb-6 text-sm text-gray-600">
This action cannot be undone.
</p>
<div className="flex justify-end gap-3">
<button
onClick={() => setUnmatchConfirmDialog(null)}
disabled={busy}
className="px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
Cancel
</button>
<button
onClick={() => {
if (unmatchConfirmDialog.type === 'face') {
if (unmatchConfirmDialog.single) {
confirmUnmatchFace()
} else {
confirmBulkUnmatchFaces()
}
} else {
if (unmatchConfirmDialog.single) {
confirmUnmatchVideo()
} else {
confirmBulkUnmatchVideos()
}
}
}}
disabled={busy}
className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{busy ? 'Processing...' : 'Confirm'}
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@ -50,16 +50,16 @@ 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, sorted by last_name, first_name.
"""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.
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).label('face_count')
func.count(Face.id.distinct()).label('face_count')
)
.outerjoin(Face, Person.id == Face.person_id)
.group_by(Person.id)
@ -75,6 +75,25 @@ def list_people_with_faces(
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,
@ -84,6 +103,7 @@ def list_people_with_faces(
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
]

View File

@ -66,3 +66,4 @@ def update_role_permissions(
features = [RoleFeatureSchema(**feature) for feature in ROLE_FEATURES]
return RolePermissionsResponse(features=features, permissions=permissions)

View File

@ -335,3 +335,4 @@ def get_video_file(
response.headers["Cache-Control"] = "public, max-age=3600"
return response

View File

@ -66,6 +66,7 @@ class PersonWithFacesResponse(BaseModel):
maiden_name: Optional[str] = None
date_of_birth: Optional[date] = None
face_count: int
video_count: int
class PeopleWithFacesListResponse(BaseModel):

View File

@ -40,3 +40,4 @@ class RolePermissionsUpdateRequest(BaseModel):
def build_feature_list() -> list[RoleFeatureSchema]:
return [RoleFeatureSchema(**feature) for feature in ROLE_FEATURES]

View File

@ -88,3 +88,4 @@ class RemoveVideoPersonResponse(BaseModel):
removed: bool
message: str