Auto-mode commit 7aa33cd accidentally deleted 78 files (14,814 lines) during M005
execution. Subsequent commits rebuilt some frontend files but backend/, alembic/,
tests/, whisper/, docker configs, and prompts were never restored in this repo.
This commit restores the full project tree by syncing from ub01's working directory,
which has all M001-M007 features running in production containers.
Restored: backend/ (config, models, routers, database, redis, search_service, worker),
alembic/ (6 migrations), docker/ (Dockerfiles, nginx, compose), prompts/ (4 stages),
tests/, whisper/, README.md, .env.example, chrysopedia-spec.md
147 lines
4.8 KiB
Python
147 lines
4.8 KiB
Python
"""Content reports router — public submission + admin management.
|
|
|
|
Public:
|
|
POST /reports Submit a content issue report
|
|
|
|
Admin:
|
|
GET /admin/reports List reports (filterable by status, content_type)
|
|
GET /admin/reports/{id} Get single report detail
|
|
PATCH /admin/reports/{id} Update status / add admin notes
|
|
"""
|
|
|
|
import logging
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from typing import Annotated
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from sqlalchemy import func, select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from database import get_session
|
|
from models import ContentReport, ReportStatus
|
|
from schemas import (
|
|
ContentReportCreate,
|
|
ContentReportListResponse,
|
|
ContentReportRead,
|
|
ContentReportUpdate,
|
|
)
|
|
|
|
logger = logging.getLogger("chrysopedia.reports")
|
|
|
|
router = APIRouter(tags=["reports"])
|
|
|
|
|
|
# ── Public ───────────────────────────────────────────────────────────────────
|
|
|
|
@router.post("/reports", response_model=ContentReportRead, status_code=201)
|
|
async def submit_report(
|
|
body: ContentReportCreate,
|
|
db: AsyncSession = Depends(get_session),
|
|
):
|
|
"""Submit a content issue report (public, no auth)."""
|
|
report = ContentReport(
|
|
content_type=body.content_type,
|
|
content_id=body.content_id,
|
|
content_title=body.content_title,
|
|
report_type=body.report_type,
|
|
description=body.description,
|
|
page_url=body.page_url,
|
|
)
|
|
db.add(report)
|
|
await db.commit()
|
|
await db.refresh(report)
|
|
|
|
logger.info(
|
|
"New content report: id=%s type=%s content=%s/%s",
|
|
report.id, report.report_type, report.content_type, report.content_id,
|
|
)
|
|
return report
|
|
|
|
|
|
# ── Admin ────────────────────────────────────────────────────────────────────
|
|
|
|
@router.get("/admin/reports", response_model=ContentReportListResponse)
|
|
async def list_reports(
|
|
status: Annotated[str | None, Query(description="Filter by status")] = None,
|
|
content_type: Annotated[str | None, Query(description="Filter by content type")] = None,
|
|
offset: Annotated[int, Query(ge=0)] = 0,
|
|
limit: Annotated[int, Query(ge=1, le=100)] = 50,
|
|
db: AsyncSession = Depends(get_session),
|
|
):
|
|
"""List content reports with optional filters."""
|
|
stmt = select(ContentReport)
|
|
|
|
if status:
|
|
stmt = stmt.where(ContentReport.status == status)
|
|
if content_type:
|
|
stmt = stmt.where(ContentReport.content_type == content_type)
|
|
|
|
# Count
|
|
count_stmt = select(func.count()).select_from(stmt.subquery())
|
|
total = (await db.execute(count_stmt)).scalar() or 0
|
|
|
|
# Fetch page
|
|
stmt = stmt.order_by(ContentReport.created_at.desc())
|
|
stmt = stmt.offset(offset).limit(limit)
|
|
result = await db.execute(stmt)
|
|
items = result.scalars().all()
|
|
|
|
return {"items": items, "total": total, "offset": offset, "limit": limit}
|
|
|
|
|
|
@router.get("/admin/reports/{report_id}", response_model=ContentReportRead)
|
|
async def get_report(
|
|
report_id: uuid.UUID,
|
|
db: AsyncSession = Depends(get_session),
|
|
):
|
|
"""Get a single content report by ID."""
|
|
result = await db.execute(
|
|
select(ContentReport).where(ContentReport.id == report_id)
|
|
)
|
|
report = result.scalar_one_or_none()
|
|
if not report:
|
|
raise HTTPException(status_code=404, detail="Report not found")
|
|
return report
|
|
|
|
|
|
@router.patch("/admin/reports/{report_id}", response_model=ContentReportRead)
|
|
async def update_report(
|
|
report_id: uuid.UUID,
|
|
body: ContentReportUpdate,
|
|
db: AsyncSession = Depends(get_session),
|
|
):
|
|
"""Update report status and/or admin notes."""
|
|
result = await db.execute(
|
|
select(ContentReport).where(ContentReport.id == report_id)
|
|
)
|
|
report = result.scalar_one_or_none()
|
|
if not report:
|
|
raise HTTPException(status_code=404, detail="Report not found")
|
|
|
|
if body.status is not None:
|
|
# Validate status value
|
|
try:
|
|
ReportStatus(body.status)
|
|
except ValueError:
|
|
raise HTTPException(
|
|
status_code=422,
|
|
detail=f"Invalid status: {body.status}. Must be one of: open, acknowledged, resolved, dismissed",
|
|
)
|
|
report.status = body.status
|
|
if body.status in ("resolved", "dismissed"):
|
|
report.resolved_at = datetime.now(timezone.utc).replace(tzinfo=None)
|
|
elif body.status == "open":
|
|
report.resolved_at = None
|
|
|
|
if body.admin_notes is not None:
|
|
report.admin_notes = body.admin_notes
|
|
|
|
await db.commit()
|
|
await db.refresh(report)
|
|
|
|
logger.info(
|
|
"Report updated: id=%s status=%s",
|
|
report.id, report.status,
|
|
)
|
|
return report
|