Skip to content

Commit 75498fb

Browse files
aapelivclaude
andcommitted
Discover moderated content models dynamically
Replace the hand-maintained moderated-model lists (the per-type branches in moderation_state_column_visible and the moderationobjecttype2model dict) with a registry built from __moderation_object_type__ declared on each model, and factor the duplicated mutual-block clause into a shared helper. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 83915f9 commit 75498fb

8 files changed

Lines changed: 96 additions & 132 deletions

File tree

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from couchers.constants import DATETIME_INFINITY, DATETIME_MINUS_INFINITY
1111
from couchers.models.base import Base
1212
from couchers.models.host_requests import HostRequestStatus
13+
from couchers.models.moderation import ModerationObjectType
1314
from couchers.utils import now
1415

1516
if TYPE_CHECKING:
@@ -38,6 +39,7 @@ class GroupChat(Base, kw_only=True):
3839

3940
__tablename__ = "group_chats"
4041
__moderation_author_column__ = "creator_id"
42+
__moderation_object_type__ = ModerationObjectType.group_chat
4143

4244
conversation_id: Mapped[int] = mapped_column("id", ForeignKey("conversations.id"), primary_key=True)
4345

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from sqlalchemy.orm import Mapped, column_property, mapped_column, relationship
66

77
from couchers.models.base import Base, communities_seq
8+
from couchers.models.moderation import ModerationObjectType
89

910
if TYPE_CHECKING:
1011
from couchers.models import Cluster, User
@@ -17,6 +18,7 @@ class Discussion(Base, kw_only=True):
1718

1819
__tablename__ = "discussions"
1920
__moderation_author_column__ = "creator_user_id"
21+
__moderation_object_type__ = ModerationObjectType.discussion
2022

2123
id: Mapped[int] = mapped_column(
2224
BigInteger, communities_seq, primary_key=True, server_default=communities_seq.next_value(), init=False
@@ -84,6 +86,7 @@ class Comment(Base, kw_only=True):
8486

8587
__tablename__ = "comments"
8688
__moderation_author_column__ = "author_user_id"
89+
__moderation_object_type__ = ModerationObjectType.comment
8790

8891
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
8992

@@ -104,6 +107,7 @@ class Reply(Base, kw_only=True):
104107

105108
__tablename__ = "replies"
106109
__moderation_author_column__ = "author_user_id"
110+
__moderation_object_type__ = ModerationObjectType.reply
107111

108112
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
109113

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from sqlalchemy.sql.elements import ColumnElement
2525

2626
from couchers.models.base import Base, Geom, communities_seq
27+
from couchers.models.moderation import ModerationObjectType
2728
from couchers.utils import get_coordinates
2829

2930
if TYPE_CHECKING:
@@ -112,6 +113,7 @@ class Event(Base, kw_only=True):
112113
class EventOccurrence(Base, kw_only=True):
113114
__tablename__ = "event_occurrences"
114115
__moderation_author_column__ = "creator_user_id"
116+
__moderation_object_type__ = ModerationObjectType.event_occurrence
115117

116118
id: Mapped[int] = mapped_column(
117119
BigInteger, communities_seq, primary_key=True, server_default=communities_seq.next_value(), init=False

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from sqlalchemy.sql import expression
1010

1111
from couchers.models.base import Base, Geom
12+
from couchers.models.moderation import ModerationObjectType
1213
from couchers.utils import date_in_timezone, now
1314

1415
if TYPE_CHECKING:
@@ -41,6 +42,7 @@ class HostRequest(Base, kw_only=True):
4142

4243
__tablename__ = "host_requests"
4344
__moderation_author_column__ = "initiator_user_id"
45+
__moderation_object_type__ = ModerationObjectType.host_request
4446

4547
conversation_id: Mapped[int] = mapped_column("id", ForeignKey("conversations.id"), primary_key=True)
4648
initiator_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)

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

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,26 @@
77

88
import enum
99
from datetime import datetime
10-
from typing import TYPE_CHECKING
10+
from functools import cache
11+
from typing import TYPE_CHECKING, Any, NamedTuple
1112

1213
from sqlalchemy import BigInteger, DateTime, Enum, ForeignKey, Index, String, func
1314
from sqlalchemy.orm import Mapped, mapped_column, relationship
1415

1516
from couchers.models.base import Base, moderation_seq
1617

1718
if TYPE_CHECKING:
19+
from couchers.models.conversations import GroupChat
20+
from couchers.models.discussions import Comment, Discussion, Reply
21+
from couchers.models.events import EventOccurrence
22+
from couchers.models.host_requests import HostRequest
23+
from couchers.models.rest import FriendRelationship
1824
from couchers.models.users import User
1925

26+
type ModeratedContentModel = type[
27+
HostRequest | GroupChat | FriendRelationship | EventOccurrence | Comment | Reply | Discussion
28+
]
29+
2030

2131
class ModerationVisibility(enum.Enum):
2232
# Only visible to moderators
@@ -186,3 +196,33 @@ class ModerationLog(Base, kw_only=True):
186196

187197
def __repr__(self) -> str:
188198
return f"ModerationLog(id={self.id}, state_id={self.moderation_state_id}, action={self.action}, moderator={self.moderator_user_id}, time={self.time})"
199+
200+
201+
class ModeratedModel(NamedTuple):
202+
"""A model governed by the UMS, with its moderation metadata resolved."""
203+
204+
object_type: ModerationObjectType
205+
model: ModeratedContentModel
206+
# the InstrumentedAttribute of the model's author column; Any avoids descriptor-unwrapping on access
207+
author_column: Any
208+
209+
210+
@cache
211+
def get_moderated_models() -> dict[ModerationObjectType, ModeratedModel]:
212+
"""
213+
Maps each ModerationObjectType to its model and resolved moderation metadata.
214+
215+
Discovered from every mapped model that declares __moderation_object_type__, so the moderation
216+
metadata stays on the models themselves rather than in a separate hand-maintained list.
217+
"""
218+
models: dict[ModerationObjectType, ModeratedModel] = {}
219+
for mapper in Base.registry.mappers:
220+
cls = mapper.class_
221+
if not hasattr(cls, "__moderation_object_type__"):
222+
continue
223+
models[cls.__moderation_object_type__] = ModeratedModel(
224+
object_type=cls.__moderation_object_type__,
225+
model=cls,
226+
author_column=getattr(cls, cls.__moderation_author_column__),
227+
)
228+
return models

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929

3030
from couchers.constants import GUIDELINES_VERSION
3131
from couchers.models.base import Base, Geom
32+
from couchers.models.moderation import ModerationObjectType
3233
from couchers.models.users import HostingStatus
3334
from couchers.utils import now
3435

@@ -73,6 +74,7 @@ class FriendRelationship(Base, kw_only=True):
7374

7475
__tablename__ = "friend_relationships"
7576
__moderation_author_column__ = "from_user_id"
77+
__moderation_object_type__ = ModerationObjectType.friend_request
7678

7779
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
7880

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

Lines changed: 9 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import logging
2-
from typing import TYPE_CHECKING
32

43
import grpc
54
from sqlalchemy import and_, exists, not_, or_, select
@@ -36,14 +35,12 @@
3635
NotificationDelivery,
3736
Reply,
3837
User,
38+
get_moderated_models,
3939
)
4040
from couchers.proto import moderation_pb2, moderation_pb2_grpc
4141
from couchers.proto.internal import jobs_pb2
4242
from couchers.utils import Timestamp_from_datetime, now
4343

