From a52b3ed26d8fdf414ab54fa103f2cfc90880d6c6 Mon Sep 17 00:00:00 2001 From: coder-bot Date: Tue, 18 Aug 2026 18:26:05 +0000 Subject: [PATCH] WIP checkpoint: Trust & Safety: Moderation data model + Alembic migration --- backend/alembic/env.py | 2 +- backend/app/models/__init__.py | 3 +- backend/app/models/moderation.py | 286 +++++++++++++++++++++++++++++++ 3 files changed, 289 insertions(+), 2 deletions(-) create mode 100644 backend/app/models/moderation.py diff --git a/backend/alembic/env.py b/backend/alembic/env.py index d9411ca..c27ed23 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import create_async_engine from app.config import settings from app.database import Base -from app.models import user, session, world, asset # noqa: F401 +from app.models import user, session, world, asset, moderation # noqa: F401 # this is the Alembic Config object, which provides # access to the values within the .ini file in use. diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 96e2dc3..10b9125 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -2,5 +2,6 @@ from app.models.user import User from app.models.session import Session from app.models.world import World from app.models.asset import Asset +from app.models.moderation import Report, ModerationAction, Appeal -__all__ = ["User", "Session", "World", "Asset"] +__all__ = ["User", "Session", "World", "Asset", "Report", "ModerationAction", "Appeal"] diff --git a/backend/app/models/moderation.py b/backend/app/models/moderation.py new file mode 100644 index 0000000..3ad8fa4 --- /dev/null +++ b/backend/app/models/moderation.py @@ -0,0 +1,286 @@ +"""Moderation-related models: Report, ModerationAction, Appeal.""" + +from datetime import datetime +from typing import Optional + +from sqlalchemy import ( + Column, + Enum, + ForeignKey, + Integer, + String, + Text, + DateTime, + Index, +) +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func + +from app.database import Base + + +# --- Enums --- + +class TargetType(str): + USER = "user" + WORLD = "world" + ASSET = "asset" + CHAT_MESSAGE = "chat_message" + + +class ReasonCategory(str): + HARASSMENT = "harassment" + HATE_SPEECH = "hate_speech" + SEXUAL_CONTENT = "sexual_content" + VIOLENCE = "violence" + SPAM = "spam" + SELF_HARM = "self_harm" + OTHER = "other" + + +class ReportStatus(str): + OPEN = "open" + IN_REVIEW = "in_review" + RESOLVED = "resolved" + DISMISSED = "dismissed" + + +class ResolutionAction(str): + WARN = "warn" + SUSPEND = "suspend" + BAN = "ban" + RESTRICT = "restrict" + REMOVE = "remove" + RESTORE = "restore" + + +class ModerationActionType(str): + WARN = "warn" + SUSPEND = "suspend" + BAN = "ban" + RESTRICT = "restrict" + REMOVE = "remove" + RESTORE = "restore" + + +class AppealStatus(str): + PENDING = "pending" + APPROVED = "approved" + REJECTED = "rejected" + + +# --- Models --- + +class Report(Base): + """A moderation report filed by a user or system.""" + + __tablename__ = "reports" + + id = Column(Integer, primary_key=True) + reporter_id = Column( + Integer, + ForeignKey("users.id", ondelete="SET NULL"), + nullable=False, + ) + target_type = Column( + Enum( + TargetType.USER, + TargetType.WORLD, + TargetType.ASSET, + TargetType.CHAT_MESSAGE, + name="target_type", + create_type=False, + ), + nullable=False, + ) + target_id = Column(String(64), nullable=False) + reason_category = Column( + Enum( + ReasonCategory.HARASSMENT, + ReasonCategory.HATE_SPEECH, + ReasonCategory.SEXUAL_CONTENT, + ReasonCategory.VIOLENCE, + ReasonCategory.SPAM, + ReasonCategory.SELF_HARM, + ReasonCategory.OTHER, + name="reason_category", + create_type=False, + ), + nullable=False, + ) + details = Column(Text, nullable=True) + status = Column( + Enum( + ReportStatus.OPEN, + ReportStatus.IN_REVIEW, + ReportStatus.RESOLVED, + ReportStatus.DISMISSED, + name="report_status", + create_type=False, + ), + default=ReportStatus.OPEN, + nullable=False, + ) + resolved_by = Column( + Integer, + ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + ) + resolution = Column( + Enum( + ResolutionAction.WARN, + ResolutionAction.SUSPEND, + ResolutionAction.BAN, + ResolutionAction.RESTRICT, + ResolutionAction.REMOVE, + ResolutionAction.RESTORE, + name="resolution_action", + create_type=False, + ), + nullable=True, + ) + created_at = Column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + updated_at = Column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + resolved_at = Column(DateTime(timezone=True), nullable=True) + + # Relationships + reporter = relationship("User", foreign_keys=[reporter_id]) + resolver = relationship("User", foreign_keys=[resolved_by]) + actions = relationship( + "ModerationAction", + back_populates="report", + cascade="all, delete-orphan", + ) + + __table_args__ = ( + Index("ix_reports_target", "target_type", "target_id"), + Index("ix_reports_status", "status"), + Index("ix_reports_created_at", "created_at"), + ) + + +class ModerationAction(Base): + """A moderation action taken by a moderator/admin.""" + + __tablename__ = "moderation_actions" + + id = Column(Integer, primary_key=True) + moderator_id = Column( + Integer, + ForeignKey("users.id", ondelete="SET NULL"), + nullable=False, + ) + target_user_id = Column( + Integer, + ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + ) + target_type = Column( + Enum( + TargetType.USER, + TargetType.WORLD, + TargetType.ASSET, + TargetType.CHAT_MESSAGE, + name="mod_target_type", + create_type=False, + ), + nullable=False, + ) + target_id = Column(String(64), nullable=False) + action = Column( + Enum( + ModerationActionType.WARN, + ModerationActionType.SUSPEND, + ModerationActionType.BAN, + ModerationActionType.RESTRICT, + ModerationActionType.REMOVE, + ModerationActionType.RESTORE, + name="moderation_action_type", + create_type=False, + ), + nullable=False, + ) + reason = Column(Text, nullable=True) + report_id = Column( + Integer, + ForeignKey("reports.id", ondelete="SET NULL"), + nullable=True, + ) + expires_at = Column(DateTime(timezone=True), nullable=True) + created_at = Column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + + # Relationships + moderator = relationship("User", foreign_keys=[moderator_id]) + target_user = relationship("User", foreign_keys=[target_user_id]) + report = relationship("Report", back_populates="actions") + appeals = relationship("Appeal", back_populates="moderation_action") + + __table_args__ = ( + Index("ix_moderation_actions_target", "target_type", "target_id"), + Index("ix_moderation_actions_created_at", "created_at"), + ) + + +class Appeal(Base): + """An appeal filed by a user against a moderation action.""" + + __tablename__ = "appeals" + + id = Column(Integer, primary_key=True) + moderation_action_id = Column( + Integer, + ForeignKey("moderation_actions.id", ondelete="CASCADE"), + nullable=False, + ) + user_id = Column( + Integer, + ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ) + reason = Column(Text, nullable=False) + status = Column( + Enum( + AppealStatus.PENDING, + AppealStatus.APPROVED, + AppealStatus.REJECTED, + name="appeal_status", + create_type=False, + ), + default=AppealStatus.PENDING, + nullable=False, + ) + reviewed_by = Column( + Integer, + ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + ) + reviewed_at = Column(DateTime(timezone=True), nullable=True) + created_at = Column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + + # Relationships + moderation_action = relationship( + "ModerationAction", back_populates="appeals" + ) + reviewer = relationship("User", foreign_keys=[reviewed_by]) + + __table_args__ = ( + Index("ix_appeals_status", "status"), + Index("ix_appeals_created_at", "created_at"), + )