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)
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
"""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,
|
||||
)
|
||||
Reference in New Issue
Block a user