feat: Add identification report and clear denied records functionality

This commit introduces new API endpoints for generating identification reports and clearing denied records from the database. The frontend has been updated to include a report button that fetches user identification statistics, allowing admins to view how many faces each user identified over a specified date range. Additionally, a clear denied records button has been added to permanently remove all denied identifications. The necessary data models and response structures have been implemented to support these features. Documentation has been updated to reflect these changes.
This commit is contained in:
tanyar09 2025-11-24 14:58:11 -05:00
parent dbffaef298
commit a036169b0f
10 changed files with 673 additions and 38 deletions

View File

@ -37,6 +37,27 @@ export interface ApproveDenyResponse {
errors: string[]
}
export interface UserIdentificationStats {
user_id: number
username: string
full_name: string
email: string
face_count: number
first_identification_date: string | null
last_identification_date: string | null
}
export interface IdentificationReportResponse {
items: UserIdentificationStats[]
total_faces: number
total_users: number
}
export interface ClearDatabaseResponse {
deleted_records: number
errors: string[]
}
export const pendingIdentificationsApi = {
list: async (includeDenied: boolean = false): Promise<PendingIdentificationsListResponse> => {
const res = await apiClient.get<PendingIdentificationsListResponse>(
@ -52,6 +73,22 @@ export const pendingIdentificationsApi = {
)
return res.data
},
getReport: async (dateFrom?: string, dateTo?: string): Promise<IdentificationReportResponse> => {
const params: Record<string, string> = {}
if (dateFrom) params.date_from = dateFrom
if (dateTo) params.date_to = dateTo
const res = await apiClient.get<IdentificationReportResponse>(
'/api/v1/pending-identifications/report',
{ params }
)
return res.data
},
clearDenied: async (): Promise<ClearDatabaseResponse> => {
const res = await apiClient.post<ClearDatabaseResponse>(
'/api/v1/pending-identifications/clear-denied'
)
return res.data
},
}
export default pendingIdentificationsApi

View File

@ -1,5 +1,9 @@
import { useEffect, useState, useCallback } from 'react'
import pendingIdentificationsApi, { PendingIdentification } from '../api/pendingIdentifications'
import pendingIdentificationsApi, {
PendingIdentification,
IdentificationReportResponse,
UserIdentificationStats
} from '../api/pendingIdentifications'
import { apiClient } from '../api/client'
export default function ApproveIdentified() {
@ -9,6 +13,13 @@ export default function ApproveIdentified() {
const [decisions, setDecisions] = useState<Record<number, 'approve' | 'deny' | null>>({})
const [submitting, setSubmitting] = useState(false)
const [includeDenied, setIncludeDenied] = useState(false)
const [showReport, setShowReport] = useState(false)
const [reportData, setReportData] = useState<IdentificationReportResponse | null>(null)
const [reportLoading, setReportLoading] = useState(false)
const [reportError, setReportError] = useState<string | null>(null)
const [dateFrom, setDateFrom] = useState<string>('')
const [dateTo, setDateTo] = useState<string>('')
const [clearing, setClearing] = useState(false)
const loadPendingIdentifications = useCallback(async () => {
setLoading(true)
@ -51,10 +62,20 @@ export default function ApproveIdentified() {
}
const handleDecisionChange = (id: number, decision: 'approve' | 'deny') => {
setDecisions(prev => ({
...prev,
[id]: decision
}))
setDecisions(prev => {
const currentDecision = prev[id]
// If clicking the same checkbox, deselect it
if (currentDecision === decision) {
const updated = { ...prev }
delete updated[id]
return updated
}
// Otherwise, set the new decision (this will automatically deselect the other)
return {
...prev,
[id]: decision
}
})
}
const handleSubmit = async () => {
@ -109,6 +130,78 @@ export default function ApproveIdentified() {
}
}
const loadReport = useCallback(async () => {
setReportLoading(true)
setReportError(null)
try {
const response = await pendingIdentificationsApi.getReport(
dateFrom || undefined,
dateTo || undefined
)
setReportData(response)
} catch (err: any) {
setReportError(err.response?.data?.detail || err.message || 'Failed to load report')
console.error('Error loading report:', err)
} finally {
setReportLoading(false)
}
}, [dateFrom, dateTo])
const handleOpenReport = () => {
setShowReport(true)
loadReport()
}
const handleCloseReport = () => {
setShowReport(false)
setReportData(null)
setReportError(null)
setDateFrom('')
setDateTo('')
}
const formatDateTime = (dateString: string | null | undefined): string => {
if (!dateString) return '-'
try {
const date = new Date(dateString)
return date.toLocaleString()
} catch {
return dateString
}
}
const handleClearDenied = async () => {
if (!confirm('Are you sure you want to delete all denied records? This action cannot be undone.')) {
return
}
setClearing(true)
try {
const response = await pendingIdentificationsApi.clearDenied()
const message = [
`✅ Deleted ${response.deleted_records} denied record(s)`,
response.errors.length > 0 ? `⚠️ Errors: ${response.errors.length}` : ''
].filter(Boolean).join('\n')
alert(message)
if (response.errors.length > 0) {
console.error('Errors:', response.errors)
alert('Errors:\n' + response.errors.join('\n'))
}
// Reload the list to reflect changes
await loadPendingIdentifications()
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to clear denied records'
alert(`Error: ${errorMessage}`)
console.error('Error clearing denied records:', err)
} finally {
setClearing(false)
}
}
return (
<div>
<div className="bg-white rounded-lg shadow p-6">
@ -126,7 +219,7 @@ export default function ApproveIdentified() {
<p className="text-sm mt-1">{error}</p>
<button
onClick={loadPendingIdentifications}
className="mt-3 px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700"
className="mt-3 px-3 py-1.5 text-sm bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 font-medium"
>
Retry
</button>
@ -140,6 +233,20 @@ export default function ApproveIdentified() {
Total pending identifications: <span className="font-semibold">{pendingIdentifications.length}</span>
</div>
<div className="flex items-center gap-4">
<button
onClick={handleOpenReport}
className="px-3 py-1.5 text-sm bg-green-100 text-green-700 rounded-md hover:bg-green-200 font-medium"
>
📊 Report
</button>
<button
onClick={handleClearDenied}
disabled={clearing}
className="px-3 py-1.5 text-sm bg-red-100 text-red-700 rounded-md hover:bg-red-200 disabled:bg-gray-300 disabled:text-gray-500 disabled:cursor-not-allowed font-medium"
title="Delete all denied records from the database"
>
{clearing ? 'Clearing...' : '🗑️ Clear Database'}
</button>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
@ -275,23 +382,37 @@ export default function ApproveIdentified() {
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name={`decision-${pending.id}`}
value="approve"
type="checkbox"
checked={decisions[pending.id] === 'approve'}
onChange={() => handleDecisionChange(pending.id, 'approve')}
className="w-4 h-4 text-green-600 focus:ring-green-500"
onChange={() => {
const currentDecision = decisions[pending.id]
if (currentDecision === 'approve') {
// Deselect if already selected
handleDecisionChange(pending.id, 'approve')
} else {
// Select approve (this will deselect deny if selected)
handleDecisionChange(pending.id, 'approve')
}
}}
className="w-4 h-4 text-green-600 focus:ring-green-500 rounded"
/>
<span className="text-sm text-gray-700">Approve</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name={`decision-${pending.id}`}
value="deny"
type="checkbox"
checked={decisions[pending.id] === 'deny'}
onChange={() => handleDecisionChange(pending.id, 'deny')}
className="w-4 h-4 text-red-600 focus:ring-red-500"
onChange={() => {
const currentDecision = decisions[pending.id]
if (currentDecision === 'deny') {
// Deselect if already selected
handleDecisionChange(pending.id, 'deny')
} else {
// Select deny (this will deselect approve if selected)
handleDecisionChange(pending.id, 'deny')
}
}}
className="w-4 h-4 text-red-600 focus:ring-red-500 rounded"
/>
<span className="text-sm text-gray-700">Deny</span>
</label>
@ -309,6 +430,159 @@ export default function ApproveIdentified() {
</>
)}
</div>
{/* Report Modal */}
{showReport && (
<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 max-w-4xl w-full mx-4 max-h-[90vh] overflow-hidden flex flex-col">
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
<h2 className="text-xl font-bold text-gray-900">Identification Report</h2>
<button
onClick={handleCloseReport}
className="text-gray-400 hover:text-gray-600 text-2xl font-bold"
>
×
</button>
</div>
<div className="px-6 py-4 border-b border-gray-200">
<div className="flex items-center gap-4">
<div className="flex-1">
<label className="block text-sm font-medium text-gray-700 mb-1">
Date From
</label>
<input
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="flex-1">
<label className="block text-sm font-medium text-gray-700 mb-1">
Date To
</label>
<input
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="flex items-end">
<button
onClick={loadReport}
disabled={reportLoading}
className="px-3 py-1.5 text-sm bg-blue-100 text-blue-700 rounded-md hover:bg-blue-200 disabled:bg-gray-300 disabled:text-gray-500 disabled:cursor-not-allowed font-medium"
>
{reportLoading ? 'Loading...' : 'Apply Filter'}
</button>
</div>
</div>
</div>
<div className="flex-1 overflow-y-auto px-6 py-4">
{reportLoading && (
<div className="text-center py-8">
<p className="text-gray-600">Loading report...</p>
</div>
)}
{reportError && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded text-red-700">
<p className="font-semibold">Error loading report</p>
<p className="text-sm mt-1">{reportError}</p>
</div>
)}
{!reportLoading && !reportError && reportData && (
<>
<div className="mb-4 p-4 bg-blue-50 border border-blue-200 rounded">
<div className="grid grid-cols-3 gap-4 text-sm">
<div>
<span className="font-semibold text-gray-700">Total Users:</span>{' '}
<span className="text-gray-900">{reportData.total_users}</span>
</div>
<div>
<span className="font-semibold text-gray-700">Total Faces:</span>{' '}
<span className="text-gray-900">{reportData.total_faces}</span>
</div>
<div>
<span className="font-semibold text-gray-700">Average per User:</span>{' '}
<span className="text-gray-900">
{reportData.total_users > 0
? Math.round((reportData.total_faces / reportData.total_users) * 10) / 10
: 0}
</span>
</div>
</div>
</div>
{reportData.items.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<p>No identifications found for the selected date range.</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
User
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Email
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Faces Identified
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
First Identification
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Last Identification
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{reportData.items.map((stat: UserIdentificationStats) => (
<tr key={stat.user_id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">
{stat.full_name}
</div>
<div className="text-sm text-gray-500">{stat.username}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-500">{stat.email}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-semibold text-gray-900">
{stat.face_count}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-500">
{formatDateTime(stat.first_identification_date)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-500">
{formatDateTime(stat.last_identification_date)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
)}
</div>
</div>
</div>
)}
</div>
)
}

View File

@ -427,24 +427,6 @@ export default function PendingPhotos() {
<option value="rejected">Rejected</option>
</select>
</div>
{isAdmin && (
<div className="flex items-center gap-2">
<button
onClick={() => handleCleanupFiles()}
className="px-3 py-1.5 text-sm bg-orange-100 text-orange-700 rounded-md hover:bg-orange-200 font-medium"
title="Delete files from shared space for approved/rejected photos"
>
🗑 Cleanup Files
</button>
<button
onClick={() => handleCleanupDatabase()}
className="px-3 py-1.5 text-sm bg-red-100 text-red-700 rounded-md hover:bg-red-200 font-medium"
title="Delete all records from pending_photos table"
>
🗑 Clear Database
</button>
</div>
)}
<div className="flex items-center gap-2">
{pendingPhotos.filter((p) => p.status === 'pending').length > 0 && (
<>
@ -462,6 +444,24 @@ export default function PendingPhotos() {
</button>
</>
)}
{isAdmin && (
<>
<button
onClick={() => handleCleanupFiles()}
className="px-3 py-1.5 text-sm bg-orange-100 text-orange-700 rounded-md hover:bg-orange-200 font-medium"
title="Delete files from shared space for approved/rejected photos"
>
🗑 Cleanup Files
</button>
<button
onClick={() => handleCleanupDatabase()}
className="px-3 py-1.5 text-sm bg-red-100 text-red-700 rounded-md hover:bg-red-200 font-medium"
title="Delete all records from pending_photos table"
>
🗑 Clear Database
</button>
</>
)}
<button
onClick={handleSubmit}
disabled={

View File

@ -74,6 +74,50 @@ def get_current_user(
)
def get_current_user_with_id(
current_user: Annotated[dict, Depends(get_current_user)],
db: Session = Depends(get_db),
) -> dict:
"""Get current user with ID from main database.
Looks up the user in the main database and returns username and user_id.
If user doesn't exist, creates them (for bootstrap scenarios).
"""
username = current_user["username"]
# Check if user exists in main database
user = db.query(User).filter(User.username == username).first()
# If user doesn't exist, create them (for bootstrap scenarios)
if not user:
from src.web.utils.password import hash_password
# Generate unique email to avoid conflicts
base_email = f"{username}@example.com"
email = base_email
counter = 1
# Ensure email is unique
while db.query(User).filter(User.email == email).first():
email = f"{username}+{counter}@example.com"
counter += 1
# Create user (they should change password)
default_password_hash = hash_password("changeme")
user = User(
username=username,
password_hash=default_password_hash,
email=email,
full_name=username,
is_active=True,
is_admin=False,
)
db.add(user)
db.commit()
db.refresh(user)
return {"username": username, "user_id": user.id}
@router.post("/login", response_model=TokenResponse)
def login(credentials: LoginRequest, db: Session = Depends(get_db)) -> TokenResponse:
"""Authenticate user and return tokens.

View File

@ -8,8 +8,10 @@ from rq import Queue
from redis import Redis
from sqlalchemy import func
from sqlalchemy.orm import Session
from typing import Annotated
from src.web.db.session import get_db
from src.web.api.auth import get_current_user_with_id
from src.web.schemas.faces import (
ProcessFacesRequest,
ProcessFacesResponse,
@ -260,12 +262,16 @@ def get_batch_similarities(
def identify_face(
face_id: int,
request: IdentifyFaceRequest,
current_user: Annotated[dict, Depends(get_current_user_with_id)],
db: Session = Depends(get_db),
) -> IdentifyFaceResponse:
"""Assign a face (and optional batch) to a person, creating if needed.
Also inserts into person_encodings for each identified face as desktop does.
Tracks which user identified the face.
"""
user_id = current_user["user_id"]
# Validate target face
face = db.query(Face).filter(Face.id == face_id).first()
if not face:
@ -307,6 +313,7 @@ def identify_face(
if not f:
continue
f.person_id = person.id
f.identified_by_user_id = user_id
db.add(f)
# Insert person_encoding
pe = PersonEncoding(

View File

@ -5,18 +5,53 @@ from __future__ import annotations
from datetime import date, datetime
from typing import Annotated, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, ConfigDict
from sqlalchemy import text
from sqlalchemy import text, func
from sqlalchemy.orm import Session
from src.web.db.session import get_auth_db, get_db
from src.web.db.models import Face, Person, PersonEncoding
from src.web.db.models import Face, Person, PersonEncoding, User
from src.web.api.users import get_current_admin_user
from src.web.utils.password import hash_password
router = APIRouter(prefix="/pending-identifications", tags=["pending-identifications"])
def get_or_create_frontend_user(db: Session) -> User:
"""Get or create the special 'FrontEndUser' system user.
This user represents identifications made through the frontend approval UI,
distinguishing them from direct user identifications.
"""
FRONTEND_USERNAME = "FrontEndUser"
# Try to get existing user
user = db.query(User).filter(User.username == FRONTEND_USERNAME).first()
if user:
return user
# Create the system user if it doesn't exist
# Use a non-loginable password hash (random, won't be used for login)
default_password_hash = hash_password("system_user_not_for_login")
user = User(
username=FRONTEND_USERNAME,
password_hash=default_password_hash,
email="frontend@punimtag.system",
full_name="Frontend System User",
is_active=False, # Not an active user, just a system marker
is_admin=False,
password_change_required=False,
)
db.add(user)
db.commit()
db.refresh(user)
return user
class PendingIdentificationResponse(BaseModel):
"""Pending identification DTO returned from API."""
@ -74,6 +109,39 @@ class ApproveDenyResponse(BaseModel):
errors: list[str]
class UserIdentificationStats(BaseModel):
"""Statistics for a single user's identifications."""
model_config = ConfigDict(protected_namespaces=())
user_id: int
username: str
full_name: str
email: str
face_count: int
first_identification_date: Optional[datetime] = None
last_identification_date: Optional[datetime] = None
class IdentificationReportResponse(BaseModel):
"""Response containing identification statistics grouped by user."""
model_config = ConfigDict(protected_namespaces=())
items: list[UserIdentificationStats]
total_faces: int
total_users: int
class ClearDatabaseResponse(BaseModel):
"""Response from clearing denied records."""
model_config = ConfigDict(protected_namespaces=())
deleted_records: int
errors: list[str]
@router.get("", response_model=PendingIdentificationsListResponse)
def list_pending_identifications(
current_admin: Annotated[dict, Depends(get_current_admin_user)],
@ -277,7 +345,10 @@ def approve_deny_pending_identifications(
created_person = True
# Link face to person
# Use FrontEndUser to indicate this was approved through the frontend UI
frontend_user = get_or_create_frontend_user(main_db)
face.person_id = person.id
face.identified_by_user_id = frontend_user.id
main_db.add(face)
# Insert person_encoding
@ -327,3 +398,144 @@ def approve_deny_pending_identifications(
errors=errors
)
@router.get("/report", response_model=IdentificationReportResponse)
def get_identification_report(
current_admin: Annotated[dict, Depends(get_current_admin_user)],
date_from: Optional[str] = Query(None, description="Filter by identification date (from) - YYYY-MM-DD"),
date_to: Optional[str] = Query(None, description="Filter by identification date (to) - YYYY-MM-DD"),
main_db: Session = Depends(get_db),
) -> IdentificationReportResponse:
"""Get identification statistics grouped by user.
Shows how many faces each user identified and when.
Can be filtered by date range using PersonEncoding.created_date.
"""
# Query faces that have been identified (have person_id and identified_by_user_id)
# Join with PersonEncoding to get created_date (when face was identified)
# Join with User to get user information
# Use distinct count to avoid counting the same face multiple times
# (in case person encodings were updated, creating multiple PersonEncoding records)
query = (
main_db.query(
User.id.label('user_id'),
User.username,
User.full_name,
User.email,
func.count(func.distinct(Face.id)).label('face_count'),
func.min(PersonEncoding.created_date).label('first_date'),
func.max(PersonEncoding.created_date).label('last_date')
)
.join(Face, User.id == Face.identified_by_user_id)
.join(PersonEncoding, Face.id == PersonEncoding.face_id)
.filter(Face.person_id.isnot(None))
.filter(Face.identified_by_user_id.isnot(None))
.group_by(User.id, User.username, User.full_name, User.email)
)
# Apply date filtering if provided (filter before grouping)
if date_from:
try:
date_from_obj = datetime.strptime(date_from, "%Y-%m-%d").date()
query = query.filter(func.date(PersonEncoding.created_date) >= date_from_obj)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid date_from format. Use YYYY-MM-DD"
)
if date_to:
try:
date_to_obj = datetime.strptime(date_to, "%Y-%m-%d").date()
query = query.filter(func.date(PersonEncoding.created_date) <= date_to_obj)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid date_to format. Use YYYY-MM-DD"
)
# Execute query and get results
results = query.order_by(func.count(Face.id).desc(), User.username.asc()).all()
# Convert to response model
items = []
total_faces = 0
for row in results:
total_faces += row.face_count
items.append(
UserIdentificationStats(
user_id=row.user_id,
username=row.username,
full_name=row.full_name or row.username,
email=row.email,
face_count=row.face_count,
first_identification_date=row.first_date,
last_identification_date=row.last_date,
)
)
return IdentificationReportResponse(
items=items,
total_faces=total_faces,
total_users=len(items)
)
@router.post("/clear-denied", response_model=ClearDatabaseResponse)
def clear_denied_identifications(
current_admin: Annotated[dict, Depends(get_current_admin_user)],
auth_db: Session = Depends(get_auth_db),
) -> ClearDatabaseResponse:
"""Delete all denied pending identifications from the database.
This permanently removes all records with status='denied' from the
pending_identifications table in the auth database.
"""
deleted_records = 0
errors = []
try:
# First check if there are any denied records
check_result = auth_db.execute(text("""
SELECT COUNT(*) as count FROM pending_identifications
WHERE status = 'denied'
"""))
denied_count = check_result.fetchone().count if check_result else 0
if denied_count == 0:
# No denied records to delete
return ClearDatabaseResponse(
deleted_records=0,
errors=[]
)
# Delete all denied records
result = auth_db.execute(text("""
DELETE FROM pending_identifications
WHERE status = 'denied'
"""))
deleted_records = result.rowcount if hasattr(result, 'rowcount') else 0
auth_db.commit()
if deleted_records == 0 and denied_count > 0:
errors.append("No records were deleted despite finding denied records")
except Exception as e:
auth_db.rollback()
error_msg = str(e)
errors.append(f"Error deleting denied records: {error_msg}")
# Check if it's a permission error
if "permission denied" in error_msg.lower() or "insufficient privilege" in error_msg.lower():
errors.append(
"Database permission error. Please run this command manually:\n"
"sudo -u postgres psql -d punimtag_auth -c \"GRANT DELETE ON TABLE pending_identifications TO punimtag;\""
)
return ClearDatabaseResponse(
deleted_records=deleted_records,
errors=errors
)

View File

@ -2,12 +2,15 @@
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func
from sqlalchemy.orm import Session
from src.web.db.session import get_db
from src.web.db.models import Person, Face
from src.web.api.auth import get_current_user_with_id
from src.web.schemas.people import (
PeopleListResponse,
PersonCreateRequest,
@ -185,6 +188,7 @@ def get_person_faces(person_id: int, db: Session = Depends(get_db)) -> PersonFac
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.
@ -193,9 +197,13 @@ def accept_matches(
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 src.web.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
db, person_id, request.face_ids, user_id=user_id
)
return IdentifyFaceResponse(

View File

@ -221,6 +221,47 @@ def ensure_user_email_unique_constraint(inspector) -> None:
print(" SQLite: Unique constraint on email will be enforced by model definition for new tables")
def ensure_face_identified_by_user_id_column(inspector) -> None:
"""Ensure faces table contains identified_by_user_id column."""
if "faces" not in inspector.get_table_names():
return
columns = {column["name"] for column in inspector.get_columns("faces")}
if "identified_by_user_id" in columns:
print(" identified_by_user_id column already exists in faces table")
return
print("🔄 Adding identified_by_user_id column to faces table...")
dialect = engine.dialect.name
with engine.connect() as connection:
with connection.begin():
if dialect == "postgresql":
connection.execute(
text("ALTER TABLE faces ADD COLUMN IF NOT EXISTS identified_by_user_id INTEGER REFERENCES users(id)")
)
# Add index
try:
connection.execute(
text("CREATE INDEX IF NOT EXISTS idx_faces_identified_by ON faces(identified_by_user_id)")
)
except Exception:
pass # Index might already exist
else:
# SQLite
connection.execute(
text("ALTER TABLE faces ADD COLUMN identified_by_user_id INTEGER REFERENCES users(id)")
)
# SQLite doesn't support IF NOT EXISTS for indexes, so we'll try to create it
try:
connection.execute(
text("CREATE INDEX idx_faces_identified_by ON faces(identified_by_user_id)")
)
except Exception:
pass # Index might already exist
print("✅ Added identified_by_user_id column to faces table")
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Lifespan context manager for startup and shutdown events."""
@ -255,6 +296,7 @@ async def lifespan(app: FastAPI):
ensure_user_password_hash_column(inspector)
ensure_user_password_change_required_column(inspector)
ensure_user_email_unique_constraint(inspector)
ensure_face_identified_by_user_id_column(inspector)
except Exception as exc:
print(f"❌ Database initialization failed: {exc}")
raise

View File

@ -102,6 +102,7 @@ class Face(Base):
pitch_angle = Column(Numeric, nullable=True)
roll_angle = Column(Numeric, nullable=True)
landmarks = Column(Text, nullable=True) # JSON string of facial landmarks
identified_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True)
photo = relationship("Photo", back_populates="faces")
person = relationship("Person", back_populates="faces")
@ -114,6 +115,7 @@ class Face(Base):
Index("idx_faces_photo_id", "photo_id"),
Index("idx_faces_quality", "quality_score"),
Index("idx_faces_pose_mode", "pose_mode"),
Index("idx_faces_identified_by", "identified_by_user_id"),
)

View File

@ -1988,6 +1988,7 @@ def accept_auto_match_matches(
db: Session,
person_id: int,
face_ids: List[int],
user_id: int | None = None,
) -> Tuple[int, int]:
"""Accept auto-match matches for a person, matching desktop logic exactly.
@ -1996,6 +1997,12 @@ def accept_auto_match_matches(
2. Insert person_encodings for each identified face
3. Update person encodings (remove old, add current)
Args:
db: Database session
person_id: Person ID to identify faces with
face_ids: List of face IDs to identify
user_id: User ID who is performing the identification (optional)
Returns:
(identified_count, updated_count) tuple
"""
@ -2017,6 +2024,8 @@ def accept_auto_match_matches(
for face in faces:
# Set person_id on face
face.person_id = person_id
if user_id is not None:
face.identified_by_user_id = user_id
db.add(face)
# Insert person_encoding (matching desktop)