Skip to content

Commit e09c573

Browse files
aapelivclaude
andcommitted
Use a typing.Protocol for moderated content models
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent b980051 commit e09c573

3 files changed

Lines changed: 36 additions & 28 deletions

File tree

app/backend/src/couchers/models/moderation.py

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,25 +9,16 @@
99
from dataclasses import dataclass
1010
from datetime import datetime
1111
from functools import cache
12-
from typing import TYPE_CHECKING, Any
12+
from typing import TYPE_CHECKING, Any, Protocol
1313

1414
from sqlalchemy import BigInteger, DateTime, Enum, ForeignKey, Index, String, func
1515
from sqlalchemy.orm import Mapped, mapped_column, relationship
1616

1717
from couchers.models.base import Base, moderation_seq
1818

1919
if TYPE_CHECKING:
20-
from couchers.models.conversations import GroupChat
21-
from couchers.models.discussions import Comment, Discussion, Reply
22-
from couchers.models.events import EventOccurrence
23-
from couchers.models.host_requests import HostRequest
24-
from couchers.models.rest import FriendRelationship
2520
from couchers.models.users import User
2621

27-
type ModeratedContentModel = type[
28-
HostRequest | GroupChat | FriendRelationship | EventOccurrence | Comment | Reply | Discussion
29-
]
30-
3122

3223
class ModerationVisibility(enum.Enum):
3324
# Only visible to moderators
@@ -199,14 +190,23 @@ def __repr__(self) -> str:
199190
return f"ModerationLog(id={self.id}, state_id={self.moderation_state_id}, action={self.action}, moderator={self.moderator_user_id}, time={self.time})"
200191

201192

193+
class ModeratedContent(Protocol):
194+
"""A model governed by the UMS, identified by the moderation metadata it declares as class attributes."""
195+
196+
__moderation_object_type__: ModerationObjectType
197+
__moderation_author_column__: str
198+
199+
202200
@dataclass(frozen=True)
203201
class ModeratedModel:
204202
"""A model governed by the UMS, with its moderation metadata resolved."""
205203

206204
object_type: ModerationObjectType
207-
model: ModeratedContentModel
208-
# the InstrumentedAttribute of the model's author column; Any avoids descriptor-unwrapping on access
205+
model: type[ModeratedContent]
206+
# InstrumentedAttributes/Columns resolved from the model; Any avoids descriptor-unwrapping on access
209207
author_column: Any
208+
object_id_column: Any
209+
moderation_state_id_column: Any
210210

211211

