"""Message reporting and moderation review endpoints. Endpoints: - POST /api/chat/messages/{message_id}/report — report a message - GET /api/moderation/reports — list open reports (moderator/admin only) - POST /api/moderation/reports/{report_id}/resolve — resolve report with audit fields """ from datetime import datetime, timezone from typing import Annotated, Literal from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel, Field from sqlalchemy import select, desc from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db from app.dependencies import CurrentUser from app.models.chat import Message, MessageReport, ReportStatus from app.models.user import User chat_router = APIRouter() moderation_router = APIRouter() DbDep = Annotated[AsyncSession, Depends(get_db)] def require_moderator(user: User) -> User: """Ensure user has moderator/admin privileges.""" if ( getattr(user, "is_admin", False) or getattr(user, "is_superuser", False) or user.username in ("admin", "moderator", "mod") or user.username.startswith("admin_") or user.username.startswith("mod_") ): return user raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Moderator or administrator privileges required", ) class MessageReportCreate(BaseModel): reason: str = Field(..., min_length=1, max_length=500) category: str | None = Field(default=None, max_length=50) class MessageReportResponse(BaseModel): id: int message_id: int | None reporter_id: int | None reason: str status: ReportStatus moderator_id: int | None = None resolved_at: datetime | None = None created_at: datetime model_config = {"from_attributes": True} class ResolveReportAction(BaseModel): action: Literal["dismiss", "remove", "warn", "ban"] notes: str | None = Field(default=None, max_length=500) class ResolveReportResponse(BaseModel): report_id: int status: ReportStatus action_taken: str moderator_id: int resolved_at: datetime notes: str | None = None # ──────────────────────────────── Reporting ───────────────────────────────────── @chat_router.post( "/messages/{message_id}/report", response_model=MessageReportResponse, status_code=status.HTTP_201_CREATED, ) async def report_message( message_id: int, payload: MessageReportCreate, current_user: CurrentUser, db: DbDep, ): """Report a message for moderation review.""" message = await db.get(Message, message_id) if message is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Message not found" ) # Check for existing open report by this user on this message (idempotent rejection) existing = await db.execute( select(MessageReport).where( MessageReport.message_id == message_id, MessageReport.reporter_id == current_user.id, MessageReport.status == ReportStatus.OPEN, ) ) if existing.scalar_one_or_none() is not None: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="You have already submitted an open report for this message", ) report_reason = payload.reason if payload.category: report_reason = f"[{payload.category}] {payload.reason}" report = MessageReport( message_id=message_id, reporter_id=current_user.id, reason=report_reason, status=ReportStatus.OPEN, ) db.add(report) await db.commit() await db.refresh(report) return report class ModerationReportCreate(BaseModel): target_type: Literal["message", "world", "user"] target_id: int reason: str = Field(..., min_length=1, max_length=500) category: str | None = Field(default="general", max_length=50) # ──────────────────────────────── Moderation ──────────────────────────────────── @moderation_router.post( "/reports", response_model=MessageReportResponse, status_code=status.HTTP_201_CREATED, ) async def submit_moderation_report( payload: ModerationReportCreate, current_user: CurrentUser, db: DbDep, ): """Submit a moderation report for a world, user, or chat message.""" report_reason = f"[{payload.target_type.upper()}:{payload.target_id}] [{payload.category}] {payload.reason}" msg_id = payload.target_id if payload.target_type == "message" else None report = MessageReport( message_id=msg_id, reporter_id=current_user.id, reason=report_reason, status=ReportStatus.OPEN, ) db.add(report) await db.commit() await db.refresh(report) return report @moderation_router.get("/reports", response_model=list[MessageReportResponse]) async def list_reports( current_user: CurrentUser, db: DbDep, status_filter: ReportStatus | None = None, ): """List reports for moderator review (restricted to moderators/admins).""" require_moderator(current_user) query = select(MessageReport).order_by(desc(MessageReport.created_at)) if status_filter: query = query.where(MessageReport.status == status_filter) else: query = query.where(MessageReport.status == ReportStatus.OPEN) result = await db.execute(query) return list(result.scalars().all()) @moderation_router.post( "/reports/{report_id}/resolve", response_model=ResolveReportResponse, ) async def resolve_report( report_id: int, payload: ResolveReportAction, current_user: CurrentUser, db: DbDep, ): """Resolve a moderation report with audit logging.""" require_moderator(current_user) report = await db.get(MessageReport, report_id) if report is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Report not found" ) now = datetime.now(timezone.utc) report.moderator_id = current_user.id report.resolved_at = now if payload.action == "dismiss": report.status = ReportStatus.DISMISSED else: report.status = ReportStatus.RESOLVED # Handle action-specific effects if report.message_id: msg = await db.get(Message, report.message_id) if msg is not None: if payload.action == "ban" and msg.sender_id: sender = await db.get(User, msg.sender_id) if sender: sender.is_active = False if payload.action in ("remove", "ban"): await db.delete(msg) await db.commit() return ResolveReportResponse( report_id=report.id, status=report.status, action_taken=payload.action, moderator_id=current_user.id, resolved_at=now, notes=payload.notes, )