Sprint C#15-16: paginate Approve Identified API/UI and in-app photo preview.
This commit is contained in:
@@ -61,10 +61,22 @@ export interface ClearDatabaseResponse {
|
||||
}
|
||||
|
||||
export const pendingIdentificationsApi = {
|
||||
list: async (includeDenied: boolean = false): Promise<PendingIdentificationsListResponse> => {
|
||||
list: async (
|
||||
includeDenied: boolean = false,
|
||||
options?: { page?: number; pageSize?: number }
|
||||
): Promise<PendingIdentificationsListResponse> => {
|
||||
const params: Record<string, string | number | boolean> = {
|
||||
include_denied: includeDenied,
|
||||
}
|
||||
if (options?.page !== undefined) {
|
||||
params.page = options.page
|
||||
}
|
||||
if (options?.pageSize !== undefined) {
|
||||
params.page_size = options.pageSize
|
||||
}
|
||||
const res = await apiClient.get<PendingIdentificationsListResponse>(
|
||||
'/api/v1/pending-identifications',
|
||||
{ params: { include_denied: includeDenied } }
|
||||
{ params }
|
||||
)
|
||||
return res.data
|
||||
},
|
||||
|
||||
@@ -26,13 +26,21 @@ export default function ApproveIdentified() {
|
||||
const [dateFrom, setDateFrom] = useState<string>('')
|
||||
const [dateTo, setDateTo] = useState<string>('')
|
||||
const [clearing, setClearing] = useState(false)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize] = useState(25)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [previewPhotoUrl, setPreviewPhotoUrl] = useState<string | null>(null)
|
||||
|
||||
const loadPendingIdentifications = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const response = await pendingIdentificationsApi.list(includeDenied)
|
||||
const response = await pendingIdentificationsApi.list(includeDenied, {
|
||||
page,
|
||||
pageSize,
|
||||
})
|
||||
setPendingIdentifications(response.items)
|
||||
setTotal(response.total)
|
||||
} catch (err: any) {
|
||||
let errorMessage = 'Failed to load pending identifications'
|
||||
if (err.response?.data?.detail) {
|
||||
@@ -49,6 +57,10 @@ export default function ApproveIdentified() {
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [includeDenied, page, pageSize])
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1)
|
||||
}, [includeDenied])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -284,7 +296,12 @@ export default function ApproveIdentified() {
|
||||
<>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="text-sm text-gray-600">
|
||||
Total pending identifications: <span className="font-semibold">{pendingIdentifications.length}</span>
|
||||
Total pending identifications: <span className="font-semibold">{total}</span>
|
||||
{total > 0 && (
|
||||
<span className="ml-2 text-gray-500">
|
||||
(page {page} of {Math.max(1, Math.ceil(total / pageSize))})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
@@ -458,10 +475,11 @@ export default function ApproveIdentified() {
|
||||
<div
|
||||
className="cursor-pointer hover:opacity-90 transition-opacity"
|
||||
onClick={() => {
|
||||
const photoUrl = `${apiClient.defaults.baseURL}/api/v1/photos/${pending.photo_id}/image`
|
||||
window.open(photoUrl, '_blank')
|
||||
setPreviewPhotoUrl(
|
||||
`${apiClient.defaults.baseURL}/api/v1/photos/${pending.photo_id}/image`
|
||||
)
|
||||
}}
|
||||
title="Click to open full photo"
|
||||
title="Click to preview full photo"
|
||||
>
|
||||
<img
|
||||
src={`${apiClient.defaults.baseURL}/api/v1/faces/${pending.face_id}/crop`}
|
||||
@@ -571,10 +589,57 @@ export default function ApproveIdentified() {
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{total > pageSize && (
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page <= 1 || loading}
|
||||
className="px-3 py-1.5 text-sm bg-gray-100 text-gray-700 rounded hover:bg-gray-200 disabled:opacity-50"
|
||||
>
|
||||
Previous page
|
||||
</button>
|
||||
<span className="text-sm text-gray-600">
|
||||
Showing {(page - 1) * pageSize + 1}–{Math.min(page * pageSize, total)} of {total}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
disabled={page * pageSize >= total || loading}
|
||||
className="px-3 py-1.5 text-sm bg-gray-100 text-gray-700 rounded hover:bg-gray-200 disabled:opacity-50"
|
||||
>
|
||||
Next page
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{previewPhotoUrl && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Photo preview"
|
||||
onClick={() => setPreviewPhotoUrl(null)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-4 top-4 rounded bg-white/90 px-3 py-1 text-sm font-medium text-gray-900"
|
||||
onClick={() => setPreviewPhotoUrl(null)}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<img
|
||||
src={previewPhotoUrl}
|
||||
alt="Full photo preview"
|
||||
className="max-h-[90vh] max-w-full rounded object-contain"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Report Modal */}
|
||||
{showReport && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
|
||||
@@ -152,23 +152,37 @@ def list_pending_identifications(
|
||||
dict, Depends(require_feature_permission("user_identified"))
|
||||
],
|
||||
include_denied: bool = False,
|
||||
page: int = Query(1, ge=1, description="Page number (1-based)"),
|
||||
page_size: int = Query(50, ge=1, le=500, description="Items per page"),
|
||||
db: Session = Depends(get_auth_db),
|
||||
main_db: Session = Depends(get_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.
|
||||
"""List pending identifications from the auth database (paginated).
|
||||
|
||||
By default, only shows records with status='pending' for approval.
|
||||
Set include_denied=True to also show denied records.
|
||||
"""
|
||||
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
|
||||
# Optionally include denied records if include_denied is True
|
||||
if include_denied:
|
||||
result = db.execute(text("""
|
||||
where_clause = (
|
||||
"WHERE pi.status IN ('pending', 'denied')"
|
||||
if include_denied
|
||||
else "WHERE pi.status = 'pending'"
|
||||
)
|
||||
order_clause = (
|
||||
"ORDER BY pi.status ASC, pi.last_name ASC, pi.first_name ASC, pi.created_at DESC"
|
||||
if include_denied
|
||||
else "ORDER BY pi.last_name ASC, pi.first_name ASC, pi.created_at DESC"
|
||||
)
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
count_row = db.execute(
|
||||
text(f"SELECT COUNT(*) AS count FROM pending_identifications pi {where_clause}")
|
||||
).fetchone()
|
||||
total = int(count_row.count) if count_row else 0
|
||||
|
||||
result = db.execute(
|
||||
text(
|
||||
f"""
|
||||
SELECT
|
||||
pi.id,
|
||||
pi.face_id,
|
||||
@@ -187,46 +201,29 @@ def list_pending_identifications(
|
||||
pi.updated_at
|
||||
FROM pending_identifications pi
|
||||
LEFT JOIN users u ON pi.user_id = u.id
|
||||
WHERE pi.status IN ('pending', 'denied')
|
||||
ORDER BY pi.status ASC, pi.last_name ASC, pi.first_name ASC, pi.created_at DESC
|
||||
"""))
|
||||
else:
|
||||
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.email,
|
||||
pi.phone,
|
||||
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
|
||||
"""))
|
||||
|
||||
{where_clause}
|
||||
{order_clause}
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""
|
||||
),
|
||||
{"limit": page_size, "offset": offset},
|
||||
)
|
||||
|
||||
rows = result.fetchall()
|
||||
face_ids = [row.face_id for row in rows]
|
||||
photo_by_face: dict[int, int | None] = {}
|
||||
if face_ids:
|
||||
for face_id, photo_id in main_db.query(Face.id, Face.photo_id).filter(
|
||||
Face.id.in_(face_ids)
|
||||
):
|
||||
photo_by_face[face_id] = photo_id
|
||||
|
||||
items = []
|
||||
for row in rows:
|
||||
# Get photo_id from main database
|
||||
photo_id = None
|
||||
face = main_db.query(Face).filter(Face.id == row.face_id).first()
|
||||
if face:
|
||||
photo_id = face.photo_id
|
||||
|
||||
items.append(PendingIdentificationResponse(
|
||||
id=row.id,
|
||||
face_id=row.face_id,
|
||||
photo_id=photo_id,
|
||||
photo_id=photo_by_face.get(row.face_id),
|
||||
user_id=row.user_id,
|
||||
user_name=row.user_name,
|
||||
user_email=row.user_email,
|
||||
@@ -241,8 +238,8 @@ def list_pending_identifications(
|
||||
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))
|
||||
|
||||
return PendingIdentificationsListResponse(items=items, total=total)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
|
||||
Reference in New Issue
Block a user