429cf6f46c
- 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)
198 lines
6.0 KiB
Python
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,
|
|
)
|