Coverage for app / backend / src / couchers / models / moderation.py: 95%
64 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-19 14:14 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-19 14:14 +0000
1"""
2Unified Moderation System (UMS) models
4These models provide a flexible, generic moderation system that can be applied
5to any moderatable content on the platform (host requests, discussions, events, etc.)
6"""
8import enum
9from datetime import datetime
10from typing import TYPE_CHECKING
12from sqlalchemy import BigInteger, DateTime, Enum, ForeignKey, Index, String, func
13from sqlalchemy.orm import Mapped, mapped_column, relationship
15from couchers.models.base import Base, moderation_seq
17if TYPE_CHECKING:
18 from couchers.models.users import User
21class ModerationVisibility(enum.Enum):
22 # Only visible to moderators
23 hidden = enum.auto()
24 # Visible only to content author
25 shadowed = enum.auto()
26 # Visible to everyone, does not appear in listings
27 unlisted = enum.auto()
28 # Visible to everyone, appears in listings
29 visible = enum.auto()
32class ModerationTrigger(enum.Enum):
33 """What triggered adding an item to the moderation queue"""
35 # New content requiring triage
36 initial_review = enum.auto()
37 # User reported/flagged content
38 user_flag = enum.auto()
39 # Automod flagged content
40 machine_flag = enum.auto()
41 # Moderator requested additional review
42 moderator_review = enum.auto()
45class ModerationAction(enum.Enum):
46 """Types of moderation actions that can be taken"""
48 # Initial creation of moderation state
49 create = enum.auto()
50 # Approve content (make visible and listed)
51 approve = enum.auto()
52 # Hide content from everyone
53 hide = enum.auto()
54 # Flag for review
55 flag = enum.auto()
56 # Remove flag
57 unflag = enum.auto()
60class ModerationObjectType(enum.Enum):
61 """Types of objects that can be moderated"""
63 host_request = enum.auto()
64 group_chat = enum.auto()
65 friend_request = enum.auto()
66 event_occurrence = enum.auto()
69class ModerationState(Base, kw_only=True):
70 """
71 Moderation state for any moderatable object on the platform
73 This table tracks the visibility and listing state of content.
74 Notifications are linked directly via the moderation_state_id FK on Notification.
75 """
77 __tablename__ = "moderation_states"
79 id: Mapped[int] = mapped_column(
80 BigInteger, moderation_seq, primary_key=True, server_default=moderation_seq.next_value(), init=False
81 )
83 # Generic reference to the moderated object
84 object_type: Mapped[ModerationObjectType] = mapped_column(Enum(ModerationObjectType))
85 object_id: Mapped[int] = mapped_column(BigInteger)
87 visibility: Mapped[ModerationVisibility] = mapped_column(Enum(ModerationVisibility))
89 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
90 updated: Mapped[datetime] = mapped_column(
91 DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), init=False
92 )
94 __table_args__ = (
95 # Each object can only have one moderation state
96 Index("ix_moderation_states_object", object_type, object_id, unique=True),
97 # Covering index for visibility filtering - enables index-only scans in where_moderated_content_visible
98 Index("ix_moderation_states_id_visibility", id, visibility),
99 )
101 def __repr__(self) -> str:
102 return f"ModerationState(id={self.id}, type={self.object_type}, object_id={self.object_id}, visibility={self.visibility})"
105class ModerationQueueItem(Base, kw_only=True):
106 """
107 Action items in the moderation queue
109 This table tracks what moderators need to review. Items remain in the queue
110 until they are resolved (linked to a ModerationLog entry).
111 """
113 __tablename__ = "moderation_queue"
115 id: Mapped[int] = mapped_column(
116 BigInteger, moderation_seq, primary_key=True, server_default=moderation_seq.next_value(), init=False
117 )
118 moderation_state_id: Mapped[int] = mapped_column(ForeignKey("moderation_states.id"), index=True)
120 time_created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
121 trigger: Mapped[ModerationTrigger] = mapped_column(Enum(ModerationTrigger))
122 reason: Mapped[str] = mapped_column(String)
124 # When resolved, this links to the log entry that resolved it
125 resolved_by_log_id: Mapped[int | None] = mapped_column(ForeignKey("moderation_log.id"), index=True, default=None)
127 # Relationships
128 moderation_state: Mapped[ModerationState] = relationship(init=False)
130 __table_args__ = (
131 # Fast lookup of unresolved items
132 Index(
133 "ix_moderation_queue_unresolved",
134 moderation_state_id,
135 time_created,
136 postgresql_where=resolved_by_log_id.is_(None),
137 ),
138 )
140 def __repr__(self) -> str:
141 return (
142 f"ModerationQueueItem(id={self.id}, trigger={self.trigger}, resolved={self.resolved_by_log_id is not None})"
143 )
146class ModerationLog(Base, kw_only=True):
147 """
148 History of moderation actions
150 This table provides a complete audit trail of all moderation actions taken,
151 including who performed the action and what changed.
152 """
154 __tablename__ = "moderation_log"
156 id: Mapped[int] = mapped_column(
157 BigInteger, moderation_seq, primary_key=True, server_default=moderation_seq.next_value(), init=False
158 )
159 moderation_state_id: Mapped[int] = mapped_column(ForeignKey("moderation_states.id"), index=True)
161 time: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
162 action: Mapped[ModerationAction] = mapped_column(Enum(ModerationAction))
163 moderator_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
165 # State changes (nullable - only include fields that changed)
166 new_visibility: Mapped[ModerationVisibility | None] = mapped_column(Enum(ModerationVisibility), default=None)
168 # Explanation for the action
169 reason: Mapped[str] = mapped_column(String)
171 # Relationships
172 moderation_state: Mapped[ModerationState] = relationship(init=False)
173 moderator: Mapped[User] = relationship(init=False)
175 __table_args__ = (
176 # Fast lookup of log entries for a given state, ordered by time
177 Index("ix_moderation_log_state_time", moderation_state_id, time.desc()),
178 )
180 def __repr__(self) -> str:
181 return f"ModerationLog(id={self.id}, state_id={self.moderation_state_id}, action={self.action}, moderator={self.moderator_user_id}, time={self.time})"