diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 96e2dc3..8e95c5b 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -2,5 +2,8 @@ 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.report import Report +from app.models.moderation_action import ModerationAction +from app.models.appeal import Appeal -__all__ = ["User", "Session", "World", "Asset"] +__all__ = ["User", "Session", "World", "Asset", "Report", "ModerationAction", "Appeal"] diff --git a/backend/app/models/appeal.py b/backend/app/models/appeal.py new file mode 100644 index 0000000..ad8bda4 --- /dev/null +++ b/backend/app/models/appeal.py @@ -0,0 +1,74 @@ +"""Appeal model for moderation actions.""" + +from datetime import datetime +from typing import Optional + +from sqlalchemy import ( + Column, + Enum, + ForeignKey, + Integer, + String, + Text, + DateTime, + CheckConstraint, +) +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func + +from app.database import Base + + +class Appeal(Base): + __tablename__ = "appeals" + + # Status values + APPEAL_STATUS = Enum( + "pending", "approved", "rejected", + name="appeal_status", + create_type=False, + ) + + 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( + APPEAL_STATUS, + nullable=False, + default=APPEAL_STATUS.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") + reviewer = relationship( + "User", + foreign_keys=[reviewed_by], + backref="reviewed_appeals", + ) + + __table_args__ = ( + CheckConstraint( + "status IN ('pending', 'approved', 'rejected')", + name="check_appeal_status", + ), + ) diff --git a/backend/app/models/moderation_action.py b/backend/app/models/moderation_action.py new file mode 100644 index 0000000..55472d2 --- /dev/null +++ b/backend/app/models/moderation_action.py @@ -0,0 +1,113 @@ +"""Moderation action model.""" + +from datetime import datetime +from enum import Enum as PyEnum +from typing import Optional + +from sqlalchemy import ( + Column, + Enum, + ForeignKey, + Integer, + String, + Text, + DateTime, + func, +) +from sqlalchemy.dialects.postgresql import TIMESTAMPTZ +from sqlalchemy.orm import relationship + +from app.database import Base + + +class TargetType(str, PyEnum): + """Type of entity being reported or moderated.""" + USER = "user" + WORLD = "world" + ASSET = "asset" + CHAT_MESSAGE = "chat_message" + + +class ReasonCategory(str, PyEnum): + """Category of report reason.""" + HARASSMENT = "harassment" + HATE_SPEECH = "hate_speech" + SEXUAL_CONTENT = "sexual_content" + VIOLENCE = "violence" + SPAM = "spam" + SELF_HARM = "self_harm" + OTHER = "other" + + +class ReportStatus(str, PyEnum): + """Status of a report.""" + OPEN = "open" + IN_REVIEW = "in_review" + RESOLVED = "resolved" + DISMISSED = "dismissed" + + +class Resolution(str, PyEnum): + """Resolution outcome of a report.""" + ACTION_TAKEN = "action_taken" + NO_ACTION = "no_action" + + +class ModerationActionType(str, PyEnum): + """Type of moderation action.""" + WARN = "warn" + SUSPEND = "suspend" + BAN = "ban" + RESTRICT = "restrict" + REMOVE = "remove" + RESTORE = "restore" + + +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=True, + ) + target_user_id = Column( + Integer, + ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + ) + target_type = Column( + Enum(TargetType, name="target_type_enum", create_type=False), + nullable=False, + ) + target_id = Column(String(64), nullable=False) + action = Column( + Enum(ModerationActionType, name="moderation_action_type_enum", 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(TIMESTAMPTZ, nullable=True) + revoked_at = Column(TIMESTAMPTZ, nullable=True) + created_at = Column( + DateTime(timezone=True), + server_default=func.now(), + ) + + # Relationships + moderator = relationship( + "User", + foreign_keys=[moderator_id], + back_populates="moderation_actions_given", + ) + target_user = relationship( + "User", + foreign_keys=[target_user_id], + back_populates="moderation_actions_received", + ) + report = relationship("Report", back_populates="moderation_actions") diff --git a/backend/app/models/report.py b/backend/app/models/report.py new file mode 100644 index 0000000..8420d4a --- /dev/null +++ b/backend/app/models/report.py @@ -0,0 +1,145 @@ +"""Report model for user-generated moderation reports.""" + +from datetime import datetime +from typing import Optional + +from sqlalchemy import ( + Column, + DateTime, + Enum, + ForeignKey, + Integer, + String, + Text, +) +from sqlalchemy.dialects.postgresql import TIMESTAMPTZ +from sqlalchemy.orm import relationship + +from app.database import Base + + +class ReportStatus: + """Valid statuses for a Report.""" + + OPEN = "open" + IN_REVIEW = "in_review" + RESOLVED = "resolved" + DISMISSED = "dismissed" + + +class Resolution: + """Possible resolution outcomes for a Report.""" + + ACTION_TAKEN = "action_taken" + NO_ACTION = "no_action" + + +class TargetType: + """Types of entities that can be reported.""" + + USER = "user" + WORLD = "world" + ASSET = "asset" + CHAT_MESSAGE = "chat_message" + + +class ReasonCategory: + """Categories for why something was reported.""" + + HARASSMENT = "harassment" + HATE_SPEECH = "hate_speech" + SEXUAL_CONTENT = "sexual_content" + VIOLENCE = "violence" + SPAM = "spam" + SELF_HARM = "self_harm" + OTHER = "other" + + +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.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, + ), + nullable=False, + default=ReportStatus.OPEN, + ) + resolved_by = Column( + Integer, + ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + ) + resolution = Column( + Enum( + Resolution.ACTION_TAKEN, + Resolution.NO_ACTION, + name="resolution", + create_type=False, + ), + nullable=True, + ) + created_at = Column( + TIMESTAMPTZ, + nullable=False, + server_default=sa.func.now(), + ) + updated_at = Column( + TIMESTAMPTZ, + nullable=False, + server_default=sa.func.now(), + onupdate=sa.func.now(), + ) + resolved_at = Column(TIMESTAMPTZ, nullable=True) + + # Relationships + reporter = relationship("User", foreign_keys=[reporter_id]) + resolver = relationship("User", 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", + )