Compare commits

..

2 Commits

6 changed files with 412 additions and 454 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import create_async_engine
from app.config import settings
from app.database import Base
from app.models import user, session, world, asset, moderation # noqa: F401
from app.models import user, session, world, asset # noqa: F401
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
@@ -1,310 +0,0 @@
"""add moderation schema
Revision ID: 0003_add_moderation_schema
Revises: 0002_add_date_of_birth_to_users
Create Date: 2025-01-01 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "0003_add_moderation_schema"
down_revision: Union[str, None] = "0002_add_date_of_birth_to_users"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Create moderation tables and extend users table."""
# ── ENUM types ──────────────────────────────────────────────
target_type_enum = postgresql.ENUM(
"user", "world", "asset", "chat_message",
name="target_type",
)
target_type_enum.create(op.get_bind(), checkfirst=True)
reason_category_enum = postgresql.ENUM(
"harassment", "hate_speech", "sexual_content", "violence",
"spam", "self_harm", "other",
name="reason_category",
)
reason_category_enum.create(op.get_bind(), checkfirst=True)
report_status_enum = postgresql.ENUM(
"open", "in_review", "resolved", "dismissed",
name="report_status",
)
report_status_enum.create(op.get_bind(), checkfirst=True)
resolution_enum = postgresql.ENUM(
"upheld", "modified", "dismissed",
name="resolution",
)
resolution_enum.create(op.get_bind(), checkfirst=True)
moderation_action_type_enum = postgresql.ENUM(
"warn", "suspend", "ban", "restrict", "remove", "restore",
name="moderation_action_type",
)
moderation_action_type_enum.create(op.get_bind(), checkfirst=True)
appeal_status_enum = postgresql.ENUM(
"pending", "approved", "rejected",
name="appeal_status",
)
appeal_status_enum.create(op.get_bind(), checkfirst=True)
user_role_enum = postgresql.ENUM(
"user", "moderator", "admin",
name="user_role",
)
user_role_enum.create(op.get_bind(), checkfirst=True)
# ── users table extensions ──────────────────────────────────
op.add_column(
"users",
sa.Column(
"role",
postgresql.ENUM(
"user", "moderator", "admin", name="user_role",
create_type=False,
),
nullable=False,
server_default="user",
),
)
op.add_column(
"users",
sa.Column("banned_until", sa.DateTime(timezone=True), nullable=True),
)
op.add_column(
"users",
sa.Column("suspended_until", sa.DateTime(timezone=True), nullable=True),
)
# ── reports table ───────────────────────────────────────────
op.create_table(
"reports",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("reporter_id", sa.Integer(), nullable=False),
sa.Column(
"target_type",
postgresql.ENUM(
"user", "world", "asset", "chat_message",
name="target_type", create_type=False,
),
nullable=False,
),
sa.Column("target_id", sa.String(length=64), nullable=False),
sa.Column(
"reason_category",
postgresql.ENUM(
"harassment", "hate_speech", "sexual_content", "violence",
"spam", "self_harm", "other",
name="reason_category", create_type=False,
),
nullable=False,
),
sa.Column("details", sa.Text(), nullable=True),
sa.Column(
"status",
postgresql.ENUM(
"open", "in_review", "resolved", "dismissed",
name="report_status", create_type=False,
),
nullable=False,
server_default="open",
),
sa.Column("resolved_by", sa.Integer(), nullable=True),
sa.Column(
"resolution",
postgresql.ENUM(
"upheld", "modified", "dismissed",
name="resolution", create_type=False,
),
nullable=True,
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(
["reporter_id"], ["users.id"], ondelete="CASCADE",
),
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_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_target"), "reports", ["target_type", "target_id"], unique=False,
)
# ── moderation_actions table ────────────────────────────────
op.create_table(
"moderation_actions",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("moderator_id", sa.Integer(), nullable=False),
sa.Column("target_user_id", sa.Integer(), nullable=False),
sa.Column(
"target_type",
postgresql.ENUM(
"user", "world", "asset", "chat_message",
name="target_type", create_type=False,
),
nullable=False,
),
sa.Column("target_id", sa.String(length=64), nullable=False),
sa.Column(
"action",
postgresql.ENUM(
"warn", "suspend", "ban", "restrict", "remove", "restore",
name="moderation_action_type", create_type=False,
),
nullable=False,
),
sa.Column("reason", sa.Text(), nullable=True),
sa.Column("report_id", sa.Integer(), nullable=True),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.ForeignKeyConstraint(
["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",
),
)
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_target"),
"moderation_actions", ["target_type", "target_id"], unique=False,
)
# ── appeals table ───────────────────────────────────────────
op.create_table(
"appeals",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"moderation_action_id", sa.Integer(), 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", create_type=False,
),
nullable=False,
server_default="pending",
),
sa.Column("reviewed_by", sa.Integer(), nullable=True),
sa.Column("reviewed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.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,
)
def downgrade() -> None:
"""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_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_table("appeals")
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_moderator_id"), table_name="moderation_actions")
op.drop_index(op.f("ix_moderation_actions_id"), table_name="moderation_actions")
op.drop_table("moderation_actions")
op.drop_index(op.f("ix_reports_target"), 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_id"), table_name="reports")
op.drop_table("reports")
op.drop_column("users", "suspended_until")
op.drop_column("users", "banned_until")
op.drop_column("users", "role")
# Drop ENUM types
postgresql.ENUM(name="appeal_status").drop(op.get_bind(), checkfirst=True)
postgresql.ENUM(name="moderation_action_type").drop(op.get_bind(), checkfirst=True)
postgresql.ENUM(name="resolution").drop(op.get_bind(), checkfirst=True)
postgresql.ENUM(name="report_status").drop(op.get_bind(), checkfirst=True)
postgresql.ENUM(name="reason_category").drop(op.get_bind(), checkfirst=True)
postgresql.ENUM(name="target_type").drop(op.get_bind(), checkfirst=True)
postgresql.ENUM(name="user_role").drop(op.get_bind(), checkfirst=True)
@@ -0,0 +1,268 @@
"""add trust & safety columns and tables
Revision ID: 0003_trust_safety
Revises: 0002_add_date_of_birth_to_users
Create Date: 2025-07-10 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "0003_trust_safety"
down_revision: Union[str, None] = "0002_add_date_of_birth_to_users"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Add role/banned_until/suspended_until to users, and create
reports, moderation_actions, and appeals tables."""
# ── ENUM types ──────────────────────────────────────────────
role_enum = postgresql.ENUM("user", "moderator", "admin", name="role")
role_enum.create(op.get_bind(), checkfirst=True)
target_type_enum = postgresql.ENUM(
"user", "world", "asset", "chat_message", name="target_type"
)
target_type_enum.create(op.get_bind(), checkfirst=True)
reason_category_enum = postgresql.ENUM(
"harassment",
"hate_speech",
"sexual_content",
"violence",
"spam",
"self_harm",
"other",
name="reason_category",
)
reason_category_enum.create(op.get_bind(), checkfirst=True)
report_status_enum = postgresql.ENUM(
"open", "in_review", "resolved", "dismissed", name="report_status"
)
report_status_enum.create(op.get_bind(), checkfirst=True)
resolution_enum = postgresql.ENUM(
"action_taken",
"action_not_needed",
"duplicate",
"escalated",
name="resolution",
)
resolution_enum.create(op.get_bind(), checkfirst=True)
moderation_action_enum = postgresql.ENUM(
"warn", "suspend", "ban", "restrict", "remove", "restore",
name="moderation_action",
)
moderation_action_enum.create(op.get_bind(), checkfirst=True)
appeal_status_enum = postgresql.ENUM(
"pending", "approved", "rejected", name="appeal_status"
)
appeal_status_enum.create(op.get_bind(), checkfirst=True)
# ── users table changes ─────────────────────────────────────
op.add_column(
"users",
sa.Column(
"role",
role_enum,
nullable=False,
server_default="user",
),
)
op.add_column(
"users",
sa.Column("banned_until", sa.DateTime(timezone=True), nullable=True),
)
op.add_column(
"users",
sa.Column("suspended_until", sa.DateTime(timezone=True), nullable=True),
)
# ── reports table ───────────────────────────────────────────
op.create_table(
"reports",
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
sa.Column(
"reporter_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False
),
sa.Column(
"target_type",
target_type_enum,
nullable=False,
),
sa.Column("target_id", sa.String(length=64), nullable=False),
sa.Column(
"reason_category",
reason_category_enum,
nullable=False,
),
sa.Column("details", sa.Text(), nullable=True),
sa.Column(
"status",
report_status_enum,
nullable=False,
server_default="open",
),
sa.Column(
"resolved_by",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column(
"resolution",
resolution_enum,
nullable=True,
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
onupdate=sa.func.now(),
nullable=False,
),
sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True),
sa.UniqueConstraint("reporter_id", "target_type", "target_id"),
)
op.create_index(op.f("ix_reports_id"), "reports", ["id"], unique=False)
op.create_index(op.f("ix_reports_reporter_id"), "reports", ["reporter_id"], unique=False)
# ── moderation_actions table ────────────────────────────────
op.create_table(
"moderation_actions",
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
sa.Column(
"moderator_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"target_user_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column(
"target_type",
target_type_enum,
nullable=False,
),
sa.Column("target_id", sa.String(length=64), nullable=False),
sa.Column(
"action",
moderation_action_enum,
nullable=False,
),
sa.Column("reason", sa.Text(), nullable=False),
sa.Column(
"report_id",
sa.Integer(),
sa.ForeignKey("reports.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
)
op.create_index(op.f("ix_moderation_actions_id"), "moderation_actions", ["id"], unique=False)
op.create_index(
op.f("ix_moderation_actions_moderator_id"),
"moderation_actions",
["moderator_id"],
unique=False,
)
# ── appeals table ───────────────────────────────────────────
op.create_table(
"appeals",
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
sa.Column(
"moderation_action_id",
sa.Integer(),
sa.ForeignKey("moderation_actions.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"user_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("reason", sa.Text(), nullable=False),
sa.Column(
"status",
appeal_status_enum,
nullable=False,
server_default="pending",
),
sa.Column(
"reviewed_by",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("reviewed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.UniqueConstraint("moderation_action_id", "user_id"),
)
op.create_index(op.f("ix_appeals_id"), "appeals", ["id"], unique=False)
op.create_index(op.f("ix_appeals_user_id"), "appeals", ["user_id"], unique=False)
def downgrade() -> None:
"""Remove trust & safety tables and columns."""
op.drop_index(op.f("ix_appeals_user_id"), table_name="appeals")
op.drop_index(op.f("ix_appeals_id"), table_name="appeals")
op.drop_table("appeals")
op.drop_index(op.f("ix_moderation_actions_moderator_id"), table_name="moderation_actions")
op.drop_index(op.f("ix_moderation_actions_id"), table_name="moderation_actions")
op.drop_table("moderation_actions")
op.drop_index(op.f("ix_reports_reporter_id"), table_name="reports")
op.drop_index(op.f("ix_reports_id"), table_name="reports")
op.drop_table("reports")
op.drop_column("users", "suspended_until")
op.drop_column("users", "banned_until")
op.drop_column("users", "role")
# Drop ENUM types
postgresql.ENUM(name="appeal_status").drop(op.get_bind(), checkfirst=True)
postgresql.ENUM(name="moderation_action").drop(op.get_bind(), checkfirst=True)
postgresql.ENUM(name="resolution").drop(op.get_bind(), checkfirst=True)
postgresql.ENUM(name="report_status").drop(op.get_bind(), checkfirst=True)
postgresql.ENUM(name="reason_category").drop(op.get_bind(), checkfirst=True)
postgresql.ENUM(name="target_type").drop(op.get_bind(), checkfirst=True)
postgresql.ENUM(name="role").drop(op.get_bind(), checkfirst=True)
+2 -26
View File
@@ -2,30 +2,6 @@ from app.models.user import User
from app.models.session import Session
from app.models.world import World
from app.models.asset import Asset
from app.models.moderation import (
Report,
ModerationAction,
Appeal,
TargetType,
ReasonCategory,
ReportStatus,
Resolution,
ModerationActionType,
AppealStatus,
)
from app.models.moderation import Report, ModerationAction, Appeal
__all__ = [
"User",
"Session",
"World",
"Asset",
"Report",
"ModerationAction",
"Appeal",
"TargetType",
"ReasonCategory",
"ReportStatus",
"Resolution",
"ModerationActionType",
"AppealStatus",
]
__all__ = ["User", "Session", "World", "Asset", "Report", "ModerationAction", "Appeal"]
+139 -100
View File
@@ -1,186 +1,225 @@
"""Moderation data models for Trust & Safety."""
"""Trust & Safety models: reports, moderation actions, and appeals."""
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 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 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"
# ---------------------------------------------------------------------------
# ENUM helpers — created explicitly in Alembic, not via SQLAlchemy create_type
# ---------------------------------------------------------------------------
_role_enum = PG_ENUM(
"user", "moderator", "admin",
name="user_role",
create_type=False,
)
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"
_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,
)
class ReportStatus(str, enum.Enum):
"""Status of a report."""
OPEN = "open"
IN_REVIEW = "in_review"
RESOLVED = "resolved"
DISMISSED = "dismissed"
_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,
)
class Resolution(str, enum.Enum):
"""Resolution outcome of a report."""
UPHELD = "upheld"
MODIFIED = "modified"
DISMISSED = "dismissed"
_moderation_action_enum = PG_ENUM(
"warn", "suspend", "ban", "restrict", "remove", "restore",
name="moderation_action",
create_type=False,
)
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"
_appeal_status_enum = PG_ENUM(
"pending", "approved", "rejected",
name="appeal_status",
create_type=False,
)
class Report(Base):
"""A report filed by a user against a target entity."""
"""A user-submitted report about another entity."""
__tablename__ = "reports"
id = Column(Integer, primary_key=True, index=True)
id = Column(Integer, primary_key=True, autoincrement=True)
reporter_id = Column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
target_type = Column(
ENUM(TargetType, name="target_type", create_type=False),
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(
ENUM(ReasonCategory, name="reason_category", create_type=False),
nullable=False,
)
reason_category = Column(_reason_category_enum, nullable=False)
details = Column(Text, nullable=True)
status = Column(
ENUM(ReportStatus, name="report_status", create_type=False),
default=ReportStatus.OPEN,
_report_status_enum,
nullable=False,
server_default="open",
)
resolved_by = Column(
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
resolution = Column(
ENUM(Resolution, name="resolution", create_type=False),
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(), nullable=False
DateTime(timezone=True),
server_default=func.now(),
)
updated_at = Column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
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", foreign_keys=[reporter_id])
resolver = relationship("User", foreign_keys=[resolved_by])
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"
"ModerationAction",
back_populates="report",
cascade="all, delete-orphan",
)
appeals = relationship(
"Appeal", back_populates="report", cascade="all, delete-orphan"
"Appeal",
back_populates="report",
cascade="all, delete-orphan",
)
class ModerationAction(Base):
"""A moderation action taken by a moderator against a user or entity."""
"""A moderation action taken against a target."""
__tablename__ = "moderation_actions"
id = Column(Integer, primary_key=True, index=True)
id = Column(Integer, primary_key=True, autoincrement=True)
moderator_id = Column(
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=False
Integer,
ForeignKey("users.id", ondelete="CASCADE"),
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,
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(
ENUM(ModerationActionType, name="moderation_action_type", create_type=False),
nullable=False,
)
reason = Column(Text, nullable=True)
action = Column(_moderation_action_enum, nullable=False)
reason = Column(Text, nullable=False)
report_id = Column(
Integer, ForeignKey("reports.id", ondelete="SET NULL"), nullable=True
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
DateTime(timezone=True),
server_default=func.now(),
)
# Relationships
moderator = relationship("User", foreign_keys=[moderator_id])
target_user = relationship("User", foreign_keys=[target_user_id])
report = relationship("Report", back_populates="moderation_actions")
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"
"Appeal",
back_populates="moderation_action",
cascade="all, delete-orphan",
)
class Appeal(Base):
"""An appeal filed by a user against a moderation action."""
"""An appeal against a moderation action."""
__tablename__ = "appeals"
id = Column(Integer, primary_key=True, index=True)
id = Column(Integer, primary_key=True, autoincrement=True)
moderation_action_id = Column(
Integer, ForeignKey("moderation_actions.id", ondelete="CASCADE"),
Integer,
ForeignKey("moderation_actions.id", ondelete="CASCADE"),
nullable=False,
)
user_id = Column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
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,
_appeal_status_enum,
nullable=False,
server_default="pending",
)
reviewed_by = Column(
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
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
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", foreign_keys=[user_id])
reviewer = relationship("User", foreign_keys=[reviewed_by])
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])
+2 -17
View File
@@ -1,21 +1,13 @@
from datetime import date
from typing import Optional
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Date, func
from sqlalchemy.dialects.postgresql import ENUM
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Date
from sqlalchemy.orm import relationship
import enum
from sqlalchemy.sql import func
from app.database import Base
class UserRole(str, enum.Enum):
"""User role for moderation and access control."""
USER = "user"
MODERATOR = "moderator"
ADMIN = "admin"
class User(Base):
__tablename__ = "users"
@@ -25,13 +17,6 @@ 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", create_type=False),
default=UserRole.USER,
nullable=False,
)
banned_until = Column(DateTime(timezone=True), nullable=True)
suspended_until = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())