Compare commits

..

5 Commits

6 changed files with 412 additions and 375 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import create_async_engine
from app.config import settings from app.config import settings
from app.database import Base 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 # this is the Alembic Config object, which provides
# access to the values within the .ini file in use. # access to the values within the .ini file in use.
+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" "public", "private", "friends_only", name="world_visibility", create_type=False
), ),
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", name="asset_type", create_type=False,
), ),
nullable=False, nullable=False,
), ),
+211
View File
@@ -0,0 +1,211 @@
"""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")
@@ -1,268 +0,0 @@
"""add trust & safety columns and tables
Revision ID: 0003_trust_safety
Revises: 0002_add_date_of_birth_to_users
Create Date: 2025-07-10 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_trust_safety"
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 role/banned_until/suspended_until to users, and create
reports, moderation_actions, and appeals tables."""
# ── ENUM types ──────────────────────────────────────────────
role_enum = postgresql.ENUM("user", "moderator", "admin", name="role")
role_enum.create(op.get_bind(), checkfirst=True)
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(
"action_taken",
"action_not_needed",
"duplicate",
"escalated",
name="resolution",
)
resolution_enum.create(op.get_bind(), checkfirst=True)
moderation_action_enum = postgresql.ENUM(
"warn", "suspend", "ban", "restrict", "remove", "restore",
name="moderation_action",
)
moderation_action_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)
# ── users table changes ─────────────────────────────────────
op.add_column(
"users",
sa.Column(
"role",
role_enum,
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.BigInteger(), primary_key=True, autoincrement=True),
sa.Column(
"reporter_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False
),
sa.Column(
"target_type",
target_type_enum,
nullable=False,
),
sa.Column("target_id", sa.String(length=64), nullable=False),
sa.Column(
"reason_category",
reason_category_enum,
nullable=False,
),
sa.Column("details", sa.Text(), nullable=True),
sa.Column(
"status",
report_status_enum,
nullable=False,
server_default="open",
),
sa.Column(
"resolved_by",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column(
"resolution",
resolution_enum,
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),
onupdate=sa.func.now(),
nullable=False,
),
sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True),
sa.UniqueConstraint("reporter_id", "target_type", "target_id"),
)
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)
# ── moderation_actions table ────────────────────────────────
op.create_table(
"moderation_actions",
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
sa.Column(
"moderator_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"target_user_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column(
"target_type",
target_type_enum,
nullable=False,
),
sa.Column("target_id", sa.String(length=64), nullable=False),
sa.Column(
"action",
moderation_action_enum,
nullable=False,
),
sa.Column("reason", sa.Text(), nullable=False),
sa.Column(
"report_id",
sa.Integer(),
sa.ForeignKey("reports.id", ondelete="SET NULL"),
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,
),
)
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,
)
# ── appeals table ───────────────────────────────────────────
op.create_table(
"appeals",
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
sa.Column(
"moderation_action_id",
sa.Integer(),
sa.ForeignKey("moderation_actions.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"user_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("reason", sa.Text(), nullable=False),
sa.Column(
"status",
appeal_status_enum,
nullable=False,
server_default="pending",
),
sa.Column(
"reviewed_by",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="SET NULL"),
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.UniqueConstraint("moderation_action_id", "user_id"),
)
op.create_index(op.f("ix_appeals_id"), "appeals", ["id"], unique=False)
op.create_index(op.f("ix_appeals_user_id"), "appeals", ["user_id"], unique=False)
def downgrade() -> None:
"""Remove trust & safety tables and columns."""
op.drop_index(op.f("ix_appeals_user_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_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_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").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="role").drop(op.get_bind(), checkfirst=True)
+163 -102
View File
@@ -1,142 +1,182 @@
"""Trust & Safety models: reports, moderation actions, and appeals.""" """Moderation-related models: Report, ModerationAction, Appeal."""
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
from sqlalchemy import ( from sqlalchemy import (
CheckConstraint,
Column, Column,
DateTime,
Enum, Enum,
ForeignKey, ForeignKey,
Integer, Integer,
String, String,
Text, Text,
UniqueConstraint, DateTime,
func, Index,
) )
from sqlalchemy.dialects.postgresql import ENUM as PG_ENUM
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.database import Base from app.database import Base
# --------------------------------------------------------------------------- # --- Enums ---
# ENUM helpers — created explicitly in Alembic, not via SQLAlchemy create_type
# ---------------------------------------------------------------------------
_role_enum = PG_ENUM( class TargetType(str):
"user", "moderator", "admin", USER = "user"
name="user_role", WORLD = "world"
create_type=False, ASSET = "asset"
) CHAT_MESSAGE = "chat_message"
_target_type_enum = PG_ENUM(
"user", "world", "asset", "chat_message",
name="target_type",
create_type=False,
)
_reason_category_enum = PG_ENUM( class ReasonCategory(str):
"harassment", "hate_speech", "sexual_content", "violence", HARASSMENT = "harassment"
"spam", "self_harm", "other", HATE_SPEECH = "hate_speech"
name="reason_category", SEXUAL_CONTENT = "sexual_content"
create_type=False, VIOLENCE = "violence"
) SPAM = "spam"
SELF_HARM = "self_harm"
OTHER = "other"
_report_status_enum = PG_ENUM(
"open", "in_review", "resolved", "dismissed",
name="report_status",
create_type=False,
)
_resolution_enum = PG_ENUM( class ReportStatus(str):
"action_taken", "action_not_needed", "duplicate", "escalated", OPEN = "open"
name="resolution", IN_REVIEW = "in_review"
create_type=False, RESOLVED = "resolved"
) DISMISSED = "dismissed"
_moderation_action_enum = PG_ENUM(
"warn", "suspend", "ban", "restrict", "remove", "restore",
name="moderation_action",
create_type=False,
)
_appeal_status_enum = PG_ENUM( class ResolutionAction(str):
"pending", "approved", "rejected", WARN = "warn"
name="appeal_status", SUSPEND = "suspend"
create_type=False, 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): class Report(Base):
"""A user-submitted report about another entity.""" """A moderation report filed by a user or system."""
__tablename__ = "reports" __tablename__ = "reports"
id = Column(Integer, primary_key=True, autoincrement=True) id = Column(Integer, primary_key=True)
reporter_id = Column( reporter_id = Column(
Integer, Integer,
ForeignKey("users.id", ondelete="CASCADE"), 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, nullable=False,
) )
target_type = Column(_target_type_enum, nullable=False)
target_id = Column(String(64), nullable=False) target_id = Column(String(64), nullable=False)
reason_category = Column(_reason_category_enum, 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) details = Column(Text, nullable=True)
status = Column( status = Column(
_report_status_enum, Enum(
ReportStatus.OPEN,
ReportStatus.IN_REVIEW,
ReportStatus.RESOLVED,
ReportStatus.DISMISSED,
name="report_status",
create_type=False,
),
default=ReportStatus.OPEN,
nullable=False, nullable=False,
server_default="open",
) )
resolved_by = Column( resolved_by = Column(
Integer, Integer,
ForeignKey("users.id", ondelete="SET NULL"), ForeignKey("users.id", ondelete="SET NULL"),
nullable=True, nullable=True,
) )
resolution = Column(_resolution_enum, 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( created_at = Column(
DateTime(timezone=True), DateTime(timezone=True),
server_default=func.now(), server_default=func.now(),
nullable=False,
) )
updated_at = Column( updated_at = Column(
DateTime(timezone=True), DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(), onupdate=func.now(),
nullable=False,
) )
resolved_at = Column(DateTime(timezone=True), nullable=True) 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 # Relationships
reporter = relationship("User", back_populates="reports", reporter = relationship("User", foreign_keys=[reporter_id])
foreign_keys=[reporter_id]) resolver = relationship("User", foreign_keys=[resolved_by])
resolved_by_user = relationship("User", back_populates="resolved_reports", actions = relationship(
foreign_keys=[resolved_by])
moderation_actions = relationship(
"ModerationAction", "ModerationAction",
back_populates="report", back_populates="report",
cascade="all, delete-orphan", cascade="all, delete-orphan",
) )
appeals = relationship(
"Appeal", __table_args__ = (
back_populates="report", Index("ix_reports_target", "target_type", "target_id"),
cascade="all, delete-orphan", Index("ix_reports_status", "status"),
Index("ix_reports_created_at", "created_at"),
) )
class ModerationAction(Base): class ModerationAction(Base):
"""A moderation action taken against a target.""" """A moderation action taken by a moderator/admin."""
__tablename__ = "moderation_actions" __tablename__ = "moderation_actions"
id = Column(Integer, primary_key=True, autoincrement=True) id = Column(Integer, primary_key=True)
moderator_id = Column( moderator_id = Column(
Integer, Integer,
ForeignKey("users.id", ondelete="CASCADE"), ForeignKey("users.id", ondelete="SET NULL"),
nullable=False, nullable=False,
) )
target_user_id = Column( target_user_id = Column(
@@ -144,44 +184,62 @@ class ModerationAction(Base):
ForeignKey("users.id", ondelete="SET NULL"), ForeignKey("users.id", ondelete="SET NULL"),
nullable=True, nullable=True,
) )
target_type = Column(_target_type_enum, nullable=False) 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) target_id = Column(String(64), nullable=False)
action = Column(_moderation_action_enum, nullable=False) action = Column(
reason = Column(Text, nullable=False) 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( report_id = Column(
Integer, Integer,
ForeignKey("reports.id", ondelete="SET NULL"), ForeignKey("reports.id", ondelete="SET NULL"),
nullable=True, 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(), server_default=func.now(),
nullable=False,
) )
# Relationships # Relationships
moderator = relationship("User", back_populates="moderation_actions", moderator = relationship("User", foreign_keys=[moderator_id])
foreign_keys=[moderator_id]) target_user = relationship("User", foreign_keys=[target_user_id])
target_user = relationship("User", back_populates="moderation_actions_target", report = relationship("Report", back_populates="actions")
foreign_keys=[target_user_id]) appeals = relationship("Appeal", back_populates="moderation_action")
report = relationship(
"Report", __table_args__ = (
back_populates="moderation_actions", Index("ix_moderation_actions_target", "target_type", "target_id"),
) Index("ix_moderation_actions_created_at", "created_at"),
appeals = relationship(
"Appeal",
back_populates="moderation_action",
cascade="all, delete-orphan",
) )
class Appeal(Base): class Appeal(Base):
"""An appeal against a moderation action.""" """An appeal filed by a user against a moderation action."""
__tablename__ = "appeals" __tablename__ = "appeals"
id = Column(Integer, primary_key=True, autoincrement=True) id = Column(Integer, primary_key=True)
moderation_action_id = Column( moderation_action_id = Column(
Integer, Integer,
ForeignKey("moderation_actions.id", ondelete="CASCADE"), ForeignKey("moderation_actions.id", ondelete="CASCADE"),
@@ -194,9 +252,15 @@ class Appeal(Base):
) )
reason = Column(Text, nullable=False) reason = Column(Text, nullable=False)
status = Column( status = Column(
_appeal_status_enum, Enum(
AppealStatus.PENDING,
AppealStatus.APPROVED,
AppealStatus.REJECTED,
name="appeal_status",
create_type=False,
),
default=AppealStatus.PENDING,
nullable=False, nullable=False,
server_default="pending",
) )
reviewed_by = Column( reviewed_by = Column(
Integer, Integer,
@@ -207,19 +271,16 @@ class Appeal(Base):
created_at = Column( created_at = Column(
DateTime(timezone=True), DateTime(timezone=True),
server_default=func.now(), server_default=func.now(),
) nullable=False,
# Prevent duplicate open appeals per sanction
__table_args__ = (
UniqueConstraint("moderation_action_id", "user_id",
name="uq_action_user_appeal"),
) )
# Relationships # Relationships
moderation_action = relationship( moderation_action = relationship(
"ModerationAction", "ModerationAction", back_populates="appeals"
back_populates="appeals", )
reviewer = relationship("User", foreign_keys=[reviewed_by])
__table_args__ = (
Index("ix_appeals_status", "status"),
Index("ix_appeals_created_at", "created_at"),
) )
user = relationship("User", back_populates="appeals")
reviewed_by_user = relationship("User", back_populates="reviewed_appeals",
foreign_keys=[reviewed_by])
+35 -2
View File
@@ -1,7 +1,7 @@
from datetime import date from datetime import date, datetime
from typing import Optional from typing import Optional
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Date from sqlalchemy import Column, Integer, String, Boolean, DateTime, Date, Enum
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from sqlalchemy.sql import func from sqlalchemy.sql import func
@@ -17,12 +17,45 @@ class User(Base):
hashed_password = Column(String(255), nullable=False) hashed_password = Column(String(255), nullable=False)
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(
Enum("user", "moderator", "admin", name="user_role", create_type=False),
default="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()) created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now())
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: