Compare commits

..

4 Commits

9 changed files with 334 additions and 230 deletions
+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,
), ),
@@ -1,8 +1,8 @@
"""add moderation tables and user roles """trust & safety: reports, moderation_actions, appeals + user role/ban/suspend
Revision ID: 0003_moderation_schema Revision ID: 0003_trust_safety
Revises: 0002_add_date_of_birth_to_users Revises: 0002_add_date_of_birth_to_users
Create Date: 2025-01-02 00:00:00.000000 Create Date: 2025-01-01 00:00:00.000000
""" """
from typing import Sequence, Union from typing import Sequence, Union
@@ -11,29 +11,30 @@ from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy.dialects import postgresql from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic. Kept short deliberately: alembic's # revision identifiers, used by Alembic.
# own alembic_version.version_num column is VARCHAR(32) by default, and the revision: str = "0003_trust_safety"
# 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" down_revision: Union[str, None] = "0002_add_date_of_birth_to_users"
branch_labels: Union[str, Sequence[str], None] = None branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None: def upgrade() -> None:
"""Add moderation tables and user role/ban/suspend columns.""" """Create trust & safety tables and extend users table."""
# --- ENUM types --- bind = op.get_bind()
# ── ENUM types ──────────────────────────────────────────────────────
# user_role
user_role = postgresql.ENUM("user", "moderator", "admin", name="user_role") user_role = postgresql.ENUM("user", "moderator", "admin", name="user_role")
user_role.create(op.get_bind(), checkfirst=True) user_role.create(bind, checkfirst=True)
target_type = postgresql.ENUM( # report_target_type
"user", "world", "asset", "chat_message", name="target_type" report_target_type = postgresql.ENUM(
"user", "world", "asset", "chat_message", name="report_target_type"
) )
target_type.create(op.get_bind(), checkfirst=True) report_target_type.create(bind, checkfirst=True)
# reason_category
reason_category = postgresql.ENUM( reason_category = postgresql.ENUM(
"harassment", "harassment",
"hate_speech", "hate_speech",
@@ -44,53 +45,69 @@ def upgrade() -> None:
"other", "other",
name="reason_category", name="reason_category",
) )
reason_category.create(op.get_bind(), checkfirst=True) reason_category.create(bind, checkfirst=True)
# report_status
report_status = postgresql.ENUM( report_status = postgresql.ENUM(
"open", "in_review", "resolved", "dismissed", name="report_status" "open", "in_review", "resolved", "dismissed", name="report_status"
) )
report_status.create(op.get_bind(), checkfirst=True) report_status.create(bind, checkfirst=True)
resolution = postgresql.ENUM( # resolution
"action_taken", "dismissed", "no_violation", name="resolution" resolution_enum = postgresql.ENUM(
"action_taken", "no_action", "false_positive", name="resolution"
) )
resolution.create(op.get_bind(), checkfirst=True) resolution_enum.create(bind, checkfirst=True)
action_type = postgresql.ENUM( # moderation_action_type
moderation_action_type = postgresql.ENUM(
"warn", "suspend", "ban", "restrict", "remove", "restore", "warn", "suspend", "ban", "restrict", "remove", "restore",
name="action_type", name="moderation_action_type",
) )
action_type.create(op.get_bind(), checkfirst=True) moderation_action_type.create(bind, checkfirst=True)
# moderation_target_type
moderation_target_type = postgresql.ENUM(
"user", "world", "asset", "chat_message", name="moderation_target_type"
)
moderation_target_type.create(bind, checkfirst=True)
# appeal_status
appeal_status = postgresql.ENUM( appeal_status = postgresql.ENUM(
"pending", "approved", "rejected", name="appeal_status" "pending", "approved", "rejected", name="appeal_status"
) )
appeal_status.create(op.get_bind(), checkfirst=True) appeal_status.create(bind, checkfirst=True)
# --- Add columns to users table --- # ── Extend users table ──────────────────────────────────────────────
op.add_column( op.add_column(
"users", "users",
sa.Column( sa.Column(
"role", "role",
postgresql.ENUM("user", "moderator", "admin", name="user_role", create_type=False), postgresql.ENUM("user", "moderator", "admin", name="user_role"),
nullable=False, nullable=False,
server_default="user", server_default="user",
), ),
) )
op.add_column("users", sa.Column("banned_until", sa.DateTime(timezone=True), nullable=True)) op.add_column(
op.add_column("users", sa.Column("suspended_until", sa.DateTime(timezone=True), nullable=True)) "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 --- # ── reports table ───────────────────────────────────────────────────
op.create_table( op.create_table(
"reports", "reports",
sa.Column("id", sa.Integer(), primary_key=True), sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
sa.Column("reporter_id", sa.Integer(), nullable=False), sa.Column("reporter_id", sa.Integer(), nullable=False),
sa.Column( sa.Column(
"target_type", "target_type",
postgresql.ENUM( postgresql.ENUM(
"user", "world", "asset", "chat_message", name="target_type", create_type=False, "user", "world", "asset", "chat_message", name="report_target_type"
), ),
nullable=False, nullable=False,
), ),
@@ -106,15 +123,16 @@ def upgrade() -> None:
"self_harm", "self_harm",
"other", "other",
name="reason_category", name="reason_category",
create_type=False,
), ),
nullable=False, nullable=False,
), ),
sa.Column("details", sa.Text(), nullable=True), sa.Column(
"details", sa.Text(), nullable=True,
),
sa.Column( sa.Column(
"status", "status",
postgresql.ENUM( postgresql.ENUM(
"open", "in_review", "resolved", "dismissed", name="report_status", create_type=False, "open", "in_review", "resolved", "dismissed", name="report_status"
), ),
nullable=False, nullable=False,
server_default="open", server_default="open",
@@ -123,7 +141,7 @@ def upgrade() -> None:
sa.Column( sa.Column(
"resolution", "resolution",
postgresql.ENUM( postgresql.ENUM(
"action_taken", "dismissed", "no_violation", name="resolution", create_type=False, "action_taken", "no_action", "false_positive", name="resolution"
), ),
nullable=True, nullable=True,
), ),
@@ -132,43 +150,50 @@ def upgrade() -> None:
"created_at", "created_at",
sa.DateTime(timezone=True), sa.DateTime(timezone=True),
server_default=sa.func.now(), server_default=sa.func.now(),
nullable=False,
), ),
sa.Column( sa.Column(
"updated_at", "updated_at",
sa.DateTime(timezone=True), sa.DateTime(timezone=True),
server_default=sa.func.now(), onupdate=sa.func.now(),
nullable=True,
), ),
sa.ForeignKeyConstraint(["reporter_id"], ["users.id"], ondelete="CASCADE"), sa.ForeignKeyConstraint(["reporter_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["resolved_by"], ["users.id"], ondelete="SET NULL"), sa.ForeignKeyConstraint(["resolved_by"], ["users.id"], ondelete="SET NULL"),
sa.UniqueConstraint(
"reporter_id", "target_type", "target_id",
name="uq_reporter_target",
),
) )
op.create_index(op.f("ix_reports_id"), "reports", ["id"], unique=False) 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.create_index(
op.f("ix_reports_target"), "reports", ["target_type", "target_id"], unique=False op.f("ix_reports_reporter_id"), "reports", ["reporter_id"], unique=False
)
op.create_index(
op.f("ix_reports_status"), "reports", ["status"], unique=False
) )
# --- moderation_actions table --- # ── moderation_actions table ────────────────────────────────────────
op.create_table( op.create_table(
"moderation_actions", "moderation_actions",
sa.Column("id", sa.Integer(), primary_key=True), sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
sa.Column("moderator_id", sa.Integer(), nullable=False), sa.Column("moderator_id", sa.Integer(), nullable=False),
sa.Column("target_user_id", sa.Integer(), nullable=False), sa.Column("target_user_id", sa.Integer(), nullable=True),
sa.Column( sa.Column(
"target_type", "target_type",
postgresql.ENUM( postgresql.ENUM(
"user", "world", "asset", "chat_message", name="target_type", create_type=False, "user", "world", "asset", "chat_message",
name="moderation_target_type",
), ),
nullable=False, nullable=False,
), ),
sa.Column("target_id", sa.String(length=64), nullable=False), sa.Column("target_id", sa.String(length=64), nullable=True),
sa.Column( sa.Column(
"action", "action",
postgresql.ENUM( postgresql.ENUM(
"warn", "suspend", "ban", "restrict", "remove", "restore", "warn", "suspend", "ban", "restrict", "remove", "restore",
name="action_type", create_type=False, name="moderation_action_type",
), ),
nullable=False, nullable=False,
), ),
@@ -180,32 +205,44 @@ def upgrade() -> None:
"created_at", "created_at",
sa.DateTime(timezone=True), sa.DateTime(timezone=True),
server_default=sa.func.now(), server_default=sa.func.now(),
nullable=False,
),
sa.ForeignKeyConstraint(["moderator_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(
["target_user_id"], ["users.id"], ondelete="CASCADE"
), ),
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"), 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(
# Index on target_user_id for looking up actions against a user 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.create_index(
op.f("ix_moderation_actions_target_user_id"), op.f("ix_moderation_actions_target_user_id"),
"moderation_actions", "moderation_actions",
["target_user_id"], ["target_user_id"],
unique=False, unique=False,
) )
# Index on revoked_at for finding expired/revoked actions
op.create_index( op.create_index(
op.f("ix_moderation_actions_revoked_at"), op.f("ix_moderation_actions_report_id"),
"moderation_actions", "moderation_actions",
["revoked_at"], ["report_id"],
unique=False, unique=False,
) )
# --- appeals table --- # ── appeals table ───────────────────────────────────────────────────
op.create_table( op.create_table(
"appeals", "appeals",
sa.Column("id", sa.Integer(), primary_key=True), sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
sa.Column( sa.Column(
"moderation_action_id", sa.Integer(), nullable=False "moderation_action_id", sa.Integer(), nullable=False
), ),
@@ -214,7 +251,7 @@ def upgrade() -> None:
sa.Column( sa.Column(
"status", "status",
postgresql.ENUM( postgresql.ENUM(
"pending", "approved", "rejected", name="appeal_status", create_type=False, "pending", "approved", "rejected", name="appeal_status"
), ),
nullable=False, nullable=False,
server_default="pending", server_default="pending",
@@ -225,6 +262,7 @@ def upgrade() -> None:
"created_at", "created_at",
sa.DateTime(timezone=True), sa.DateTime(timezone=True),
server_default=sa.func.now(), server_default=sa.func.now(),
nullable=False,
), ),
sa.ForeignKeyConstraint( sa.ForeignKeyConstraint(
["moderation_action_id"], ["moderation_action_id"],
@@ -232,34 +270,47 @@ def upgrade() -> None:
ondelete="CASCADE", ondelete="CASCADE",
), ),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["reviewed_by"], ["users.id"], ondelete="SET NULL"), 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_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.create_index(
op.f("ix_appeals_moderation_action_id"), op.f("ix_appeals_moderation_action_id"),
"appeals", "appeals",
["moderation_action_id"], ["moderation_action_id"],
unique=False, 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: def downgrade() -> None:
"""Downgrade database schema.""" """Remove trust & safety tables and revert users table."""
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_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_index(op.f("ix_appeals_id"), table_name="appeals")
op.drop_table("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_report_id"), 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_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_index(op.f("ix_moderation_actions_id"), table_name="moderation_actions")
op.drop_table("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_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_index(op.f("ix_reports_id"), table_name="reports")
op.drop_table("reports") op.drop_table("reports")
@@ -268,10 +319,11 @@ def downgrade() -> None:
op.drop_column("users", "role") op.drop_column("users", "role")
# Drop ENUM types # Drop ENUM types
postgresql.ENUM(name="appeal_status").drop(op.get_bind(), checkfirst=True) postgresql.ENUM(name="appeal_status").drop(bind, checkfirst=True)
postgresql.ENUM(name="action_type").drop(op.get_bind(), checkfirst=True) postgresql.ENUM(name="moderation_target_type").drop(bind, checkfirst=True)
postgresql.ENUM(name="resolution").drop(op.get_bind(), checkfirst=True) postgresql.ENUM(name="moderation_action_type").drop(bind, checkfirst=True)
postgresql.ENUM(name="report_status").drop(op.get_bind(), checkfirst=True) postgresql.ENUM(name="resolution").drop(bind, checkfirst=True)
postgresql.ENUM(name="reason_category").drop(op.get_bind(), checkfirst=True) postgresql.ENUM(name="report_status").drop(bind, checkfirst=True)
postgresql.ENUM(name="target_type").drop(op.get_bind(), checkfirst=True) postgresql.ENUM(name="reason_category").drop(bind, checkfirst=True)
postgresql.ENUM(name="user_role").drop(op.get_bind(), checkfirst=True) postgresql.ENUM(name="report_target_type").drop(bind, checkfirst=True)
postgresql.ENUM(name="user_role").drop(bind, checkfirst=True)
+4 -10
View File
@@ -2,14 +2,8 @@ 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.report import Report
from app.models.moderation_action import ModerationAction
from app.models.appeal import Appeal
__all__ = [ __all__ = ["User", "Session", "World", "Asset", "Report", "ModerationAction", "Appeal"]
"User",
"Session",
"World",
"Asset",
"Report",
"ModerationAction",
"Appeal",
]
+47
View File
@@ -0,0 +1,47 @@
"""Appeal model for Trust & Safety."""
from datetime import datetime
from typing import Optional
from sqlalchemy import (
Column,
Integer,
Text,
DateTime,
ForeignKey,
)
from sqlalchemy.dialects.postgresql import ENUM
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
import enum
from app.database import Base
class AppealStatus(str, enum.Enum):
PENDING = "pending"
APPROVED = "approved"
REJECTED = "rejected"
class Appeal(Base):
__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(
ENUM(AppelStatus, name="appeal_status", create_type=False),
default=AppealStatus.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(), nullable=False)
# Relationships
moderation_action = relationship("ModerationAction", back_populates="appeals")
user = relationship("User", foreign_keys=[user_id])
reviewer = relationship("User", foreign_keys=[reviewed_by])
-137
View File
@@ -1,137 +0,0 @@
from datetime import datetime
from typing import Optional
from sqlalchemy import Column, Integer, String, Text, ForeignKey, DateTime
from sqlalchemy.dialects.postgresql import ENUM
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
import enum
from app.database import Base
class TargetType(str, enum.Enum):
USER = "user"
WORLD = "world"
ASSET = "asset"
CHAT_MESSAGE = "chat_message"
class ReasonCategory(str, enum.Enum):
HARASSMENT = "harassment"
HATE_SPEECH = "hate_speech"
SEXUAL_CONTENT = "sexual_content"
VIOLENCE = "violence"
SPAM = "spam"
SELF_HARM = "self_harm"
OTHER = "other"
class ReportStatus(str, enum.Enum):
OPEN = "open"
IN_REVIEW = "in_review"
RESOLVED = "resolved"
DISMISSED = "dismissed"
class Resolution(str, enum.Enum):
ACTION_TAKEN = "action_taken"
DISMISSED = "dismissed"
NO_VIOLATION = "no_violation"
class ActionType(str, enum.Enum):
WARN = "warn"
SUSPEND = "suspend"
BAN = "ban"
RESTRICT = "restrict"
REMOVE = "remove"
RESTORE = "restore"
class AppealStatus(str, enum.Enum):
PENDING = "pending"
APPROVED = "approved"
REJECTED = "rejected"
class Report(Base):
__tablename__ = "reports"
id = Column(Integer, primary_key=True, index=True)
reporter_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
target_type = Column(
ENUM(TargetType, name="target_type", create_type=False),
nullable=False,
)
target_id = Column(String(64), nullable=False)
reason_category = Column(
ENUM(ReasonCategory, name="reason_category", create_type=False),
nullable=False,
)
details = Column(Text, nullable=True)
status = Column(
ENUM(ReportStatus, 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(Resolution, name="resolution", create_type=False),
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())
reporter = relationship("User", foreign_keys=[reporter_id])
resolved_by_user = relationship("User", foreign_keys=[resolved_by])
moderation_actions = relationship("ModerationAction", back_populates="report")
class ModerationAction(Base):
__tablename__ = "moderation_actions"
id = Column(Integer, primary_key=True, index=True)
moderator_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=False)
target_user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
target_type = Column(
ENUM(TargetType, name="target_type", create_type=False),
nullable=False,
)
target_id = Column(String(64), nullable=False)
action = Column(
ENUM(ActionType, name="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)
revoked_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
moderator = relationship("User", foreign_keys=[moderator_id])
target_user = relationship("User", foreign_keys=[target_user_id])
report = relationship("Report", back_populates="moderation_actions")
appeals = relationship("Appeal", back_populates="moderation_action")
class Appeal(Base):
__tablename__ = "appeals"
id = Column(Integer, primary_key=True, index=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, 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())
moderation_action = relationship("ModerationAction", back_populates="appeals")
user = relationship("User", foreign_keys=[user_id])
reviewed_by_user = relationship("User", foreign_keys=[reviewed_by])
+63
View File
@@ -0,0 +1,63 @@
"""ModerationAction model for Trust & Safety."""
from datetime import datetime
from typing import Optional
from sqlalchemy import (
Column,
Integer,
String,
Text,
DateTime,
ForeignKey,
)
from sqlalchemy.dialects.postgresql import ENUM
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
import enum
from app.database import Base
class ModerationActionType(str, enum.Enum):
WARN = "warn"
SUSPEND = "suspend"
BAN = "ban"
RESTRICT = "restrict"
REMOVE = "remove"
RESTORE = "restore"
class ModerationTargetType(str, enum.Enum):
USER = "user"
WORLD = "world"
ASSET = "asset"
CHAT_MESSAGE = "chat_message"
class ModerationAction(Base):
__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="CASCADE"), nullable=True)
target_type = Column(
ENUM(ModerationTargetType, name="moderation_target_type", create_type=False),
nullable=False,
)
target_id = Column(String(64), nullable=True)
action = Column(
ENUM(ModerationActionType, 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)
revoked_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", foreign_keys=[report_id])
appeals = relationship("Appeal", back_populates="moderation_action", cascade="all, delete-orphan")
+88
View File
@@ -0,0 +1,88 @@
"""Report model for Trust & Safety."""
from datetime import datetime
from typing import Optional
from sqlalchemy import (
Column,
Integer,
String,
Text,
DateTime,
ForeignKey,
UniqueConstraint,
)
from sqlalchemy.dialects.postgresql import ENUM
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
import enum
from app.database import Base
class ReportTargetType(str, enum.Enum):
USER = "user"
WORLD = "world"
ASSET = "asset"
CHAT_MESSAGE = "chat_message"
class ReasonCategory(str, enum.Enum):
HARASSMENT = "harassment"
HATE_SPEECH = "hate_speech"
SEXUAL_CONTENT = "sexual_content"
VIOLENCE = "violence"
SPAM = "spam"
SELF_HARM = "self_harm"
OTHER = "other"
class ReportStatus(str, enum.Enum):
OPEN = "open"
IN_REVIEW = "in_review"
RESOLVED = "resolved"
DISMISSED = "dismissed"
class Resolution(str, enum.Enum):
ACTION_TAKEN = "action_taken"
NO_ACTION = "no_action"
FALSE_POSITIVE = "false_positive"
class Report(Base):
__tablename__ = "reports"
id = Column(Integer, primary_key=True, autoincrement=True)
reporter_id = Column(Integer, ForeignKey("users.id"), nullable=False)
target_type = Column(
ENUM(ReportTargetType, name="report_target_type", create_type=False),
nullable=False,
)
target_id = Column(String(64), nullable=False)
reason_category = Column(
ENUM(ReasonCategory, name="reason_category", create_type=False),
nullable=False,
)
details = Column(Text, nullable=True)
status = Column(
ENUM(ReportStatus, name="report_status", create_type=False),
default=ReportStatus.OPEN,
)
resolved_by = Column(Integer, ForeignKey("users.id"), nullable=True)
resolution = Column(
ENUM(Resolution, name="resolution", create_type=False),
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), onupdate=func.now())
# Relationships
reporter = relationship("User", foreign_keys=[reporter_id])
resolved_by_user = relationship("User", foreign_keys=[resolved_by])
__table_args__ = (
UniqueConstraint("reporter_id", "target_type", "target_id",
name="uq_reporter_target"),
)
+6 -8
View File
@@ -1,8 +1,7 @@
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, 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 import enum
@@ -26,9 +25,9 @@ class User(Base):
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( role = Column(
ENUM(UserRole, name="user_role", create_type=False), Enum(UserRole, name="user_role"),
default=UserRole.USER,
nullable=False, nullable=False,
server_default="user",
) )
banned_until = Column(DateTime(timezone=True), nullable=True) banned_until = Column(DateTime(timezone=True), nullable=True)
suspended_until = Column(DateTime(timezone=True), nullable=True) suspended_until = Column(DateTime(timezone=True), nullable=True)
@@ -40,10 +39,9 @@ class User(Base):
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_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") 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_as_moderator = 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") moderation_actions_as_target = 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 = 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: