From a036169b0fa200fdab4c9d2390b62452365723f1 Mon Sep 17 00:00:00 2001 From: tanyar09 Date: Mon, 24 Nov 2025 14:58:11 -0500 Subject: [PATCH] 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. --- frontend/src/api/pendingIdentifications.ts | 37 +++ frontend/src/pages/ApproveIdentified.tsx | 306 +++++++++++++++++++-- frontend/src/pages/PendingPhotos.tsx | 36 +-- src/web/api/auth.py | 44 +++ src/web/api/faces.py | 7 + src/web/api/pending_identifications.py | 218 ++++++++++++++- src/web/api/people.py | 10 +- src/web/app.py | 42 +++ src/web/db/models.py | 2 + src/web/services/face_service.py | 9 + 10 files changed, 673 insertions(+), 38 deletions(-) diff --git a/frontend/src/api/pendingIdentifications.ts b/frontend/src/api/pendingIdentifications.ts index e266ecc..2109dfa 100644 --- a/frontend/src/api/pendingIdentifications.ts +++ b/frontend/src/api/pendingIdentifications.ts @@ -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 => { const res = await apiClient.get( @@ -52,6 +73,22 @@ export const pendingIdentificationsApi = { ) return res.data }, + getReport: async (dateFrom?: string, dateTo?: string): Promise => { + const params: Record = {} + if (dateFrom) params.date_from = dateFrom + if (dateTo) params.date_to = dateTo + const res = await apiClient.get( + '/api/v1/pending-identifications/report', + { params } + ) + return res.data + }, + clearDenied: async (): Promise => { + const res = await apiClient.post( + '/api/v1/pending-identifications/clear-denied' + ) + return res.data + }, } export default pendingIdentificationsApi diff --git a/frontend/src/pages/ApproveIdentified.tsx b/frontend/src/pages/ApproveIdentified.tsx index 64be17f..45fb6a1 100644 --- a/frontend/src/pages/ApproveIdentified.tsx +++ b/frontend/src/pages/ApproveIdentified.tsx @@ -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>({}) const [submitting, setSubmitting] = useState(false) const [includeDenied, setIncludeDenied] = useState(false) + const [showReport, setShowReport] = useState(false) + const [reportData, setReportData] = useState(null) + const [reportLoading, setReportLoading] = useState(false) + const [reportError, setReportError] = useState(null) + const [dateFrom, setDateFrom] = useState('') + const [dateTo, setDateTo] = useState('') + 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 (
@@ -126,7 +219,7 @@ export default function ApproveIdentified() {

{error}

@@ -140,6 +233,20 @@ export default function ApproveIdentified() { Total pending identifications: {pendingIdentifications.length}
+ +
+ + {/* Report Modal */} + {showReport && ( +
+
+
+

Identification Report

+ +
+ +
+
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ +
+
+
+ +
+ {reportLoading && ( +
+

Loading report...

+
+ )} + + {reportError && ( +
+

Error loading report

+

{reportError}

+
+ )} + + {!reportLoading && !reportError && reportData && ( + <> +
+
+
+ Total Users:{' '} + {reportData.total_users} +
+
+ Total Faces:{' '} + {reportData.total_faces} +
+
+ Average per User:{' '} + + {reportData.total_users > 0 + ? Math.round((reportData.total_faces / reportData.total_users) * 10) / 10 + : 0} + +
+
+
+ + {reportData.items.length === 0 ? ( +
+

No identifications found for the selected date range.

+
+ ) : ( +
+ + + + + + + + + + + + {reportData.items.map((stat: UserIdentificationStats) => ( + + + + + + + + ))} + +
+ User + + Email + + Faces Identified + + First Identification + + Last Identification +
+
+ {stat.full_name} +
+
{stat.username}
+
+
{stat.email}
+
+
+ {stat.face_count} +
+
+
+ {formatDateTime(stat.first_identification_date)} +
+
+
+ {formatDateTime(stat.last_identification_date)} +
+
+
+ )} + + )} +
+
+
+ )}
) } diff --git a/frontend/src/pages/PendingPhotos.tsx b/frontend/src/pages/PendingPhotos.tsx index d4fa389..a9e3de1 100644 --- a/frontend/src/pages/PendingPhotos.tsx +++ b/frontend/src/pages/PendingPhotos.tsx @@ -427,24 +427,6 @@ export default function PendingPhotos() { - {isAdmin && ( -
- - -
- )}
{pendingPhotos.filter((p) => p.status === 'pending').length > 0 && ( <> @@ -462,6 +444,24 @@ export default function PendingPhotos() { )} + {isAdmin && ( + <> + + + + )}