Compare commits

..

1 Commits

Author SHA1 Message Date
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 8 additions and 538 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.
+2 -1
View File
@@ -69,7 +69,8 @@ def upgrade() -> None:
sa.Column(
"visibility",
postgresql.ENUM(
"public", "private", "friends_only", name="world_visibility", create_type=False
"public", "private", "friends_only", name="world_visibility",
create_type=False,
),
nullable=True,
),
-211
View File
@@ -1,211 +0,0 @@
"""add moderation tables and extend users table
Revision ID: 0003_moderation
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_moderation"
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 _create_enum_if_not_exists(enum_name: str, values: list[str]) -> None:
"""Create a PostgreSQL ENUM type if it does not already exist."""
bind = op.get_bind()
# Check if the type already exists in pg_type
result = bind.execute(
sa.text(
"SELECT 1 FROM pg_type WHERE typname = :name"
).bindparams(name=enum_name)
).fetchone()
if result is None:
values_str = ", ".join(f"'{v}'" for v in values)
op.execute(f"CREATE TYPE {enum_name} AS ENUM ({values_str})")
def upgrade() -> None:
"""Add role, banned_until, suspended_until to users table
and create reports, moderation_actions, appeals tables."""
# --- Create ENUM types if they don't exist ---
_create_enum_if_not_exists("user_role", ["user", "moderator", "admin"])
_create_enum_if_not_exists("report_target_type", ["user", "world", "asset", "chat_message"])
_create_enum_if_not_exists("report_reason_category", [
"harassment", "hate_speech", "sexual_content",
"violence", "spam", "self_harm", "other"
])
_create_enum_if_not_exists("report_status", ["open", "in_review", "resolved", "dismissed"])
_create_enum_if_not_exists("report_resolution", ["warn", "suspend", "ban", "restrict", "remove"])
_create_enum_if_not_exists("moderation_target_type", ["user", "world", "asset", "chat_message"])
_create_enum_if_not_exists("moderation_action_type", ["warn", "suspend", "ban", "restrict", "remove", "restore"])
_create_enum_if_not_exists("appeal_status", ["pending", "approved", "rejected"])
# --- Extend users table ---
# Add role column
op.add_column(
"users",
sa.Column(
"role",
postgresql.ENUM("user", "moderator", "admin", name="user_role", create_type=False),
nullable=False,
server_default="user",
),
)
# Add banned_until column
op.add_column(
"users",
sa.Column("banned_until", sa.DateTime(timezone=True), nullable=True),
)
# Add suspended_until column
op.add_column(
"users",
sa.Column("suspended_until", sa.DateTime(timezone=True), nullable=True),
)
# --- Create reports table ---
op.create_table(
"reports",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("reporter_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False),
sa.Column(
"target_type",
postgresql.ENUM("user", "world", "asset", "chat_message", name="report_target_type", create_type=False),
nullable=False,
),
sa.Column("target_id", sa.String(64), nullable=False),
sa.Column(
"reason_category",
postgresql.ENUM(
"harassment", "hate_speech", "sexual_content",
"violence", "spam", "self_harm", "other",
name="report_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(), sa.ForeignKey("users.id"), nullable=True),
sa.Column(
"resolution",
postgresql.ENUM("warn", "suspend", "ban", "restrict", "remove", name="report_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),
)
# Indexes for reports
op.create_index("ix_reports_reporter_id", "reports", ["reporter_id"])
op.create_index("ix_reports_target", "reports", ["target_type", "target_id"])
op.create_index("ix_reports_status", "reports", ["status"])
# --- Create moderation_actions table ---
op.create_table(
"moderation_actions",
sa.Column("id", sa.Integer(), primary_key=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(
"target_type",
postgresql.ENUM("user", "world", "asset", "chat_message", name="moderation_target_type", create_type=False),
nullable=False,
),
sa.Column("target_id", sa.String(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(), sa.ForeignKey("reports.id"), nullable=True),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
# Indexes for moderation_actions
op.create_index("ix_moderation_actions_moderator_id", "moderation_actions", ["moderator_id"])
op.create_index("ix_moderation_actions_target_user", "moderation_actions", ["target_user_id"])
op.create_index("ix_moderation_actions_target", "moderation_actions", ["target_type", "target_id"])
op.create_index("ix_moderation_actions_report_id", "moderation_actions", ["report_id"])
# --- Create appeals table ---
op.create_table(
"appeals",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("moderation_action_id", sa.Integer(), sa.ForeignKey("moderation_actions.id"), nullable=False),
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), 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(), sa.ForeignKey("users.id"), 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),
)
# Indexes for appeals
op.create_index("ix_appeals_moderation_action_id", "appeals", ["moderation_action_id"])
op.create_index("ix_appeals_user_id", "appeals", ["user_id"])
op.create_index("ix_appeals_status", "appeals", ["status"])
def downgrade() -> None:
"""Remove moderation tables and revert users table changes."""
# Drop appeals table
op.drop_index("ix_appeals_status", table_name="appeals")
op.drop_index("ix_appeals_user_id", table_name="appeals")
op.drop_index("ix_appeals_moderation_action_id", table_name="appeals")
op.drop_table("appeals")
# Drop moderation_actions table
op.drop_index("ix_moderation_actions_report_id", table_name="moderation_actions")
op.drop_index("ix_moderation_actions_target", table_name="moderation_actions")
op.drop_index("ix_moderation_actions_target_user", table_name="moderation_actions")
op.drop_index("ix_moderation_actions_moderator_id", table_name="moderation_actions")
op.drop_table("moderation_actions")
# Drop reports table
op.drop_index("ix_reports_status", table_name="reports")
op.drop_index("ix_reports_target", table_name="reports")
op.drop_index("ix_reports_reporter_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 the enum types
op.execute("DROP TYPE IF EXISTS user_role")
op.execute("DROP TYPE IF EXISTS report_target_type")
op.execute("DROP TYPE IF EXISTS report_reason_category")
op.execute("DROP TYPE IF EXISTS report_status")
op.execute("DROP TYPE IF EXISTS report_resolution")
op.execute("DROP TYPE IF EXISTS moderation_target_type")
op.execute("DROP TYPE IF EXISTS moderation_action_type")
op.execute("DROP TYPE IF EXISTS appeal_status")
+1 -2
View File
@@ -2,6 +2,5 @@ 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
__all__ = ["User", "Session", "World", "Asset", "Report", "ModerationAction", "Appeal"]
__all__ = ["User", "Session", "World", "Asset"]
-286
View File
@@ -1,286 +0,0 @@
"""Moderation-related models: Report, ModerationAction, Appeal."""
from datetime import datetime
from typing import Optional
from sqlalchemy import (
Column,
Enum,
ForeignKey,
Integer,
String,
Text,
DateTime,
Index,
)
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.database import Base
# --- Enums ---
class TargetType(str):
USER = "user"
WORLD = "world"
ASSET = "asset"
CHAT_MESSAGE = "chat_message"
class ReasonCategory(str):
HARASSMENT = "harassment"
HATE_SPEECH = "hate_speech"
SEXUAL_CONTENT = "sexual_content"
VIOLENCE = "violence"
SPAM = "spam"
SELF_HARM = "self_harm"
OTHER = "other"
class ReportStatus(str):
OPEN = "open"
IN_REVIEW = "in_review"
RESOLVED = "resolved"
DISMISSED = "dismissed"
class ResolutionAction(str):
WARN = "warn"
SUSPEND = "suspend"
BAN = "ban"
RESTRICT = "restrict"
REMOVE = "remove"
RESTORE = "restore"
class ModerationActionType(str):
WARN = "warn"
SUSPEND = "suspend"
BAN = "ban"
RESTRICT = "restrict"
REMOVE = "remove"
RESTORE = "restore"
class AppealStatus(str):
PENDING = "pending"
APPROVED = "approved"
REJECTED = "rejected"
# --- Models ---
class Report(Base):
"""A moderation report filed by a user or system."""
__tablename__ = "reports"
id = Column(Integer, primary_key=True)
reporter_id = Column(
Integer,
ForeignKey("users.id", ondelete="SET NULL"),
nullable=False,
)
target_type = Column(
Enum(
TargetType.USER,
TargetType.WORLD,
TargetType.ASSET,
TargetType.CHAT_MESSAGE,
name="target_type",
create_type=False,
),
nullable=False,
)
target_id = Column(String(64), nullable=False)
reason_category = Column(
Enum(
ReasonCategory.HARASSMENT,
ReasonCategory.HATE_SPEECH,
ReasonCategory.SEXUAL_CONTENT,
ReasonCategory.VIOLENCE,
ReasonCategory.SPAM,
ReasonCategory.SELF_HARM,
ReasonCategory.OTHER,
name="reason_category",
create_type=False,
),
nullable=False,
)
details = Column(Text, nullable=True)
status = Column(
Enum(
ReportStatus.OPEN,
ReportStatus.IN_REVIEW,
ReportStatus.RESOLVED,
ReportStatus.DISMISSED,
name="report_status",
create_type=False,
),
default=ReportStatus.OPEN,
nullable=False,
)
resolved_by = Column(
Integer,
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
)
resolution = Column(
Enum(
ResolutionAction.WARN,
ResolutionAction.SUSPEND,
ResolutionAction.BAN,
ResolutionAction.RESTRICT,
ResolutionAction.REMOVE,
ResolutionAction.RESTORE,
name="resolution_action",
create_type=False,
),
nullable=True,
)
created_at = Column(
DateTime(timezone=True),
server_default=func.now(),
nullable=False,
)
updated_at = Column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
resolved_at = Column(DateTime(timezone=True), nullable=True)
# Relationships
reporter = relationship("User", foreign_keys=[reporter_id])
resolver = relationship("User", foreign_keys=[resolved_by])
actions = relationship(
"ModerationAction",
back_populates="report",
cascade="all, delete-orphan",
)
__table_args__ = (
Index("ix_reports_target", "target_type", "target_id"),
Index("ix_reports_status", "status"),
Index("ix_reports_created_at", "created_at"),
)
class ModerationAction(Base):
"""A moderation action taken by a moderator/admin."""
__tablename__ = "moderation_actions"
id = Column(Integer, primary_key=True)
moderator_id = Column(
Integer,
ForeignKey("users.id", ondelete="SET NULL"),
nullable=False,
)
target_user_id = Column(
Integer,
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
)
target_type = Column(
Enum(
TargetType.USER,
TargetType.WORLD,
TargetType.ASSET,
TargetType.CHAT_MESSAGE,
name="mod_target_type",
create_type=False,
),
nullable=False,
)
target_id = Column(String(64), nullable=False)
action = Column(
Enum(
ModerationActionType.WARN,
ModerationActionType.SUSPEND,
ModerationActionType.BAN,
ModerationActionType.RESTRICT,
ModerationActionType.REMOVE,
ModerationActionType.RESTORE,
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)
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", back_populates="actions")
appeals = relationship("Appeal", back_populates="moderation_action")
__table_args__ = (
Index("ix_moderation_actions_target", "target_type", "target_id"),
Index("ix_moderation_actions_created_at", "created_at"),
)
class Appeal(Base):
"""An appeal filed by a user against a moderation action."""
__tablename__ = "appeals"
id = Column(Integer, primary_key=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(
AppealStatus.PENDING,
AppealStatus.APPROVED,
AppealStatus.REJECTED,
name="appeal_status",
create_type=False,
),
default=AppealStatus.PENDING,
nullable=False,
)
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"
)
reviewer = relationship("User", foreign_keys=[reviewed_by])
__table_args__ = (
Index("ix_appeals_status", "status"),
Index("ix_appeals_created_at", "created_at"),
)
+2 -35
View File
@@ -1,7 +1,7 @@
from datetime import date, datetime
from datetime import date
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.sql import func
@@ -17,45 +17,12 @@ 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("user", "moderator", "admin", name="user_role", create_type=False),
default="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())
sessions = relationship("Session", back_populates="user", cascade="all, delete-orphan")
worlds = relationship("World", back_populates="owner")
assets = relationship("Asset", back_populates="owner")
reported_by = relationship(
"Report",
foreign_keys="Report.reporter_id",
back_populates="reporter",
)
resolved_reports = relationship(
"Report",
foreign_keys="Report.resolved_by",
back_populates="resolver",
)
moderation_actions = relationship(
"ModerationAction",
foreign_keys="ModerationAction.moderator_id",
back_populates="moderator",
)
target_moderation_actions = relationship(
"ModerationAction",
foreign_keys="ModerationAction.target_user_id",
back_populates="target_user",
)
appeals = relationship("Appeal", back_populates="user")
reviewed_appeals = relationship(
"Appeal",
foreign_keys="Appeal.reviewed_by",
back_populates="reviewer",
)
@property
def is_adult(self) -> bool:
+2 -2
View File
@@ -8,7 +8,7 @@ services:
POSTGRES_PASSWORD: agp_password
POSTGRES_DB: agp_db
ports:
- "5432:5432"
- "${POSTGRES_HOST_PORT:-5432}:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
@@ -20,7 +20,7 @@ services:
redis:
image: redis:7-alpine
ports:
- "6379:6379"
- "${REDIS_HOST_PORT:-6379}:6379"
volumes:
- redis_data:/data
healthcheck: