feat: Add Pending Photos management with API integration and UI updates

This commit introduces a new Pending Photos feature, allowing admins to manage user-uploaded photos awaiting review. A dedicated PendingPhotos page has been created in the frontend, which fetches and displays pending photos with options to approve or reject them. The backend has been updated with new API endpoints for listing and reviewing pending photos, ensuring seamless integration with the frontend. The Layout component has been modified to include navigation to the new Pending Photos page, enhancing the overall user experience. Documentation has been updated to reflect these changes.
This commit is contained in:
tanyar09 2025-11-21 11:00:38 -05:00
parent 2c3b2d7a08
commit e6c66e564e
6 changed files with 1046 additions and 0 deletions

View File

@ -15,6 +15,7 @@ import FacesMaintenance from './pages/FacesMaintenance'
import ApproveIdentified from './pages/ApproveIdentified'
import ManageUsers from './pages/ManageUsers'
import ReportedPhotos from './pages/ReportedPhotos'
import PendingPhotos from './pages/PendingPhotos'
import Settings from './pages/Settings'
import Help from './pages/Help'
import Layout from './components/Layout'
@ -98,6 +99,14 @@ function AppRoutes() {
</AdminRoute>
}
/>
<Route
path="pending-photos"
element={
<AdminRoute>
<PendingPhotos />
</AdminRoute>
}
/>
<Route path="settings" element={<Settings />} />
<Route path="help" element={<Help />} />
</Route>

View File

@ -0,0 +1,76 @@
import apiClient from './client'
export interface PendingPhotoResponse {
id: number
user_id: number
user_name: string | null
user_email: string | null
filename: string
original_filename: string
file_path: string
file_size: number
mime_type: string
status: string
submitted_at: string
reviewed_at: string | null
reviewed_by: number | null
rejection_reason: string | null
}
export interface PendingPhotosListResponse {
items: PendingPhotoResponse[]
total: number
}
export interface ReviewDecision {
id: number
decision: 'approve' | 'reject'
rejection_reason?: string | null
}
export interface ReviewRequest {
decisions: ReviewDecision[]
}
export interface ReviewResponse {
approved: number
rejected: number
errors: string[]
}
export const pendingPhotosApi = {
listPendingPhotos: async (statusFilter?: string): Promise<PendingPhotosListResponse> => {
const { data } = await apiClient.get<PendingPhotosListResponse>(
'/api/v1/pending-photos',
{
params: statusFilter ? { status_filter: statusFilter } : undefined,
}
)
return data
},
getPendingPhotoImage: (photoId: number): string => {
return `${apiClient.defaults.baseURL}/api/v1/pending-photos/${photoId}/image`
},
getPendingPhotoImageBlob: async (photoId: number): Promise<string> => {
// Fetch image as blob with authentication
const response = await apiClient.get(
`/api/v1/pending-photos/${photoId}/image`,
{
responseType: 'blob',
}
)
// Create object URL from blob
return URL.createObjectURL(response.data)
},
reviewPendingPhotos: async (request: ReviewRequest): Promise<ReviewResponse> => {
const { data } = await apiClient.post<ReviewResponse>(
'/api/v1/pending-photos/review',
request
)
return data
},
}

View File

@ -17,6 +17,7 @@ export default function Layout() {
{ path: '/approve-identified', label: 'Approve identified', icon: '✅', adminOnly: true },
{ path: '/manage-users', label: 'Manage users', icon: '👥', adminOnly: true },
{ path: '/reported-photos', label: 'Reported photos', icon: '🚩', adminOnly: true },
{ path: '/pending-photos', label: 'Manage User Uploaded Photos', icon: '📤', adminOnly: true },
{ path: '/settings', label: 'Settings', icon: '⚙️', adminOnly: false },
{ path: '/help', label: 'Help', icon: '📚', adminOnly: false },
]

View File