44-
if TYPE_CHECKING:
45-
from couchers.sql import _ModeratedContent
46-
4744
logger = logging.getLogger(__name__)
4845

4946
MAX_PAGINATION_LENGTH = 1_000
@@ -123,17 +120,6 @@
123120
moderation_pb2.MODERATION_OBJECT_TYPE_DISCUSSION: ModerationObjectType.discussion,
124121
}
125122

126-
# Mapping from ModerationObjectType to the SQLAlchemy model class
127-
moderationobjecttype2model: dict[ModerationObjectType, _ModeratedContent] = {
128-
ModerationObjectType.host_request: HostRequest,
129-
ModerationObjectType.group_chat: GroupChat,
130-
ModerationObjectType.friend_request: FriendRelationship,
131-
ModerationObjectType.event_occurrence: EventOccurrence,
132-
ModerationObjectType.comment: Comment,
133-
ModerationObjectType.reply: Reply,
134-
ModerationObjectType.discussion: Discussion,
135-
}
136-
137123

138124
def bulk_set_user_content_visibility(
139125
session: Session,
@@ -147,10 +133,9 @@ def bulk_set_user_content_visibility(
147133
final_reason = reason or f"Bulk visibility update for user {user.id} to {new_visibility.name}"
148134

149135
author_exists_clauses = []
150-
for model in moderationobjecttype2model.values():
151-
author_col = getattr(model, model.__moderation_author_column__)
136+
for entry in get_moderated_models().values():
152137
author_exists_clauses.append(
153-
exists().where(and_(model.moderation_state_id == ModerationState.id, author_col == user.id))
138+
exists().where(and_(entry.model.moderation_state_id == ModerationState.id, entry.author_column == user.id))
154139
)
155140

156141
states = session.execute(select(ModerationState).where(or_(*author_exists_clauses))).scalars().all()
@@ -346,13 +331,12 @@ def GetModerationQueue(
346331

347332
# Use EXISTS for efficient author filtering
348333
author_exists_clauses = []
349-
for model in moderationobjecttype2model.values():
350-
author_col = getattr(model, model.__moderation_author_column__)
334+
for entry in get_moderated_models().values():
351335
author_exists_clauses.append(
352336
exists().where(
353337
and_(
354-
model.moderation_state_id == ModerationQueueItem.moderation_state_id,
355-
author_col == author_user_id,
338+
entry.model.moderation_state_id == ModerationQueueItem.moderation_state_id,
339+
entry.author_column == author_user_id,
356340
)
357341
)
358342
)
@@ -687,13 +671,12 @@ def ListModerationStates(
687671

688672
if request.author_user_id:
689673
author_exists_clauses = []
690-
for model in moderationobjecttype2model.values():
691-
author_col = getattr(model, model.__moderation_author_column__)
674+
for entry in get_moderated_models().values():
692675
author_exists_clauses.append(
693676
exists().where(
694677
and_(
695-
model.moderation_state_id == ModerationState.id,
696-
author_col == request.author_user_id,
678+
entry.model.moderation_state_id == ModerationState.id,
679+
entry.author_column == request.author_user_id,
697680
)
698681
)
699682
)

0 commit comments

Comments
 (0)