212212
@cache
@@ -222,9 +222,12 @@ def get_moderated_models() -> dict[ModerationObjectType, ModeratedModel]:
222222
cls = mapper.class_
223223
if not hasattr(cls, "__moderation_object_type__"):
224224
continue
225-
models[cls.__moderation_object_type__] = ModeratedModel(
226-
object_type=cls.__moderation_object_type__,
227-
model=cls,
228-
author_column=getattr(cls, cls.__moderation_author_column__),
225+
model: type[ModeratedContent] = cls
226+
models[model.__moderation_object_type__] = ModeratedModel(
227+
object_type=model.__moderation_object_type__,
228+
model=model,
229+
author_column=getattr(model, model.__moderation_author_column__),
230+
object_id_column=mapper.primary_key[0],
231+
moderation_state_id_column=mapper.columns["moderation_state_id"],
229232
)
230233
return models

app/backend/src/couchers/servicers/moderation.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ def bulk_set_user_content_visibility(
135135
author_exists_clauses = []
136136
for entry in get_moderated_models().values():
137137
author_exists_clauses.append(
138-
exists().where(and_(entry.model.moderation_state_id == ModerationState.id, entry.author_column == user.id))
138+
exists().where(and_(entry.moderation_state_id_column == ModerationState.id, entry.author_column == user.id))
139139
)
140140

141141
states = session.execute(select(ModerationState).where(or_(*author_exists_clauses))).scalars().all()
@@ -335,7 +335,7 @@ def GetModerationQueue(
335335
author_exists_clauses.append(
336336
exists().where(
337337
and_(
338-
entry.model.moderation_state_id == ModerationQueueItem.moderation_state_id,
338+
entry.moderation_state_id_column == ModerationQueueItem.moderation_state_id,
339339
entry.author_column == author_user_id,
340340
)
341341
)
@@ -675,7 +675,7 @@ def ListModerationStates(
675675
author_exists_clauses.append(
676676
exists().where(
677677
and_(
678-
entry.model.moderation_state_id == ModerationState.id,
678+
entry.moderation_state_id_column == ModerationState.id,
679679
entry.author_column == request.author_user_id,
680680
)
681681
)

app/backend/src/couchers/sql.py

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818
if TYPE_CHECKING:
1919
from couchers.materialized_views import LiteUser
20-
from couchers.models.moderation import ModeratedContentModel
20+
from couchers.models.moderation import ModeratedContent
2121

2222
type _UserLike = type[User | LiteUser | SignupFlow]
2323
type _User = type[User | LiteUser]
@@ -139,10 +139,11 @@ def where_user_columns_visible_to_each_other[T: tuple[Any, ...]](
139139

140140
def where_moderated_content_visible_to_user_column[T: tuple[Any, ...]](
141141
query: Select[T],
142-
table: ModeratedContentModel,
142+
table: type[ModeratedContent],
143143
user_id_column: InstrumentedAttribute[int],
144144
is_list_operation: bool = False,
145145
) -> Select[T]:
146+
entry = get_moderated_models()[table.__moderation_object_type__]
146147
aliased_mod_state = aliased(ModerationState)
147148
conditions = [aliased_mod_state.visibility == ModerationVisibility.visible]
148149

@@ -154,19 +155,22 @@ def where_moderated_content_visible_to_user_column[T: tuple[Any, ...]](
154155
conditions.append(
155156
and_(
156157
aliased_mod_state.visibility == ModerationVisibility.shadowed,
157-
getattr(table, table.__moderation_author_column__) == user_id_column,
158+
entry.author_column == user_id_column,
158159
)
159160
)
160161

161-
return query.join(aliased_mod_state, aliased_mod_state.id == table.moderation_state_id).where(or_(*conditions))
162+
return query.join(aliased_mod_state, aliased_mod_state.id == entry.moderation_state_id_column).where(
163+
or_(*conditions)
164+
)
162165

163166

164167
def where_moderated_content_visible[T: tuple[Any, ...]](
165168
query: Select[T],
166169
context: CouchersContext,
167-
table: ModeratedContentModel,
170+
table: type[ModeratedContent],
168171
is_list_operation: bool = False,
169172
) -> Select[T]:
173+
entry = get_moderated_models()[table.__moderation_object_type__]
170174
aliased_mod_state = aliased(ModerationState)
171175
conditions = [aliased_mod_state.visibility == ModerationVisibility.visible]
172176

@@ -179,11 +183,13 @@ def where_moderated_content_visible[T: tuple[Any, ...]](
179183
conditions.append(
180184
and_(
181185
aliased_mod_state.visibility == ModerationVisibility.shadowed,
182-
getattr(table, table.__moderation_author_column__) == context.user_id,
186+
entry.author_column == context.user_id,
183187
)
184188
)
185189

186-
return query.join(aliased_mod_state, aliased_mod_state.id == table.moderation_state_id).where(or_(*conditions))
190+
return query.join(aliased_mod_state, aliased_mod_state.id == entry.moderation_state_id_column).where(
191+
or_(*conditions)
192+
)
187193

188194

189195
def moderation_state_column_visible(
@@ -207,14 +213,13 @@ def moderation_state_column_visible(
207213
shadowed_conditions: list[ColumnElement[bool]] = []
208214
if context.is_logged_in():
209215
for entry in get_moderated_models().values():
210-
object_id_column = entry.model.__mapper__.primary_key[0]
211216
shadowed_conditions.append(
212217
and_(
213218
aliased_mod_state.object_type == entry.object_type,
214219
exists(
215220
select(1)
216221
.select_from(entry.model)
217-
.where(object_id_column == aliased_mod_state.object_id)
222+
.where(entry.object_id_column == aliased_mod_state.object_id)
218223
.where(entry.author_column == context.user_id)
219224
),
220225
)

0 commit comments

Comments
 (0)