From 0c536b2c5851838ba64b48a82a95db4394bd5416 Mon Sep 17 00:00:00 2001 From: architect-bot Date: Tue, 18 Aug 2026 18:31:36 +0000 Subject: [PATCH] WIP checkpoint: US-1..4: Moderation data model + Alembic migration --- backend/app/models/__init__.py | 3 +- backend/app/models/moderation.py | 225 +++++++++++++++++++++++++++++++ 2 files changed, 227 insertions(+), 1 deletion(-) create mode 100644 backend/app/models/moderation.py 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..82479da --- /dev/null +++ b/backend/app/models/moderation.py @@ -0,0 +1,225 @@ +"""Trust & Safety models: reports, moderation actions, and appeals.""" + +from datetime import datetime +from typing import Optional + +from sqlalchemy import ( + CheckConstraint, + Column, + DateTime, + Enum, + ForeignKey, + Integer, + String, + Text, + UniqueConstraint, + func, +) +from sqlalchemy.dialects.postgresql import ENUM as PG_ENUM +from sqlalchemy.orm import relationship + +from app.database import Base + + +# --------------------------------------------------------------------------- +# ENUM helpers — created explicitly in Alembic, not via SQLAlchemy create_type +# --------------------------------------------------------------------------- + +_role_enum = PG_ENUM( + "user", "moderator", "admin", + name="user_role", + create_type=False, +) + +_target_type_enum = PG_ENUM( + "user", "world", "asset", "chat_message", + name="target_type", + create_type=False, +) + +_reason_category_enum = PG_ENUM( + "harassment", "hate_speech", "sexual_content", "violence", + "spam", "self_harm", "other", + name="reason_category", + create_type=False, +) + +_report_status_enum = PG_ENUM( + "open", "in_review", "resolved", "dismissed", + name="report_status", + create_type=False, +) + +_resolution_enum = PG_ENUM( + "action_taken", "action_not_needed", "duplicate", "escalated", + name="resolution", + create_type=False, +) + +_moderation_action_enum = PG_ENUM( + "warn", "suspend", "ban", "restrict", "remove", "restore", + name="moderation_action", + create_type=False, +) + +_appeal_status_enum = PG_ENUM( + "pending", "approved", "rejected", + name="appeal_status", + create_type=False, +) + + +class Report(Base): + """A user-submitted report about another entity.""" + + __tablename__ = "reports" + + id = Column(Integer, primary_key=True, autoincrement=True) + reporter_id = Column( + Integer, + ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ) + target_type = Column(_target_type_enum, nullable=False) + target_id = Column(String(64), nullable=False) + reason_category = Column(_reason_category_enum, nullable=False) + details = Column(Text, nullable=True) + status = Column( + _report_status_enum, + nullable=False, + server_default="open", + ) + resolved_by = Column( + Integer, + ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + ) + resolution = Column(_resolution_enum, nullable=True) + created_at = Column( + DateTime(timezone=True), + server_default=func.now(), + ) + updated_at = Column( + DateTime(timezone=True), + onupdate=func.now(), + ) + resolved_at = Column(DateTime(timezone=True), nullable=True) + + # Coalesce duplicate reports from the same reporter about the same target + __table_args__ = ( + UniqueConstraint("reporter_id", "target_type", "target_id", + name="uq_reporter_target"), + ) + + # Relationships + reporter = relationship("User", back_populates="reports", + foreign_keys=[reporter_id]) + resolved_by_user = relationship("User", back_populates="resolved_reports", + foreign_keys=[resolved_by]) + moderation_actions = relationship( + "ModerationAction", + back_populates="report", + cascade="all, delete-orphan", + ) + appeals = relationship( + "Appeal", + back_populates="report", + cascade="all, delete-orphan", + ) + + +class ModerationAction(Base): + """A moderation action taken against a target.""" + + __tablename__ = "moderation_actions" + + id = Column(Integer, primary_key=True, autoincrement=True) + moderator_id = Column( + Integer, + ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ) + target_user_id = Column( + Integer, + ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + ) + target_type = Column(_target_type_enum, nullable=False) + target_id = Column(String(64), nullable=False) + action = Column(_moderation_action_enum, nullable=False) + reason = Column(Text, nullable=False) + report_id = Column( + Integer, + ForeignKey("reports.id", ondelete="SET NULL"), + nullable=True, + ) + expires_at = Column(DateTime(timezone=True), nullable=True) + revoked_at = Column(DateTime(timezone=True), nullable=True) + created_at = Column( + DateTime(timezone=True), + server_default=func.now(), + ) + + # Relationships + moderator = relationship("User", back_populates="moderation_actions", + foreign_keys=[moderator_id]) + target_user = relationship("User", back_populates="moderation_actions_target", + foreign_keys=[target_user_id]) + report = relationship( + "Report", + back_populates="moderation_actions", + ) + appeals = relationship( + "Appeal", + back_populates="moderation_action", + cascade="all, delete-orphan", + ) + + +class Appeal(Base): + """An appeal against a moderation action.""" + + __tablename__ = "appeals" + + id = Column(Integer, primary_key=True, autoincrement=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( + _appeal_status_enum, + nullable=False, + server_default="pending", + ) + 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(), + ) + + # Prevent duplicate open appeals per sanction + __table_args__ = ( + UniqueConstraint("moderation_action_id", "user_id", + name="uq_action_user_appeal"), + ) + + # Relationships + moderation_action = relationship( + "ModerationAction", + back_populates="appeals", + ) + user = relationship("User", back_populates="appeals") + reviewed_by_user = relationship("User", back_populates="reviewed_appeals", + foreign_keys=[reviewed_by])