diff --git a/frontend/src/api/pendingIdentifications.ts b/frontend/src/api/pendingIdentifications.ts index 1b0bf83..e266ecc 100644 --- a/frontend/src/api/pendingIdentifications.ts +++ b/frontend/src/api/pendingIdentifications.ts @@ -3,6 +3,7 @@ import apiClient from './client' export interface PendingIdentification { id: number face_id: number + photo_id?: number | null user_id: number user_name?: string | null user_email: string @@ -21,10 +22,33 @@ export interface PendingIdentificationsListResponse { total: number } +export interface ApproveDenyDecision { + id: number + decision: 'approve' | 'deny' +} + +export interface ApproveDenyRequest { + decisions: ApproveDenyDecision[] +} + +export interface ApproveDenyResponse { + approved: number + denied: number + errors: string[] +} + export const pendingIdentificationsApi = { - list: async (): Promise => { + list: async (includeDenied: boolean = false): Promise => { const res = await apiClient.get( - '/api/v1/pending-identifications' + '/api/v1/pending-identifications', + { params: { include_denied: includeDenied } } + ) + return res.data + }, + approveDeny: async (request: ApproveDenyRequest): Promise => { + const res = await apiClient.post( + '/api/v1/pending-identifications/approve-deny', + request ) return res.data }, diff --git a/frontend/src/pages/ApproveIdentified.tsx b/frontend/src/pages/ApproveIdentified.tsx index 6b1cdef..e87ce78 100644 --- a/frontend/src/pages/ApproveIdentified.tsx +++ b/frontend/src/pages/ApproveIdentified.tsx @@ -1,20 +1,20 @@ -import { useEffect, useState } from 'react' +import { useEffect, useState, useCallback } from 'react' import pendingIdentificationsApi, { PendingIdentification } from '../api/pendingIdentifications' +import { apiClient } from '../api/client' export default function ApproveIdentified() { const [pendingIdentifications, setPendingIdentifications] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) + const [decisions, setDecisions] = useState>({}) + const [submitting, setSubmitting] = useState(false) + const [includeDenied, setIncludeDenied] = useState(false) - useEffect(() => { - loadPendingIdentifications() - }, []) - - const loadPendingIdentifications = async () => { + const loadPendingIdentifications = useCallback(async () => { setLoading(true) setError(null) try { - const response = await pendingIdentificationsApi.list() + const response = await pendingIdentificationsApi.list(includeDenied) setPendingIdentifications(response.items) } catch (err: any) { setError(err.response?.data?.detail || err.message || 'Failed to load pending identifications') @@ -22,7 +22,11 @@ export default function ApproveIdentified() { } finally { setLoading(false) } - } + }, [includeDenied]) + + useEffect(() => { + loadPendingIdentifications() + }, [loadPendingIdentifications]) const formatDate = (dateString: string | null | undefined): string => { if (!dateString) return '-' @@ -46,6 +50,65 @@ export default function ApproveIdentified() { return parts.join(' ') } + const handleDecisionChange = (id: number, decision: 'approve' | 'deny') => { + setDecisions(prev => ({ + ...prev, + [id]: decision + })) + } + + const handleSubmit = async () => { + // Get all decisions that have been made, but only for pending items + const decisionsList = Object.entries(decisions) + .filter(([id, decision]) => { + const pending = pendingIdentifications.find(p => p.id === parseInt(id)) + return decision !== null && pending && pending.status === 'pending' + }) + .map(([id, decision]) => ({ + id: parseInt(id), + decision: decision! + })) + + if (decisionsList.length === 0) { + alert('Please select Approve or Deny for at least one identification.') + return + } + + if (!confirm(`Submit ${decisionsList.length} decision(s)?`)) { + return + } + + setSubmitting(true) + try { + const response = await pendingIdentificationsApi.approveDeny({ + decisions: decisionsList + }) + + const message = [ + `✅ Approved: ${response.approved}`, + `❌ Denied: ${response.denied}`, + response.errors.length > 0 ? `⚠️ Errors: ${response.errors.length}` : '' + ].filter(Boolean).join('\n') + + alert(message) + + if (response.errors.length > 0) { + console.error('Errors:', response.errors) + } + + // Reload the list to show updated status + await loadPendingIdentifications() + // Clear decisions + setDecisions({}) + } catch (err: any) { + const errorMessage = err.response?.data?.detail || err.message || 'Failed to submit decisions' + alert(`Error: ${errorMessage}`) + console.error('Error submitting decisions:', err) + } finally { + setSubmitting(false) + } + } + return (
@@ -72,8 +135,28 @@ export default function ApproveIdentified() { {!loading && !error && ( <> -
- Total pending identifications: {pendingIdentifications.length} +
+
+ Total pending identifications: {pendingIdentifications.length} +
+
+ + +
{pendingIdentifications.length === 0 ? ( @@ -92,7 +175,7 @@ export default function ApproveIdentified() { Date of Birth - Face ID + Face User @@ -100,11 +183,17 @@ export default function ApproveIdentified() { Created + + Approve + - {pendingIdentifications.map((pending) => ( - + {pendingIdentifications.map((pending) => { + const isDenied = pending.status === 'denied' + const isApproved = pending.status === 'approved' + return ( +
{formatName(pending)} @@ -116,8 +205,53 @@ export default function ApproveIdentified() {
-
- {pending.face_id} +
+ {pending.photo_id ? ( +
{ + const photoUrl = `${apiClient.defaults.baseURL}/api/v1/photos/${pending.photo_id}/image` + window.open(photoUrl, '_blank') + }} + title="Click to open full photo" + > + {`Face { + 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 = `#${pending.face_id}` + parent.appendChild(fallback) + } + }} + /> +
+ ) : ( + {`Face { + 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 = `#${pending.face_id}` + parent.appendChild(fallback) + } + }} + /> + )}
@@ -130,8 +264,41 @@ export default function ApproveIdentified() { {formatDate(pending.created_at)}
+ + {isDenied ? ( +
Denied
+ ) : isApproved ? ( +
Approved
+ ) : ( +
+ + +
+ )} + - ))} + ) + })}
diff --git a/src/web/api/pending_identifications.py b/src/web/api/pending_identifications.py index baffcdb..d29b91d 100644 --- a/src/web/api/pending_identifications.py +++ b/src/web/api/pending_identifications.py @@ -2,7 +2,7 @@ from __future__ import annotations -from datetime import date +from datetime import date, datetime from typing import Optional from fastapi import APIRouter, Depends, HTTPException, status @@ -10,7 +10,8 @@ from pydantic import BaseModel, ConfigDict from sqlalchemy import text from sqlalchemy.orm import Session -from src.web.db.session import get_auth_db +from src.web.db.session import get_auth_db, get_db +from src.web.db.models import Face, Person, PersonEncoding router = APIRouter(prefix="/pending-identifications", tags=["pending-identifications"]) @@ -22,6 +23,7 @@ class PendingIdentificationResponse(BaseModel): id: int face_id: int + photo_id: Optional[int] = None user_id: int user_name: Optional[str] = None user_email: str @@ -44,47 +46,107 @@ class PendingIdentificationsListResponse(BaseModel): total: int +class ApproveDenyDecision(BaseModel): + """Decision for a single pending identification.""" + + model_config = ConfigDict(protected_namespaces=()) + + id: int + decision: str # 'approve' or 'deny' + + +class ApproveDenyRequest(BaseModel): + """Request to approve/deny multiple pending identifications.""" + + model_config = ConfigDict(protected_namespaces=()) + + decisions: list[ApproveDenyDecision] + + +class ApproveDenyResponse(BaseModel): + """Response from approve/deny operation.""" + + model_config = ConfigDict(protected_namespaces=()) + + approved: int + denied: int + errors: list[str] + + @router.get("", response_model=PendingIdentificationsListResponse) def list_pending_identifications( + include_denied: bool = False, db: Session = Depends(get_auth_db), + main_db: Session = Depends(get_db), ) -> PendingIdentificationsListResponse: """List all pending identifications from the auth database. This endpoint reads from the separate auth database (DATABASE_URL_AUTH) and returns all pending identifications from the pending_identifications table. - Only shows records with status='pending' for approval. + By default, only shows records with status='pending' for approval. + Set include_denied=True to also show denied records. """ try: # Query pending_identifications from auth database using raw SQL # Join with users table to get user name/email # Filter by status='pending' to show only records awaiting approval - result = db.execute(text(""" - SELECT - pi.id, - pi.face_id, - pi.user_id, - u.name as user_name, - u.email as user_email, - pi.first_name, - pi.last_name, - pi.middle_name, - pi.maiden_name, - pi.date_of_birth, - pi.status, - pi.created_at, - pi.updated_at - FROM pending_identifications pi - LEFT JOIN users u ON pi.user_id = u.id - WHERE pi.status = 'pending' - ORDER BY pi.last_name ASC, pi.first_name ASC, pi.created_at DESC - """)) + # Optionally include denied records if include_denied is True + if include_denied: + result = db.execute(text(""" + SELECT + pi.id, + pi.face_id, + pi.user_id, + u.name as user_name, + u.email as user_email, + pi.first_name, + pi.last_name, + pi.middle_name, + pi.maiden_name, + pi.date_of_birth, + pi.status, + pi.created_at, + pi.updated_at + FROM pending_identifications pi + LEFT JOIN users u ON pi.user_id = u.id + WHERE pi.status IN ('pending', 'denied') + ORDER BY pi.status ASC, pi.last_name ASC, pi.first_name ASC, pi.created_at DESC + """)) + else: + result = db.execute(text(""" + SELECT + pi.id, + pi.face_id, + pi.user_id, + u.name as user_name, + u.email as user_email, + pi.first_name, + pi.last_name, + pi.middle_name, + pi.maiden_name, + pi.date_of_birth, + pi.status, + pi.created_at, + pi.updated_at + FROM pending_identifications pi + LEFT JOIN users u ON pi.user_id = u.id + WHERE pi.status = 'pending' + ORDER BY pi.last_name ASC, pi.first_name ASC, pi.created_at DESC + """)) rows = result.fetchall() items = [] for row in rows: + # Get photo_id from main database + photo_id = None + face = main_db.query(Face).filter(Face.id == row.face_id).first() + if face: + photo_id = face.photo_id + items.append(PendingIdentificationResponse( id=row.id, face_id=row.face_id, + photo_id=photo_id, user_id=row.user_id, user_name=row.user_name, user_email=row.user_email, @@ -105,3 +167,159 @@ def list_pending_identifications( detail=f"Error reading from auth database: {str(e)}" ) + +@router.post("/approve-deny", response_model=ApproveDenyResponse) +def approve_deny_pending_identifications( + request: ApproveDenyRequest, + auth_db: Session = Depends(get_auth_db), + main_db: Session = Depends(get_db), +) -> ApproveDenyResponse: + """Approve or deny pending identifications. + + For approved identifications: + - Updates status in auth database to 'approved' + - Identifies the face in main database + - Creates person if needed + + For denied identifications: + - Updates status in auth database to 'denied' + """ + approved_count = 0 + denied_count = 0 + errors = [] + + for decision in request.decisions: + try: + # Get pending identification from auth database + result = auth_db.execute(text(""" + SELECT + pi.id, + pi.face_id, + pi.first_name, + pi.last_name, + pi.middle_name, + pi.maiden_name, + pi.date_of_birth + FROM pending_identifications pi + WHERE pi.id = :id AND pi.status = 'pending' + """), {"id": decision.id}) + + row = result.fetchone() + if not row: + errors.append(f"Pending identification {decision.id} not found or already processed") + continue + + if decision.decision == 'approve': + # Identify the face in main database + face = main_db.query(Face).filter(Face.id == row.face_id).first() + if not face: + errors.append(f"Face {row.face_id} not found in main database") + # Still update status to denied since we can't process it + auth_db.execute(text(""" + UPDATE pending_identifications + SET status = 'denied', updated_at = :updated_at + WHERE id = :id + """), {"id": decision.id, "updated_at": datetime.utcnow()}) + auth_db.commit() + denied_count += 1 + continue + + # Check if person already exists (by name and DOB) + # Match the unique constraint: first_name, last_name, middle_name, maiden_name, date_of_birth + person = None + if row.date_of_birth: + # Build query with proper None handling + query = main_db.query(Person).filter( + Person.first_name == row.first_name, + Person.last_name == row.last_name, + Person.date_of_birth == row.date_of_birth + ) + # Handle optional fields - use IS NULL for None values + if row.middle_name: + query = query.filter(Person.middle_name == row.middle_name) + else: + query = query.filter(Person.middle_name.is_(None)) + + if row.maiden_name: + query = query.filter(Person.maiden_name == row.maiden_name) + else: + query = query.filter(Person.maiden_name.is_(None)) + + person = query.first() + + # Create person if doesn't exist + created_person = False + if not person: + if not row.date_of_birth: + errors.append(f"Pending identification {decision.id} missing date_of_birth (required for person creation)") + auth_db.execute(text(""" + UPDATE pending_identifications + SET status = 'denied', updated_at = :updated_at + WHERE id = :id + """), {"id": decision.id, "updated_at": datetime.utcnow()}) + auth_db.commit() + denied_count += 1 + continue + + person = Person( + first_name=row.first_name, + last_name=row.last_name, + middle_name=row.middle_name, + maiden_name=row.maiden_name, + date_of_birth=row.date_of_birth, + ) + main_db.add(person) + main_db.flush() # get person.id + created_person = True + + # Link face to person + face.person_id = person.id + main_db.add(face) + + # Insert person_encoding + pe = PersonEncoding( + person_id=person.id, + face_id=face.id, + encoding=face.encoding, + quality_score=face.quality_score, + detector_backend=face.detector_backend, + model_name=face.model_name, + ) + main_db.add(pe) + main_db.commit() + + # Update status in auth database + auth_db.execute(text(""" + UPDATE pending_identifications + SET status = 'approved', updated_at = :updated_at + WHERE id = :id + """), {"id": decision.id, "updated_at": datetime.utcnow()}) + auth_db.commit() + + approved_count += 1 + + elif decision.decision == 'deny': + # Update status to denied + auth_db.execute(text(""" + UPDATE pending_identifications + SET status = 'denied', updated_at = :updated_at + WHERE id = :id + """), {"id": decision.id, "updated_at": datetime.utcnow()}) + auth_db.commit() + + denied_count += 1 + else: + errors.append(f"Invalid decision '{decision.decision}' for pending identification {decision.id}") + + except Exception as e: + errors.append(f"Error processing pending identification {decision.id}: {str(e)}") + # Rollback any partial changes + main_db.rollback() + auth_db.rollback() + + return ApproveDenyResponse( + approved=approved_count, + denied=denied_count, + errors=errors + ) +