Compare commits

..

4 Commits

Author SHA1 Message Date
coder-bot d61f08210d Fix double CREATE TYPE crashes + oversized revision id in moderation migration
0001_initial.py: restore create_type=False on the two inline ENUM column
declarations (world_visibility, asset_type) -- this task's own auto-checkpoint
had accidentally reverted the 2026-08-18 fix (PR #25) while working on an
unrelated file.

0003_add_moderation_tables_and_user_roles.py: same bug pattern, self-inflicted
this time -- added create_type=False to all 8 inline column ENUM declarations
(user_role, target_type x2, reason_category, report_status, resolution,
action_type, appeal_status). Also shortened the revision id from
0003_add_moderation_tables_and_user_roles (42 chars) to 0003_moderation_schema
(23 chars) -- alembic_version.version_num is VARCHAR(32) by default, so the
migration ran to completion and then failed writing its own bookkeeping row.

Verified against a genuinely fresh Postgres container: full chain
(0001 -> 0002 -> 0003) now runs clean, all 8 tables land.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 13:51:08 +00:00
coder-bot dd56f6a22f WIP checkpoint: Trust & Safety: Moderation data model + Alembic migration 2026-08-19 12:37:46 +00:00
agent-bot ff610c0466 Merge pull request 'Fix migration 0001_initial double CREATE TYPE + port collision' (#25) from fix/migration-0001-double-enum-and-port-collision into main 2026-08-18 21:57:01 +00:00
claude-mvp-fix acbc518b4a Fix migration 0001_initial: double CREATE TYPE crashes every clean bootstrap
world_visibility and asset_type were each declared twice: once explicitly
via `.create(checkfirst=True)`, and again implicitly by SQLAlchemy when
create_table() compiled the inline postgresql.ENUM() column type (which
defaults create_type=True). On a genuinely empty database this raises
asyncpg.DuplicateObjectError mid-transaction and the migration never
completes -- verified this was true 100% of the time from a fresh
container, meaning the backend has never successfully bootstrapped from
scratch. Fixed by passing create_type=False on the inline column
declarations, since the type is already created explicitly above.

Also parameterized both services' host ports (POSTGRES_HOST_PORT /
REDIS_HOST_PORT, defaulting to the original 5432/6379) so concurrent
agent workspaces sharing this same docker-compose.yml can run isolated
databases without colliding on the host port.
2026-08-18 21:55:34 +00:00
7 changed files with 394 additions and 443 deletions
+3 -2
View File
@@ -69,7 +69,8 @@ def upgrade() -> None:
sa.Column( sa.Column(
"visibility", "visibility",
postgresql.ENUM( postgresql.ENUM(
"public", "private", "friends_only", name="world_visibility" "public", "private", "friends_only", name="world_visibility",
create_type=False,
), ),
nullable=True, nullable=True,
), ),
@@ -89,7 +90,7 @@ def upgrade() -> None:
"asset_type", "asset_type",
postgresql.ENUM( postgresql.ENUM(
"image", "audio", "video", "document", "model_3d", "other", "image", "audio", "video", "document", "model_3d", "other",
name="asset_type", name="asset_type", create_type=False,
), ),
nullable=False, nullable=False,
), ),
@@ -0,0 +1,277 @@
"""add moderation tables and user roles
Revision ID: 0003_moderation_schema
Revises: 0002_add_date_of_birth_to_users
Create Date: 2025-01-02 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. Kept short deliberately: alembic's
# own alembic_version.version_num column is VARCHAR(32) by default, and the
# original "0003_add_moderation_tables_and_user_roles" (42 chars) exceeded
# it -- migration ran to completion and then failed writing its own version
# bookkeeping row. Verified 2026-08-19 against a genuinely fresh database.
revision: str = "0003_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:
"""Add moderation tables and user role/ban/suspend columns."""
# --- ENUM types ---
user_role = postgresql.ENUM("user", "moderator", "admin", name="user_role")
user_role.create(op.get_bind(), checkfirst=True)
target_type = postgresql.ENUM(
"user", "world", "asset", "chat_message", name="target_type"
)
target_type.create(op.get_bind(), checkfirst=True)
reason_category = postgresql.ENUM(
"harassment",
"hate_speech",
"sexual_content",
"violence",
"spam",
"self_harm",
"other",
name="reason_category",
)
reason_category.create(op.get_bind(), checkfirst=True)
report_status = postgresql.ENUM(
"open", "in_review", "resolved", "dismissed", name="report_status"
)
report_status.create(op.get_bind(), checkfirst=True)
resolution = postgresql.ENUM(
"action_taken", "dismissed", "no_violation", name="resolution"
)
resolution.create(op.get_bind(), checkfirst=True)
action_type = postgresql.ENUM(
"warn", "suspend", "ban", "restrict", "remove", "restore",
name="action_type",
)
action_type.create(op.get_bind(), checkfirst=True)
appeal_status = postgresql.ENUM(
"pending", "approved", "rejected", name="appeal_status"
)
appeal_status.create(op.get_bind(), checkfirst=True)
# --- Add columns to users table ---
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(
"action_taken", "dismissed", "no_violation", name="resolution", create_type=False,
),
nullable=True,
),
sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
),
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)
# Index on reports.status for filtering by status
op.create_index(op.f("ix_reports_status"), "reports", ["status"], unique=False)
# Composite index for duplicate detection on target
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="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(),
),
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)
# Index on target_user_id for looking up actions against a user
op.create_index(
op.f("ix_moderation_actions_target_user_id"),
"moderation_actions",
["target_user_id"],
unique=False,
)
# Index on revoked_at for finding expired/revoked actions
op.create_index(
op.f("ix_moderation_actions_revoked_at"),
"moderation_actions",
["revoked_at"],
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(),
),
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)
# Index on appeals.status for filtering by status
op.create_index(op.f("ix_appeals_status"), "appeals", ["status"], unique=False)
# Index on moderation_action_id for looking up appeals against an action
op.create_index(
op.f("ix_appeals_moderation_action_id"),
"appeals",
["moderation_action_id"],
unique=False,
)
def downgrade() -> None:
"""Downgrade database schema."""
op.drop_index(op.f("ix_appeals_moderation_action_id"), table_name="appeals")
op.drop_index(op.f("ix_appeals_status"), 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_revoked_at"), 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_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_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="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)
@@ -1,268 +0,0 @@
"""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)
+9 -1
View File
@@ -4,4 +4,12 @@ from app.models.world import World
from app.models.asset import Asset from app.models.asset import Asset
from app.models.moderation import Report, ModerationAction, Appeal from app.models.moderation import Report, ModerationAction, Appeal
__all__ = ["User", "Session", "World", "Asset", "Report", "ModerationAction", "Appeal"] __all__ = [
"User",
"Session",
"World",
"Asset",
"Report",
"ModerationAction",
"Appeal",
]
+83 -171
View File
@@ -1,225 +1,137 @@
"""Trust & Safety models: reports, moderation actions, and appeals."""
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
from sqlalchemy import ( from sqlalchemy import Column, Integer, String, Text, ForeignKey, DateTime
CheckConstraint, from sqlalchemy.dialects.postgresql import ENUM
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.orm import relationship
from sqlalchemy.sql import func
import enum
from app.database import Base from app.database import Base
# --------------------------------------------------------------------------- class TargetType(str, enum.Enum):
# ENUM helpers — created explicitly in Alembic, not via SQLAlchemy create_type USER = "user"
# --------------------------------------------------------------------------- WORLD = "world"
ASSET = "asset"
CHAT_MESSAGE = "chat_message"
_role_enum = PG_ENUM(
"user", "moderator", "admin",
name="user_role",
create_type=False,
)
_target_type_enum = PG_ENUM( class ReasonCategory(str, enum.Enum):
"user", "world", "asset", "chat_message", HARASSMENT = "harassment"
name="target_type", HATE_SPEECH = "hate_speech"
create_type=False, SEXUAL_CONTENT = "sexual_content"
) VIOLENCE = "violence"
SPAM = "spam"
SELF_HARM = "self_harm"
OTHER = "other"
_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( class ReportStatus(str, enum.Enum):
"open", "in_review", "resolved", "dismissed", OPEN = "open"
name="report_status", IN_REVIEW = "in_review"
create_type=False, RESOLVED = "resolved"
) DISMISSED = "dismissed"
_resolution_enum = PG_ENUM(
"action_taken", "action_not_needed", "duplicate", "escalated",
name="resolution",
create_type=False,
)
_moderation_action_enum = PG_ENUM( class Resolution(str, enum.Enum):
"warn", "suspend", "ban", "restrict", "remove", "restore", ACTION_TAKEN = "action_taken"
name="moderation_action", DISMISSED = "dismissed"
create_type=False, NO_VIOLATION = "no_violation"
)
_appeal_status_enum = PG_ENUM(
"pending", "approved", "rejected", class ActionType(str, enum.Enum):
name="appeal_status", WARN = "warn"
create_type=False, SUSPEND = "suspend"
) BAN = "ban"
RESTRICT = "restrict"
REMOVE = "remove"
RESTORE = "restore"
class AppealStatus(str, enum.Enum):
PENDING = "pending"
APPROVED = "approved"
REJECTED = "rejected"
class Report(Base): class Report(Base):
"""A user-submitted report about another entity."""
__tablename__ = "reports" __tablename__ = "reports"
id = Column(Integer, primary_key=True, autoincrement=True) id = Column(Integer, primary_key=True, index=True)
reporter_id = Column( reporter_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
Integer, target_type = Column(
ForeignKey("users.id", ondelete="CASCADE"), ENUM(TargetType, name="target_type", create_type=False),
nullable=False, nullable=False,
) )
target_type = Column(_target_type_enum, nullable=False)
target_id = Column(String(64), nullable=False) target_id = Column(String(64), nullable=False)
reason_category = Column(_reason_category_enum, nullable=False) reason_category = Column(
ENUM(ReasonCategory, name="reason_category", create_type=False),
nullable=False,
)
details = Column(Text, nullable=True) details = Column(Text, nullable=True)
status = Column( status = Column(
_report_status_enum, ENUM(ReportStatus, name="report_status", create_type=False),
default=ReportStatus.OPEN,
nullable=False, nullable=False,
server_default="open",
) )
resolved_by = Column( resolved_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
Integer, resolution = Column(
ForeignKey("users.id", ondelete="SET NULL"), ENUM(Resolution, name="resolution", create_type=False),
nullable=True, 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) resolved_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
# Coalesce duplicate reports from the same reporter about the same target reporter = relationship("User", foreign_keys=[reporter_id])
__table_args__ = ( resolved_by_user = relationship("User", foreign_keys=[resolved_by])
UniqueConstraint("reporter_id", "target_type", "target_id", moderation_actions = relationship("ModerationAction", back_populates="report")
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): class ModerationAction(Base):
"""A moderation action taken against a target."""
__tablename__ = "moderation_actions" __tablename__ = "moderation_actions"
id = Column(Integer, primary_key=True, autoincrement=True) id = Column(Integer, primary_key=True, index=True)
moderator_id = Column( moderator_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=False)
Integer, target_user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
ForeignKey("users.id", ondelete="CASCADE"), target_type = Column(
ENUM(TargetType, name="target_type", create_type=False),
nullable=False, 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) target_id = Column(String(64), nullable=False)
action = Column(_moderation_action_enum, nullable=False) action = Column(
reason = Column(Text, nullable=False) ENUM(ActionType, name="action_type", create_type=False),
report_id = Column( nullable=False,
Integer,
ForeignKey("reports.id", ondelete="SET NULL"),
nullable=True,
) )
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) expires_at = Column(DateTime(timezone=True), nullable=True)
revoked_at = Column(DateTime(timezone=True), nullable=True) revoked_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column( created_at = Column(DateTime(timezone=True), server_default=func.now())
DateTime(timezone=True),
server_default=func.now(),
)
# Relationships moderator = relationship("User", foreign_keys=[moderator_id])
moderator = relationship("User", back_populates="moderation_actions", target_user = relationship("User", foreign_keys=[target_user_id])
foreign_keys=[moderator_id]) report = relationship("Report", back_populates="moderation_actions")
target_user = relationship("User", back_populates="moderation_actions_target", appeals = relationship("Appeal", back_populates="moderation_action")
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): class Appeal(Base):
"""An appeal against a moderation action."""
__tablename__ = "appeals" __tablename__ = "appeals"
id = Column(Integer, primary_key=True, autoincrement=True) id = Column(Integer, primary_key=True, index=True)
moderation_action_id = Column( moderation_action_id = Column(Integer, ForeignKey("moderation_actions.id", ondelete="CASCADE"), nullable=False)
Integer, user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
ForeignKey("moderation_actions.id", ondelete="CASCADE"),
nullable=False,
)
user_id = Column(
Integer,
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
)
reason = Column(Text, nullable=False) reason = Column(Text, nullable=False)
status = Column( status = Column(
_appeal_status_enum, ENUM(AppealStatus, name="appeal_status", create_type=False),
default=AppealStatus.PENDING,
nullable=False, nullable=False,
server_default="pending",
)
reviewed_by = Column(
Integer,
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
) )
reviewed_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
reviewed_at = Column(DateTime(timezone=True), nullable=True) reviewed_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column( created_at = Column(DateTime(timezone=True), server_default=func.now())
DateTime(timezone=True),
server_default=func.now(),
)
# Prevent duplicate open appeals per sanction moderation_action = relationship("ModerationAction", back_populates="appeals")
__table_args__ = ( user = relationship("User", foreign_keys=[user_id])
UniqueConstraint("moderation_action_id", "user_id", reviewed_by_user = relationship("User", foreign_keys=[reviewed_by])
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])
+21
View File
@@ -2,12 +2,20 @@ 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
from sqlalchemy.dialects.postgresql import 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 +25,25 @@ 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", 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()) 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_made = relationship("ModerationAction", foreign_keys="ModerationAction.moderator_id", back_populates="moderator")
moderation_actions_targeted = relationship("ModerationAction", foreign_keys="ModerationAction.target_user_id", back_populates="target_user")
appeals_made = relationship("Appeal", foreign_keys="Appeal.user_id", back_populates="user")
appeals_reviewed = relationship("Appeal", foreign_keys="Appeal.reviewed_by", back_populates="reviewed_by_user")
@property @property
def is_adult(self) -> bool: def is_adult(self) -> bool:
+1 -1
View File
@@ -8,7 +8,7 @@ services:
POSTGRES_PASSWORD: agp_password POSTGRES_PASSWORD: agp_password
POSTGRES_DB: agp_db POSTGRES_DB: agp_db
ports: ports:
- "5432:5432" - "55433:5432"
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
healthcheck: healthcheck: