Recovered orphaned WIP before switching to a new task

This commit is contained in:
2026-08-18 20:00:14 +00:00
parent dee8b19d6a
commit 9f8a3fb137
3 changed files with 219 additions and 95 deletions
+136 -94
View File
@@ -1,4 +1,4 @@
"""trust & safety schema """trust & safety: reports, moderation_actions, appeals + user role/ban/suspend
Revision ID: 0003_trust_safety Revision ID: 0003_trust_safety
Revises: 0002_add_date_of_birth_to_users Revises: 0002_add_date_of_birth_to_users
@@ -20,65 +20,65 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None: def upgrade() -> None:
"""Create trust & safety tables and extend users table.""" """Create trust & safety tables and extend users table."""
# ── ENUM types ────────────────────────────────────────────── bind = op.get_bind()
# role enum for users table # ── ENUM types ──────────────────────────────────────────────────────
user_role = postgresql.ENUM(
"user", "moderator", "admin", name="user_role", create_type=True
)
user_role.create(op.get_bind(), checkfirst=True)
# report target_type enum # user_role
user_role = postgresql.ENUM("user", "moderator", "admin", name="user_role")
user_role.create(bind, checkfirst=True)
# report_target_type
report_target_type = postgresql.ENUM( report_target_type = postgresql.ENUM(
"user", "world", "asset", "chat_message", "user", "world", "asset", "chat_message", name="report_target_type"
name="report_target_type", create_type=True,
) )
report_target_type.create(op.get_bind(), checkfirst=True) report_target_type.create(bind, checkfirst=True)
# reason_category enum # reason_category
reason_category = postgresql.ENUM( reason_category = postgresql.ENUM(
"harassment", "hate_speech", "sexual_content", "violence", "harassment",
"spam", "self_harm", "other", "hate_speech",
name="reason_category", create_type=True, "sexual_content",
"violence",
"spam",
"self_harm",
"other",
name="reason_category",
) )
reason_category.create(op.get_bind(), checkfirst=True) reason_category.create(bind, checkfirst=True)
# report status enum # report_status
report_status = postgresql.ENUM( report_status = postgresql.ENUM(
"open", "in_review", "resolved", "dismissed", "open", "in_review", "resolved", "dismissed", name="report_status"
name="report_status", create_type=True,
) )
report_status.create(op.get_bind(), checkfirst=True) report_status.create(bind, checkfirst=True)
# resolution enum # resolution
resolution_enum = postgresql.ENUM( resolution_enum = postgresql.ENUM(
"action_taken", "no_action", "false_positive", "action_taken", "no_action", "false_positive", name="resolution"
name="resolution", create_type=True,
) )
resolution_enum.create(op.get_bind(), checkfirst=True) resolution_enum.create(bind, checkfirst=True)
# moderation action enum # moderation_action_type
moderation_action = postgresql.ENUM( moderation_action_type = postgresql.ENUM(
"warn", "suspend", "ban", "restrict", "remove", "restore", "warn", "suspend", "ban", "restrict", "remove", "restore",
name="moderation_action", create_type=True, name="moderation_action_type",
) )
moderation_action.create(op.get_bind(), checkfirst=True) moderation_action_type.create(bind, checkfirst=True)
# moderation target_type enum # moderation_target_type
mod_target_type = postgresql.ENUM( moderation_target_type = postgresql.ENUM(
"user", "world", "asset", "chat_message", "user", "world", "asset", "chat_message", name="moderation_target_type"
name="moderation_target_type", create_type=True,
) )
mod_target_type.create(op.get_bind(), checkfirst=True) moderation_target_type.create(bind, checkfirst=True)
# appeal status enum # appeal_status
appeal_status = postgresql.ENUM( appeal_status = postgresql.ENUM(
"pending", "approved", "rejected", "pending", "approved", "rejected", name="appeal_status"
name="appeal_status", create_type=True,
) )
appeal_status.create(op.get_bind(), checkfirst=True) appeal_status.create(bind, checkfirst=True)
# ── Extend users table ────────────────────────────────────── # ── Extend users table ──────────────────────────────────────────────
op.add_column( op.add_column(
"users", "users",
@@ -91,27 +91,19 @@ def upgrade() -> None:
) )
op.add_column( op.add_column(
"users", "users",
sa.Column( sa.Column("banned_until", sa.DateTime(timezone=True), nullable=True),
"banned_until",
sa.DateTime(timezone=True),
nullable=True,
),
) )
op.add_column( op.add_column(
"users", "users",
sa.Column( sa.Column("suspended_until", sa.DateTime(timezone=True), nullable=True),
"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.BigInteger(), primary_key=True, autoincrement=True),
sa.Column("reporter_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False), sa.Column("reporter_id", sa.Integer(), nullable=False),
sa.Column( sa.Column(
"target_type", "target_type",
postgresql.ENUM( postgresql.ENUM(
@@ -123,32 +115,33 @@ def upgrade() -> None:
sa.Column( sa.Column(
"reason_category", "reason_category",
postgresql.ENUM( 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",
), ),
nullable=False, nullable=False,
), ),
sa.Column( sa.Column(
"details", "details", sa.Text(), nullable=True,
sa.Text(),
nullable=True,
), ),
sa.Column( sa.Column(
"status", "status",
postgresql.ENUM( postgresql.ENUM(
"open", "in_review", "resolved", "dismissed", "open", "in_review", "resolved", "dismissed", name="report_status"
name="report_status",
), ),
nullable=False, nullable=False,
server_default="open", server_default="open",
), ),
sa.Column("resolved_by", sa.Integer(), sa.ForeignKey("users.id"), nullable=True), sa.Column("resolved_by", sa.Integer(), nullable=True),
sa.Column( sa.Column(
"resolution", "resolution",
postgresql.ENUM( postgresql.ENUM(
"action_taken", "no_action", "false_positive", "action_taken", "no_action", "false_positive", name="resolution"
name="resolution",
), ),
nullable=True, nullable=True,
), ),
@@ -165,22 +158,28 @@ def upgrade() -> None:
onupdate=sa.func.now(), onupdate=sa.func.now(),
nullable=True, nullable=True,
), ),
sa.ForeignKeyConstraint(["reporter_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["resolved_by"], ["users.id"], ondelete="SET NULL"),
sa.UniqueConstraint( sa.UniqueConstraint(
"reporter_id", "target_type", "target_id", "reporter_id", "target_type", "target_id",
name="uq_reporter_target", name="uq_reporter_target",
), ),
) )
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.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_reporter_id"), "reports", ["reporter_id"], unique=False
)
op.create_index(
op.f("ix_reports_status"), "reports", ["status"], 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.BigInteger(), primary_key=True, autoincrement=True),
sa.Column("moderator_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False), sa.Column("moderator_id", sa.Integer(), nullable=False),
sa.Column("target_user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True), sa.Column("target_user_id", sa.Integer(), nullable=True),
sa.Column( sa.Column(
"target_type", "target_type",
postgresql.ENUM( postgresql.ENUM(
@@ -194,12 +193,12 @@ def upgrade() -> None:
"action", "action",
postgresql.ENUM( postgresql.ENUM(
"warn", "suspend", "ban", "restrict", "remove", "restore", "warn", "suspend", "ban", "restrict", "remove", "restore",
name="moderation_action", name="moderation_action_type",
), ),
nullable=False, nullable=False,
), ),
sa.Column("reason", sa.Text(), nullable=True), sa.Column("reason", sa.Text(), nullable=True),
sa.Column("report_id", sa.Integer(), sa.ForeignKey("reports.id"), nullable=True), sa.Column("report_id", sa.Integer(), nullable=True),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True), sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.Column( sa.Column(
@@ -208,35 +207,56 @@ 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(
["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_report_id"),
"moderation_actions",
["report_id"],
unique=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)
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_report_id"), "moderation_actions", ["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.BigInteger(), primary_key=True, autoincrement=True),
sa.Column( sa.Column(
"moderation_action_id", "moderation_action_id", sa.Integer(), nullable=False
sa.Integer(),
sa.ForeignKey("moderation_actions.id"),
nullable=False,
), ),
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), 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", "pending", "approved", "rejected", name="appeal_status"
name="appeal_status",
), ),
nullable=False, nullable=False,
server_default="pending", server_default="pending",
), ),
sa.Column("reviewed_by", sa.Integer(), sa.ForeignKey("users.id"), nullable=True), sa.Column("reviewed_by", sa.Integer(), nullable=True),
sa.Column("reviewed_at", sa.DateTime(timezone=True), nullable=True), sa.Column("reviewed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column( sa.Column(
"created_at", "created_at",
@@ -244,16 +264,39 @@ def upgrade() -> None:
server_default=sa.func.now(), server_default=sa.func.now(),
nullable=False, 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_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.create_index(op.f("ix_appeals_user_id"), "appeals", ["user_id"], unique=False) op.f("ix_appeals_moderation_action_id"),
op.create_index(op.f("ix_appeals_status"), "appeals", ["status"], unique=False) "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: def downgrade() -> None:
"""Drop trust & safety tables and revert users table.""" """Remove trust & safety tables and revert users table."""
# Drop indexes first
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")
@@ -271,17 +314,16 @@ def downgrade() -> None:
op.drop_index(op.f("ix_reports_id"), table_name="reports") op.drop_index(op.f("ix_reports_id"), table_name="reports")
op.drop_table("reports") op.drop_table("reports")
# Remove columns from users table
op.drop_column("users", "suspended_until") op.drop_column("users", "suspended_until")
op.drop_column("users", "banned_until") op.drop_column("users", "banned_until")
op.drop_column("users", "role") op.drop_column("users", "role")
# Drop ENUM types # Drop ENUM types
postgresql.ENUM(name="appeal_status").drop(op.get_bind(), checkfirst=True) postgresql.ENUM(name="appeal_status").drop(bind, checkfirst=True)
postgresql.ENUM(name="moderation_target_type").drop(op.get_bind(), checkfirst=True) postgresql.ENUM(name="moderation_target_type").drop(bind, checkfirst=True)
postgresql.ENUM(name="moderation_action").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="report_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)
+63
View File
@@ -0,0 +1,63 @@
"""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")
+20 -1
View File
@@ -1,13 +1,20 @@
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 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
import enum
from app.database import Base from app.database import Base
class UserRole(str, enum.Enum):
USER = "user"
MODERATOR = "moderator"
ADMIN = "admin"
class User(Base): class User(Base):
__tablename__ = "users" __tablename__ = "users"
@@ -17,12 +24,24 @@ 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(UserRole, name="user_role"),
nullable=False,
server_default="user",
)
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")
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: