feat: Enhance reported photos management with cleanup functionality and report comment
This commit introduces a new cleanup feature for reported photos, allowing admins to delete records based on their review status. The API has been updated with a new endpoint for cleanup operations, and the frontend now includes a button to trigger this action. Additionally, a report comment field has been added to the reported photo response model, improving the detail available for each reported photo. The user interface has been updated to display report comments and provide a confirmation dialog for the cleanup action. Documentation has been updated to reflect these changes.
This commit is contained in:
parent
51eaf6a52b
commit
f9e8c476bc
@ -11,6 +11,7 @@ export interface ReportedPhotoResponse {
|
||||
reviewed_at: string | null
|
||||
reviewed_by: number | null
|
||||
review_notes: string | null
|
||||
report_comment: string | null
|
||||
photo_path: string | null
|
||||
photo_filename: string | null
|
||||
}
|
||||
@ -36,6 +37,12 @@ export interface ReviewResponse {
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
export interface ReportedCleanupResponse {
|
||||
deleted_records: number
|
||||
errors: string[]
|
||||
warnings?: string[]
|
||||
}
|
||||
|
||||
export const reportedPhotosApi = {
|
||||
listReportedPhotos: async (statusFilter?: string): Promise<ReportedPhotosListResponse> => {
|
||||
const { data } = await apiClient.get<ReportedPhotosListResponse>(
|
||||
@ -54,5 +61,13 @@ export const reportedPhotosApi = {
|
||||
)
|
||||
return data
|
||||
},
|
||||
|
||||
cleanupReportedPhotos: async (): Promise<ReportedCleanupResponse> => {
|
||||
const { data } = await apiClient.post<ReportedCleanupResponse>(
|
||||
'/api/v1/reported-photos/cleanup',
|
||||
{},
|
||||
)
|
||||
return data
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@ -27,7 +27,6 @@ export default function Layout() {
|
||||
{ path: '/auto-match', label: 'Auto-Match', icon: '🤖', adminOnly: false },
|
||||
{ path: '/modify', label: 'Modify', icon: '✏️', adminOnly: false },
|
||||
{ path: '/tags', label: 'Tag', icon: '🏷️', adminOnly: false },
|
||||
{ path: '/manage-photos', label: 'Manage Photos', icon: '📷', adminOnly: false },
|
||||
{ path: '/faces-maintenance', label: 'Faces Maintenance', icon: '🔧', adminOnly: false },
|
||||
{ path: '/approve-identified', label: 'Approve identified', icon: '✅', adminOnly: true },
|
||||
{ path: '/manage-users', label: 'Manage users', icon: '👥', adminOnly: true },
|
||||
|
||||
@ -13,6 +13,7 @@ export default function ReportedPhotos() {
|
||||
const [decisions, setDecisions] = useState<Record<number, 'keep' | 'remove' | null>>({})
|
||||
const [reviewNotes, setReviewNotes] = useState<Record<number, string>>({})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [clearing, setClearing] = useState(false)
|
||||
const [statusFilter, setStatusFilter] = useState<string>('pending')
|
||||
|
||||
const loadReportedPhotos = useCallback(async () => {
|
||||
@ -55,10 +56,14 @@ export default function ReportedPhotos() {
|
||||
}
|
||||
|
||||
const handleDecisionChange = (id: number, decision: 'keep' | 'remove') => {
|
||||
setDecisions((prev) => ({
|
||||
...prev,
|
||||
[id]: decision,
|
||||
}))
|
||||
setDecisions((prev) => {
|
||||
const currentDecision = prev[id] ?? null
|
||||
const nextDecision = currentDecision === decision ? null : decision
|
||||
return {
|
||||
...prev,
|
||||
[id]: nextDecision,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleReviewNotesChange = (id: number, notes: string) => {
|
||||
@ -124,18 +129,21 @@ export default function ReportedPhotos() {
|
||||
decisions: decisionsList,
|
||||
})
|
||||
|
||||
const message = [
|
||||
const messageParts = [
|
||||
`✅ Kept: ${response.kept}`,
|
||||
`❌ Removed: ${response.removed}`,
|
||||
response.errors.length > 0 ? `⚠️ Errors: ${response.errors.length}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
|
||||
if (import.meta.env.DEV && response.errors.length > 0) {
|
||||
messageParts.push(`⚠️ Errors: ${response.errors.length}`)
|
||||
}
|
||||
|
||||
const message = messageParts.join('\n')
|
||||
|
||||
alert(message)
|
||||
|
||||
if (response.errors.length > 0) {
|
||||
console.error('Errors:', response.errors)
|
||||
console.error('Reported photo review errors:', response.errors)
|
||||
}
|
||||
|
||||
// Reload the list to show updated status
|
||||
@ -153,6 +161,51 @@ export default function ReportedPhotos() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleClearDatabase = async () => {
|
||||
const confirmMessage = [
|
||||
'Delete all kept and removed reported photo records from the auth database?',
|
||||
'',
|
||||
'Only photos with Pending status will remain.',
|
||||
'This action cannot be undone.',
|
||||
].join('\n')
|
||||
|
||||
if (!confirm(confirmMessage)) {
|
||||
return
|
||||
}
|
||||
|
||||
setClearing(true)
|
||||
try {
|
||||
const response = await reportedPhotosApi.cleanupReportedPhotos()
|
||||
const summary = [
|
||||
`✅ Deleted ${response.deleted_records} record(s)`,
|
||||
response.warnings && response.warnings.length > 0
|
||||
? `ℹ️ ${response.warnings.join('; ')}`
|
||||
: '',
|
||||
response.errors.length > 0 ? `⚠️ ${response.errors.join('; ')}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
|
||||
alert(summary || 'Cleanup complete.')
|
||||
|
||||
if (response.errors.length > 0) {
|
||||
console.error('Cleanup errors:', response.errors)
|
||||
}
|
||||
if (response.warnings && response.warnings.length > 0) {
|
||||
console.info('Cleanup warnings:', response.warnings)
|
||||
}
|
||||
|
||||
await loadReportedPhotos()
|
||||
} catch (err: any) {
|
||||
const errorMessage =
|
||||
err.response?.data?.detail || err.message || 'Failed to cleanup reported photos'
|
||||
alert(`Error: ${errorMessage}`)
|
||||
console.error('Error clearing reported photos:', err)
|
||||
} finally {
|
||||
setClearing(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
@ -179,32 +232,47 @@ export default function ReportedPhotos() {
|
||||
|
||||
{!loading && !error && (
|
||||
<>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-sm text-gray-600">
|
||||
Total reported photos: <span className="font-semibold">{reportedPhotos.length}</span>
|
||||
<div className="mb-4 flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-sm text-gray-600">
|
||||
Total reported photos:{' '}
|
||||
<span className="font-semibold">{reportedPhotos.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="reviewed">Reviewed</option>
|
||||
<option value="dismissed">Dismissed</option>
|
||||
</select>
|
||||
</div>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="px-3 py-1 border border-gray-300 rounded-md text-sm"
|
||||
<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"
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="reviewed">Reviewed</option>
|
||||
<option value="dismissed">Dismissed</option>
|
||||
</select>
|
||||
{submitting ? 'Submitting...' : 'Submit Decisions'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
onClick={handleClearDatabase}
|
||||
disabled={clearing}
|
||||
className="px-3 py-1.5 text-sm bg-red-100 text-red-700 rounded-md hover:bg-red-200 disabled:bg-gray-200 disabled:text-gray-500 disabled:cursor-not-allowed font-medium"
|
||||
>
|
||||
{clearing ? 'Clearing...' : '🗑️ Clear Database'}
|
||||
</button>
|
||||
<span className="text-sm text-gray-700">
|
||||
Clear kept/removed records
|
||||
</span>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
{reportedPhotos.length === 0 ? (
|
||||
@ -225,6 +293,9 @@ export default function ReportedPhotos() {
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Reported At
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Report Comment
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
@ -299,6 +370,15 @@ export default function ReportedPhotos() {
|
||||
{formatDate(reported.reported_at)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{reported.report_comment ? (
|
||||
<div className="text-sm text-gray-700 whitespace-pre-wrap bg-gray-50 p-2 rounded border border-gray-200">
|
||||
{reported.report_comment}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-gray-400 italic">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span
|
||||
className={`px-2 py-1 text-xs font-semibold rounded-full ${
|
||||
@ -317,8 +397,8 @@ export default function ReportedPhotos() {
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name={`decision-${reported.id}`}
|
||||
type="checkbox"
|
||||
name={`decision-${reported.id}-keep`}
|
||||
value="keep"
|
||||
checked={decisions[reported.id] === 'keep'}
|
||||
onChange={() => handleDecisionChange(reported.id, 'keep')}
|
||||
@ -328,8 +408,8 @@ export default function ReportedPhotos() {
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name={`decision-${reported.id}`}
|
||||
type="checkbox"
|
||||
name={`decision-${reported.id}-remove`}
|
||||
value="remove"
|
||||
checked={decisions[reported.id] === 'remove'}
|
||||
onChange={() => handleDecisionChange(reported.id, 'remove')}
|
||||
|
||||
@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
@ -32,6 +32,7 @@ class ReportedPhotoResponse(BaseModel):
|
||||
reviewed_at: Optional[str] = None
|
||||
reviewed_by: Optional[int] = None
|
||||
review_notes: Optional[str] = None
|
||||
report_comment: Optional[str] = None
|
||||
# Photo details from main database
|
||||
photo_path: Optional[str] = None
|
||||
photo_filename: Optional[str] = None
|
||||
@ -74,6 +75,16 @@ class ReviewResponse(BaseModel):
|
||||
errors: list[str]
|
||||
|
||||
|
||||
class CleanupResponse(BaseModel):
|
||||
"""Response payload for cleanup operations."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
deleted_records: int
|
||||
errors: list[str]
|
||||
warnings: list[str] = []
|
||||
|
||||
|
||||
@router.get("", response_model=ReportedPhotosListResponse)
|
||||
def list_reported_photos(
|
||||
current_admin: Annotated[dict, Depends(get_current_admin_user)],
|
||||
@ -102,7 +113,8 @@ def list_reported_photos(
|
||||
ipr.reported_at,
|
||||
ipr.reviewed_at,
|
||||
ipr.reviewed_by,
|
||||
ipr.review_notes
|
||||
ipr.review_notes,
|
||||
ipr.report_comment
|
||||
FROM inappropriate_photo_reports ipr
|
||||
LEFT JOIN users u ON ipr.user_id = u.id
|
||||
WHERE ipr.status = :status_filter
|
||||
@ -120,7 +132,8 @@ def list_reported_photos(
|
||||
ipr.reported_at,
|
||||
ipr.reviewed_at,
|
||||
ipr.reviewed_by,
|
||||
ipr.review_notes
|
||||
ipr.review_notes,
|
||||
ipr.report_comment
|
||||
FROM inappropriate_photo_reports ipr
|
||||
LEFT JOIN users u ON ipr.user_id = u.id
|
||||
ORDER BY ipr.reported_at DESC
|
||||
@ -148,6 +161,7 @@ def list_reported_photos(
|
||||
reviewed_at=str(row.reviewed_at) if row.reviewed_at else None,
|
||||
reviewed_by=row.reviewed_by,
|
||||
review_notes=row.review_notes,
|
||||
report_comment=row.report_comment,
|
||||
photo_path=photo_path,
|
||||
photo_filename=photo_filename,
|
||||
))
|
||||
@ -205,11 +219,9 @@ def review_reported_photos(
|
||||
# Delete photo from main database (cascade will handle related records)
|
||||
photo = main_db.query(Photo).filter(Photo.id == row.photo_id).first()
|
||||
if not photo:
|
||||
errors.append(f"Photo {row.photo_id} not found in main database")
|
||||
# Still update status to reviewed since we can't process it
|
||||
auth_db.execute(text("""
|
||||
UPDATE inappropriate_photo_reports
|
||||
SET status = 'reviewed',
|
||||
SET status = 'dismissed',
|
||||
reviewed_at = :reviewed_at,
|
||||
reviewed_by = :reviewed_by,
|
||||
review_notes = :review_notes
|
||||
@ -218,10 +230,10 @@ def review_reported_photos(
|
||||
"id": decision.id,
|
||||
"reviewed_at": now,
|
||||
"reviewed_by": admin_user_id,
|
||||
"review_notes": decision.review_notes or "Photo not found in database"
|
||||
"review_notes": decision.review_notes or "Photo not found in database; auto-dismissed"
|
||||
})
|
||||
auth_db.commit()
|
||||
kept_count += 1 # Count as kept since we couldn't remove it
|
||||
removed_count += 1
|
||||
continue
|
||||
|
||||
# Delete tag linkages for this photo
|
||||
@ -284,3 +296,72 @@ def review_reported_photos(
|
||||
errors=errors
|
||||
)
|
||||
|
||||
|
||||
@router.post("/cleanup", response_model=CleanupResponse)
|
||||
def cleanup_reported_photos(
|
||||
current_admin: Annotated[dict, Depends(get_current_admin_user)],
|
||||
status_filter: Annotated[
|
||||
Optional[str],
|
||||
Query(description="Use 'keep' to clear reviewed or 'remove' to clear dismissed records.")
|
||||
] = None,
|
||||
auth_db: Session = Depends(get_auth_db),
|
||||
) -> CleanupResponse:
|
||||
"""Delete rows from inappropriate_photo_reports based on review status."""
|
||||
status_mapping = {
|
||||
"keep": "reviewed",
|
||||
"remove": "dismissed",
|
||||
}
|
||||
|
||||
if status_filter and status_filter not in status_mapping:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid status_filter. Use 'keep', 'remove', or omit the parameter.",
|
||||
)
|
||||
|
||||
db_status_filter = status_mapping.get(status_filter)
|
||||
warnings: list[str] = []
|
||||
|
||||
try:
|
||||
if db_status_filter:
|
||||
result = auth_db.execute(
|
||||
text(
|
||||
"""
|
||||
DELETE FROM inappropriate_photo_reports
|
||||
WHERE status = :status_filter
|
||||
"""
|
||||
),
|
||||
{"status_filter": db_status_filter},
|
||||
)
|
||||
else:
|
||||
result = auth_db.execute(
|
||||
text(
|
||||
"""
|
||||
DELETE FROM inappropriate_photo_reports
|
||||
WHERE status IN ('reviewed', 'dismissed')
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
deleted_records = result.rowcount if hasattr(result, "rowcount") else 0
|
||||
auth_db.commit()
|
||||
|
||||
if deleted_records == 0:
|
||||
if db_status_filter:
|
||||
warnings.append(
|
||||
f"No reported photos matched the '{status_filter}' decision filter."
|
||||
)
|
||||
else:
|
||||
warnings.append("No reviewed or dismissed reported photos to delete.")
|
||||
|
||||
return CleanupResponse(
|
||||
deleted_records=deleted_records,
|
||||
errors=[],
|
||||
warnings=warnings,
|
||||
)
|
||||
except Exception as exc:
|
||||
auth_db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to cleanup reported photos: {exc}",
|
||||
)
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user