chrysopedia/backend/routers/reports.py
jlightner 4b0914b12b fix: restore complete project tree from ub01 canonical state
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
2026-03-31 02:10:41 +00:00

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