feat: Add Approve Identified page and API for pending identifications
This commit introduces a new Approve Identified page in the frontend, allowing users to view and manage pending identifications. The page fetches data from a new API endpoint that lists pending identifications from the auth database. Additionally, the necessary API routes and database session management for handling pending identifications have been implemented. The Layout component has been updated to include navigation to the new page, enhancing the user experience. Documentation has been updated to reflect these changes.
This commit is contained in:
parent
7f48d48b80
commit
1d8ca7e592
@ -11,6 +11,7 @@ import AutoMatch from './pages/AutoMatch'
|
||||
import Modify from './pages/Modify'
|
||||
import Tags from './pages/Tags'
|
||||
import FacesMaintenance from './pages/FacesMaintenance'
|
||||
import ApproveIdentified from './pages/ApproveIdentified'
|
||||
import Settings from './pages/Settings'
|
||||
import Help from './pages/Help'
|
||||
import Layout from './components/Layout'
|
||||
@ -44,6 +45,7 @@ function AppRoutes() {
|
||||
<Route path="modify" element={<Modify />} />
|
||||
<Route path="tags" element={<Tags />} />
|
||||
<Route path="faces-maintenance" element={<FacesMaintenance />} />
|
||||
<Route path="approve-identified" element={<ApproveIdentified />} />
|
||||
<Route path="settings" element={<Settings />} />
|
||||
<Route path="help" element={<Help />} />
|
||||
</Route>
|
||||
|
||||
34
frontend/src/api/pendingIdentifications.ts
Normal file
34
frontend/src/api/pendingIdentifications.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import apiClient from './client'
|
||||
|
||||
export interface PendingIdentification {
|
||||
id: number
|
||||
face_id: number
|
||||
user_id: number
|
||||
user_name?: string | null
|
||||
user_email: string
|
||||
first_name: string
|
||||
last_name: string
|
||||
middle_name?: string | null
|
||||
maiden_name?: string | null
|
||||
date_of_birth?: string | null
|
||||
status: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface PendingIdentificationsListResponse {
|
||||
items: PendingIdentification[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export const pendingIdentificationsApi = {
|
||||
list: async (): Promise<PendingIdentificationsListResponse> => {
|
||||
const res = await apiClient.get<PendingIdentificationsListResponse>(
|
||||
'/api/v1/pending-identifications'
|
||||
)
|
||||
return res.data
|
||||
},
|
||||
}
|
||||
|
||||
export default pendingIdentificationsApi
|
||||
|
||||
@ -14,6 +14,7 @@ export default function Layout() {
|
||||
{ path: '/modify', label: 'Modify', icon: '✏️' },
|
||||
{ path: '/tags', label: 'Tag', icon: '🏷️' },
|
||||
{ path: '/faces-maintenance', label: 'Faces Maintenance', icon: '🔧' },
|
||||
{ path: '/approve-identified', label: 'Approve identified', icon: '✅' },
|
||||
{ path: '/settings', label: 'Settings', icon: '⚙️' },
|
||||
{ path: '/help', label: 'Help', icon: '📚' },
|
||||
]
|
||||
|
||||
145
frontend/src/pages/ApproveIdentified.tsx
Normal file
145
frontend/src/pages/ApproveIdentified.tsx
Normal file
@ -0,0 +1,145 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import pendingIdentificationsApi, { PendingIdentification } from '../api/pendingIdentifications'
|
||||
|
||||
export default function ApproveIdentified() {
|
||||
const [pendingIdentifications, setPendingIdentifications] = useState<PendingIdentification[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadPendingIdentifications()
|
||||
}, [])
|
||||
|
||||
const loadPendingIdentifications = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const response = await pendingIdentificationsApi.list()
|
||||
setPendingIdentifications(response.items)
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || err.message || 'Failed to load pending identifications')
|
||||
console.error('Error loading pending identifications:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string | null | undefined): string => {
|
||||
if (!dateString) return '-'
|
||||
try {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString()
|
||||
} catch {
|
||||
return dateString
|
||||
}
|
||||
}
|
||||
|
||||
const formatName = (pending: PendingIdentification): string => {
|
||||
const parts = [
|
||||
pending.first_name,
|
||||
pending.middle_name,
|
||||
pending.last_name,
|
||||
].filter(Boolean)
|
||||
if (pending.maiden_name) {
|
||||
parts.push(`(${pending.maiden_name})`)
|
||||
}
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">Approve Identified</h1>
|
||||
|
||||
{loading && (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-600">Loading identified people...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded text-red-700">
|
||||
<p className="font-semibold">Error loading data</p>
|
||||
<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"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && (
|
||||
<>
|
||||
<div className="mb-4 text-sm text-gray-600">
|
||||
Total pending identifications: <span className="font-semibold">{pendingIdentifications.length}</span>
|
||||
</div>
|
||||
|
||||
{pendingIdentifications.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<p>No pending identifications found.</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">
|
||||
Name
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Date of Birth
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Face ID
|
||||
</th>
|
||||
<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">
|
||||
Created
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{pendingIdentifications.map((pending) => (
|
||||
<tr key={pending.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{formatName(pending)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-500">
|
||||
{formatDate(pending.date_of_birth)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-500">
|
||||
{pending.face_id}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-500">
|
||||
{pending.user_name || pending.user_email}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-500">
|
||||
{formatDate(pending.created_at)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -57,3 +57,5 @@ echo "1. Install python-dotenv: pip install python-dotenv"
|
||||
echo "2. The .env file is already configured with the connection string"
|
||||
echo "3. Run your application - it will connect to PostgreSQL automatically"
|
||||
|
||||
|
||||
|
||||
|
||||
107
src/web/api/pending_identifications.py
Normal file
107
src/web/api/pending_identifications.py
Normal file
@ -0,0 +1,107 @@
|
||||
"""Pending identifications endpoints for approval workflow."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.web.db.session import get_auth_db
|
||||
|
||||
router = APIRouter(prefix="/pending-identifications", tags=["pending-identifications"])
|
||||
|
||||
|
||||
class PendingIdentificationResponse(BaseModel):
|
||||
"""Pending identification DTO returned from API."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True, protected_namespaces=())
|
||||
|
||||
id: int
|
||||
face_id: int
|
||||
user_id: int
|
||||
user_name: Optional[str] = None
|
||||
user_email: str
|
||||
first_name: str
|
||||
last_name: str
|
||||
middle_name: Optional[str] = None
|
||||
maiden_name: Optional[str] = None
|
||||
date_of_birth: Optional[date] = None
|
||||
status: str
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
class PendingIdentificationsListResponse(BaseModel):
|
||||
"""List of pending identifications."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
items: list[PendingIdentificationResponse]
|
||||
total: int
|
||||
|
||||
|
||||
@router.get("", response_model=PendingIdentificationsListResponse)
|
||||
def list_pending_identifications(
|
||||
db: Session = Depends(get_auth_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.
|
||||
"""
|
||||
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
|
||||
"""))
|
||||
|
||||
rows = result.fetchall()
|
||||
items = []
|
||||
for row in rows:
|
||||
items.append(PendingIdentificationResponse(
|
||||
id=row.id,
|
||||
face_id=row.face_id,
|
||||
user_id=row.user_id,
|
||||
user_name=row.user_name,
|
||||
user_email=row.user_email,
|
||||
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,
|
||||
status=row.status,
|
||||
created_at=str(row.created_at) if row.created_at else '',
|
||||
updated_at=str(row.updated_at) if row.updated_at else '',
|
||||
))
|
||||
|
||||
return PendingIdentificationsListResponse(items=items, total=len(items))
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Error reading from auth database: {str(e)}"
|
||||
)
|
||||
|
||||
@ -15,6 +15,7 @@ from src.web.api.health import router as health_router
|
||||
from src.web.api.jobs import router as jobs_router
|
||||
from src.web.api.metrics import router as metrics_router
|
||||
from src.web.api.people import router as people_router
|
||||
from src.web.api.pending_identifications import router as pending_identifications_router
|
||||
from src.web.api.photos import router as photos_router
|
||||
from src.web.api.tags import router as tags_router
|
||||
from src.web.api.version import router as version_router
|
||||
@ -151,6 +152,7 @@ def create_app() -> FastAPI:
|
||||
app.include_router(photos_router, prefix="/api/v1")
|
||||
app.include_router(faces_router, prefix="/api/v1")
|
||||
app.include_router(people_router, prefix="/api/v1")
|
||||
app.include_router(pending_identifications_router, prefix="/api/v1")
|
||||
app.include_router(tags_router, prefix="/api/v1")
|
||||
|
||||
return app
|
||||
|
||||
@ -23,6 +23,15 @@ def get_database_url() -> str:
|
||||
return "sqlite:///data/punimtag.db"
|
||||
|
||||
|
||||
def get_auth_database_url() -> str:
|
||||
"""Fetch auth database URL from environment."""
|
||||
import os
|
||||
db_url = os.getenv("DATABASE_URL_AUTH")
|
||||
if not db_url:
|
||||
raise ValueError("DATABASE_URL_AUTH environment variable not set")
|
||||
return db_url
|
||||
|
||||
|
||||
database_url = get_database_url()
|
||||
# SQLite-specific configuration
|
||||
connect_args = {}
|
||||
@ -56,3 +65,42 @@ def get_db() -> Generator:
|
||||
db.close()
|
||||
|
||||
|
||||
# Auth database setup
|
||||
try:
|
||||
auth_database_url = get_auth_database_url()
|
||||
auth_connect_args = {}
|
||||
if auth_database_url.startswith("sqlite"):
|
||||
auth_connect_args = {"check_same_thread": False}
|
||||
|
||||
auth_pool_kwargs = {"pool_pre_ping": True}
|
||||
if auth_database_url.startswith("postgresql"):
|
||||
auth_pool_kwargs.update({
|
||||
"pool_size": 10,
|
||||
"max_overflow": 20,
|
||||
"pool_recycle": 3600,
|
||||
})
|
||||
|
||||
auth_engine = create_engine(
|
||||
auth_database_url,
|
||||
future=True,
|
||||
connect_args=auth_connect_args,
|
||||
**auth_pool_kwargs
|
||||
)
|
||||
AuthSessionLocal = sessionmaker(bind=auth_engine, autoflush=False, autocommit=False, future=True)
|
||||
except ValueError:
|
||||
# DATABASE_URL_AUTH not set - auth database not available
|
||||
auth_engine = None
|
||||
AuthSessionLocal = None
|
||||
|
||||
|
||||
def get_auth_db() -> Generator:
|
||||
"""Yield a DB session for auth database request lifecycle."""
|
||||
if AuthSessionLocal is None:
|
||||
raise ValueError("Auth database not configured. Set DATABASE_URL_AUTH environment variable.")
|
||||
db = AuthSessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user