From 926e738a13033ae48a233d289188a763458d070c Mon Sep 17 00:00:00 2001 From: tanyar09 Date: Wed, 19 Nov 2025 13:48:26 -0500 Subject: [PATCH] feat: Implement approve/deny functionality for pending identifications This commit adds the ability to approve or deny pending identifications through a new API endpoint and updates the frontend to support this feature. The `PendingIdentification` interface has been extended to include an optional `photo_id`, and new request/response models for approval decisions have been introduced. The ApproveIdentified page now allows users to submit their decisions, with UI updates for better user interaction. Documentation has been updated to reflect these changes. --- frontend/src/api/pendingIdentifications.ts | 28 ++- frontend/src/pages/ApproveIdentified.tsx | 199 ++++++++++++++-- src/web/api/pending_identifications.py | 264 +++++++++++++++++++-- 3 files changed, 450 insertions(+), 41 deletions(-) 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 + ) +