Compare commits

..

1 Commits

Author SHA1 Message Date
coder-bot dd07baff78 WIP checkpoint: T1: Moderation data model + Alembic migration 2026-08-18 19:07:17 +00:00
8 changed files with 404 additions and 313 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import create_async_engine
from app.config import settings from app.config import settings
from app.database import Base from app.database import Base
from app.models import user, session, world, asset # noqa: F401 from app.models import user, session, world, asset, moderation # noqa: F401
# this is the Alembic Config object, which provides # this is the Alembic Config object, which provides
# access to the values within the .ini file in use. # access to the values within the .ini file in use.
+2 -3
View File
@@ -69,8 +69,7 @@ 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,
), ),
@@ -90,7 +89,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", create_type=False, name="asset_type",
), ),
nullable=False, nullable=False,
), ),
@@ -0,0 +1,310 @@
"""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)
@@ -1,277 +0,0 @@
"""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)
+17 -1
View File
@@ -2,7 +2,17 @@ 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.moderation import Report, ModerationAction, Appeal from app.models.moderation import (
Report,
ModerationAction,
Appeal,
TargetType,
ReasonCategory,
ReportStatus,
Resolution,
ModerationActionType,
AppealStatus,
)
__all__ = [ __all__ = [
"User", "User",
@@ -12,4 +22,10 @@ __all__ = [
"Report", "Report",
"ModerationAction", "ModerationAction",
"Appeal", "Appeal",
"TargetType",
"ReasonCategory",
"ReportStatus",
"Resolution",
"ModerationActionType",
"AppealStatus",
] ]
+71 -22
View File
@@ -1,16 +1,19 @@
"""Moderation data models for Trust & Safety."""
import enum
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
from sqlalchemy import Column, Integer, String, Text, ForeignKey, DateTime from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey
from sqlalchemy.dialects.postgresql import ENUM 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 TargetType(str, enum.Enum): class TargetType(str, enum.Enum):
"""Type of entity being reported or moderated."""
USER = "user" USER = "user"
WORLD = "world" WORLD = "world"
ASSET = "asset" ASSET = "asset"
@@ -18,6 +21,7 @@ class TargetType(str, enum.Enum):
class ReasonCategory(str, enum.Enum): class ReasonCategory(str, enum.Enum):
"""Category of reason for a report."""
HARASSMENT = "harassment" HARASSMENT = "harassment"
HATE_SPEECH = "hate_speech" HATE_SPEECH = "hate_speech"
SEXUAL_CONTENT = "sexual_content" SEXUAL_CONTENT = "sexual_content"
@@ -28,6 +32,7 @@ class ReasonCategory(str, enum.Enum):
class ReportStatus(str, enum.Enum): class ReportStatus(str, enum.Enum):
"""Status of a report."""
OPEN = "open" OPEN = "open"
IN_REVIEW = "in_review" IN_REVIEW = "in_review"
RESOLVED = "resolved" RESOLVED = "resolved"
@@ -35,12 +40,14 @@ class ReportStatus(str, enum.Enum):
class Resolution(str, enum.Enum): class Resolution(str, enum.Enum):
ACTION_TAKEN = "action_taken" """Resolution outcome of a report."""
UPHELD = "upheld"
MODIFIED = "modified"
DISMISSED = "dismissed" DISMISSED = "dismissed"
NO_VIOLATION = "no_violation"
class ActionType(str, enum.Enum): class ModerationActionType(str, enum.Enum):
"""Type of moderation action taken."""
WARN = "warn" WARN = "warn"
SUSPEND = "suspend" SUSPEND = "suspend"
BAN = "ban" BAN = "ban"
@@ -50,16 +57,20 @@ class ActionType(str, enum.Enum):
class AppealStatus(str, enum.Enum): class AppealStatus(str, enum.Enum):
"""Status of an appeal."""
PENDING = "pending" PENDING = "pending"
APPROVED = "approved" APPROVED = "approved"
REJECTED = "rejected" REJECTED = "rejected"
class Report(Base): class Report(Base):
"""A report filed by a user against a target entity."""
__tablename__ = "reports" __tablename__ = "reports"
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
reporter_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) reporter_id = Column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
target_type = Column( target_type = Column(
ENUM(TargetType, name="target_type", create_type=False), ENUM(TargetType, name="target_type", create_type=False),
nullable=False, nullable=False,
@@ -75,63 +86,101 @@ class Report(Base):
default=ReportStatus.OPEN, default=ReportStatus.OPEN,
nullable=False, nullable=False,
) )
resolved_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) resolved_by = Column(
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
resolution = Column( resolution = Column(
ENUM(Resolution, name="resolution", create_type=False), ENUM(Resolution, name="resolution", create_type=False),
nullable=True, 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) 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())
# Relationships
reporter = relationship("User", foreign_keys=[reporter_id]) reporter = relationship("User", foreign_keys=[reporter_id])
resolved_by_user = relationship("User", foreign_keys=[resolved_by]) resolver = relationship("User", foreign_keys=[resolved_by])
moderation_actions = relationship("ModerationAction", back_populates="report") 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 by a moderator against a user or entity."""
__tablename__ = "moderation_actions" __tablename__ = "moderation_actions"
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
moderator_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=False) moderator_id = Column(
target_user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=False
)
target_user_id = Column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
target_type = Column( target_type = Column(
ENUM(TargetType, name="target_type", create_type=False), ENUM(TargetType, name="target_type", create_type=False),
nullable=False, nullable=False,
) )
target_id = Column(String(64), nullable=False) target_id = Column(String(64), nullable=False)
action = Column( action = Column(
ENUM(ActionType, name="action_type", create_type=False), ENUM(ModerationActionType, name="moderation_action_type", create_type=False),
nullable=False, nullable=False,
) )
reason = Column(Text, nullable=True) reason = Column(Text, nullable=True)
report_id = Column(Integer, ForeignKey("reports.id", ondelete="SET NULL"), 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(DateTime(timezone=True), server_default=func.now()) created_at = Column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
# Relationships
moderator = relationship("User", foreign_keys=[moderator_id]) moderator = relationship("User", foreign_keys=[moderator_id])
target_user = relationship("User", foreign_keys=[target_user_id]) target_user = relationship("User", foreign_keys=[target_user_id])
report = relationship("Report", back_populates="moderation_actions") report = relationship("Report", back_populates="moderation_actions")
appeals = relationship("Appeal", back_populates="moderation_action") appeals = relationship(
"Appeal", back_populates="moderation_action", cascade="all, delete-orphan"
)
class Appeal(Base): class Appeal(Base):
"""An appeal filed by a user against a moderation action."""
__tablename__ = "appeals" __tablename__ = "appeals"
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
moderation_action_id = Column(Integer, ForeignKey("moderation_actions.id", ondelete="CASCADE"), nullable=False) moderation_action_id = Column(
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) 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) reason = Column(Text, nullable=False)
status = Column( status = Column(
ENUM(AppealStatus, name="appeal_status", create_type=False), ENUM(AppealStatus, name="appeal_status", create_type=False),
default=AppealStatus.PENDING, default=AppealStatus.PENDING,
nullable=False, nullable=False,
) )
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(DateTime(timezone=True), server_default=func.now()) created_at = Column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
# Relationships
moderation_action = relationship("ModerationAction", back_populates="appeals") moderation_action = relationship("ModerationAction", back_populates="appeals")
user = relationship("User", foreign_keys=[user_id]) user = relationship("User", foreign_keys=[user_id])
reviewed_by_user = relationship("User", foreign_keys=[reviewed_by]) reviewer = relationship("User", foreign_keys=[reviewed_by])
+2 -8
View File
@@ -1,16 +1,16 @@
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 from sqlalchemy import Column, Integer, String, Boolean, DateTime, Date, func
from sqlalchemy.dialects.postgresql import ENUM from sqlalchemy.dialects.postgresql import ENUM
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
import enum import enum
from app.database import Base from app.database import Base
class UserRole(str, enum.Enum): class UserRole(str, enum.Enum):
"""User role for moderation and access control."""
USER = "user" USER = "user"
MODERATOR = "moderator" MODERATOR = "moderator"
ADMIN = "admin" ADMIN = "admin"
@@ -38,12 +38,6 @@ class User(Base):
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:
- "55433:5432" - "5432:5432"
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
healthcheck: healthcheck: