chrysopedia/backend/routers/reports.py
jlightner 324e933670 feat: Content issue reporting — submit from technique pages, manage in admin reports page
- ContentReport model with generic content_type/content_id (supports any entity)
- Alembic migration 003: content_reports table with status + content indexes
- POST /reports (public), GET/PATCH /admin/reports (admin triage)
- Report modal on technique pages with issue type dropdown + description
- Admin reports page with status filter, expand/collapse detail, triage actions
- All CSS uses var(--*) tokens, dark theme consistent
2026-03-30 02:53:56 -05: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