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:
parent
dbffaef298
commit
a036169b0f
@ -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
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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={
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user