Coverage for app/backend/src/couchers/models/moderation.py: 97%
88 statements
« prev ^ index » next coverage.py v7.14.2, created at 2026-06-21 09:29 +0000
« prev ^ index » next coverage.py v7.14.2, created at 2026-06-21 09:29 +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 dataclasses import dataclass
10from datetime import datetime
11from functools import cache
12from typing import TYPE_CHECKING, Protocol
14from sqlalchemy import BigInteger, ColumnElement, DateTime, Enum, ForeignKey, Index, Integer, String, func
15from sqlalchemy.orm import Mapped, mapped_column, relationship
17from couchers.models.base import Base, moderation_seq
19if TYPE_CHECKING:
20 from couchers.models.users import User
23class ModerationVisibility(enum.Enum):
24 # Only visible to moderators
25 hidden = enum.auto()
26 # Visible only to content author
27 shadowed = enum.auto()
28 # Visible to everyone, does not appear in listings
29 unlisted = enum.auto()
30 # Visible to everyone, appears in listings
31 visible = enum.auto()
34class ModerationTrigger(enum.Enum):
35 """What triggered adding an item to the moderation queue"""
37 # New content requiring triage
38 initial_review = enum.auto()
39 # User reported/flagged content
40 user_flag = enum.auto()
41 # Automod flagged content
42 machine_flag = enum.auto()
43 # Moderator requested additional review
44 moderator_review = enum.auto()
47class ModerationAction(enum.Enum):
48 """Types of moderation actions that can be taken"""
50 # Initial creation of moderation state
51 create = enum.auto()
52 # Approve content (make visible and listed)
53 approve = enum.auto()
54 # Hide content from everyone
55 hide = enum.auto()
56 # Flag for review
57 flag = enum.auto()
58 # Remove flag
59 unflag = enum.auto()
60 # Change a flag's priority
61 set_priority = enum.auto()
62 # Bulk visibility change applied to every item authored by a user
63 bulk_set_visibility = enum.auto()
66class ModerationObjectType(enum.Enum):
67 """Types of objects that can be moderated"""
69 host_request = enum.auto()
70 group_chat = enum.auto()
71 friend_request = enum.auto()
72 event_occurrence = enum.auto()
73 comment = enum.auto()
74 reply = enum.auto()
75 discussion = enum.auto()
76 reference = enum.auto()
79class ModerationState(Base, kw_only=True):
80 """
81 Moderation state for any moderatable object on the platform
83 This table tracks the visibility and listing state of content.
84 Notifications are linked directly via the moderation_state_id FK on Notification.
85 """
87 __tablename__ = "moderation_states"
89 id: Mapped[int] = mapped_column(
90 BigInteger, moderation_seq, primary_key=True, server_default=moderation_seq.next_value(), init=False
91 )
93 # Generic reference to the moderated object
94 object_type: Mapped[ModerationObjectType] = mapped_column(Enum(ModerationObjectType))
95 object_id: Mapped[int] = mapped_column(BigInteger)
97 visibility: Mapped[ModerationVisibility] = mapped_column(Enum(ModerationVisibility))
99 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
100 updated: Mapped[datetime] = mapped_column(
101 DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), init=False
102 )
104 __table_args__ = (
105 # Each object can only have one moderation state
106 Index("ix_moderation_states_object", object_type, object_id, unique=True),
107 # Covering index for visibility filtering - enables index-only scans in where_moderated_content_visible
108 Index("ix_moderation_states_id_visibility", id, visibility),
109 # Fast filtering by object type and visibility
110 Index("ix_moderation_states_type_visibility", object_type, visibility),
111 )
113 def __repr__(self) -> str:
114 return f"ModerationState(id={self.id}, type={self.object_type}, object_id={self.object_id}, visibility={self.visibility})"
117class ModerationQueueItem(Base, kw_only=True):
118 """
119 Action items in the moderation queue
121 This table tracks what moderators need to review. Items remain in the queue
122 until they are resolved (linked to a ModerationLog entry).
123 """
125 __tablename__ = "moderation_queue"
127 id: Mapped[int] = mapped_column(
128 BigInteger, moderation_seq, primary_key=True, server_default=moderation_seq.next_value(), init=False
129 )
130 moderation_state_id: Mapped[int] = mapped_column(ForeignKey("moderation_states.id"), index=True)
132 time_created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
133 trigger: Mapped[ModerationTrigger] = mapped_column(Enum(ModerationTrigger))
134 reason: Mapped[str] = mapped_column(String)
136 priority: Mapped[int] = mapped_column(Integer, nullable=False, server_default="0", default=0)
138 # When resolved, this links to the log entry that resolved it
139 resolved_by_log_id: Mapped[int | None] = mapped_column(ForeignKey("moderation_log.id"), index=True, default=None)
141 # Relationships
142 moderation_state: Mapped[ModerationState] = relationship(init=False)
144 __table_args__ = (
145 # Fast lookup of unresolved items
146 Index(
147 "ix_moderation_queue_unresolved",
148 moderation_state_id,
149 time_created,
150 postgresql_where=resolved_by_log_id.is_(None),
151 ),
152 )
154 def __repr__(self) -> str:
155 return (
156 f"ModerationQueueItem(id={self.id}, trigger={self.trigger}, resolved={self.resolved_by_log_id is not None})"
157 )
160class ModerationLog(Base, kw_only=True):
161 """
162 History of moderation actions
164 This table provides a complete audit trail of all moderation actions taken,
165 including who performed the action and what changed.
166 """
168 __tablename__ = "moderation_log"
170 id: Mapped[int] = mapped_column(
171 BigInteger, moderation_seq, primary_key=True, server_default=moderation_seq.next_value(), init=False
172 )
173 moderation_state_id: Mapped[int] = mapped_column(ForeignKey("moderation_states.id"), index=True)
175 time: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
176 action: Mapped[ModerationAction] = mapped_column(Enum(ModerationAction))
177 moderator_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
179 # State changes (nullable - only include fields that changed)
180 new_visibility: Mapped[ModerationVisibility | None] = mapped_column(Enum(ModerationVisibility), default=None)
181 new_priority: Mapped[int | None] = mapped_column(Integer, default=None)
183 # The queue item (flag) this action concerned, for flag-level actions
184 queue_item_id: Mapped[int | None] = mapped_column(ForeignKey("moderation_queue.id"), index=True, default=None)
186 # Explanation for the action
187 reason: Mapped[str] = mapped_column(String)
189 # Relationships
190 moderation_state: Mapped[ModerationState] = relationship(init=False)
191 moderator: Mapped[User] = relationship(init=False)
193 __table_args__ = (
194 # Fast lookup of log entries for a given state, ordered by time
195 Index("ix_moderation_log_state_time", moderation_state_id, time.desc()),
196 )
198 def __repr__(self) -> str:
199 return f"ModerationLog(id={self.id}, state_id={self.moderation_state_id}, action={self.action}, moderator={self.moderator_user_id}, time={self.time})"
202class ModeratedContent(Protocol):
203 """A model governed by the UMS, identified by the moderation metadata it declares as class attributes."""
205 __moderation_object_type__: ModerationObjectType
206 __moderation_author_column__: str
209@dataclass(frozen=True)
210class ModeratedModel:
211 """A model governed by the UMS, with its moderation metadata resolved."""
213 object_type: ModerationObjectType
214 model: type[ModeratedContent]
215 author_column: ColumnElement[int]
216 object_id_column: ColumnElement[int]
217 moderation_state_id_column: ColumnElement[int]
220@cache
221def get_moderated_models() -> dict[ModerationObjectType, ModeratedModel]:
222 """
223 Maps each ModerationObjectType to its model and resolved moderation metadata.
225 Discovered from every mapped model that declares __moderation_object_type__, so the moderation
226 metadata stays on the models themselves rather than in a separate hand-maintained list.
227 """
228 models: dict[ModerationObjectType, ModeratedModel] = {}
229 for mapper in Base.registry.mappers:
230 cls = mapper.class_
231 if not hasattr(cls, "__moderation_object_type__"):
232 continue
233 model: type[ModeratedContent] = cls
234 models[model.__moderation_object_type__] = ModeratedModel(
235 object_type=model.__moderation_object_type__,
236 model=model,
237 author_column=mapper.columns[model.__moderation_author_column__],
238 object_id_column=mapper.primary_key[0],
239 moderation_state_id_column=mapper.columns["moderation_state_id"],
240 )
241 return models