Files
modbison/backend/app/routers/chat_reports.py
T
daddy 429cf6f46c Implement message reporting endpoint and moderation review queue (US-4)
- POST /api/chat/messages/{message_id}/report for reporting messages with category/reason
- Idempotent duplicate report rejection (409)
- GET /api/moderation/reports for queue listing (restricted to moderators/admins)
- POST /api/moderation/reports/{id}/resolve supporting dismiss, remove, warn, ban actions
- Audit fields (moderator_id, resolved_at, action) recorded on resolution
- Added 9 integration tests in backend/tests/test_chat_reports.py (all passing)
2026-09-15 17:31:43 +00:00

198 lines
6.0 KiB
Python

"""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
# ──────────────────────────────── Moderation ────────────────────────────────────
@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,
)