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
8 changed files with 319 additions and 332 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.
@@ -1,6 +1,6 @@
"""trust & safety: reports, moderation_actions, appeals + user role/ban/suspend """add moderation schema
Revision ID: 0003_trust_safety Revision ID: 0003_add_moderation_schema
Revises: 0002_add_date_of_birth_to_users Revises: 0002_add_date_of_birth_to_users
Create Date: 2025-01-01 00:00:00.000000 Create Date: 2025-01-01 00:00:00.000000
@@ -12,79 +12,69 @@ import sqlalchemy as sa
from sqlalchemy.dialects import postgresql from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = "0003_trust_safety" revision: str = "0003_add_moderation_schema"
down_revision: Union[str, None] = "0002_add_date_of_birth_to_users" down_revision: Union[str, None] = "0002_add_date_of_birth_to_users"
branch_labels: Union[str, Sequence[str], None] = None branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None: def upgrade() -> None:
"""Create trust & safety tables and extend users table.""" """Create moderation tables and extend users table."""
bind = op.get_bind() # ── ENUM types ──────────────────────────────────────────────
# ── ENUM types ────────────────────────────────────────────────────── target_type_enum = postgresql.ENUM(
"user", "world", "asset", "chat_message",
# user_role name="target_type",
user_role = postgresql.ENUM("user", "moderator", "admin", name="user_role")
user_role.create(bind, checkfirst=True)
# report_target_type
report_target_type = postgresql.ENUM(
"user", "world", "asset", "chat_message", name="report_target_type"
) )
report_target_type.create(bind, checkfirst=True) target_type_enum.create(op.get_bind(), checkfirst=True)
# reason_category reason_category_enum = postgresql.ENUM(
reason_category = postgresql.ENUM( "harassment", "hate_speech", "sexual_content", "violence",
"harassment", "spam", "self_harm", "other",
"hate_speech",
"sexual_content",
"violence",
"spam",
"self_harm",
"other",
name="reason_category", name="reason_category",
) )
reason_category.create(bind, checkfirst=True) reason_category_enum.create(op.get_bind(), checkfirst=True)
# report_status report_status_enum = postgresql.ENUM(
report_status = postgresql.ENUM( "open", "in_review", "resolved", "dismissed",
"open", "in_review", "resolved", "dismissed", name="report_status" name="report_status",
) )
report_status.create(bind, checkfirst=True) report_status_enum.create(op.get_bind(), checkfirst=True)
# resolution
resolution_enum = postgresql.ENUM( resolution_enum = postgresql.ENUM(
"action_taken", "no_action", "false_positive", name="resolution" "upheld", "modified", "dismissed",
name="resolution",
) )
resolution_enum.create(bind, checkfirst=True) resolution_enum.create(op.get_bind(), checkfirst=True)
# moderation_action_type moderation_action_type_enum = postgresql.ENUM(
moderation_action_type = postgresql.ENUM(
"warn", "suspend", "ban", "restrict", "remove", "restore", "warn", "suspend", "ban", "restrict", "remove", "restore",
name="moderation_action_type", name="moderation_action_type",
) )
moderation_action_type.create(bind, checkfirst=True) moderation_action_type_enum.create(op.get_bind(), checkfirst=True)
# moderation_target_type appeal_status_enum = postgresql.ENUM(
moderation_target_type = postgresql.ENUM( "pending", "approved", "rejected",
"user", "world", "asset", "chat_message", name="moderation_target_type" name="appeal_status",
) )
moderation_target_type.create(bind, checkfirst=True) appeal_status_enum.create(op.get_bind(), checkfirst=True)
# appeal_status user_role_enum = postgresql.ENUM(
appeal_status = postgresql.ENUM( "user", "moderator", "admin",
"pending", "approved", "rejected", name="appeal_status" name="user_role",
) )
appeal_status.create(bind, checkfirst=True) user_role_enum.create(op.get_bind(), checkfirst=True)
# ── Extend users table ────────────────────────────────────────────── # ── users table extensions ──────────────────────────────────
op.add_column( op.add_column(
"users", "users",
sa.Column( sa.Column(
"role", "role",
postgresql.ENUM("user", "moderator", "admin", name="user_role"), postgresql.ENUM(
"user", "moderator", "admin", name="user_role",
create_type=False,
),
nullable=False, nullable=False,
server_default="user", server_default="user",
), ),
@@ -98,16 +88,17 @@ def upgrade() -> None:
sa.Column("suspended_until", sa.DateTime(timezone=True), nullable=True), sa.Column("suspended_until", sa.DateTime(timezone=True), nullable=True),
) )
# ── reports table ─────────────────────────────────────────────────── # ── reports table ───────────────────────────────────────────
op.create_table( op.create_table(
"reports", "reports",
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True), sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("reporter_id", sa.Integer(), nullable=False), sa.Column("reporter_id", sa.Integer(), nullable=False),
sa.Column( sa.Column(
"target_type", "target_type",
postgresql.ENUM( postgresql.ENUM(
"user", "world", "asset", "chat_message", name="report_target_type" "user", "world", "asset", "chat_message",
name="target_type", create_type=False,
), ),
nullable=False, nullable=False,
), ),
@@ -115,24 +106,18 @@ def upgrade() -> None:
sa.Column( sa.Column(
"reason_category", "reason_category",
postgresql.ENUM( postgresql.ENUM(
"harassment", "harassment", "hate_speech", "sexual_content", "violence",
"hate_speech", "spam", "self_harm", "other",
"sexual_content", name="reason_category", create_type=False,
"violence",
"spam",
"self_harm",
"other",
name="reason_category",
), ),
nullable=False, nullable=False,
), ),
sa.Column( sa.Column("details", sa.Text(), nullable=True),
"details", sa.Text(), nullable=True,
),
sa.Column( sa.Column(
"status", "status",
postgresql.ENUM( postgresql.ENUM(
"open", "in_review", "resolved", "dismissed", name="report_status" "open", "in_review", "resolved", "dismissed",
name="report_status", create_type=False,
), ),
nullable=False, nullable=False,
server_default="open", server_default="open",
@@ -141,11 +126,11 @@ def upgrade() -> None:
sa.Column( sa.Column(
"resolution", "resolution",
postgresql.ENUM( postgresql.ENUM(
"action_taken", "no_action", "false_positive", name="resolution" "upheld", "modified", "dismissed",
name="resolution", create_type=False,
), ),
nullable=True, nullable=True,
), ),
sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True),
sa.Column( sa.Column(
"created_at", "created_at",
sa.DateTime(timezone=True), sa.DateTime(timezone=True),
@@ -155,45 +140,49 @@ def upgrade() -> None:
sa.Column( sa.Column(
"updated_at", "updated_at",
sa.DateTime(timezone=True), sa.DateTime(timezone=True),
onupdate=sa.func.now(), server_default=sa.func.now(),
nullable=True, nullable=False,
), ),
sa.ForeignKeyConstraint(["reporter_id"], ["users.id"], ondelete="CASCADE"), sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["resolved_by"], ["users.id"], ondelete="SET NULL"), sa.ForeignKeyConstraint(
sa.UniqueConstraint( ["reporter_id"], ["users.id"], ondelete="CASCADE",
"reporter_id", "target_type", "target_id", ),
name="uq_reporter_target", 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_id"), "reports", ["id"], unique=False)
op.create_index( op.create_index(
op.f("ix_reports_reporter_id"), "reports", ["reporter_id"], unique=False op.f("ix_reports_reporter_id"), "reports", ["reporter_id"], unique=False,
) )
op.create_index( op.create_index(
op.f("ix_reports_status"), "reports", ["status"], unique=False 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 ──────────────────────────────────────── # ── moderation_actions table ────────────────────────────────
op.create_table( op.create_table(
"moderation_actions", "moderation_actions",
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True), sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("moderator_id", sa.Integer(), nullable=False), sa.Column("moderator_id", sa.Integer(), nullable=False),
sa.Column("target_user_id", sa.Integer(), nullable=True), sa.Column("target_user_id", sa.Integer(), nullable=False),
sa.Column( sa.Column(
"target_type", "target_type",
postgresql.ENUM( postgresql.ENUM(
"user", "world", "asset", "chat_message", "user", "world", "asset", "chat_message",
name="moderation_target_type", name="target_type", create_type=False,
), ),
nullable=False, nullable=False,
), ),
sa.Column("target_id", sa.String(length=64), nullable=True), sa.Column("target_id", sa.String(length=64), nullable=False),
sa.Column( sa.Column(
"action", "action",
postgresql.ENUM( postgresql.ENUM(
"warn", "suspend", "ban", "restrict", "remove", "restore", "warn", "suspend", "ban", "restrict", "remove", "restore",
name="moderation_action_type", name="moderation_action_type", create_type=False,
), ),
nullable=False, nullable=False,
), ),
@@ -207,51 +196,48 @@ def upgrade() -> None:
server_default=sa.func.now(), server_default=sa.func.now(),
nullable=False, nullable=False,
), ),
sa.ForeignKeyConstraint(["moderator_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint( sa.ForeignKeyConstraint(
["target_user_id"], ["users.id"], ondelete="CASCADE" ["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",
), ),
sa.ForeignKeyConstraint(["report_id"], ["reports.id"], ondelete="SET NULL"),
) )
op.create_index( op.create_index(
op.f("ix_moderation_actions_id"), op.f("ix_moderation_actions_id"),
"moderation_actions", "moderation_actions", ["id"], unique=False,
["id"],
unique=False,
) )
op.create_index( op.create_index(
op.f("ix_moderation_actions_moderator_id"), op.f("ix_moderation_actions_moderator_id"),
"moderation_actions", "moderation_actions", ["moderator_id"], unique=False,
["moderator_id"],
unique=False,
) )
op.create_index( op.create_index(
op.f("ix_moderation_actions_target_user_id"), op.f("ix_moderation_actions_target_user_id"),
"moderation_actions", "moderation_actions", ["target_user_id"], unique=False,
["target_user_id"],
unique=False,
) )
op.create_index( op.create_index(
op.f("ix_moderation_actions_report_id"), op.f("ix_moderation_actions_target"),
"moderation_actions", "moderation_actions", ["target_type", "target_id"], unique=False,
["report_id"],
unique=False,
) )
# ── appeals table ─────────────────────────────────────────────────── # ── appeals table ───────────────────────────────────────────
op.create_table( op.create_table(
"appeals", "appeals",
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True), sa.Column("id", sa.Integer(), primary_key=True),
sa.Column( sa.Column(
"moderation_action_id", sa.Integer(), nullable=False "moderation_action_id", sa.Integer(), nullable=False,
), ),
sa.Column("user_id", sa.Integer(), nullable=False), sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("reason", sa.Text(), nullable=False), sa.Column("reason", sa.Text(), nullable=False),
sa.Column( sa.Column(
"status", "status",
postgresql.ENUM( postgresql.ENUM(
"pending", "approved", "rejected", name="appeal_status" "pending", "approved", "rejected",
name="appeal_status", create_type=False,
), ),
nullable=False, nullable=False,
server_default="pending", server_default="pending",
@@ -266,49 +252,45 @@ def upgrade() -> None:
), ),
sa.ForeignKeyConstraint( sa.ForeignKeyConstraint(
["moderation_action_id"], ["moderation_action_id"],
["moderation_actions.id"], ["moderation_actions.id"], ondelete="CASCADE",
ondelete="CASCADE",
), ),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint( sa.ForeignKeyConstraint(
["reviewed_by"], ["users.id"], ondelete="SET NULL" ["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_id"), "appeals", ["id"], unique=False)
op.create_index( op.create_index(
op.f("ix_appeals_moderation_action_id"), op.f("ix_appeals_moderation_action_id"),
"appeals", "appeals", ["moderation_action_id"], unique=False,
["moderation_action_id"],
unique=False,
) )
op.create_index( op.create_index(
op.f("ix_appeals_user_id"), op.f("ix_appeals_user_id"),
"appeals", "appeals", ["user_id"], unique=False,
["user_id"],
unique=False,
) )
op.create_index( op.create_index(
op.f("ix_appeals_status"), op.f("ix_appeals_status"),
"appeals", "appeals", ["status"], unique=False,
["status"],
unique=False,
) )
def downgrade() -> None: def downgrade() -> None:
"""Remove trust & safety tables and revert users table.""" """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_status"), table_name="appeals")
op.drop_index(op.f("ix_appeals_user_id"), 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_moderation_action_id"), table_name="appeals")
op.drop_index(op.f("ix_appeals_id"), table_name="appeals") op.drop_index(op.f("ix_appeals_id"), table_name="appeals")
op.drop_table("appeals") op.drop_table("appeals")
op.drop_index(op.f("ix_moderation_actions_report_id"), table_name="moderation_actions") 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_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_moderator_id"), table_name="moderation_actions")
op.drop_index(op.f("ix_moderation_actions_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_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_status"), table_name="reports")
op.drop_index(op.f("ix_reports_reporter_id"), 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_index(op.f("ix_reports_id"), table_name="reports")
@@ -319,11 +301,10 @@ def downgrade() -> None:
op.drop_column("users", "role") op.drop_column("users", "role")
# Drop ENUM types # Drop ENUM types
postgresql.ENUM(name="appeal_status").drop(bind, checkfirst=True) postgresql.ENUM(name="appeal_status").drop(op.get_bind(), checkfirst=True)
postgresql.ENUM(name="moderation_target_type").drop(bind, checkfirst=True) postgresql.ENUM(name="moderation_action_type").drop(op.get_bind(), checkfirst=True)
postgresql.ENUM(name="moderation_action_type").drop(bind, checkfirst=True) postgresql.ENUM(name="resolution").drop(op.get_bind(), checkfirst=True)
postgresql.ENUM(name="resolution").drop(bind, checkfirst=True) postgresql.ENUM(name="report_status").drop(op.get_bind(), checkfirst=True)
postgresql.ENUM(name="report_status").drop(bind, checkfirst=True) postgresql.ENUM(name="reason_category").drop(op.get_bind(), checkfirst=True)
postgresql.ENUM(name="reason_category").drop(bind, checkfirst=True) postgresql.ENUM(name="target_type").drop(op.get_bind(), checkfirst=True)
postgresql.ENUM(name="report_target_type").drop(bind, checkfirst=True) postgresql.ENUM(name="user_role").drop(op.get_bind(), checkfirst=True)
postgresql.ENUM(name="user_role").drop(bind, checkfirst=True)
+26 -4
View File
@@ -2,8 +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.report import Report from app.models.moderation import (
from app.models.moderation_action import ModerationAction Report,
from app.models.appeal import Appeal 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",
]
-47
View File
@@ -1,47 +0,0 @@
"""Appeal model for Trust & Safety."""
from datetime import datetime
from typing import Optional
from sqlalchemy import (
Column,
Integer,
Text,
DateTime,
ForeignKey,
)
from sqlalchemy.dialects.postgresql import ENUM
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
import enum
from app.database import Base
class AppealStatus(str, enum.Enum):
PENDING = "pending"
APPROVED = "approved"
REJECTED = "rejected"
class Appeal(Base):
__tablename__ = "appeals"
id = Column(Integer, primary_key=True, autoincrement=True)
moderation_action_id = Column(
Integer, ForeignKey("moderation_actions.id", ondelete="CASCADE"), nullable=False
)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
reason = Column(Text, nullable=False)
status = Column(
ENUM(AppelStatus, name="appeal_status", create_type=False),
default=AppealStatus.PENDING,
)
reviewed_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
reviewed_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
# Relationships
moderation_action = relationship("ModerationAction", back_populates="appeals")
user = relationship("User", foreign_keys=[user_id])
reviewer = relationship("User", foreign_keys=[reviewed_by])
+186
View File
@@ -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])
-63
View File
@@ -1,63 +0,0 @@
"""ModerationAction model for Trust & Safety."""
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
import enum
from app.database import Base
class ModerationActionType(str, enum.Enum):
WARN = "warn"
SUSPEND = "suspend"
BAN = "ban"
RESTRICT = "restrict"
REMOVE = "remove"
RESTORE = "restore"
class ModerationTargetType(str, enum.Enum):
USER = "user"
WORLD = "world"
ASSET = "asset"
CHAT_MESSAGE = "chat_message"
class ModerationAction(Base):
__tablename__ = "moderation_actions"
id = Column(Integer, primary_key=True, autoincrement=True)
moderator_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
target_user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True)
target_type = Column(
ENUM(ModerationTargetType, name="moderation_target_type", create_type=False),
nullable=False,
)
target_id = Column(String(64), nullable=True)
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", foreign_keys=[report_id])
appeals = relationship("Appeal", back_populates="moderation_action", cascade="all, delete-orphan")
-88
View File
@@ -1,88 +0,0 @@
"""Report model for Trust & Safety."""
from datetime import datetime
from typing import Optional
from sqlalchemy import (
Column,
Integer,
String,
Text,
DateTime,
ForeignKey,
UniqueConstraint,
)
from sqlalchemy.dialects.postgresql import ENUM
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
import enum
from app.database import Base
class ReportTargetType(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"
NO_ACTION = "no_action"
FALSE_POSITIVE = "false_positive"
class Report(Base):
__tablename__ = "reports"
id = Column(Integer, primary_key=True, autoincrement=True)
reporter_id = Column(Integer, ForeignKey("users.id"), nullable=False)
target_type = Column(
ENUM(ReportTargetType, name="report_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,
)
resolved_by = Column(Integer, ForeignKey("users.id"), 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), onupdate=func.now())
# Relationships
reporter = relationship("User", foreign_keys=[reporter_id])
resolved_by_user = relationship("User", foreign_keys=[resolved_by])
__table_args__ = (
UniqueConstraint("reporter_id", "target_type", "target_id",
name="uq_reporter_target"),
)
+5 -9
View File
@@ -1,15 +1,16 @@
from datetime import date 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 import enum
from app.database import Base from app.database import Base
class UserRole(str, enum.Enum): class UserRole(str, enum.Enum):
"""User role for moderation and access control."""
USER = "user" USER = "user"
MODERATOR = "moderator" MODERATOR = "moderator"
ADMIN = "admin" ADMIN = "admin"
@@ -25,9 +26,9 @@ 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(UserRole, name="user_role"), ENUM(UserRole, name="user_role", create_type=False),
default=UserRole.USER,
nullable=False, nullable=False,
server_default="user",
) )
banned_until = Column(DateTime(timezone=True), nullable=True) banned_until = Column(DateTime(timezone=True), nullable=True)
suspended_until = Column(DateTime(timezone=True), nullable=True) suspended_until = Column(DateTime(timezone=True), nullable=True)
@@ -37,11 +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")
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_as_moderator = relationship("ModerationAction", foreign_keys="ModerationAction.moderator_id", back_populates="moderator")
moderation_actions_as_target = relationship("ModerationAction", foreign_keys="ModerationAction.target_user_id", back_populates="target_user")
appeals = relationship("Appeal", foreign_keys="Appeal.user_id", back_populates="user")
@property @property
def is_adult(self) -> bool: def is_adult(self) -> bool: