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.
This commit is contained in:
tanyar09 2025-11-19 13:48:26 -05:00
parent 1d8ca7e592
commit 926e738a13
3 changed files with 450 additions and 41 deletions

View File

@ -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<PendingIdentificationsListResponse> => {
list: async (includeDenied: boolean = false): Promise<PendingIdentificationsListResponse> => {
const res = await apiClient.get<PendingIdentificationsListResponse>(
'/api/v1/pending-identifications'
'/api/v1/pending-identifications',
{ params: { include_denied: includeDenied } }
)
return res.data
},
approveDeny: async (request: ApproveDenyRequest): Promise<ApproveDenyResponse> => {
const res = await apiClient.post<ApproveDenyResponse>(
'/api/v1/pending-identifications/approve-deny',
request
)
return res.data
},

View File

@ -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<PendingIdentification[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [decisions, setDecisions] = useState<Record<number, 'approve' | 'deny' | null>>({})
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 (
<div>
<div className="bg-white rounded-lg shadow p-6">
@ -72,8 +135,28 @@ export default function ApproveIdentified() {
{!loading && !error && (
<>
<div className="mb-4 text-sm text-gray-600">
Total pending identifications: <span className="font-semibold">{pendingIdentifications.length}</span>
<div className="mb-4 flex items-center justify-between">
<div className="text-sm text-gray-600">
Total pending identifications: <span className="font-semibold">{pendingIdentifications.length}</span>
</div>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={includeDenied}
onChange={(e) => setIncludeDenied(e.target.checked)}
className="w-4 h-4 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700">Include denied</span>
</label>
<button
onClick={handleSubmit}
disabled={submitting || Object.values(decisions).filter(d => d !== null).length === 0}
className="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed font-medium"
>
{submitting ? 'Submitting...' : 'Submit Decisions'}
</button>
</div>
</div>
{pendingIdentifications.length === 0 ? (
@ -92,7 +175,7 @@ export default function ApproveIdentified() {
Date of Birth
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Face ID
Face
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
User
@ -100,11 +183,17 @@ export default function ApproveIdentified() {
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Created
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Approve
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{pendingIdentifications.map((pending) => (
<tr key={pending.id} className="hover:bg-gray-50">
{pendingIdentifications.map((pending) => {
const isDenied = pending.status === 'denied'
const isApproved = pending.status === 'approved'
return (
<tr key={pending.id} className={`hover:bg-gray-50 ${isDenied ? 'opacity-60 bg-gray-50' : ''} ${isApproved ? 'opacity-60 bg-green-50' : ''}`}>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">
{formatName(pending)}
@ -116,8 +205,53 @@ export default function ApproveIdentified() {
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-500">
{pending.face_id}
<div className="flex items-center">
{pending.photo_id ? (
<div
className="cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => {
const photoUrl = `${apiClient.defaults.baseURL}/api/v1/photos/${pending.photo_id}/image`
window.open(photoUrl, '_blank')
}}
title="Click to open full photo"
>
<img
src={`/api/v1/faces/${pending.face_id}/crop`}
alt={`Face ${pending.face_id}`}
className="w-16 h-16 object-cover rounded border border-gray-300"
loading="lazy"
onError={(e) => {
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)
}
}}
/>
</div>
) : (
<img
src={`/api/v1/faces/${pending.face_id}/crop`}
alt={`Face ${pending.face_id}`}
className="w-16 h-16 object-cover rounded border border-gray-300"
loading="lazy"
onError={(e) => {
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)
}
}}
/>
)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
@ -130,8 +264,41 @@ export default function ApproveIdentified() {
{formatDate(pending.created_at)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{isDenied ? (
<div className="text-sm text-red-600 font-medium">Denied</div>
) : isApproved ? (
<div className="text-sm text-green-600 font-medium">Approved</div>
) : (
<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"
checked={decisions[pending.id] === 'approve'}
onChange={() => handleDecisionChange(pending.id, 'approve')}
className="w-4 h-4 text-green-600 focus:ring-green-500"
/>
<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"
checked={decisions[pending.id] === 'deny'}
onChange={() => handleDecisionChange(pending.id, 'deny')}
className="w-4 h-4 text-red-600 focus:ring-red-500"
/>
<span className="text-sm text-gray-700">Deny</span>
</label>
</div>
)}
</td>
</tr>
))}
)
})}
</tbody>
</table>
</div>

View File

@ -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
)