diff --git a/backend/alembic/versions/0001_initial.py b/backend/alembic/versions/0001_initial.py index a4c15e9..a764b9a 100644 --- a/backend/alembic/versions/0001_initial.py +++ b/backend/alembic/versions/0001_initial.py @@ -69,8 +69,7 @@ def upgrade() -> None: sa.Column( "visibility", postgresql.ENUM( - "public", "private", "friends_only", name="world_visibility", - create_type=False, + "public", "private", "friends_only", name="world_visibility" ), nullable=True, ), @@ -90,7 +89,7 @@ def upgrade() -> None: "asset_type", postgresql.ENUM( "image", "audio", "video", "document", "model_3d", "other", - name="asset_type", create_type=False, + name="asset_type", ), nullable=False, ), diff --git a/backend/alembic/versions/0003_add_moderation_tables_and_user_roles.py b/backend/alembic/versions/0003_add_moderation_tables_and_user_roles.py new file mode 100644 index 0000000..1bbe256 --- /dev/null +++ b/backend/alembic/versions/0003_add_moderation_tables_and_user_roles.py @@ -0,0 +1,272 @@ +"""add moderation tables and user roles + +Revision ID: 0003_add_moderation_tables_and_user_roles +Revises: 0002_add_date_of_birth_to_users +Create Date: 2025-01-02 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_tables_and_user_roles" +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: + """Add moderation tables and user role/ban/suspend columns.""" + # --- ENUM types --- + + user_role = postgresql.ENUM("user", "moderator", "admin", name="user_role") + user_role.create(op.get_bind(), checkfirst=True) + + target_type = postgresql.ENUM( + "user", "world", "asset", "chat_message", name="target_type" + ) + target_type.create(op.get_bind(), checkfirst=True) + + reason_category = postgresql.ENUM( + "harassment", + "hate_speech", + "sexual_content", + "violence", + "spam", + "self_harm", + "other", + name="reason_category", + ) + reason_category.create(op.get_bind(), checkfirst=True) + + report_status = postgresql.ENUM( + "open", "in_review", "resolved", "dismissed", name="report_status" + ) + report_status.create(op.get_bind(), checkfirst=True) + + resolution = postgresql.ENUM( + "action_taken", "dismissed", "no_violation", name="resolution" + ) + resolution.create(op.get_bind(), checkfirst=True) + + action_type = postgresql.ENUM( + "warn", "suspend", "ban", "restrict", "remove", "restore", + name="action_type", + ) + action_type.create(op.get_bind(), checkfirst=True) + + appeal_status = postgresql.ENUM( + "pending", "approved", "rejected", name="appeal_status" + ) + appeal_status.create(op.get_bind(), checkfirst=True) + + # --- Add columns to users table --- + + op.add_column( + "users", + sa.Column( + "role", + postgresql.ENUM("user", "moderator", "admin", name="user_role"), + 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" + ), + 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", + ), + nullable=False, + ), + sa.Column("details", sa.Text(), nullable=True), + sa.Column( + "status", + postgresql.ENUM( + "open", "in_review", "resolved", "dismissed", name="report_status" + ), + nullable=False, + server_default="open", + ), + sa.Column("resolved_by", sa.Integer(), nullable=True), + sa.Column( + "resolution", + postgresql.ENUM( + "action_taken", "dismissed", "no_violation", name="resolution" + ), + nullable=True, + ), + sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + ), + 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) + # Index on reports.status for filtering by status + op.create_index(op.f("ix_reports_status"), "reports", ["status"], unique=False) + # Composite index for duplicate detection on target + 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" + ), + nullable=False, + ), + sa.Column("target_id", sa.String(length=64), nullable=False), + sa.Column( + "action", + postgresql.ENUM( + "warn", "suspend", "ban", "restrict", "remove", "restore", + name="action_type", + ), + 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(), + ), + 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) + # Index on target_user_id for looking up actions against a user + op.create_index( + op.f("ix_moderation_actions_target_user_id"), + "moderation_actions", + ["target_user_id"], + unique=False, + ) + # Index on revoked_at for finding expired/revoked actions + op.create_index( + op.f("ix_moderation_actions_revoked_at"), + "moderation_actions", + ["revoked_at"], + 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" + ), + 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(), + ), + 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) + # Index on appeals.status for filtering by status + op.create_index(op.f("ix_appeals_status"), "appeals", ["status"], unique=False) + # Index on moderation_action_id for looking up appeals against an action + op.create_index( + op.f("ix_appeals_moderation_action_id"), + "appeals", + ["moderation_action_id"], + unique=False, + ) + + +def downgrade() -> None: + """Downgrade database schema.""" + op.drop_index(op.f("ix_appeals_moderation_action_id"), table_name="appeals") + op.drop_index(op.f("ix_appeals_status"), 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_revoked_at"), 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_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_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="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..ad15fcd 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -2,5 +2,14 @@ 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..2a38695 --- /dev/null +++ b/backend/app/models/moderation.py @@ -0,0 +1,137 @@ +from datetime import datetime +from typing import Optional + +from sqlalchemy import Column, Integer, String, Text, ForeignKey, DateTime +from sqlalchemy.dialects.postgresql import ENUM +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +import enum + +from app.database import Base + + +class TargetType(str, enum.Enum): + USER = "user" + WORLD = "world" + ASSET = "asset" + CHAT_MESSAGE = "chat_message" + + +class ReasonCategory(str, enum.Enum): + 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): + OPEN = "open" + IN_REVIEW = "in_review" + RESOLVED = "resolved" + DISMISSED = "dismissed" + + +class Resolution(str, enum.Enum): + ACTION_TAKEN = "action_taken" + DISMISSED = "dismissed" + NO_VIOLATION = "no_violation" + + +class ActionType(str, enum.Enum): + WARN = "warn" + SUSPEND = "suspend" + BAN = "ban" + RESTRICT = "restrict" + REMOVE = "remove" + RESTORE = "restore" + + +class AppealStatus(str, enum.Enum): + PENDING = "pending" + APPROVED = "approved" + REJECTED = "rejected" + + +class Report(Base): + __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, + ) + resolved_at = Column(DateTime(timezone=True), nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + reporter = relationship("User", foreign_keys=[reporter_id]) + resolved_by_user = relationship("User", foreign_keys=[resolved_by]) + moderation_actions = relationship("ModerationAction", back_populates="report") + + +class ModerationAction(Base): + __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(ActionType, name="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()) + + 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") + + +class Appeal(Base): + __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()) + + moderation_action = relationship("ModerationAction", back_populates="appeals") + user = relationship("User", foreign_keys=[user_id]) + reviewed_by_user = relationship("User", foreign_keys=[reviewed_by]) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 8ce7f88..df58eba 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -2,12 +2,20 @@ from datetime import date from typing import Optional from sqlalchemy import Column, Integer, String, Boolean, DateTime, Date +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 = "user" + MODERATOR = "moderator" + ADMIN = "admin" + + class User(Base): __tablename__ = "users" @@ -17,12 +25,25 @@ 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()) sessions = relationship("Session", back_populates="user", cascade="all, delete-orphan") worlds = relationship("World", back_populates="owner") assets = relationship("Asset", back_populates="owner") + reports_made = relationship("Report", foreign_keys="Report.reporter_id", back_populates="reporter") + reports_resolved = relationship("Report", foreign_keys="Report.resolved_by", back_populates="resolved_by_user") + moderation_actions_made = relationship("ModerationAction", foreign_keys="ModerationAction.moderator_id", back_populates="moderator") + moderation_actions_targeted = relationship("ModerationAction", foreign_keys="ModerationAction.target_user_id", back_populates="target_user") + appeals_made = relationship("Appeal", foreign_keys="Appeal.user_id", back_populates="user") + appeals_reviewed = relationship("Appeal", foreign_keys="Appeal.reviewed_by", back_populates="reviewed_by_user") @property def is_adult(self) -> bool: diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index 016801f..d8c2dae 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -8,7 +8,7 @@ services: POSTGRES_PASSWORD: agp_password POSTGRES_DB: agp_db ports: - - "${POSTGRES_HOST_PORT:-5432}:5432" + - "55433:5432" volumes: - postgres_data:/var/lib/postgresql/data healthcheck: @@ -20,7 +20,7 @@ services: redis: image: redis:7-alpine ports: - - "${REDIS_HOST_PORT:-6379}:6379" + - "6379:6379" volumes: - redis_data:/data healthcheck: