Compare commits

..

2 Commits

4 changed files with 33 additions and 495 deletions
@@ -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)
+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"]
+32
View File
@@ -3,6 +3,10 @@ from sqlalchemy.dialects.postgresql import ENUM
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
import enum
import json
from typing import Optional, List
from pydantic import BaseModel as PydanticBaseModel
from app.database import Base
@@ -38,3 +42,31 @@ class Asset(Base):
owner = relationship("User", back_populates="assets")
world = relationship("World", back_populates="assets")
class GLTFMetadata(PydanticBaseModel):
"""Pydantic schema for glTF asset metadata."""
mesh_count: Optional[int] = None
vertex_count: Optional[int] = None
has_animation: bool = False
texture_refs: Optional[List[str]] = None
tags: Optional[List[str]] = None
model_config = {"from_attributes": True}
def parse_metadata_json(metadata_text: Optional[str]) -> dict:
"""Parse the metadata_json text field into a dict, handling None gracefully."""
if not metadata_text:
return {}
try:
return json.loads(metadata_text)
except (json.JSONDecodeError, TypeError):
return {}
def serialize_metadata(metadata: dict) -> Optional[str]:
"""Serialize metadata dict to JSON string for storage."""
if not metadata:
return None
return json.dumps(metadata)
-225
View File
@@ -1,225 +0,0 @@
"""Trust & Safety models: reports, moderation actions, and appeals."""
from datetime import datetime
from typing import Optional
from sqlalchemy import (
CheckConstraint,
Column,
DateTime,
Enum,
ForeignKey,
Integer,
String,
Text,
UniqueConstraint,
func,
)
from sqlalchemy.dialects.postgresql import ENUM as PG_ENUM
from sqlalchemy.orm import relationship
from app.database import Base
# ---------------------------------------------------------------------------
# ENUM helpers — created explicitly in Alembic, not via SQLAlchemy create_type
# ---------------------------------------------------------------------------
_role_enum = PG_ENUM(
"user", "moderator", "admin",
name="user_role",
create_type=False,
)
_target_type_enum = PG_ENUM(
"user", "world", "asset", "chat_message",
name="target_type",
create_type=False,
)
_reason_category_enum = PG_ENUM(
"harassment", "hate_speech", "sexual_content", "violence",
"spam", "self_harm", "other",
name="reason_category",
create_type=False,
)
_report_status_enum = PG_ENUM(
"open", "in_review", "resolved", "dismissed",
name="report_status",
create_type=False,
)
_resolution_enum = PG_ENUM(
"action_taken", "action_not_needed", "duplicate", "escalated",
name="resolution",
create_type=False,
)
_moderation_action_enum = PG_ENUM(
"warn", "suspend", "ban", "restrict", "remove", "restore",
name="moderation_action",
create_type=False,
)
_appeal_status_enum = PG_ENUM(
"pending", "approved", "rejected",
name="appeal_status",
create_type=False,
)
class Report(Base):
"""A user-submitted report about another entity."""
__tablename__ = "reports"
id = Column(Integer, primary_key=True, autoincrement=True)
reporter_id = Column(
Integer,
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
)
target_type = Column(_target_type_enum, nullable=False)
target_id = Column(String(64), nullable=False)
reason_category = Column(_reason_category_enum, nullable=False)
details = Column(Text, nullable=True)
status = Column(
_report_status_enum,
nullable=False,
server_default="open",
)
resolved_by = Column(
Integer,
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
)
resolution = Column(_resolution_enum, nullable=True)
created_at = Column(
DateTime(timezone=True),
server_default=func.now(),
)
updated_at = Column(
DateTime(timezone=True),
onupdate=func.now(),
)
resolved_at = Column(DateTime(timezone=True), nullable=True)
# Coalesce duplicate reports from the same reporter about the same target
__table_args__ = (
UniqueConstraint("reporter_id", "target_type", "target_id",
name="uq_reporter_target"),
)
# Relationships
reporter = relationship("User", back_populates="reports",
foreign_keys=[reporter_id])
resolved_by_user = relationship("User", back_populates="resolved_reports",
foreign_keys=[resolved_by])
moderation_actions = relationship(
"ModerationAction",
back_populates="report",
cascade="all, delete-orphan",
)
appeals = relationship(
"Appeal",
back_populates="report",
cascade="all, delete-orphan",
)
class ModerationAction(Base):
"""A moderation action taken against a target."""
__tablename__ = "moderation_actions"
id = Column(Integer, primary_key=True, autoincrement=True)
moderator_id = Column(
Integer,
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
)
target_user_id = Column(
Integer,
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
)
target_type = Column(_target_type_enum, nullable=False)
target_id = Column(String(64), nullable=False)
action = Column(_moderation_action_enum, nullable=False)
reason = Column(Text, nullable=False)
report_id = Column(
Integer,
ForeignKey("reports.id", ondelete="SET NULL"),
nullable=True,
)
expires_at = Column(DateTime(timezone=True), nullable=True)
revoked_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(
DateTime(timezone=True),
server_default=func.now(),
)
# Relationships
moderator = relationship("User", back_populates="moderation_actions",
foreign_keys=[moderator_id])
target_user = relationship("User", back_populates="moderation_actions_target",
foreign_keys=[target_user_id])
report = relationship(
"Report",
back_populates="moderation_actions",
)
appeals = relationship(
"Appeal",
back_populates="moderation_action",
cascade="all, delete-orphan",
)
class Appeal(Base):
"""An appeal against a moderation action."""
__tablename__ = "appeals"
id = Column(Integer, primary_key=True, autoincrement=True)
moderation_action_id = Column(
Integer,
ForeignKey("moderation_actions.id", ondelete="CASCADE"),
nullable=False,
)
user_id = Column(
Integer,
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
)
reason = Column(Text, nullable=False)
status = Column(
_appeal_status_enum,
nullable=False,
server_default="pending",
)
reviewed_by = Column(
Integer,
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
)
reviewed_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(
DateTime(timezone=True),
server_default=func.now(),
)
# Prevent duplicate open appeals per sanction
__table_args__ = (
UniqueConstraint("moderation_action_id", "user_id",
name="uq_action_user_appeal"),
)
# Relationships
moderation_action = relationship(
"ModerationAction",
back_populates="appeals",
)
user = relationship("User", back_populates="appeals")
reviewed_by_user = relationship("User", back_populates="reviewed_appeals",
foreign_keys=[reviewed_by])