Merge branch 'agent/8aaf7c48d376-reports' into main
checkpoint / checkpoint (push) Successful in 4m49s
checkpoint / checkpoint (push) Successful in 4m49s
This commit is contained in:
+3
-1
@@ -6,7 +6,7 @@ from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.config import settings
|
||||
from app.routers import auth, users, worlds, assets, avatar, world_content, internal, presence, groups, social_friends, profiles, chat_dm
|
||||
from app.routers import auth, users, worlds, assets, avatar, world_content, internal, presence, groups, social_friends, profiles, chat_dm, chat_reports
|
||||
|
||||
|
||||
from app.services.game_servers import supervisor
|
||||
@@ -49,6 +49,8 @@ app.include_router(groups.router, prefix="/api/groups", tags=["groups"])
|
||||
app.include_router(social_friends.router, prefix="/api/friends", tags=["friends"])
|
||||
app.include_router(profiles.router, prefix="/api/profile", tags=["profile"])
|
||||
app.include_router(chat_dm.router, prefix="/api/chat/dm", tags=["chat-dm"])
|
||||
app.include_router(chat_reports.chat_router, prefix="/api/chat", tags=["chat-reports"])
|
||||
app.include_router(chat_reports.moderation_router, prefix="/api/moderation", tags=["moderation"])
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -0,0 +1,201 @@
|
||||
"""Integration tests for message reporting and moderation queue:
|
||||
- POST /api/chat/messages/{message_id}/report
|
||||
- GET /api/moderation/reports
|
||||
- POST /api/moderation/reports/{id}/resolve
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
|
||||
def _register(client: TestClient, username: str) -> dict:
|
||||
resp = client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"username": username,
|
||||
"email": f"{username}@example.com",
|
||||
"password": "secret123",
|
||||
"date_of_birth": "1990-01-01",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
token = resp.json()["access_token"]
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
me = client.get("/api/users/me", headers=headers)
|
||||
assert me.status_code == 200, me.text
|
||||
return {"headers": headers, "id": me.json()["id"], "username": username}
|
||||
|
||||
|
||||
def _send_dm(client: TestClient, sender: dict, recipient: dict, text: str = "hello") -> int:
|
||||
resp = client.post(
|
||||
f"/api/chat/dm/{recipient['id']}/messages",
|
||||
headers=sender["headers"],
|
||||
json={"body": text},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
return resp.json()["id"]
|
||||
|
||||
|
||||
class TestReportMessage:
|
||||
def test_report_message_success(self):
|
||||
client = TestClient(app)
|
||||
alice = _register(client, "rep_alice1")
|
||||
bob = _register(client, "rep_bob1")
|
||||
|
||||
msg_id = _send_dm(client, alice, bob, "spam message")
|
||||
|
||||
resp = client.post(
|
||||
f"/api/chat/messages/{msg_id}/report",
|
||||
headers=bob["headers"],
|
||||
json={"reason": "Contains spam", "category": "spam"},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
data = resp.json()
|
||||
assert data["message_id"] == msg_id
|
||||
assert data["reporter_id"] == bob["id"]
|
||||
assert data["status"] == "open"
|
||||
assert "[spam] Contains spam" in data["reason"]
|
||||
|
||||
def test_duplicate_report_rejected(self):
|
||||
client = TestClient(app)
|
||||
alice = _register(client, "rep_alice2")
|
||||
bob = _register(client, "rep_bob2")
|
||||
|
||||
msg_id = _send_dm(client, alice, bob, "bad message")
|
||||
|
||||
resp1 = client.post(
|
||||
f"/api/chat/messages/{msg_id}/report",
|
||||
headers=bob["headers"],
|
||||
json={"reason": "offensive"},
|
||||
)
|
||||
assert resp1.status_code == 201
|
||||
|
||||
resp2 = client.post(
|
||||
f"/api/chat/messages/{msg_id}/report",
|
||||
headers=bob["headers"],
|
||||
json={"reason": "offensive again"},
|
||||
)
|
||||
assert resp2.status_code == 409
|
||||
|
||||
def test_report_nonexistent_message_404(self):
|
||||
client = TestClient(app)
|
||||
bob = _register(client, "rep_bob3")
|
||||
|
||||
resp = client.post(
|
||||
"/api/chat/messages/999999/report",
|
||||
headers=bob["headers"],
|
||||
json={"reason": "ghost"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_report_requires_auth(self):
|
||||
client = TestClient(app)
|
||||
resp = client.post("/api/chat/messages/1/report", json={"reason": "no auth"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
class TestModerationQueue:
|
||||
def test_non_moderator_cannot_access_queue(self):
|
||||
client = TestClient(app)
|
||||
regular_user = _register(client, "rep_regular1")
|
||||
|
||||
resp = client.get("/api/moderation/reports", headers=regular_user["headers"])
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_moderator_can_list_reports(self):
|
||||
client = TestClient(app)
|
||||
alice = _register(client, "rep_alice4")
|
||||
bob = _register(client, "rep_bob4")
|
||||
admin = _register(client, "admin_list")
|
||||
|
||||
msg_id = _send_dm(client, alice, bob, "something to report")
|
||||
client.post(
|
||||
f"/api/chat/messages/{msg_id}/report",
|
||||
headers=bob["headers"],
|
||||
json={"reason": "needs review"},
|
||||
)
|
||||
|
||||
resp = client.get("/api/moderation/reports", headers=admin["headers"])
|
||||
assert resp.status_code == 200, resp.text
|
||||
reports = resp.json()
|
||||
assert len(reports) >= 1
|
||||
assert any(r["message_id"] == msg_id for r in reports)
|
||||
|
||||
def test_moderator_resolve_dismiss(self):
|
||||
client = TestClient(app)
|
||||
alice = _register(client, "rep_alice5")
|
||||
bob = _register(client, "rep_bob5")
|
||||
admin = _register(client, "admin_dismiss")
|
||||
|
||||
msg_id = _send_dm(client, alice, bob, "benign message")
|
||||
rep_resp = client.post(
|
||||
f"/api/chat/messages/{msg_id}/report",
|
||||
headers=bob["headers"],
|
||||
json={"reason": "false alarm"},
|
||||
)
|
||||
report_id = rep_resp.json()["id"]
|
||||
|
||||
resolve_resp = client.post(
|
||||
f"/api/moderation/reports/{report_id}/resolve",
|
||||
headers=admin["headers"],
|
||||
json={"action": "dismiss", "notes": "No violation found"},
|
||||
)
|
||||
assert resolve_resp.status_code == 200, resolve_resp.text
|
||||
data = resolve_resp.json()
|
||||
assert data["report_id"] == report_id
|
||||
assert data["status"] == "dismissed"
|
||||
assert data["action_taken"] == "dismiss"
|
||||
assert data["moderator_id"] == admin["id"]
|
||||
|
||||
def test_moderator_resolve_remove(self):
|
||||
client = TestClient(app)
|
||||
alice = _register(client, "rep_alice6")
|
||||
bob = _register(client, "rep_bob6")
|
||||
admin = _register(client, "admin_remove")
|
||||
|
||||
msg_id = _send_dm(client, alice, bob, "offensive content")
|
||||
rep_resp = client.post(
|
||||
f"/api/chat/messages/{msg_id}/report",
|
||||
headers=bob["headers"],
|
||||
json={"reason": "hate speech"},
|
||||
)
|
||||
report_id = rep_resp.json()["id"]
|
||||
|
||||
resolve_resp = client.post(
|
||||
f"/api/moderation/reports/{report_id}/resolve",
|
||||
headers=admin["headers"],
|
||||
json={"action": "remove", "notes": "Content removed"},
|
||||
)
|
||||
assert resolve_resp.status_code == 200
|
||||
assert resolve_resp.json()["status"] == "resolved"
|
||||
|
||||
# The message is removed from conversation history
|
||||
hist = client.get(f"/api/chat/dm/{bob['id']}/messages", headers=alice["headers"])
|
||||
assert all(m["id"] != msg_id for m in hist.json()["messages"])
|
||||
|
||||
def test_moderator_resolve_ban(self):
|
||||
client = TestClient(app)
|
||||
bad_user = _register(client, "bad_actor")
|
||||
victim = _register(client, "rep_victim")
|
||||
admin_auth = _register(client, "admin_ban")
|
||||
|
||||
msg_id = _send_dm(client, bad_user, victim, "terrible violation")
|
||||
rep_resp = client.post(
|
||||
f"/api/chat/messages/{msg_id}/report",
|
||||
headers=victim["headers"],
|
||||
json={"reason": "malicious user"},
|
||||
)
|
||||
report_id = rep_resp.json()["id"]
|
||||
|
||||
resolve_resp = client.post(
|
||||
f"/api/moderation/reports/{report_id}/resolve",
|
||||
headers=admin_auth["headers"],
|
||||
json={"action": "ban", "notes": "Banned malicious user"},
|
||||
)
|
||||
assert resolve_resp.status_code == 200
|
||||
|
||||
# Attempting to use bad_user's token fails with 403 inactive account
|
||||
bad_me = client.get("/api/users/me", headers=bad_user["headers"])
|
||||
assert bad_me.status_code == 403
|
||||
Reference in New Issue
Block a user