Compare commits

..

2 Commits

7 changed files with 327 additions and 382 deletions
+100 -161
View File
@@ -1,8 +1,8 @@
"""trust & safety: reports, moderation_actions, appeals + user role/ban/suspend """add trust & safety columns and tables
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
Create Date: 2025-01-01 00:00:00.000000 Create Date: 2025-07-10 00:00:00.000000
""" """
from typing import Sequence, Union from typing import Sequence, Union
@@ -19,23 +19,20 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None: def upgrade() -> None:
"""Create trust & safety tables and extend users table.""" """Add role/banned_until/suspended_until to users, and create
bind = op.get_bind() reports, moderation_actions, and appeals tables."""
# ── ENUM types ────────────────────────────────────────────────────── # ── ENUM types ──────────────────────────────────────────────
# user_role role_enum = postgresql.ENUM("user", "moderator", "admin", name="role")
user_role = postgresql.ENUM("user", "moderator", "admin", name="user_role") role_enum.create(op.get_bind(), checkfirst=True)
user_role.create(bind, checkfirst=True)
# report_target_type target_type_enum = postgresql.ENUM(
report_target_type = postgresql.ENUM( "user", "world", "asset", "chat_message", name="target_type"
"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", "harassment",
"hate_speech", "hate_speech",
"sexual_content", "sexual_content",
@@ -45,46 +42,40 @@ def upgrade() -> None:
"other", "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", name="report_status" "open", "in_review", "resolved", "dismissed", 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" "action_taken",
"action_not_needed",
"duplicate",
"escalated",
name="resolution",
) )
resolution_enum.create(bind, checkfirst=True) resolution_enum.create(op.get_bind(), checkfirst=True)
# moderation_action_type moderation_action_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",
) )
moderation_action_type.create(bind, checkfirst=True) moderation_action_enum.create(op.get_bind(), checkfirst=True)
# moderation_target_type appeal_status_enum = postgresql.ENUM(
moderation_target_type = postgresql.ENUM(
"user", "world", "asset", "chat_message", name="moderation_target_type"
)
moderation_target_type.create(bind, checkfirst=True)
# appeal_status
appeal_status = postgresql.ENUM(
"pending", "approved", "rejected", name="appeal_status" "pending", "approved", "rejected", name="appeal_status"
) )
appeal_status.create(bind, checkfirst=True) appeal_status_enum.create(op.get_bind(), checkfirst=True)
# ── Extend users table ────────────────────────────────────────────── # ── users table changes ─────────────────────────────────────
op.add_column( op.add_column(
"users", "users",
sa.Column( sa.Column(
"role", "role",
postgresql.ENUM("user", "moderator", "admin", name="user_role"), role_enum,
nullable=False, nullable=False,
server_default="user", server_default="user",
), ),
@@ -98,54 +89,43 @@ 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.BigInteger(), primary_key=True, autoincrement=True),
sa.Column("reporter_id", sa.Integer(), nullable=False), sa.Column(
"reporter_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False
),
sa.Column( sa.Column(
"target_type", "target_type",
postgresql.ENUM( target_type_enum,
"user", "world", "asset", "chat_message", name="report_target_type"
),
nullable=False, nullable=False,
), ),
sa.Column("target_id", sa.String(length=64), nullable=False), sa.Column("target_id", sa.String(length=64), nullable=False),
sa.Column( sa.Column(
"reason_category", "reason_category",
postgresql.ENUM( reason_category_enum,
"harassment",
"hate_speech",
"sexual_content",
"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( report_status_enum,
"open", "in_review", "resolved", "dismissed", name="report_status"
),
nullable=False, nullable=False,
server_default="open", server_default="open",
), ),
sa.Column("resolved_by", sa.Integer(), nullable=True),
sa.Column( sa.Column(
"resolution", "resolved_by",
postgresql.ENUM( sa.Integer(),
"action_taken", "no_action", "false_positive", name="resolution" sa.ForeignKey("users.id", ondelete="SET NULL"),
), nullable=True,
),
sa.Column(
"resolution",
resolution_enum,
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),
@@ -156,49 +136,49 @@ def upgrade() -> None:
"updated_at", "updated_at",
sa.DateTime(timezone=True), sa.DateTime(timezone=True),
onupdate=sa.func.now(), onupdate=sa.func.now(),
nullable=True, nullable=False,
),
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",
), ),
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_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.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(), nullable=False), sa.Column(
sa.Column("target_user_id", sa.Integer(), nullable=True), "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( sa.Column(
"target_type", "target_type",
postgresql.ENUM( target_type_enum,
"user", "world", "asset", "chat_message",
name="moderation_target_type",
),
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( moderation_action_enum,
"warn", "suspend", "ban", "restrict", "remove", "restore",
name="moderation_action_type",
),
nullable=False, nullable=False,
), ),
sa.Column("reason", sa.Text(), nullable=True), sa.Column("reason", sa.Text(), nullable=False),
sa.Column("report_id", sa.Integer(), nullable=True), 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("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(
@@ -207,56 +187,45 @@ 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_id"), "moderation_actions", ["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"], ["moderator_id"],
unique=False, 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", sa.Integer(), nullable=False "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("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( appeal_status_enum,
"pending", "approved", "rejected", name="appeal_status"
),
nullable=False, nullable=False,
server_default="pending", server_default="pending",
), ),
sa.Column("reviewed_by", sa.Integer(), nullable=True), 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("reviewed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column( sa.Column(
"created_at", "created_at",
@@ -264,52 +233,23 @@ def upgrade() -> None:
server_default=sa.func.now(), server_default=sa.func.now(),
nullable=False, nullable=False,
), ),
sa.ForeignKeyConstraint( sa.UniqueConstraint("moderation_action_id", "user_id"),
["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.create_index(op.f("ix_appeals_user_id"), "appeals", ["user_id"], unique=False)
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: def downgrade() -> None:
"""Remove trust & safety tables and revert users table.""" """Remove trust & safety tables and columns."""
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_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_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_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")
op.drop_table("reports") op.drop_table("reports")
@@ -319,11 +259,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").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="role").drop(op.get_bind(), checkfirst=True)
postgresql.ENUM(name="user_role").drop(bind, checkfirst=True)
+1 -3
View File
@@ -2,8 +2,6 @@ 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 Report, ModerationAction, Appeal
from app.models.moderation_action import ModerationAction
from app.models.appeal import Appeal
__all__ = ["User", "Session", "World", "Asset", "Report", "ModerationAction", "Appeal"] __all__ = ["User", "Session", "World", "Asset", "Report", "ModerationAction", "Appeal"]
-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])
+225
View File
@@ -0,0 +1,225 @@
"""Trust & Safety models: reports, moderation actions, and appeals."""
from datetime import datetime
from typing import Optional
from sqlalchemy import (
CheckConstraint,
Column,
DateTime,
Enum,
ForeignKey,
Integer,
String,
Text,
UniqueConstraint,
func,
)
from sqlalchemy.dialects.postgresql import ENUM as PG_ENUM
from sqlalchemy.orm import relationship
from app.database import Base
# ---------------------------------------------------------------------------
# ENUM helpers — created explicitly in Alembic, not via SQLAlchemy create_type
# ---------------------------------------------------------------------------
_role_enum = PG_ENUM(
"user", "moderator", "admin",
name="user_role",
create_type=False,
)
_target_type_enum = PG_ENUM(
"user", "world", "asset", "chat_message",
name="target_type",
create_type=False,
)
_reason_category_enum = PG_ENUM(
"harassment", "hate_speech", "sexual_content", "violence",
"spam", "self_harm", "other",
name="reason_category",
create_type=False,
)
_report_status_enum = PG_ENUM(
"open", "in_review", "resolved", "dismissed",
name="report_status",
create_type=False,
)
_resolution_enum = PG_ENUM(
"action_taken", "action_not_needed", "duplicate", "escalated",
name="resolution",
create_type=False,
)
_moderation_action_enum = PG_ENUM(
"warn", "suspend", "ban", "restrict", "remove", "restore",
name="moderation_action",
create_type=False,
)
_appeal_status_enum = PG_ENUM(
"pending", "approved", "rejected",
name="appeal_status",
create_type=False,
)
class Report(Base):
"""A user-submitted report about another entity."""
__tablename__ = "reports"
id = Column(Integer, primary_key=True, autoincrement=True)
reporter_id = Column(
Integer,
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
)
target_type = Column(_target_type_enum, nullable=False)
target_id = Column(String(64), nullable=False)
reason_category = Column(_reason_category_enum, nullable=False)
details = Column(Text, nullable=True)
status = Column(
_report_status_enum,
nullable=False,
server_default="open",
)
resolved_by = Column(
Integer,
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
)
resolution = Column(_resolution_enum, nullable=True)
created_at = Column(
DateTime(timezone=True),
server_default=func.now(),
)
updated_at = Column(
DateTime(timezone=True),
onupdate=func.now(),
)
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
reporter = relationship("User", back_populates="reports",
foreign_keys=[reporter_id])
resolved_by_user = relationship("User", back_populates="resolved_reports",
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 against a target."""
__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="SET NULL"),
nullable=True,
)
target_type = Column(_target_type_enum, nullable=False)
target_id = Column(String(64), nullable=False)
action = Column(_moderation_action_enum, nullable=False)
reason = Column(Text, nullable=False)
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(),
)
# Relationships
moderator = relationship("User", back_populates="moderation_actions",
foreign_keys=[moderator_id])
target_user = relationship("User", back_populates="moderation_actions_target",
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 against a moderation action."""
__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(
_appeal_status_enum,
nullable=False,
server_default="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(),
)
# Prevent duplicate open appeals per sanction
__table_args__ = (
UniqueConstraint("moderation_action_id", "user_id",
name="uq_action_user_appeal"),
)
# Relationships
moderation_action = relationship(
"ModerationAction",
back_populates="appeals",
)
user = relationship("User", back_populates="appeals")
reviewed_by_user = relationship("User", back_populates="reviewed_appeals",
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"),
)
+1 -20
View File
@@ -1,20 +1,13 @@
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
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"
@@ -24,24 +17,12 @@ 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: