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:
parent
2c3b2d7a08
commit
e6c66e564e
@ -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>
|
||||
|
||||
76
frontend/src/api/pendingPhotos.ts
Normal file
76
frontend/src/api/pendingPhotos.ts
Normal 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
|
||||
},
|
||||
}
|
||||
|
||||
@ -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 },
|
||||
]
|
||||
|
||||
562
frontend/src/pages/PendingPhotos.tsx
Normal file
562
frontend/src/pages/PendingPhotos.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
396
src/web/api/pending_photos.py
Normal file
396
src/web/api/pending_photos.py
Normal 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
|
||||
)
|
||||
|
||||
@ -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")
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user