Compare commits

..

1 Commits

Author SHA1 Message Date
coder-bot dd07baff78 WIP checkpoint: T1: Moderation data model + Alembic migration 2026-08-18 19:07:17 +00:00
6 changed files with 405 additions and 400 deletions
+2 -2
View File
@@ -69,7 +69,7 @@ def upgrade() -> None:
sa.Column( sa.Column(
"visibility", "visibility",
postgresql.ENUM( postgresql.ENUM(
"public", "private", "friends_only", name="world_visibility", create_type=False "public", "private", "friends_only", name="world_visibility"
), ),
nullable=True, nullable=True,
), ),
@@ -89,7 +89,7 @@ def upgrade() -> None:
"asset_type", "asset_type",
postgresql.ENUM( postgresql.ENUM(
"image", "audio", "video", "document", "model_3d", "other", "image", "audio", "video", "document", "model_3d", "other",
name="asset_type", create_type=False, name="asset_type",
), ),
nullable=False, nullable=False,
), ),
@@ -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)
-211
View File
@@ -1,211 +0,0 @@
"""add moderation tables and extend users table
Revision ID: 0003_moderation
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_moderation"
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 _create_enum_if_not_exists(enum_name: str, values: list[str]) -> None:
"""Create a PostgreSQL ENUM type if it does not already exist."""
bind = op.get_bind()
# Check if the type already exists in pg_type
result = bind.execute(
sa.text(
"SELECT 1 FROM pg_type WHERE typname = :name"
).bindparams(name=enum_name)
).fetchone()
if result is None:
values_str = ", ".join(f"'{v}'" for v in values)
op.execute(f"CREATE TYPE {enum_name} AS ENUM ({values_str})")
def upgrade() -> None:
"""Add role, banned_until, suspended_until to users table
and create reports, moderation_actions, appeals tables."""
# --- Create ENUM types if they don't exist ---
_create_enum_if_not_exists("user_role", ["user", "moderator", "admin"])
_create_enum_if_not_exists("report_target_type", ["user", "world", "asset", "chat_message"])
_create_enum_if_not_exists("report_reason_category", [
"harassment", "hate_speech", "sexual_content",
"violence", "spam", "self_harm", "other"
])
_create_enum_if_not_exists("report_status", ["open", "in_review", "resolved", "dismissed"])
_create_enum_if_not_exists("report_resolution", ["warn", "suspend", "ban", "restrict", "remove"])
_create_enum_if_not_exists("moderation_target_type", ["user", "world", "asset", "chat_message"])
_create_enum_if_not_exists("moderation_action_type", ["warn", "suspend", "ban", "restrict", "remove", "restore"])
_create_enum_if_not_exists("appeal_status", ["pending", "approved", "rejected"])
# --- Extend users table ---
# Add role column
op.add_column(
"users",
sa.Column(
"role",
postgresql.ENUM("user", "moderator", "admin", name="user_role", create_type=False),
nullable=False,
server_default="user",
),
)
# Add banned_until column
op.add_column(
"users",
sa.Column("banned_until", sa.DateTime(timezone=True), nullable=True),
)
# Add suspended_until column
op.add_column(
"users",
sa.Column("suspended_until", sa.DateTime(timezone=True), nullable=True),
)
# --- Create reports table ---
op.create_table(
"reports",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("reporter_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False),
sa.Column(
"target_type",
postgresql.ENUM("user", "world", "asset", "chat_message", name="report_target_type", create_type=False),
nullable=False,
),
sa.Column("target_id", sa.String(64), nullable=False),
sa.Column(
"reason_category",
postgresql.ENUM(
"harassment", "hate_speech", "sexual_content",
"violence", "spam", "self_harm", "other",
name="report_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(), sa.ForeignKey("users.id"), nullable=True),
sa.Column(
"resolution",
postgresql.ENUM("warn", "suspend", "ban", "restrict", "remove", name="report_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),
)
# Indexes for reports
op.create_index("ix_reports_reporter_id", "reports", ["reporter_id"])
op.create_index("ix_reports_target", "reports", ["target_type", "target_id"])
op.create_index("ix_reports_status", "reports", ["status"])
# --- Create moderation_actions table ---
op.create_table(
"moderation_actions",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("moderator_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False),
sa.Column("target_user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True),
sa.Column(
"target_type",
postgresql.ENUM("user", "world", "asset", "chat_message", name="moderation_target_type", create_type=False),
nullable=False,
),
sa.Column("target_id", sa.String(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(), sa.ForeignKey("reports.id"), nullable=True),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
# Indexes for moderation_actions
op.create_index("ix_moderation_actions_moderator_id", "moderation_actions", ["moderator_id"])
op.create_index("ix_moderation_actions_target_user", "moderation_actions", ["target_user_id"])
op.create_index("ix_moderation_actions_target", "moderation_actions", ["target_type", "target_id"])
op.create_index("ix_moderation_actions_report_id", "moderation_actions", ["report_id"])
# --- Create appeals table ---
op.create_table(
"appeals",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("moderation_action_id", sa.Integer(), sa.ForeignKey("moderation_actions.id"), nullable=False),
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), 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(), sa.ForeignKey("users.id"), 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),
)
# Indexes for appeals
op.create_index("ix_appeals_moderation_action_id", "appeals", ["moderation_action_id"])
op.create_index("ix_appeals_user_id", "appeals", ["user_id"])
op.create_index("ix_appeals_status", "appeals", ["status"])
def downgrade() -> None:
"""Remove moderation tables and revert users table changes."""
# Drop appeals table
op.drop_index("ix_appeals_status", table_name="appeals")
op.drop_index("ix_appeals_user_id", table_name="appeals")
op.drop_index("ix_appeals_moderation_action_id", table_name="appeals")
op.drop_table("appeals")
# Drop moderation_actions table
op.drop_index("ix_moderation_actions_report_id", table_name="moderation_actions")
op.drop_index("ix_moderation_actions_target", table_name="moderation_actions")
op.drop_index("ix_moderation_actions_target_user", table_name="moderation_actions")
op.drop_index("ix_moderation_actions_moderator_id", table_name="moderation_actions")
op.drop_table("moderation_actions")
# Drop reports table
op.drop_index("ix_reports_status", table_name="reports")
op.drop_index("ix_reports_target", table_name="reports")
op.drop_index("ix_reports_reporter_id", table_name="reports")
op.drop_table("reports")
# Remove columns from users table
op.drop_column("users", "suspended_until")
op.drop_column("users", "banned_until")
op.drop_column("users", "role")
# Drop the enum types
op.execute("DROP TYPE IF EXISTS user_role")
op.execute("DROP TYPE IF EXISTS report_target_type")
op.execute("DROP TYPE IF EXISTS report_reason_category")
op.execute("DROP TYPE IF EXISTS report_status")
op.execute("DROP TYPE IF EXISTS report_resolution")
op.execute("DROP TYPE IF EXISTS moderation_target_type")
op.execute("DROP TYPE IF EXISTS moderation_action_type")
op.execute("DROP TYPE IF EXISTS appeal_status")
+26 -2
View File
@@ -2,6 +2,30 @@ from app.models.user import User
from app.models.session import Session from app.models.session import Session
from app.models.world import World from app.models.world import World
from app.models.asset import Asset from app.models.asset import Asset
from app.models.moderation import Report, ModerationAction, Appeal from app.models.moderation import (
Report,
ModerationAction,
Appeal,
TargetType,
ReasonCategory,
ReportStatus,
Resolution,
ModerationActionType,
AppealStatus,
)
__all__ = ["User", "Session", "World", "Asset", "Report", "ModerationAction", "Appeal"] __all__ = [
"User",
"Session",
"World",
"Asset",
"Report",
"ModerationAction",
"Appeal",
"TargetType",
"ReasonCategory",
"ReportStatus",
"Resolution",
"ModerationActionType",
"AppealStatus",
]
+54 -154
View File
@@ -1,34 +1,27 @@
"""Moderation-related models: Report, ModerationAction, Appeal.""" """Moderation data models for Trust & Safety."""
import enum
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
from sqlalchemy import ( from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey
Column, from sqlalchemy.dialects.postgresql import ENUM
Enum,
ForeignKey,
Integer,
String,
Text,
DateTime,
Index,
)
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from sqlalchemy.sql import func from sqlalchemy.sql import func
from app.database import Base from app.database import Base
# --- Enums --- class TargetType(str, enum.Enum):
"""Type of entity being reported or moderated."""
class TargetType(str):
USER = "user" USER = "user"
WORLD = "world" WORLD = "world"
ASSET = "asset" ASSET = "asset"
CHAT_MESSAGE = "chat_message" CHAT_MESSAGE = "chat_message"
class ReasonCategory(str): class ReasonCategory(str, enum.Enum):
"""Category of reason for a report."""
HARASSMENT = "harassment" HARASSMENT = "harassment"
HATE_SPEECH = "hate_speech" HATE_SPEECH = "hate_speech"
SEXUAL_CONTENT = "sexual_content" SEXUAL_CONTENT = "sexual_content"
@@ -38,14 +31,23 @@ class ReasonCategory(str):
OTHER = "other" OTHER = "other"
class ReportStatus(str): class ReportStatus(str, enum.Enum):
"""Status of a report."""
OPEN = "open" OPEN = "open"
IN_REVIEW = "in_review" IN_REVIEW = "in_review"
RESOLVED = "resolved" RESOLVED = "resolved"
DISMISSED = "dismissed" DISMISSED = "dismissed"
class ResolutionAction(str): 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" WARN = "warn"
SUSPEND = "suspend" SUSPEND = "suspend"
BAN = "ban" BAN = "ban"
@@ -54,95 +56,45 @@ class ResolutionAction(str):
RESTORE = "restore" RESTORE = "restore"
class ModerationActionType(str): class AppealStatus(str, enum.Enum):
WARN = "warn" """Status of an appeal."""
SUSPEND = "suspend"
BAN = "ban"
RESTRICT = "restrict"
REMOVE = "remove"
RESTORE = "restore"
class AppealStatus(str):
PENDING = "pending" PENDING = "pending"
APPROVED = "approved" APPROVED = "approved"
REJECTED = "rejected" REJECTED = "rejected"
# --- Models ---
class Report(Base): class Report(Base):
"""A moderation report filed by a user or system.""" """A report filed by a user against a target entity."""
__tablename__ = "reports" __tablename__ = "reports"
id = Column(Integer, primary_key=True) id = Column(Integer, primary_key=True, index=True)
reporter_id = Column( reporter_id = Column(
Integer, Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
ForeignKey("users.id", ondelete="SET NULL"),
nullable=False,
) )
target_type = Column( target_type = Column(
Enum( ENUM(TargetType, name="target_type", create_type=False),
TargetType.USER,
TargetType.WORLD,
TargetType.ASSET,
TargetType.CHAT_MESSAGE,
name="target_type",
create_type=False,
),
nullable=False, nullable=False,
) )
target_id = Column(String(64), nullable=False) target_id = Column(String(64), nullable=False)
reason_category = Column( reason_category = Column(
Enum( ENUM(ReasonCategory, name="reason_category", create_type=False),
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, nullable=False,
) )
details = Column(Text, nullable=True) details = Column(Text, nullable=True)
status = Column( status = Column(
Enum( ENUM(ReportStatus, name="report_status", create_type=False),
ReportStatus.OPEN,
ReportStatus.IN_REVIEW,
ReportStatus.RESOLVED,
ReportStatus.DISMISSED,
name="report_status",
create_type=False,
),
default=ReportStatus.OPEN, default=ReportStatus.OPEN,
nullable=False, nullable=False,
) )
resolved_by = Column( resolved_by = Column(
Integer, Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
) )
resolution = Column( resolution = Column(
Enum( ENUM(Resolution, name="resolution", create_type=False),
ResolutionAction.WARN,
ResolutionAction.SUSPEND,
ResolutionAction.BAN,
ResolutionAction.RESTRICT,
ResolutionAction.REMOVE,
ResolutionAction.RESTORE,
name="resolution_action",
create_type=False,
),
nullable=True, nullable=True,
) )
created_at = Column( created_at = Column(
DateTime(timezone=True), DateTime(timezone=True), server_default=func.now(), nullable=False
server_default=func.now(),
nullable=False,
) )
updated_at = Column( updated_at = Column(
DateTime(timezone=True), DateTime(timezone=True),
@@ -155,132 +107,80 @@ class Report(Base):
# Relationships # Relationships
reporter = relationship("User", foreign_keys=[reporter_id]) reporter = relationship("User", foreign_keys=[reporter_id])
resolver = relationship("User", foreign_keys=[resolved_by]) resolver = relationship("User", foreign_keys=[resolved_by])
actions = relationship( moderation_actions = relationship(
"ModerationAction", "ModerationAction", back_populates="report", cascade="all, delete-orphan"
back_populates="report",
cascade="all, delete-orphan",
) )
appeals = relationship(
__table_args__ = ( "Appeal", back_populates="report", cascade="all, delete-orphan"
Index("ix_reports_target", "target_type", "target_id"),
Index("ix_reports_status", "status"),
Index("ix_reports_created_at", "created_at"),
) )
class ModerationAction(Base): class ModerationAction(Base):
"""A moderation action taken by a moderator/admin.""" """A moderation action taken by a moderator against a user or entity."""
__tablename__ = "moderation_actions" __tablename__ = "moderation_actions"
id = Column(Integer, primary_key=True) id = Column(Integer, primary_key=True, index=True)
moderator_id = Column( moderator_id = Column(
Integer, Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=False
ForeignKey("users.id", ondelete="SET NULL"),
nullable=False,
) )
target_user_id = Column( target_user_id = Column(
Integer, Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
) )
target_type = Column( target_type = Column(
Enum( ENUM(TargetType, name="target_type", create_type=False),
TargetType.USER,
TargetType.WORLD,
TargetType.ASSET,
TargetType.CHAT_MESSAGE,
name="mod_target_type",
create_type=False,
),
nullable=False, nullable=False,
) )
target_id = Column(String(64), nullable=False) target_id = Column(String(64), nullable=False)
action = Column( action = Column(
Enum( ENUM(ModerationActionType, name="moderation_action_type", create_type=False),
ModerationActionType.WARN,
ModerationActionType.SUSPEND,
ModerationActionType.BAN,
ModerationActionType.RESTRICT,
ModerationActionType.REMOVE,
ModerationActionType.RESTORE,
name="moderation_action_type",
create_type=False,
),
nullable=False, nullable=False,
) )
reason = Column(Text, nullable=True) reason = Column(Text, nullable=True)
report_id = Column( report_id = Column(
Integer, Integer, ForeignKey("reports.id", ondelete="SET NULL"), nullable=True
ForeignKey("reports.id", ondelete="SET NULL"),
nullable=True,
) )
expires_at = Column(DateTime(timezone=True), nullable=True) expires_at = Column(DateTime(timezone=True), nullable=True)
revoked_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column( created_at = Column(
DateTime(timezone=True), DateTime(timezone=True), server_default=func.now(), nullable=False
server_default=func.now(),
nullable=False,
) )
# Relationships # Relationships
moderator = relationship("User", foreign_keys=[moderator_id]) moderator = relationship("User", foreign_keys=[moderator_id])
target_user = relationship("User", foreign_keys=[target_user_id]) target_user = relationship("User", foreign_keys=[target_user_id])
report = relationship("Report", back_populates="actions") report = relationship("Report", back_populates="moderation_actions")
appeals = relationship("Appeal", back_populates="moderation_action") appeals = relationship(
"Appeal", back_populates="moderation_action", cascade="all, delete-orphan"
__table_args__ = (
Index("ix_moderation_actions_target", "target_type", "target_id"),
Index("ix_moderation_actions_created_at", "created_at"),
) )
class Appeal(Base): class Appeal(Base):
"""An appeal filed by a user against a moderation action.""" """An appeal filed by a user against a moderation action."""
__tablename__ = "appeals" __tablename__ = "appeals"
id = Column(Integer, primary_key=True) id = Column(Integer, primary_key=True, index=True)
moderation_action_id = Column( moderation_action_id = Column(
Integer, Integer, ForeignKey("moderation_actions.id", ondelete="CASCADE"),
ForeignKey("moderation_actions.id", ondelete="CASCADE"),
nullable=False, nullable=False,
) )
user_id = Column( user_id = Column(
Integer, Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
) )
reason = Column(Text, nullable=False) reason = Column(Text, nullable=False)
status = Column( status = Column(
Enum( ENUM(AppealStatus, name="appeal_status", create_type=False),
AppealStatus.PENDING,
AppealStatus.APPROVED,
AppealStatus.REJECTED,
name="appeal_status",
create_type=False,
),
default=AppealStatus.PENDING, default=AppealStatus.PENDING,
nullable=False, nullable=False,
) )
reviewed_by = Column( reviewed_by = Column(
Integer, Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
) )
reviewed_at = Column(DateTime(timezone=True), nullable=True) reviewed_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column( created_at = Column(
DateTime(timezone=True), DateTime(timezone=True), server_default=func.now(), nullable=False
server_default=func.now(),
nullable=False,
) )
# Relationships # Relationships
moderation_action = relationship( moderation_action = relationship("ModerationAction", back_populates="appeals")
"ModerationAction", back_populates="appeals" user = relationship("User", foreign_keys=[user_id])
)
reviewer = relationship("User", foreign_keys=[reviewed_by]) reviewer = relationship("User", foreign_keys=[reviewed_by])
__table_args__ = (
Index("ix_appeals_status", "status"),
Index("ix_appeals_created_at", "created_at"),
)
+13 -31
View File
@@ -1,13 +1,21 @@
from datetime import date, datetime from datetime import date
from typing import Optional from typing import Optional
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Date, Enum from sqlalchemy import Column, Integer, String, Boolean, DateTime, Date, func
from sqlalchemy.dialects.postgresql import ENUM
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from sqlalchemy.sql import func import enum
from app.database import Base 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): class User(Base):
__tablename__ = "users" __tablename__ = "users"
@@ -18,8 +26,8 @@ class User(Base):
date_of_birth = Column(Date, nullable=True) date_of_birth = Column(Date, nullable=True)
is_active = Column(Boolean, default=True) is_active = Column(Boolean, default=True)
role = Column( role = Column(
Enum("user", "moderator", "admin", name="user_role", create_type=False), ENUM(UserRole, name="user_role", create_type=False),
default="user", default=UserRole.USER,
nullable=False, nullable=False,
) )
banned_until = Column(DateTime(timezone=True), nullable=True) banned_until = Column(DateTime(timezone=True), nullable=True)
@@ -30,32 +38,6 @@ class User(Base):
sessions = relationship("Session", back_populates="user", cascade="all, delete-orphan") sessions = relationship("Session", back_populates="user", cascade="all, delete-orphan")
worlds = relationship("World", back_populates="owner") worlds = relationship("World", back_populates="owner")
assets = relationship("Asset", back_populates="owner") assets = relationship("Asset", back_populates="owner")
reported_by = relationship(
"Report",
foreign_keys="Report.reporter_id",
back_populates="reporter",
)
resolved_reports = relationship(
"Report",
foreign_keys="Report.resolved_by",
back_populates="resolver",
)
moderation_actions = relationship(
"ModerationAction",
foreign_keys="ModerationAction.moderator_id",
back_populates="moderator",
)
target_moderation_actions = relationship(
"ModerationAction",
foreign_keys="ModerationAction.target_user_id",
back_populates="target_user",
)
appeals = relationship("Appeal", back_populates="user")
reviewed_appeals = relationship(
"Appeal",
foreign_keys="Appeal.reviewed_by",
back_populates="reviewer",
)
@property @property
def is_adult(self) -> bool: def is_adult(self) -> bool: