From dd07baff7817c20e8f1e016a9b5a8b8d0f80502d Mon Sep 17 00:00:00 2001 From: coder-bot Date: Tue, 18 Aug 2026 19:07:17 +0000 Subject: [PATCH] WIP checkpoint: T1: Moderation data model + Alembic migration --- backend/alembic/env.py | 2 +- .../versions/0003_add_moderation_schema.py | 310 ++++++++++++++++++ backend/app/models/__init__.py | 27 +- backend/app/models/moderation.py | 186 +++++++++++ backend/app/models/user.py | 19 +- 5 files changed, 540 insertions(+), 4 deletions(-) create mode 100644 backend/alembic/versions/0003_add_moderation_schema.py 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/alembic/versions/0003_add_moderation_schema.py b/backend/alembic/versions/0003_add_moderation_schema.py new file mode 100644 index 0000000..ea14187 --- /dev/null +++ b/backend/alembic/versions/0003_add_moderation_schema.py @@ -0,0 +1,310 @@ +"""add moderation schema + +Revision ID: 0003_add_moderation_schema +Revises: 0002_add_date_of_birth_to_users +Create Date: 2025-01-01 00:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = "0003_add_moderation_schema" +down_revision: Union[str, None] = "0002_add_date_of_birth_to_users" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Create moderation tables and extend users table.""" + # ── ENUM types ────────────────────────────────────────────── + + target_type_enum = postgresql.ENUM( + "user", "world", "asset", "chat_message", + name="target_type", + ) + target_type_enum.create(op.get_bind(), checkfirst=True) + + reason_category_enum = postgresql.ENUM( + "harassment", "hate_speech", "sexual_content", "violence", + "spam", "self_harm", "other", + name="reason_category", + ) + reason_category_enum.create(op.get_bind(), checkfirst=True) + + report_status_enum = postgresql.ENUM( + "open", "in_review", "resolved", "dismissed", + name="report_status", + ) + report_status_enum.create(op.get_bind(), checkfirst=True) + + resolution_enum = postgresql.ENUM( + "upheld", "modified", "dismissed", + name="resolution", + ) + resolution_enum.create(op.get_bind(), checkfirst=True) + + moderation_action_type_enum = postgresql.ENUM( + "warn", "suspend", "ban", "restrict", "remove", "restore", + name="moderation_action_type", + ) + moderation_action_type_enum.create(op.get_bind(), checkfirst=True) + + appeal_status_enum = postgresql.ENUM( + "pending", "approved", "rejected", + name="appeal_status", + ) + appeal_status_enum.create(op.get_bind(), checkfirst=True) + + user_role_enum = postgresql.ENUM( + "user", "moderator", "admin", + name="user_role", + ) + user_role_enum.create(op.get_bind(), checkfirst=True) + + # ── users table extensions ────────────────────────────────── + + op.add_column( + "users", + sa.Column( + "role", + postgresql.ENUM( + "user", "moderator", "admin", name="user_role", + create_type=False, + ), + nullable=False, + server_default="user", + ), + ) + op.add_column( + "users", + sa.Column("banned_until", sa.DateTime(timezone=True), nullable=True), + ) + op.add_column( + "users", + sa.Column("suspended_until", sa.DateTime(timezone=True), nullable=True), + ) + + # ── reports table ─────────────────────────────────────────── + + op.create_table( + "reports", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("reporter_id", sa.Integer(), nullable=False), + sa.Column( + "target_type", + postgresql.ENUM( + "user", "world", "asset", "chat_message", + name="target_type", create_type=False, + ), + nullable=False, + ), + sa.Column("target_id", sa.String(length=64), nullable=False), + sa.Column( + "reason_category", + postgresql.ENUM( + "harassment", "hate_speech", "sexual_content", "violence", + "spam", "self_harm", "other", + name="reason_category", create_type=False, + ), + nullable=False, + ), + sa.Column("details", sa.Text(), nullable=True), + sa.Column( + "status", + postgresql.ENUM( + "open", "in_review", "resolved", "dismissed", + name="report_status", create_type=False, + ), + nullable=False, + server_default="open", + ), + sa.Column("resolved_by", sa.Integer(), nullable=True), + sa.Column( + "resolution", + postgresql.ENUM( + "upheld", "modified", "dismissed", + name="resolution", create_type=False, + ), + nullable=True, + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint( + ["reporter_id"], ["users.id"], ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["resolved_by"], ["users.id"], ondelete="SET NULL", + ), + ) + op.create_index(op.f("ix_reports_id"), "reports", ["id"], unique=False) + op.create_index( + op.f("ix_reports_reporter_id"), "reports", ["reporter_id"], unique=False, + ) + op.create_index( + op.f("ix_reports_status"), "reports", ["status"], unique=False, + ) + op.create_index( + op.f("ix_reports_target"), "reports", ["target_type", "target_id"], unique=False, + ) + + # ── moderation_actions table ──────────────────────────────── + + op.create_table( + "moderation_actions", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("moderator_id", sa.Integer(), nullable=False), + sa.Column("target_user_id", sa.Integer(), nullable=False), + sa.Column( + "target_type", + postgresql.ENUM( + "user", "world", "asset", "chat_message", + name="target_type", create_type=False, + ), + nullable=False, + ), + sa.Column("target_id", sa.String(length=64), nullable=False), + sa.Column( + "action", + postgresql.ENUM( + "warn", "suspend", "ban", "restrict", "remove", "restore", + name="moderation_action_type", create_type=False, + ), + nullable=False, + ), + sa.Column("reason", sa.Text(), nullable=True), + sa.Column("report_id", sa.Integer(), nullable=True), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["moderator_id"], ["users.id"], ondelete="SET NULL", + ), + sa.ForeignKeyConstraint( + ["target_user_id"], ["users.id"], ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["report_id"], ["reports.id"], ondelete="SET NULL", + ), + ) + op.create_index( + op.f("ix_moderation_actions_id"), + "moderation_actions", ["id"], unique=False, + ) + op.create_index( + op.f("ix_moderation_actions_moderator_id"), + "moderation_actions", ["moderator_id"], unique=False, + ) + op.create_index( + op.f("ix_moderation_actions_target_user_id"), + "moderation_actions", ["target_user_id"], unique=False, + ) + op.create_index( + op.f("ix_moderation_actions_target"), + "moderation_actions", ["target_type", "target_id"], unique=False, + ) + + # ── appeals table ─────────────────────────────────────────── + + op.create_table( + "appeals", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column( + "moderation_action_id", sa.Integer(), nullable=False, + ), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("reason", sa.Text(), nullable=False), + sa.Column( + "status", + postgresql.ENUM( + "pending", "approved", "rejected", + name="appeal_status", create_type=False, + ), + nullable=False, + server_default="pending", + ), + sa.Column("reviewed_by", sa.Integer(), nullable=True), + sa.Column("reviewed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["moderation_action_id"], + ["moderation_actions.id"], ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["user_id"], ["users.id"], ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["reviewed_by"], ["users.id"], ondelete="SET NULL", + ), + ) + op.create_index(op.f("ix_appeals_id"), "appeals", ["id"], unique=False) + op.create_index( + op.f("ix_appeals_moderation_action_id"), + "appeals", ["moderation_action_id"], unique=False, + ) + op.create_index( + op.f("ix_appeals_user_id"), + "appeals", ["user_id"], unique=False, + ) + op.create_index( + op.f("ix_appeals_status"), + "appeals", ["status"], unique=False, + ) + + +def downgrade() -> None: + """Drop moderation tables and revert users table.""" + op.drop_index(op.f("ix_appeals_status"), table_name="appeals") + op.drop_index(op.f("ix_appeals_user_id"), table_name="appeals") + op.drop_index(op.f("ix_appeals_moderation_action_id"), table_name="appeals") + op.drop_index(op.f("ix_appeals_id"), table_name="appeals") + op.drop_table("appeals") + + op.drop_index(op.f("ix_moderation_actions_target"), table_name="moderation_actions") + op.drop_index(op.f("ix_moderation_actions_target_user_id"), table_name="moderation_actions") + op.drop_index(op.f("ix_moderation_actions_moderator_id"), table_name="moderation_actions") + op.drop_index(op.f("ix_moderation_actions_id"), table_name="moderation_actions") + op.drop_table("moderation_actions") + + op.drop_index(op.f("ix_reports_target"), table_name="reports") + op.drop_index(op.f("ix_reports_status"), table_name="reports") + op.drop_index(op.f("ix_reports_reporter_id"), table_name="reports") + op.drop_index(op.f("ix_reports_id"), table_name="reports") + op.drop_table("reports") + + op.drop_column("users", "suspended_until") + op.drop_column("users", "banned_until") + op.drop_column("users", "role") + + # Drop ENUM types + postgresql.ENUM(name="appeal_status").drop(op.get_bind(), checkfirst=True) + postgresql.ENUM(name="moderation_action_type").drop(op.get_bind(), checkfirst=True) + postgresql.ENUM(name="resolution").drop(op.get_bind(), checkfirst=True) + postgresql.ENUM(name="report_status").drop(op.get_bind(), checkfirst=True) + postgresql.ENUM(name="reason_category").drop(op.get_bind(), checkfirst=True) + postgresql.ENUM(name="target_type").drop(op.get_bind(), checkfirst=True) + postgresql.ENUM(name="user_role").drop(op.get_bind(), checkfirst=True) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 96e2dc3..755e958 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -2,5 +2,30 @@ 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, + TargetType, + ReasonCategory, + ReportStatus, + Resolution, + ModerationActionType, + AppealStatus, +) -__all__ = ["User", "Session", "World", "Asset"] +__all__ = [ + "User", + "Session", + "World", + "Asset", + "Report", + "ModerationAction", + "Appeal", + "TargetType", + "ReasonCategory", + "ReportStatus", + "Resolution", + "ModerationActionType", + "AppealStatus", +] diff --git a/backend/app/models/moderation.py b/backend/app/models/moderation.py new file mode 100644 index 0000000..2acf5a8 --- /dev/null +++ b/backend/app/models/moderation.py @@ -0,0 +1,186 @@ +"""Moderation data models for Trust & Safety.""" + +import enum +from datetime import datetime +from typing import Optional + +from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey +from sqlalchemy.dialects.postgresql import ENUM +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func + +from app.database import Base + + +class TargetType(str, enum.Enum): + """Type of entity being reported or moderated.""" + USER = "user" + WORLD = "world" + ASSET = "asset" + CHAT_MESSAGE = "chat_message" + + +class ReasonCategory(str, enum.Enum): + """Category of reason for a report.""" + HARASSMENT = "harassment" + HATE_SPEECH = "hate_speech" + SEXUAL_CONTENT = "sexual_content" + VIOLENCE = "violence" + SPAM = "spam" + SELF_HARM = "self_harm" + OTHER = "other" + + +class ReportStatus(str, enum.Enum): + """Status of a report.""" + OPEN = "open" + IN_REVIEW = "in_review" + RESOLVED = "resolved" + DISMISSED = "dismissed" + + +class Resolution(str, enum.Enum): + """Resolution outcome of a report.""" + UPHELD = "upheld" + MODIFIED = "modified" + DISMISSED = "dismissed" + + +class ModerationActionType(str, enum.Enum): + """Type of moderation action taken.""" + WARN = "warn" + SUSPEND = "suspend" + BAN = "ban" + RESTRICT = "restrict" + REMOVE = "remove" + RESTORE = "restore" + + +class AppealStatus(str, enum.Enum): + """Status of an appeal.""" + PENDING = "pending" + APPROVED = "approved" + REJECTED = "rejected" + + +class Report(Base): + """A report filed by a user against a target entity.""" + __tablename__ = "reports" + + id = Column(Integer, primary_key=True, index=True) + reporter_id = Column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) + target_type = Column( + ENUM(TargetType, name="target_type", create_type=False), + nullable=False, + ) + target_id = Column(String(64), nullable=False) + reason_category = Column( + ENUM(ReasonCategory, name="reason_category", create_type=False), + nullable=False, + ) + details = Column(Text, nullable=True) + status = Column( + ENUM(ReportStatus, 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(Resolution, name="resolution", 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]) + 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 by a moderator against a user or entity.""" + __tablename__ = "moderation_actions" + + id = Column(Integer, primary_key=True, index=True) + moderator_id = Column( + Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=False + ) + target_user_id = Column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) + target_type = Column( + ENUM(TargetType, name="target_type", create_type=False), + nullable=False, + ) + target_id = Column(String(64), nullable=False) + action = Column( + ENUM(ModerationActionType, 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) + revoked_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="moderation_actions") + appeals = relationship( + "Appeal", back_populates="moderation_action", cascade="all, delete-orphan" + ) + + +class Appeal(Base): + """An appeal filed by a user against a moderation action.""" + __tablename__ = "appeals" + + id = Column(Integer, primary_key=True, index=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, 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") + user = relationship("User", foreign_keys=[user_id]) + reviewer = relationship("User", foreign_keys=[reviewed_by]) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 8ce7f88..3f5e460 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -1,13 +1,21 @@ from datetime import date from typing import Optional -from sqlalchemy import Column, Integer, String, Boolean, DateTime, Date +from sqlalchemy import Column, Integer, String, Boolean, DateTime, Date, func +from sqlalchemy.dialects.postgresql import ENUM from sqlalchemy.orm import relationship -from sqlalchemy.sql import func +import enum from app.database import Base +class UserRole(str, enum.Enum): + """User role for moderation and access control.""" + USER = "user" + MODERATOR = "moderator" + ADMIN = "admin" + + class User(Base): __tablename__ = "users" @@ -17,6 +25,13 @@ class User(Base): hashed_password = Column(String(255), nullable=False) date_of_birth = Column(Date, nullable=True) is_active = Column(Boolean, default=True) + role = Column( + ENUM(UserRole, name="user_role", create_type=False), + default=UserRole.USER, + nullable=False, + ) + banned_until = Column(DateTime(timezone=True), nullable=True) + suspended_until = Column(DateTime(timezone=True), nullable=True) created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now())