@ -0,0 +1,562 @@
import { useEffect, useState, useCallback, useRef } from 'react'
import {
pendingPhotosApi,
PendingPhotoResponse,
ReviewDecision,
} from '../api/pendingPhotos'
import { apiClient } from '../api/client'
export default function PendingPhotos() {
const [pendingPhotos, setPendingPhotos] = useState<PendingPhotoResponse[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [decisions, setDecisions] = useState<Record<number, 'approve' | 'reject' | null>>({})
const [rejectionReasons, setRejectionReasons] = useState<Record<number, string>>({})
const [bulkRejectionReason, setBulkRejectionReason] = useState<string>('')
const [submitting, setSubmitting] = useState(false)
const [statusFilter, setStatusFilter] = useState<string>('pending')
const [imageUrls, setImageUrls] = useState<Record<number, string>>({})
const imageUrlsRef = useRef<Record<number, string>>({})
const loadPendingPhotos = useCallback(async () => {
setLoading(true)
setError(null)
try {
const response = await pendingPhotosApi.listPendingPhotos(
statusFilter || undefined
)
setPendingPhotos(response.items)
// Clear decisions when loading different status
setDecisions({})
setRejectionReasons({})
// Load images as blobs with authentication
const newImageUrls: Record<number, string> = {}
for (const photo of response.items) {
try {
const blobUrl = await pendingPhotosApi.getPendingPhotoImageBlob(photo.id)
newImageUrls[photo.id] = blobUrl
} catch (err) {
console.error(`Failed to load image for photo ${photo.id}:`, err)
}
}
setImageUrls(newImageUrls)
imageUrlsRef.current = newImageUrls
} catch (err: any) {
setError(err.response?.data?.detail || err.message || 'Failed to load pending photos')
console.error('Error loading pending photos:', err)
} finally {
setLoading(false)
}
}, [statusFilter])
// Cleanup blob URLs on unmount
useEffect(() => {
return () => {
Object.values(imageUrlsRef.current).forEach((url) => {
URL.revokeObjectURL(url)
})
}
}, [])
useEffect(() => {
loadPendingPhotos()
}, [loadPendingPhotos])
const formatDate = (dateString: string | null | undefined): string => {
if (!dateString) return '-'
try {
const date = new Date(dateString)
return date.toLocaleString()
} catch {
return dateString
}
}
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 Bytes'
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]
}
const handleDecisionChange = (id: number, decision: 'approve' | 'reject') => {
const currentDecision = decisions[id]
const isUnselecting = currentDecision === decision
setDecisions((prev) => {
// If clicking the same option, unselect it
if (prev[id] === decision) {
const updated = { ...prev }
delete updated[id]
return updated
}
// Otherwise, set the new decision (this automatically unchecks the other checkbox)
return {
...prev,
[id]: decision,
}
})
// Handle rejection reasons
if (isUnselecting) {
// Unselecting - clear rejection reason
setRejectionReasons((prev) => {
const updated = { ...prev }
delete updated[id]
return updated
})
} else if (decision === 'approve') {
// Switching to approve - clear rejection reason
setRejectionReasons((prev) => {
const updated = { ...prev }
delete updated[id]
return updated
})
} else if (decision === 'reject' && bulkRejectionReason.trim()) {
// Switching to reject - apply bulk rejection reason if set
setRejectionReasons((prev) => ({
...prev,
[id]: bulkRejectionReason,
}))
}
}
const handleRejectionReasonChange = (id: number, reason: string) => {
setRejectionReasons((prev) => ({
...prev,
[id]: reason,
}))
}
const handleSelectAllApprove = () => {
const pendingPhotoIds = pendingPhotos
.filter((photo) => photo.status === 'pending')
.map((photo) => photo.id)
const newDecisions: Record<number, 'approve'> = {}
pendingPhotoIds.forEach((id) => {
newDecisions[id] = 'approve'
})
setDecisions((prev) => ({
...prev,
...newDecisions,
}))
// Clear all rejection reasons and bulk rejection reason since we're approving
setRejectionReasons({})
setBulkRejectionReason('')
}
const handleSelectAllReject = () => {
const pendingPhotoIds = pendingPhotos
.filter((photo) => photo.status === 'pending')
.map((photo) => photo.id)
const newDecisions: Record<number, 'reject'> = {}
pendingPhotoIds.forEach((id) => {
newDecisions[id] = 'reject'
})
setDecisions((prev) => ({
...prev,
...newDecisions,
}))
// Apply bulk rejection reason if set
if (bulkRejectionReason.trim()) {
const newRejectionReasons: Record<number, string> = {}
pendingPhotoIds.forEach((id) => {
newRejectionReasons[id] = bulkRejectionReason
})
setRejectionReasons((prev) => ({
...prev,
...newRejectionReasons,
}))
}
}
const handleBulkRejectionReasonChange = (reason: string) => {
setBulkRejectionReason(reason)
// Apply to all currently rejected photos
const rejectedPhotoIds = Object.entries(decisions)
.filter(([id, decision]) => decision === 'reject')
.map(([id]) => parseInt(id))
if (rejectedPhotoIds.length > 0) {
const newRejectionReasons: Record<number, string> = {}
rejectedPhotoIds.forEach((id) => {
newRejectionReasons[id] = reason
})
setRejectionReasons((prev) => ({
...prev,
...newRejectionReasons,
}))
}
}
const handleSubmit = async () => {
// Get all decisions that have been made for pending items
const decisionsList: ReviewDecision[] = Object.entries(decisions)
.filter(([id, decision]) => {
const photo = pendingPhotos.find((p) => p.id === parseInt(id))
return decision !== null && photo && photo.status === 'pending'
})
.map(([id, decision]) => ({
id: parseInt(id),
decision: decision!,
rejection_reason: decision === 'reject' ? (rejectionReasons[parseInt(id)] || null) : null,
}))
if (decisionsList.length === 0) {
alert('Please select Approve or Reject for at least one pending photo.')
return
}
// Show confirmation
const approveCount = decisionsList.filter((d) => d.decision === 'approve').length
const rejectCount = decisionsList.filter((d) => d.decision === 'reject').length
const confirmMessage = `Submit ${decisionsList.length} decision(s)?\n\nThis will approve ${approveCount} photo(s) and reject ${rejectCount} photo(s).`
if (!confirm(confirmMessage)) {
return
}
setSubmitting(true)
try {
const response = await pendingPhotosApi.reviewPendingPhotos({
decisions: decisionsList,
})
const message = [
`✅ Approved: ${response.approved}`,
`❌ Rejected: ${response.rejected}`,
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 loadPendingPhotos()
// Clear decisions and reasons
setDecisions({})
setRejectionReasons({})
} 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">
<h1 className="text-2xl font-bold text-gray-900 mb-6">Manage User Uploaded Photos</h1>
{loading && (
<div className="text-center py-8">
<p className="text-gray-600">Loading pending photos...</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={loadPendingPhotos}
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 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="text-sm text-gray-600">
Total photos: <span className="font-semibold">{pendingPhotos.length}</span>
</div>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-3 py-1 border border-gray-300 rounded-md text-sm"
>
<option value="">All Status</option>
<option value="pending">Pending</option>
<option value="approved">Approved</option>
<option value="rejected">Rejected</option>
</select>
</div>
<div className="flex items-center gap-2">
{pendingPhotos.filter((p) => p.status === 'pending').length > 0 && (
<>
<button
onClick={handleSelectAllApprove}
className="px-4 py-1 text-sm bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 font-medium"
>
Select All to Approve
</button>
<button
onClick={handleSelectAllReject}
className="px-4 py-1 text-sm bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 font-medium"
>
Select All to Reject
</button>
</>
)}
<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>
{Object.values(decisions).some((d) => d === 'reject') && (
<div className="flex items-center gap-2">
<label className="text-sm text-gray-700 font-medium whitespace-nowrap">
Bulk Rejection Reason:
</label>
<textarea
value={bulkRejectionReason}
onChange={(e) => handleBulkRejectionReasonChange(e.target.value)}
placeholder="Enter rejection reason to apply to all rejected photos..."
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-md resize-none focus:ring-blue-500 focus:border-blue-500"
rows={2}
/>
</div>
)}
</div>
{pendingPhotos.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<p>No pending photos 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">
Photo
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Uploaded By
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
File Info
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Submitted At
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Decision
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Rejection Reason
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{pendingPhotos.map((photo) => {
const isPending = photo.status === 'pending'
const isApproved = photo.status === 'approved'
const isRejected = photo.status === 'rejected'
const canMakeDecision = isPending
return (
<tr
key={photo.id}
className={`hover:bg-gray-50 ${
isApproved || isRejected ? 'opacity-60 bg-gray-50' : ''
}`}
>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div
className="cursor-pointer hover:opacity-90 transition-opacity"
onClick={async () => {
// For full-size view, fetch as blob and open in new tab
try {
const blobUrl = imageUrls[photo.id] || await pendingPhotosApi.getPendingPhotoImageBlob(photo.id)
// Create a new window with the blob URL
const newWindow = window.open()
if (newWindow) {
newWindow.location.href = blobUrl
}
} catch (err) {
console.error('Failed to open full-size image:', err)
alert('Failed to load full-size image')
}
}}
title="Click to open full photo"
>
{imageUrls[photo.id] ? (
<img
src={imageUrls[photo.id]}
alt={photo.original_filename}
className="w-24 h-24 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 = 'Image not found'
parent.appendChild(fallback)
}
}}
/>
) : (
<div className="w-24 h-24 bg-gray-200 rounded border border-gray-300 flex items-center justify-center text-xs text-gray-400">
Loading...
</div>
)}
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">
{photo.user_name || 'Unknown'}
</div>
<div className="text-sm text-gray-500">
{photo.user_email || '-'}
</div>
</td>
<td className="px-6 py-4">
<div className="text-sm text-gray-900">
{photo.original_filename}
</div>
<div className="text-sm text-gray-500">
{formatFileSize(photo.file_size)} {photo.mime_type}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-500">
{formatDate(photo.submitted_at)}
</div>
{photo.reviewed_at && (
<div className="text-xs text-gray-400 mt-1">
Reviewed: {formatDate(photo.reviewed_at)}
</div>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`px-2 py-1 text-xs font-semibold rounded-full ${
photo.status === 'pending'
? 'bg-yellow-100 text-yellow-800'
: photo.status === 'approved'
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}
>
{photo.status}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{canMakeDecision ? (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={decisions[photo.id] === 'approve'}
onChange={(e) => {
if (e.target.checked) {
handleDecisionChange(photo.id, 'approve')
} else {
// Unchecking - remove decision
handleDecisionChange(photo.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="checkbox"
checked={decisions[photo.id] === 'reject'}
onChange={(e) => {
if (e.target.checked) {
handleDecisionChange(photo.id, 'reject')
} else {
// Unchecking - remove decision
handleDecisionChange(photo.id, 'reject')
}
}}
className="w-4 h-4 text-red-600 focus:ring-red-500 rounded"
/>
<span className="text-sm text-gray-700">Reject</span>
</label>
</div>
</div>
) : (
<span className="text-sm text-gray-500 italic">
{isApproved ? 'Approved' : isRejected ? 'Rejected' : '-'}
</span>
)}
</td>
<td className="px-6 py-4">
{canMakeDecision && decisions[photo.id] === 'reject' ? (
<textarea
value={rejectionReasons[photo.id] || ''}
onChange={(e) =>
handleRejectionReasonChange(photo.id, e.target.value)
}
placeholder="Optional: Enter rejection reason..."
className="w-full px-2 py-1 text-sm border border-gray-300 rounded-md resize-none focus:ring-blue-500 focus:border-blue-500"
rows={2}
/>
) : isRejected && photo.rejection_reason ? (
<div className="text-sm text-gray-700 whitespace-pre-wrap">
<div className="bg-red-50 p-2 rounded border border-red-200">
{photo.rejection_reason}
</div>
</div>
) : (
<span className="text-sm text-gray-400 italic">-</span>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,396 @@
"""Pending photos endpoints - admin only."""
from __future__ import annotations
import os
from datetime import datetime
from typing import Annotated, Optional
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import FileResponse
from pydantic import BaseModel, ConfigDict
from sqlalchemy import text
from sqlalchemy.orm import Session
from src.web.db.session import get_auth_db, get_db
from src.web.api.users import get_current_admin_user
from src.web.api.auth import get_current_user
from src.web.services.photo_service import import_photo_from_path
from src.web.settings import PHOTO_STORAGE_DIR
router = APIRouter(prefix="/pending-photos", tags=["pending-photos"])
class PendingPhotoResponse(BaseModel):
"""Pending photo DTO returned from API."""
model_config = ConfigDict(from_attributes=True, protected_namespaces=())
id: int
user_id: int
user_name: Optional[str] = None
user_email: Optional[str] = None
filename: str
original_filename: str
file_path: str
file_size: int
mime_type: str
status: str
submitted_at: str
reviewed_at: Optional[str] = None
reviewed_by: Optional[int] = None
rejection_reason: Optional[str] = None
class PendingPhotosListResponse(BaseModel):
"""List of pending photos."""
model_config = ConfigDict(protected_namespaces=())
items: list[PendingPhotoResponse]
total: int
class ReviewDecision(BaseModel):
"""Decision for a single pending photo."""
model_config = ConfigDict(protected_namespaces=())
id: int
decision: str # 'approve' or 'reject'
rejection_reason: Optional[str] = None
class ReviewRequest(BaseModel):
"""Request to review multiple pending photos."""
model_config = ConfigDict(protected_namespaces=())
decisions: list[ReviewDecision]
class ReviewResponse(BaseModel):
"""Response from review operation."""
model_config = ConfigDict(protected_namespaces=())
approved: int
rejected: int
errors: list[str]
@router.get("", response_model=PendingPhotosListResponse)
def list_pending_photos(
current_admin: Annotated[dict, Depends(get_current_admin_user)],
status_filter: Optional[str] = None,
auth_db: Session = Depends(get_auth_db),
) -> PendingPhotosListResponse:
"""List all pending photos from the auth database.
This endpoint reads from the separate auth database (DATABASE_URL_AUTH)
and returns all pending photos from the pending_photos table.
Optionally filter by status: 'pending', 'approved', or 'rejected'.
"""
try:
# Query pending_photos from auth database using raw SQL
# Join with users table to get user name/email
if status_filter:
result = auth_db.execute(text("""
SELECT
pp.id,
pp.user_id,
u.name as user_name,
u.email as user_email,
pp.filename,
pp.original_filename,
pp.file_path,
pp.file_size,
pp.mime_type,
pp.status,
pp.submitted_at,
pp.reviewed_at,
pp.reviewed_by,
pp.rejection_reason
FROM pending_photos pp
LEFT JOIN users u ON pp.user_id = u.id
WHERE pp.status = :status_filter
ORDER BY pp.submitted_at DESC
"""), {"status_filter": status_filter})
else:
result = auth_db.execute(text("""
SELECT
pp.id,
pp.user_id,
u.name as user_name,
u.email as user_email,
pp.filename,
pp.original_filename,
pp.file_path,
pp.file_size,
pp.mime_type,
pp.status,
pp.submitted_at,
pp.reviewed_at,
pp.reviewed_by,
pp.rejection_reason
FROM pending_photos pp
LEFT JOIN users u ON pp.user_id = u.id
ORDER BY pp.submitted_at DESC
"""))
rows = result.fetchall()
items = []
for row in rows:
items.append(PendingPhotoResponse(
id=row.id,
user_id=row.user_id,
user_name=row.user_name,
user_email=row.user_email,
filename=row.filename,
original_filename=row.original_filename,
file_path=row.file_path,
file_size=row.file_size,
mime_type=row.mime_type,
status=row.status,
submitted_at=str(row.submitted_at) if row.submitted_at else '',
reviewed_at=str(row.reviewed_at) if row.reviewed_at else None,
reviewed_by=row.reviewed_by,
rejection_reason=row.rejection_reason,
))
return PendingPhotosListResponse(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)}"
)
@router.get("/{photo_id}/image")
def get_pending_photo_image(
photo_id: int,
current_user: Annotated[dict, Depends(get_current_user)],
auth_db: Session = Depends(get_auth_db),
) -> FileResponse:
"""Get the image file for a pending photo.
Photos are stored in /mnt/db-server-uploads. The file_path in the database
may be relative (just filename) or absolute. This function handles both cases.
"""
import os
try:
result = auth_db.execute(text("""
SELECT file_path, mime_type, filename
FROM pending_photos
WHERE id = :id
"""), {"id": photo_id})
row = result.fetchone()
if not row:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Pending photo {photo_id} not found"
)
# Base directory for uploaded photos
base_dir = Path("/mnt/db-server-uploads")
# Handle both absolute and relative paths
db_file_path = row.file_path
if os.path.isabs(db_file_path):
# Absolute path - use as is
file_path = Path(db_file_path)
else:
# Relative path - prepend base directory
file_path = base_dir / db_file_path
# If file doesn't exist at constructed path, try just the filename
if not file_path.exists():
# Try with just the filename from database
file_path = base_dir / row.filename
if not file_path.exists():
# Try with original_filename if available
result2 = auth_db.execute(text("""
SELECT original_filename
FROM pending_photos
WHERE id = :id
"""), {"id": photo_id})
row2 = result2.fetchone()
if row2 and row2.original_filename:
file_path = base_dir / row2.original_filename
if not file_path.exists():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Photo file not found at {file_path}"
)
return FileResponse(
path=str(file_path),
media_type=row.mime_type or "image/jpeg",
filename=file_path.name
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error retrieving photo: {str(e)}"
)
@router.post("/review", response_model=ReviewResponse)
def review_pending_photos(
current_admin: Annotated[dict, Depends(get_current_admin_user)],
request: ReviewRequest,
auth_db: Session = Depends(get_auth_db),
main_db: Session = Depends(get_db),
) -> ReviewResponse:
"""Review pending photos - approve or reject them.
For 'approve' decision:
- Moves photo file from /mnt/db-server-uploads to main photo storage
- Imports photo into main database (Scan process)
- Updates status in auth database to 'approved'
For 'reject' decision:
- Updates status in auth database to 'rejected'
- Photo file remains in place (can be deleted later if needed)
"""
import shutil
import uuid
approved_count = 0
rejected_count = 0
errors = []
admin_user_id = current_admin.get("user_id")
now = datetime.utcnow()
# Base directories
upload_base_dir = Path("/mnt/db-server-uploads")
main_storage_dir = Path(PHOTO_STORAGE_DIR)
main_storage_dir.mkdir(parents=True, exist_ok=True)
for decision in request.decisions:
try:
# Get pending photo from auth database with file info
# Only allow processing 'pending' status photos
result = auth_db.execute(text("""
SELECT
pp.id,
pp.status,
pp.file_path,
pp.filename,
pp.original_filename
FROM pending_photos pp
WHERE pp.id = :id AND pp.status = 'pending'
"""), {"id": decision.id})
row = result.fetchone()
if not row:
errors.append(f"Pending photo {decision.id} not found or already reviewed")
continue
if decision.decision == 'approve':
# Find the source file
db_file_path = row.file_path
source_path = None
# Try to find the file - handle both absolute and relative paths
if os.path.isabs(db_file_path):
source_path = Path(db_file_path)
else:
source_path = upload_base_dir / db_file_path
# If file doesn't exist, try with filename
if not source_path.exists():
source_path = upload_base_dir / row.filename
if not source_path.exists() and row.original_filename:
source_path = upload_base_dir / row.original_filename
if not source_path.exists():
errors.append(f"Photo file not found for pending photo {decision.id}: {source_path}")
continue
# Generate unique filename for main storage to avoid conflicts
file_ext = source_path.suffix
unique_filename = f"{uuid.uuid4()}{file_ext}"
dest_path = main_storage_dir / unique_filename
# Move file to main storage
try:
shutil.move(str(source_path), str(dest_path))
except Exception as e:
errors.append(f"Failed to move photo file for {decision.id}: {str(e)}")
continue
# Import photo into main database (Scan process)
try:
photo, is_new = import_photo_from_path(main_db, str(dest_path))
if not is_new:
# Photo already exists - delete the moved file
if dest_path.exists():
dest_path.unlink()
errors.append(f"Photo already exists in main database: {photo.path}")
continue
except Exception as e:
# If import fails, try to move file back
if dest_path.exists():
try:
shutil.move(str(dest_path), str(source_path))
except:
pass
errors.append(f"Failed to import photo {decision.id} into main database: {str(e)}")
continue
# Update status to approved in auth database
auth_db.execute(text("""
UPDATE pending_photos
SET status = 'approved',
reviewed_at = :reviewed_at,
reviewed_by = :reviewed_by
WHERE id = :id
"""), {
"id": decision.id,
"reviewed_at": now,
"reviewed_by": admin_user_id,
})
auth_db.commit()
approved_count += 1
elif decision.decision == 'reject':
# Update status to rejected
auth_db.execute(text("""
UPDATE pending_photos
SET status = 'rejected',
reviewed_at = :reviewed_at,
reviewed_by = :reviewed_by,
rejection_reason = :rejection_reason
WHERE id = :id
"""), {
"id": decision.id,
"reviewed_at": now,
"reviewed_by": admin_user_id,
"rejection_reason": decision.rejection_reason or None,
})
auth_db.commit()
rejected_count += 1
else:
errors.append(f"Invalid decision '{decision.decision}' for pending photo {decision.id}")
except Exception as e:
errors.append(f"Error processing pending photo {decision.id}: {str(e)}")
# Rollback any partial changes
auth_db.rollback()
main_db.rollback()
return ReviewResponse(
approved=approved_count,
rejected=rejected_count,
errors=errors
)

View File

@ -19,6 +19,7 @@ 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.reported_photos import router as reported_photos_router
from src.web.api.pending_photos import router as pending_photos_router
from src.web.api.tags import router as tags_router
from src.web.api.users import router as users_router
from src.web.api.version import router as version_router
@ -240,6 +241,7 @@ def create_app() -> FastAPI:
app.include_router(people_router, prefix="/api/v1")
app.include_router(pending_identifications_router, prefix="/api/v1")
app.include_router(reported_photos_router, prefix="/api/v1")
app.include_router(pending_photos_router, prefix="/api/v1")
app.include_router(tags_router, prefix="/api/v1")
app.include_router(users_router, prefix="/api/v1")