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
Revises: 0002_add_date_of_birth_to_users
@@ -20,65 +20,65 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Create trust & safety tables and extend users table."""
# ── ENUM types ──────────────────────────────────────────────
bind = op.get_bind()
# role enum for users table
user_role = postgresql.ENUM(
"user", "moderator", "admin", name="user_role", create_type=True
)
user_role.create(op.get_bind(), checkfirst=True)
# ── ENUM types ──────────────────────────────────────────────────────
# 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(
"user", "world", "asset", "chat_message",
name="report_target_type", create_type=True,
"user", "world", "asset", "chat_message", name="report_target_type"
)
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(
"harassment", "hate_speech", "sexual_content", "violence",
"spam", "self_harm", "other",
name="reason_category", create_type=True,
"harassment",
"hate_speech",
"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(
"open", "in_review", "resolved", "dismissed",
name="report_status", create_type=True,
"open", "in_review", "resolved", "dismissed", name="report_status"
)
report_status.create(op.get_bind(), checkfirst=True)
report_status.create(bind, checkfirst=True)
# resolution enum
# resolution
resolution_enum = postgresql.ENUM(
"action_taken", "no_action", "false_positive",
name="resolution", create_type=True,
"action_taken", "no_action", "false_positive", name="resolution"
)
resolution_enum.create(op.get_bind(), checkfirst=True)
resolution_enum.create(bind, checkfirst=True)
# moderation action enum
moderation_action = postgresql.ENUM(
# moderation_action_type
moderation_action_type = postgresql.ENUM(
"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
mod_target_type = postgresql.ENUM(
"user", "world", "asset", "chat_message",
name="moderation_target_type", create_type=True,
# moderation_target_type
moderation_target_type = postgresql.ENUM(
"user", "world", "asset", "chat_message", name="moderation_target_type"
)
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(
"pending", "approved", "rejected",
name="appeal_status", create_type=True,
"pending", "approved", "rejected", name="appeal_status"
)
appeal_status.create(op.get_bind(), checkfirst=True)
appeal_status.create(bind, checkfirst=True)
# ── Extend users table ──────────────────────────────────────
# ── Extend users table ──────────────────────────────────────────────
op.add_column(
"users",
@@ -91,27 +91,19 @@ def upgrade() -> None:
)
op.add_column(
"users",
sa.Column(
"banned_until",
sa.DateTime(timezone=True),
nullable=True,
),
sa.Column("banned_until", sa.DateTime(timezone=True), nullable=True),
)
op.add_column(
"users",
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(
"reports",
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(
"target_type",
postgresql.ENUM(
@@ -123,32 +115,33 @@ def upgrade() -> None:
sa.Column(
"reason_category",
postgresql.ENUM(
"harassment", "hate_speech", "sexual_content", "violence",
"spam", "self_harm", "other",
"harassment",
"hate_speech",
"sexual_content",
"violence",
"spam",
"self_harm",
"other",
name="reason_category",
),
nullable=False,
),
sa.Column(
"details",
sa.Text(),
nullable=True,
"details", sa.Text(), nullable=True,
),
sa.Column(
"status",
postgresql.ENUM(
"open", "in_review", "resolved", "dismissed",
name="report_status",
"open", "in_review", "resolved", "dismissed", name="report_status"
),
nullable=False,
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(
"resolution",
postgresql.ENUM(
"action_taken", "no_action", "false_positive",
name="resolution",
"action_taken", "no_action", "false_positive", name="resolution"
),
nullable=True,
),
@@ -165,22 +158,28 @@ def upgrade() -> None:
onupdate=sa.func.now(),
nullable=True,
),
sa.ForeignKeyConstraint(["reporter_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["resolved_by"], ["users.id"], ondelete="SET NULL"),
sa.UniqueConstraint(
"reporter_id", "target_type", "target_id",
name="uq_reporter_target",
),
)
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_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(
"moderation_actions",
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=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("moderator_id", sa.Integer(), nullable=False),
sa.Column("target_user_id", sa.Integer(), nullable=True),
sa.Column(
"target_type",
postgresql.ENUM(
@@ -194,12 +193,12 @@ def upgrade() -> None:
"action",
postgresql.ENUM(
"warn", "suspend", "ban", "restrict", "remove", "restore",
name="moderation_action",
name="moderation_action_type",
),
nullable=False,
),
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("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
@@ -208,35 +207,56 @@ def upgrade() -> None:
server_default=sa.func.now(),
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(
"appeals",
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
sa.Column(
"moderation_action_id",
sa.Integer(),
sa.ForeignKey("moderation_actions.id"),
nullable=False,
"moderation_action_id", sa.Integer(), 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(
"status",
postgresql.ENUM(
"pending", "approved", "rejected",
name="appeal_status",
"pending", "approved", "rejected", name="appeal_status"
),
nullable=False,
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(
"created_at",
@@ -244,16 +264,39 @@ def upgrade() -> None:
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)
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 trust & safety tables and revert users table."""
# Drop indexes first
"""Remove trust & safety 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")
@@ -271,17 +314,16 @@ def downgrade() -> None:
op.drop_index(op.f("ix_reports_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 ENUM types
postgresql.ENUM(name="appeal_status").drop(op.get_bind(), checkfirst=True)
postgresql.ENUM(name="moderation_target_type").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="report_target_type").drop(op.get_bind(), checkfirst=True)
postgresql.ENUM(name="user_role").drop(op.get_bind(), checkfirst=True)
postgresql.ENUM(name="appeal_status").drop(bind, checkfirst=True)
postgresql.ENUM(name="moderation_target_type").drop(bind, checkfirst=True)
postgresql.ENUM(name="moderation_action_type").drop(bind, checkfirst=True)
postgresql.ENUM(name="resolution").drop(bind, checkfirst=True)
postgresql.ENUM(name="report_status").drop(bind, checkfirst=True)
postgresql.ENUM(name="reason_category").drop(bind, checkfirst=True)
postgresql.ENUM(name="report_target_type").drop(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 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.sql import func
import enum
from app.database import Base
class UserRole(str, enum.Enum):
USER = "user"
MODERATOR = "moderator"
ADMIN = "admin"
class User(Base):
__tablename__ = "users"
@@ -17,12 +24,24 @@ class User(Base):
hashed_password = Column(String(255), nullable=False)
date_of_birth = Column(Date, nullable=True)
is_active = Column(Boolean, default=True)
role = Column(
Enum(UserRole, name="user_role"),
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())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
sessions = relationship("Session", back_populates="user", cascade="all, delete-orphan")
worlds = relationship("World", back_populates="owner")
assets = relationship("Asset", back_populates="owner")
reports_made = relationship("Report", foreign_keys="Report.reporter_id", back_populates="reporter")
reports_resolved = relationship("Report", foreign_keys="Report.resolved_by", back_populates="resolved_by_user")
moderation_actions_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
def is_adult(self) -> bool: