diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1f9d3f5..fac9322 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> diff --git a/frontend/src/api/pendingIdentifications.ts b/frontend/src/api/pendingIdentifications.ts new file mode 100644 index 0000000..1b0bf83 --- /dev/null +++ b/frontend/src/api/pendingIdentifications.ts @@ -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 => { + const res = await apiClient.get( + '/api/v1/pending-identifications' + ) + return res.data + }, +} + +export default pendingIdentificationsApi + diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 5cdfb0f..47fca52 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -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: '📚' }, ] diff --git a/frontend/src/pages/ApproveIdentified.tsx b/frontend/src/pages/ApproveIdentified.tsx new file mode 100644 index 0000000..6b1cdef --- /dev/null +++ b/frontend/src/pages/ApproveIdentified.tsx @@ -0,0 +1,145 @@ +import { useEffect, useState } from 'react' +import pendingIdentificationsApi, { PendingIdentification } from '../api/pendingIdentifications' + +export default function ApproveIdentified() { + const [pendingIdentifications, setPendingIdentifications] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(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 ( +
+
+

Approve Identified

+ + {loading && ( +
+

Loading identified people...

+
+ )} + + {error && ( +
+

Error loading data

+

{error}

+ +
+ )} + + {!loading && !error && ( + <> +
+ Total pending identifications: {pendingIdentifications.length} +
+ + {pendingIdentifications.length === 0 ? ( +
+

No pending identifications found.

+
+ ) : ( +
+ + + + + + + + + + + + {pendingIdentifications.map((pending) => ( + + + + + + + + ))} + +
+ Name + + Date of Birth + + Face ID + + User + + Created +
+
+ {formatName(pending)} +
+
+
+ {formatDate(pending.date_of_birth)} +
+
+
+ {pending.face_id} +
+
+
+ {pending.user_name || pending.user_email} +
+
+
+ {formatDate(pending.created_at)} +
+
+
+ )} + + )} +
+
+ ) +} + diff --git a/scripts/setup_postgresql.sh b/scripts/setup_postgresql.sh index d5a4ed0..2cdbab6 100755 --- a/scripts/setup_postgresql.sh +++ b/scripts/setup_postgresql.sh @@ -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" + + diff --git a/src/web/api/pending_identifications.py b/src/web/api/pending_identifications.py new file mode 100644 index 0000000..baffcdb --- /dev/null +++ b/src/web/api/pending_identifications.py @@ -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)}" + ) + diff --git a/src/web/app.py b/src/web/app.py index 9055446..4677650 100644 --- a/src/web/app.py +++ b/src/web/app.py @@ -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 diff --git a/src/web/db/session.py b/src/web/db/session.py index d3fca96..c73522a 100644 --- a/src/web/db/session.py +++ b/src/web/db/session.py @@ -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() + +