Coverage for app/backend/src/couchers/servicers/moderation.py: 95%
357 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-03 11:56 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-03 11:56 +0000
1import json
2import logging
4import grpc
5from sqlalchemy import and_, exists, not_, or_, select
6from sqlalchemy.orm import Session
8from couchers import urls
9from couchers.context import CouchersContext
10from couchers.jobs.enqueue import queue_job
11from couchers.metrics import (
12 observe_moderation_action,
13 observe_moderation_queue_item_created,
14 observe_moderation_queue_item_resolved,
15 observe_moderation_queue_resolution_time,
16 observe_moderation_visibility_transition,
17)
18from couchers.models import (
19 AdminActionLevel,
20 Cluster,
21 Comment,
22 Discussion,
23 Event,
24 EventOccurrence,
25 FriendRelationship,
26 GroupChat,
27 GroupChatSubscription,
28 HostRequest,
29 Message,
30 MessageType,
31 ModerationAction,
32 ModerationLog,
33 ModerationObjectType,
34 ModerationQueueItem,
35 ModerationState,
36 ModerationTrigger,
37 ModerationVisibility,
38 Notification,
39 NotificationDelivery,
40 Page,
41 PublicTrip,
42 Reference,
43 Reply,
44 User,
45 get_moderated_models,
46)
47from couchers.proto import moderation_pb2, moderation_pb2_grpc
48from couchers.proto.internal import jobs_pb2
49from couchers.utils import Timestamp_from_datetime, not_none, now
51logger = logging.getLogger(__name__)
53MAX_PAGINATION_LENGTH = 1_000
55# Moderation enum mappings
56moderationvisibility2api = {
57 None: moderation_pb2.MODERATION_VISIBILITY_UNSPECIFIED,
58 ModerationVisibility.hidden: moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
59 ModerationVisibility.shadowed: moderation_pb2.MODERATION_VISIBILITY_SHADOWED,
60 ModerationVisibility.visible: moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
61 ModerationVisibility.unlisted: moderation_pb2.MODERATION_VISIBILITY_UNLISTED,
62}
64moderationvisibility2sql = {
65 moderation_pb2.MODERATION_VISIBILITY_UNSPECIFIED: None,
66 moderation_pb2.MODERATION_VISIBILITY_HIDDEN: ModerationVisibility.hidden,
67 moderation_pb2.MODERATION_VISIBILITY_SHADOWED: ModerationVisibility.shadowed,
68 moderation_pb2.MODERATION_VISIBILITY_VISIBLE: ModerationVisibility.visible,
69 moderation_pb2.MODERATION_VISIBILITY_UNLISTED: ModerationVisibility.unlisted,
70}
72moderationtrigger2api = {
73 None: moderation_pb2.MODERATION_TRIGGER_UNSPECIFIED,
74 ModerationTrigger.initial_review: moderation_pb2.MODERATION_TRIGGER_INITIAL_REVIEW,
75 ModerationTrigger.user_flag: moderation_pb2.MODERATION_TRIGGER_USER_FLAG,
76 ModerationTrigger.machine_flag: moderation_pb2.MODERATION_TRIGGER_MACHINE_FLAG,
77 ModerationTrigger.moderator_review: moderation_pb2.MODERATION_TRIGGER_MODERATOR_REVIEW,
78}
80moderationtrigger2sql = {
81 moderation_pb2.MODERATION_TRIGGER_UNSPECIFIED: None,
82 moderation_pb2.MODERATION_TRIGGER_INITIAL_REVIEW: ModerationTrigger.initial_review,
83 moderation_pb2.MODERATION_TRIGGER_USER_FLAG: ModerationTrigger.user_flag,
84 moderation_pb2.MODERATION_TRIGGER_MACHINE_FLAG: ModerationTrigger.machine_flag,
85 moderation_pb2.MODERATION_TRIGGER_MODERATOR_REVIEW: ModerationTrigger.moderator_review,
86}
88moderationaction2api = {
89 None: moderation_pb2.MODERATION_ACTION_UNSPECIFIED,
90 ModerationAction.create: moderation_pb2.MODERATION_ACTION_CREATE,
91 ModerationAction.approve: moderation_pb2.MODERATION_ACTION_APPROVE,
92 ModerationAction.hide: moderation_pb2.MODERATION_ACTION_HIDE,
93 ModerationAction.flag: moderation_pb2.MODERATION_ACTION_FLAG,
94 ModerationAction.unflag: moderation_pb2.MODERATION_ACTION_UNFLAG,
95 ModerationAction.set_priority: moderation_pb2.MODERATION_ACTION_SET_PRIORITY,
96 ModerationAction.bulk_set_visibility: moderation_pb2.MODERATION_ACTION_BULK_SET_VISIBILITY,
97}
99moderationaction2sql = {
100 moderation_pb2.MODERATION_ACTION_UNSPECIFIED: None,
101 moderation_pb2.MODERATION_ACTION_CREATE: ModerationAction.create,
102 moderation_pb2.MODERATION_ACTION_APPROVE: ModerationAction.approve,
103 moderation_pb2.MODERATION_ACTION_HIDE: ModerationAction.hide,
104 moderation_pb2.MODERATION_ACTION_FLAG: ModerationAction.flag,
105 moderation_pb2.MODERATION_ACTION_UNFLAG: ModerationAction.unflag,
106 moderation_pb2.MODERATION_ACTION_SET_PRIORITY: ModerationAction.set_priority,
107 moderation_pb2.MODERATION_ACTION_BULK_SET_VISIBILITY: ModerationAction.bulk_set_visibility,
108}
110moderationobjecttype2api = {
111 None: moderation_pb2.MODERATION_OBJECT_TYPE_UNSPECIFIED,
112 ModerationObjectType.host_request: moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST,
113 ModerationObjectType.group_chat: moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT,
114 ModerationObjectType.friend_request: moderation_pb2.MODERATION_OBJECT_TYPE_FRIEND_REQUEST,
115 ModerationObjectType.event_occurrence: moderation_pb2.MODERATION_OBJECT_TYPE_EVENT_OCCURRENCE,
116 ModerationObjectType.comment: moderation_pb2.MODERATION_OBJECT_TYPE_COMMENT,
117 ModerationObjectType.reply: moderation_pb2.MODERATION_OBJECT_TYPE_REPLY,
118 ModerationObjectType.discussion: moderation_pb2.MODERATION_OBJECT_TYPE_DISCUSSION,
119 ModerationObjectType.reference: moderation_pb2.MODERATION_OBJECT_TYPE_REFERENCE,
120 ModerationObjectType.public_trip: moderation_pb2.MODERATION_OBJECT_TYPE_PUBLIC_TRIP,
121 ModerationObjectType.user: moderation_pb2.MODERATION_OBJECT_TYPE_USER,
122}
124moderationobjecttype2sql = {
125 moderation_pb2.MODERATION_OBJECT_TYPE_UNSPECIFIED: None,
126 moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST: ModerationObjectType.host_request,
127 moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT: ModerationObjectType.group_chat,
128 moderation_pb2.MODERATION_OBJECT_TYPE_FRIEND_REQUEST: ModerationObjectType.friend_request,
129 moderation_pb2.MODERATION_OBJECT_TYPE_EVENT_OCCURRENCE: ModerationObjectType.event_occurrence,
130 moderation_pb2.MODERATION_OBJECT_TYPE_COMMENT: ModerationObjectType.comment,
131 moderation_pb2.MODERATION_OBJECT_TYPE_REPLY: ModerationObjectType.reply,
132 moderation_pb2.MODERATION_OBJECT_TYPE_DISCUSSION: ModerationObjectType.discussion,
133 moderation_pb2.MODERATION_OBJECT_TYPE_REFERENCE: ModerationObjectType.reference,
134 moderation_pb2.MODERATION_OBJECT_TYPE_PUBLIC_TRIP: ModerationObjectType.public_trip,
135 moderation_pb2.MODERATION_OBJECT_TYPE_USER: ModerationObjectType.user,
136}
139def _resolve_queue_item(
140 queue_item: ModerationQueueItem,
141 log_entry: ModerationLog,
142 action: ModerationAction,
143 object_type: ModerationObjectType,
144) -> None:
145 """Resolve an open queue item against the given log entry and record metrics."""
146 queue_item.resolved_by_log_id = log_entry.id
147 observe_moderation_queue_item_resolved(queue_item.trigger, action, object_type)
148 observe_moderation_queue_resolution_time(
149 queue_item.trigger,
150 action,
151 object_type,
152 (now() - queue_item.time_created).total_seconds(),
153 )
156def bulk_set_user_content_visibility(
157 session: Session,
158 user: User,
159 new_visibility: ModerationVisibility,
160 moderator_user_id: int,
161 from_visibilities: set[ModerationVisibility] | None = None,
162 reason: str | None = None,
163) -> int:
164 """Set visibility on every UMS-governed object authored by the user. Returns count of updated states."""
165 final_reason = reason or f"Bulk visibility update for user {user.id} to {new_visibility.name}"
167 author_exists_clauses = []
168 for entry in get_moderated_models().values():
169 if entry.has_own_visibility_mechanism:
170 continue
171 author_exists_clauses.append(
172 exists().where(and_(entry.moderation_state_id_column == ModerationState.id, entry.author_column == user.id))
173 )
175 states = session.execute(select(ModerationState).where(or_(*author_exists_clauses))).scalars().all()
177 updated_count = 0
178 for moderation_state in states:
179 if from_visibilities and moderation_state.visibility not in from_visibilities:
180 continue
181 if moderation_state.visibility == new_visibility:
182 continue
184 old_visibility = not_none(moderation_state.visibility)
185 moderation_state.visibility = new_visibility
186 moderation_state.updated = now()
188 log_entry = ModerationLog(
189 moderation_state_id=moderation_state.id,
190 action=ModerationAction.bulk_set_visibility,
191 moderator_user_id=moderator_user_id,
192 new_visibility=new_visibility,
193 reason=final_reason,
194 )
195 session.add(log_entry)
196 session.flush()
198 open_items = (
199 session.execute(
200 select(ModerationQueueItem)
201 .where(ModerationQueueItem.moderation_state_id == moderation_state.id)
202 .where(ModerationQueueItem.resolved_by_log_id.is_(None))
203 )
204 .scalars()
205 .all()
206 )
207 for queue_item in open_items:
208 _resolve_queue_item(
209 queue_item, log_entry, ModerationAction.bulk_set_visibility, moderation_state.object_type
210 )
211 session.flush()
213 observe_moderation_action(ModerationAction.bulk_set_visibility, moderation_state.object_type)
214 observe_moderation_visibility_transition(old_visibility, new_visibility, moderation_state.object_type)
216 if new_visibility in (ModerationVisibility.visible, ModerationVisibility.unlisted):
217 _enqueue_pending_notifications(session, moderation_state.id)
219 updated_count += 1
221 return updated_count
224def _enqueue_pending_notifications(session: Session, moderation_state_id: int) -> None:
225 """Re-queue any pending notifications linked to the given moderation state whose deliveries were suppressed."""
226 pending_notifications = (
227 session.execute(
228 select(Notification)
229 .where(Notification.moderation_state_id == moderation_state_id)
230 .where(not_(exists().where(NotificationDelivery.notification_id == Notification.id)))
231 # they're delivered in the order they're queued, so the user reads them chronologically
232 .order_by(Notification.id)
233 )
234 .scalars()
235 .all()
236 )
238 # Import here to avoid circular dependency
239 from couchers.notifications.background import handle_notification # noqa: PLC0415
241 for notification in pending_notifications:
242 queue_job(
243 session,
244 job=handle_notification,
245 payload=jobs_pb2.HandleNotificationPayload(notification_id=notification.id),
246 )
249def moderation_state_to_pb(state: ModerationState, session: Session) -> moderation_pb2.ModerationStateInfo:
250 """Convert ModerationState model to proto message"""
251 object_type = state.object_type
252 object_id = state.object_id
254 # Get the author user ID and content based on object type
255 if object_type == ModerationObjectType.host_request:
256 author_user_id = session.execute(
257 select(HostRequest.initiator_user_id).where(HostRequest.conversation_id == object_id)
258 ).scalar_one()
259 # Get the first text message for this conversation
260 content = session.execute(
261 select(Message.text)
262 .where(Message.conversation_id == object_id)
263 .where(Message.message_type == MessageType.text)
264 .order_by(Message.id.asc())
265 .limit(1)
266 ).scalar_one_or_none()
267 elif object_type == ModerationObjectType.group_chat:
268 author_user_id = session.execute(
269 select(GroupChat.creator_id).where(GroupChat.conversation_id == object_id)
270 ).scalar_one()
271 # Get the first text message for this conversation
272 content = session.execute(
273 select(Message.text)
274 .where(Message.conversation_id == object_id)
275 .where(Message.message_type == MessageType.text)
276 .order_by(Message.id.asc())
277 .limit(1)
278 ).scalar_one_or_none()
279 elif object_type == ModerationObjectType.friend_request:
280 author_user_id = session.execute(
281 select(FriendRelationship.from_user_id).where(FriendRelationship.id == object_id)
282 ).scalar_one()
283 # Friend requests have no text content
284 content = None
285 elif object_type == ModerationObjectType.event_occurrence:
286 author_user_id, title, description = session.execute(
287 select(EventOccurrence.creator_user_id, Event.title, EventOccurrence.content)
288 .join(Event, Event.id == EventOccurrence.event_id)
289 .where(EventOccurrence.id == object_id)
290 ).one()
291 content = f"{title}\n\n{description}"
292 elif object_type == ModerationObjectType.comment:
293 author_user_id, content = session.execute(
294 select(Comment.author_user_id, Comment.content).where(Comment.id == object_id)
295 ).one()
296 elif object_type == ModerationObjectType.reply:
297 author_user_id, content = session.execute(
298 select(Reply.author_user_id, Reply.content).where(Reply.id == object_id)
299 ).one()
300 elif object_type == ModerationObjectType.discussion:
301 author_user_id, title, body = session.execute(
302 select(Discussion.creator_user_id, Discussion.title, Discussion.content).where(Discussion.id == object_id)
303 ).one()
304 content = f"{title}\n\n{body}"
305 elif object_type == ModerationObjectType.reference:
306 author_user_id, content = session.execute(
307 select(Reference.from_user_id, Reference.text).where(Reference.id == object_id)
308 ).one()
309 elif object_type == ModerationObjectType.public_trip:
310 author_user_id, content = session.execute(
311 select(PublicTrip.user_id, PublicTrip.description).where(PublicTrip.id == object_id)
312 ).one()
313 elif object_type == ModerationObjectType.user: 313 ↛ 318line 313 didn't jump to line 318 because the condition on line 313 was always true
314 author_user_id = object_id
315 username = session.execute(select(User.username).where(User.id == object_id)).scalar_one()
316 content = f"@{username} / {object_id}"
317 else:
318 raise ValueError(f"Unsupported moderation object type: {object_type}")
320 # Import here to avoid circular dependency
321 from couchers.servicers.admin import _user_to_details # noqa: PLC0415
323 author = session.execute(select(User).where(User.id == author_user_id)).scalar_one()
325 state_pb = moderation_pb2.ModerationStateInfo(
326 moderation_state_id=state.id,
327 object_type=moderationobjecttype2api[state.object_type],
328 object_id=state.object_id,
329 visibility=moderationvisibility2api[state.visibility],
330 created=Timestamp_from_datetime(state.created),
331 updated=Timestamp_from_datetime(state.updated),
332 author_user_id=author_user_id,
333 author=_user_to_details(session, author),
334 content=content or "",
335 )
337 return state_pb
340def _community_pb(session: Session, node_id: int) -> moderation_pb2.ModeratedObjectCommunity:
341 return moderation_pb2.ModeratedObjectCommunity(
342 community_id=node_id,
343 community_name=session.execute(
344 select(Cluster.name).where(Cluster.parent_node_id == node_id).where(Cluster.is_official_cluster)
345 ).scalar_one(),
346 )
349def _cluster_community_pb(session: Session, cluster: Cluster) -> moderation_pb2.ModeratedObjectCommunity:
350 if cluster.is_official_cluster:
351 return moderation_pb2.ModeratedObjectCommunity(community_id=cluster.parent_node_id, community_name=cluster.name)
352 community = _community_pb(session, cluster.parent_node_id)
353 community.group_id = cluster.id
354 community.group_name = cluster.name
355 return community
358def _owner_community_pb(
359 session: Session, parent_node_id: int, owner_cluster: Cluster | None
360) -> moderation_pb2.ModeratedObjectCommunity:
361 if owner_cluster is not None:
362 return _cluster_community_pb(session, owner_cluster)
363 return _community_pb(session, parent_node_id)
366def _event_occurrence_location(
367 session: Session, occurrence: EventOccurrence
368) -> tuple[str, moderation_pb2.ModeratedObjectCommunity]:
369 event = occurrence.event
370 return (
371 urls.event_link(occurrence_id=occurrence.id, slug=event.slug),
372 _owner_community_pb(session, event.parent_node_id, event.owner_cluster),
373 )
376def _discussion_location(
377 session: Session, discussion: Discussion
378) -> tuple[str, moderation_pb2.ModeratedObjectCommunity]:
379 return (
380 urls.discussion_link(discussion_id=str(discussion.id), slug=discussion.slug),
381 _cluster_community_pb(session, discussion.owner_cluster),
382 )
385def _thread_parent(
386 session: Session, thread_id: int
387) -> tuple[str, moderation_pb2.ModeratedObjectCommunity | None, moderation_pb2.ModeratedThreadParent | None]:
388 occurrence = session.execute(
389 select(EventOccurrence).where(EventOccurrence.thread_id == thread_id)
390 ).scalar_one_or_none()
391 if occurrence is not None:
392 url, community = _event_occurrence_location(session, occurrence)
393 return (
394 url,
395 community,
396 moderation_pb2.ModeratedThreadParent(event_occurrence_id=occurrence.id, title=occurrence.event.title),
397 )
399 discussion = session.execute(select(Discussion).where(Discussion.thread_id == thread_id)).scalar_one_or_none()
400 if discussion is not None:
401 url, community = _discussion_location(session, discussion)
402 return url, community, moderation_pb2.ModeratedThreadParent(discussion_id=discussion.id, title=discussion.title)
404 page = session.execute(select(Page).where(Page.thread_id == thread_id)).scalar_one_or_none()
405 if page is not None: 405 ↛ 412line 405 didn't jump to line 412 because the condition on line 405 was always true
406 return (
407 "",
408 _owner_community_pb(session, page.parent_node_id, page.owner_cluster),
409 moderation_pb2.ModeratedThreadParent(page_id=page.id, title=page.versions[-1].title),
410 )
412 return "", None, None
415def moderated_object_to_pb(state: ModerationState, session: Session) -> moderation_pb2.GetModeratedObjectRes:
416 """Describe where a moderated object lives and what it's about"""
417 state_pb = moderation_state_to_pb(state, session)
418 object_type = state.object_type
419 object_id = state.object_id
421 url = ""
422 community: moderation_pb2.ModeratedObjectCommunity | None = None
423 thread_parent: moderation_pb2.ModeratedThreadParent | None = None
424 parent_comment: Comment | None = None
425 involved_user_ids: list[int] = []
427 if object_type == ModerationObjectType.host_request:
428 involved_user_ids = [
429 session.execute(
430 select(HostRequest.recipient_user_id).where(HostRequest.conversation_id == object_id)
431 ).scalar_one()
432 ]
433 elif object_type == ModerationObjectType.group_chat:
434 involved_user_ids = list(
435 session.execute(
436 select(GroupChatSubscription.user_id)
437 .where(GroupChatSubscription.group_chat_id == object_id)
438 .where(GroupChatSubscription.left.is_(None))
439 .where(GroupChatSubscription.user_id != state_pb.author_user_id)
440 .order_by(GroupChatSubscription.joined.asc())
441 )
442 .scalars()
443 .all()
444 )
445 elif object_type == ModerationObjectType.friend_request:
446 involved_user_ids = [
447 session.execute(
448 select(FriendRelationship.to_user_id).where(FriendRelationship.id == object_id)
449 ).scalar_one()
450 ]
451 elif object_type == ModerationObjectType.event_occurrence:
452 occurrence = session.execute(select(EventOccurrence).where(EventOccurrence.id == object_id)).scalar_one()
453 url, community = _event_occurrence_location(session, occurrence)
454 elif object_type == ModerationObjectType.comment:
455 thread_id = session.execute(select(Comment.thread_id).where(Comment.id == object_id)).scalar_one()
456 url, community, thread_parent = _thread_parent(session, thread_id)
457 elif object_type == ModerationObjectType.reply:
458 parent_comment = session.execute(
459 select(Comment).join(Reply, Reply.comment_id == Comment.id).where(Reply.id == object_id)
460 ).scalar_one()
461 url, community, thread_parent = _thread_parent(session, parent_comment.thread_id)
462 elif object_type == ModerationObjectType.discussion:
463 discussion = session.execute(select(Discussion).where(Discussion.id == object_id)).scalar_one()
464 url, community = _discussion_location(session, discussion)
465 elif object_type == ModerationObjectType.reference:
466 to_user = session.execute(
467 select(User).join(Reference, Reference.to_user_id == User.id).where(Reference.id == object_id)
468 ).scalar_one()
469 url = urls.user_link(username=to_user.username)
470 involved_user_ids = [to_user.id]
471 elif object_type == ModerationObjectType.public_trip:
472 node_id = session.execute(select(PublicTrip.node_id).where(PublicTrip.id == object_id)).scalar_one()
473 community = _community_pb(session, node_id)
474 elif object_type == ModerationObjectType.user: 474 ↛ 477line 474 didn't jump to line 477 because the condition on line 474 was always true
475 url = urls.user_link(username=state_pb.author.username)
476 else:
477 raise ValueError(f"Unsupported moderation object type: {object_type}")
479 return moderation_pb2.GetModeratedObjectRes(
480 moderation_state=state_pb,
481 url=url,
482 community=community,
483 thread_parent=thread_parent,
484 parent_comment_id=parent_comment.id if parent_comment else None,
485 parent_comment_content=parent_comment.content if parent_comment else None,
486 involved_user_ids=involved_user_ids,
487 )
490class Moderation(moderation_pb2_grpc.ModerationServicer):
491 def GetModerationQueue(
492 self, request: moderation_pb2.GetModerationQueueReq, context: CouchersContext, session: Session
493 ) -> moderation_pb2.GetModerationQueueRes:
494 """Get moderation queue items with optional filtering"""
496 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
498 # Build query
499 statement = select(ModerationQueueItem)
501 # Apply page token filter based on ordering direction
502 if request.page_token:
503 page_token_id = int(request.page_token)
504 if request.newest_first: 504 ↛ 509line 504 didn't jump to line 509 because the condition on line 504 was always true
505 # Descending order: get items with smaller IDs
506 statement = statement.where(ModerationQueueItem.id < page_token_id)
507 else:
508 # Ascending order: get items with larger IDs
509 statement = statement.where(ModerationQueueItem.id > page_token_id)
511 # Apply filters
512 if request.triggers:
513 internal_triggers = [moderationtrigger2sql[t] for t in request.triggers]
514 statement = statement.where(ModerationQueueItem.trigger.in_(internal_triggers))
516 if request.object_type and request.object_type != moderation_pb2.MODERATION_OBJECT_TYPE_UNSPECIFIED: 516 ↛ 517line 516 didn't jump to line 517 because the condition on line 516 was never true
517 internal_object_type = moderationobjecttype2sql[request.object_type]
518 if internal_object_type:
519 statement = statement.join(ModerationState).where(ModerationState.object_type == internal_object_type)
521 if request.unresolved_only:
522 statement = statement.where(ModerationQueueItem.resolved_by_log_id.is_(None))
524 if request.HasField("priority_min"):
525 statement = statement.where(ModerationQueueItem.priority >= request.priority_min)
527 if request.HasField("priority_max"):
528 statement = statement.where(ModerationQueueItem.priority <= request.priority_max)
530 if request.HasField("created_before"):
531 created_before = request.created_before.ToDatetime()
532 statement = statement.where(ModerationQueueItem.time_created < created_before)
534 if request.HasField("created_after"):
535 created_after = request.created_after.ToDatetime()
536 statement = statement.where(ModerationQueueItem.time_created > created_after)
538 if request.item_author_user_id:
539 author_user_id = request.item_author_user_id
541 # Use EXISTS for efficient author filtering
542 author_exists_clauses = []
543 for entry in get_moderated_models().values():
544 author_exists_clauses.append(
545 exists().where(
546 and_(
547 entry.moderation_state_id_column == ModerationQueueItem.moderation_state_id,
548 entry.author_column == author_user_id,
549 )
550 )
551 )
552 statement = statement.where(or_(*author_exists_clauses))
554 if request.newest_first:
555 statement = statement.order_by(ModerationQueueItem.id.desc())
556 else:
557 statement = statement.order_by(ModerationQueueItem.id.asc())
559 queue_items = session.execute(statement.limit(page_size + 1)).scalars().all()
561 # Convert to proto
562 queue_items_pb = []
563 for item in queue_items[:page_size]:
564 # Fetch the moderation state for this queue item
565 mod_state = session.execute(
566 select(ModerationState).where(ModerationState.id == item.moderation_state_id)
567 ).scalar_one()
569 queue_item_pb = moderation_pb2.ModerationQueueItemInfo(
570 queue_item_id=item.id,
571 moderation_state_id=item.moderation_state_id,
572 time_created=Timestamp_from_datetime(item.time_created),
573 trigger=moderationtrigger2api[item.trigger],
574 reason=item.reason,
575 is_resolved=item.resolved_by_log_id is not None,
576 resolved_by_log_id=item.resolved_by_log_id or 0,
577 moderation_state=moderation_state_to_pb(mod_state, session),
578 priority=item.priority,
579 data=json.dumps(item.data) if item.data is not None else "",
580 )
582 queue_items_pb.append(queue_item_pb)
584 return moderation_pb2.GetModerationQueueRes(
585 queue_items=queue_items_pb,
586 # Use the ID of the last returned item (not the extra fetched item) as the cursor
587 next_page_token=str(queue_items[page_size - 1].id) if len(queue_items) > page_size else None,
588 )
590 def GetModerationState(
591 self, request: moderation_pb2.GetModerationStateReq, context: CouchersContext, session: Session
592 ) -> moderation_pb2.GetModerationStateRes:
593 """Get moderation state by object type and object ID"""
594 object_type = moderationobjecttype2sql[request.object_type]
595 if object_type is None:
596 context.abort(grpc.StatusCode.INVALID_ARGUMENT, "Object type must be specified.")
598 moderation_state = session.execute(
599 select(ModerationState)
600 .where(ModerationState.object_type == object_type)
601 .where(ModerationState.object_id == request.object_id)
602 ).scalar_one_or_none()
603 if moderation_state is None:
604 context.abort(grpc.StatusCode.NOT_FOUND, "Moderation state not found.")
606 return moderation_pb2.GetModerationStateRes(
607 moderation_state=moderation_state_to_pb(moderation_state, session),
608 )
610 def GetModerationLog(
611 self, request: moderation_pb2.GetModerationLogReq, context: CouchersContext, session: Session
612 ) -> moderation_pb2.GetModerationLogRes:
613 """Get moderation log for a specific moderation state"""
614 # Get the moderation state
615 moderation_state = session.execute(
616 select(ModerationState).where(ModerationState.id == request.moderation_state_id)
617 ).scalar_one_or_none()
618 if moderation_state is None:
619 context.abort(grpc.StatusCode.NOT_FOUND, "Moderation state not found.")
621 # Get all log entries for this state, ordered by time (most recent first)
622 log_entries = (
623 session.execute(
624 select(ModerationLog)
625 .where(ModerationLog.moderation_state_id == request.moderation_state_id)
626 .order_by(ModerationLog.time.desc(), ModerationLog.id.desc())
627 )
628 .scalars()
629 .all()
630 )
632 # Convert moderation state to proto first (while still in session)
633 moderation_state_pb = moderation_state_to_pb(moderation_state, session)
635 # Convert to proto
636 log_entries_pb = []
637 for entry in log_entries:
638 log_entry_pb = moderation_pb2.ModerationLogEntryInfo(
639 log_entry_id=entry.id,
640 moderation_state_id=entry.moderation_state_id,
641 time=Timestamp_from_datetime(entry.time),
642 action=moderationaction2api[entry.action],
643 moderator_user_id=entry.moderator_user_id,
644 reason=entry.reason,
645 )
647 # Only include changed fields
648 if entry.new_visibility is not None:
649 log_entry_pb.new_visibility = moderationvisibility2api[entry.new_visibility]
650 if entry.new_priority is not None: 650 ↛ 651line 650 didn't jump to line 651 because the condition on line 650 was never true
651 log_entry_pb.new_priority = entry.new_priority
652 if entry.queue_item_id is not None:
653 log_entry_pb.queue_item_id = entry.queue_item_id
655 log_entries_pb.append(log_entry_pb)
657 return moderation_pb2.GetModerationLogRes(
658 log_entries=log_entries_pb,
659 moderation_state=moderation_state_pb,
660 )
662 def ModerateContent(
663 self, request: moderation_pb2.ModerateContentReq, context: CouchersContext, session: Session
664 ) -> moderation_pb2.ModerateContentRes:
665 """Single moderation entrypoint, dispatching on action.
667 APPROVE/HIDE act on the state's visibility; FLAG/SET_PRIORITY/UNFLAG act on a single
668 queue item. Every action appends a ModerationLog row.
669 """
671 moderation_state = session.execute(
672 select(ModerationState).where(ModerationState.id == request.moderation_state_id)
673 ).scalar_one_or_none()
674 if moderation_state is None:
675 context.abort(grpc.StatusCode.NOT_FOUND, "Moderation state not found.")
677 action = moderationaction2sql[request.action]
678 if action is None: 678 ↛ 679line 678 didn't jump to line 679 because the condition on line 678 was never true
679 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "admin:action_must_be_specified")
681 reason = request.reason or "Moderated by admin"
682 object_type = moderation_state.object_type
684 data = None
685 if request.data.strip():
686 if action != ModerationAction.flag:
687 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "admin:data_only_allowed_on_flag")
688 try:
689 data = json.loads(request.data)
690 except json.JSONDecodeError:
691 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "admin:data_must_be_valid_json")
693 if action in (ModerationAction.approve, ModerationAction.hide):
694 if get_moderated_models()[object_type].has_own_visibility_mechanism:
695 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "admin:cannot_set_visibility_on_state")
697 new_visibility = moderationvisibility2sql[request.visibility]
698 if new_visibility is None: 698 ↛ 699line 698 didn't jump to line 699 because the condition on line 698 was never true
699 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "admin:visibility_must_be_specified")
701 old_visibility = not_none(moderation_state.visibility)
702 moderation_state.visibility = new_visibility
703 moderation_state.updated = now()
705 log_entry = ModerationLog(
706 moderation_state_id=moderation_state.id,
707 action=action,
708 moderator_user_id=context.user_id,
709 new_visibility=new_visibility,
710 reason=reason,
711 )
712 session.add(log_entry)
713 session.flush()
715 if request.clear_flags:
716 open_items = (
717 session.execute(
718 select(ModerationQueueItem)
719 .where(ModerationQueueItem.moderation_state_id == moderation_state.id)
720 .where(ModerationQueueItem.resolved_by_log_id.is_(None))
721 )
722 .scalars()
723 .all()
724 )
725 for queue_item in open_items:
726 _resolve_queue_item(queue_item, log_entry, action, object_type)
727 session.flush()
729 observe_moderation_action(action, object_type)
730 observe_moderation_visibility_transition(old_visibility, new_visibility, object_type)
732 if new_visibility in (ModerationVisibility.visible, ModerationVisibility.unlisted):
733 _enqueue_pending_notifications(session, moderation_state.id)
735 elif action == ModerationAction.flag:
736 trigger = moderationtrigger2sql[request.trigger]
737 if trigger is None:
738 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "admin:trigger_must_be_specified")
740 queue_item = ModerationQueueItem(
741 moderation_state_id=moderation_state.id,
742 trigger=trigger,
743 reason=reason,
744 priority=request.priority,
745 data=data,
746 )
747 session.add(queue_item)
748 session.flush()
750 log_entry = ModerationLog(
751 moderation_state_id=moderation_state.id,
752 action=ModerationAction.flag,
753 moderator_user_id=context.user_id,
754 queue_item_id=queue_item.id,
755 reason=reason,
756 )
757 session.add(log_entry)
758 session.flush()
760 if request.supersede_queue_item_id:
761 superseded = session.execute(
762 select(ModerationQueueItem)
763 .where(ModerationQueueItem.id == request.supersede_queue_item_id)
764 .where(ModerationQueueItem.moderation_state_id == moderation_state.id)
765 ).scalar_one_or_none()
766 if superseded is None: 766 ↛ 767line 766 didn't jump to line 767 because the condition on line 766 was never true
767 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "admin:queue_item_not_found")
768 if superseded.resolved_by_log_id is None: 768 ↛ 772line 768 didn't jump to line 772 because the condition on line 768 was always true
769 _resolve_queue_item(superseded, log_entry, ModerationAction.flag, object_type)
770 session.flush()
772 observe_moderation_action(ModerationAction.flag, object_type)
773 observe_moderation_queue_item_created(trigger, object_type)
775 elif action == ModerationAction.set_priority:
776 queue_item = self._get_queue_item_for_state(request, context, session, moderation_state.id)
777 queue_item.priority = request.priority
779 log_entry = ModerationLog(
780 moderation_state_id=moderation_state.id,
781 action=ModerationAction.set_priority,
782 moderator_user_id=context.user_id,
783 queue_item_id=queue_item.id,
784 new_priority=request.priority,
785 reason=reason,
786 )
787 session.add(log_entry)
788 session.flush()
790 observe_moderation_action(ModerationAction.set_priority, object_type)
792 elif action == ModerationAction.unflag: 792 ↛ 813line 792 didn't jump to line 813 because the condition on line 792 was always true
793 queue_item = self._get_queue_item_for_state(request, context, session, moderation_state.id)
794 moderation_state.updated = now()
796 log_entry = ModerationLog(
797 moderation_state_id=moderation_state.id,
798 action=ModerationAction.unflag,
799 moderator_user_id=context.user_id,
800 queue_item_id=queue_item.id,
801 reason=reason,
802 )
803 session.add(log_entry)
804 session.flush()
806 if queue_item.resolved_by_log_id is None: 806 ↛ 810line 806 didn't jump to line 810 because the condition on line 806 was always true
807 _resolve_queue_item(queue_item, log_entry, ModerationAction.unflag, object_type)
808 session.flush()
810 observe_moderation_action(ModerationAction.unflag, object_type)
812 else:
813 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "admin:unsupported_action")
815 return moderation_pb2.ModerateContentRes(
816 moderation_state=moderation_state_to_pb(moderation_state, session),
817 )
819 def _get_queue_item_for_state(
820 self,
821 request: moderation_pb2.ModerateContentReq,
822 context: CouchersContext,
823 session: Session,
824 moderation_state_id: int,
825 ) -> ModerationQueueItem:
826 """Resolve the request's target queue item, asserting it belongs to the given state."""
827 if not request.queue_item_id:
828 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "admin:queue_item_id_must_be_specified")
829 queue_item = session.execute(
830 select(ModerationQueueItem)
831 .where(ModerationQueueItem.id == request.queue_item_id)
832 .where(ModerationQueueItem.moderation_state_id == moderation_state_id)
833 ).scalar_one_or_none()
834 if queue_item is None: 834 ↛ 835line 834 didn't jump to line 835 because the condition on line 834 was never true
835 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "admin:queue_item_not_found")
836 return queue_item
838 def SetUserContentVisibility(
839 self, request: moderation_pb2.SetUserContentVisibilityReq, context: CouchersContext, session: Session
840 ) -> moderation_pb2.SetUserContentVisibilityRes:
841 """Bulk-set visibility on every UMS-governed object authored by the given user.
843 If from_visibility is non-empty, only states currently at one of those visibilities are swept.
844 """
845 new_visibility = moderationvisibility2sql[request.visibility]
846 if new_visibility is None:
847 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "admin:visibility_must_be_specified")
849 raw_from_visibilities = {moderationvisibility2sql.get(v) for v in request.from_visibility}
850 if None in raw_from_visibilities:
851 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "admin:visibility_must_be_specified")
852 from_visibilities: set[ModerationVisibility] | None = {
853 v for v in raw_from_visibilities if v is not None
854 } or None
856 user = session.execute(select(User).where(User.id == request.user_id)).scalar_one_or_none()
857 if not user:
858 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "user_not_found")
860 updated_count = bulk_set_user_content_visibility(
861 session=session,
862 user=user,
863 new_visibility=new_visibility,
864 moderator_user_id=context.user_id,
865 from_visibilities=from_visibilities,
866 reason=request.reason or None,
867 )
869 # Import here to avoid circular dependency
870 from couchers.servicers.admin import log_admin_action # noqa: PLC0415
872 log_admin_action(
873 session,
874 context,
875 user,
876 "set_user_content_visibility",
877 note=request.reason or None,
878 tag=new_visibility.name,
879 level=AdminActionLevel.high,
880 )
882 return moderation_pb2.SetUserContentVisibilityRes(updated_count=updated_count)
884 def ListModerationStates(
885 self, request: moderation_pb2.ListModerationStatesReq, context: CouchersContext, session: Session
886 ) -> moderation_pb2.ListModerationStatesRes:
887 """Paginated list of ModerationState rows in id order. Optional author_user_id filter."""
888 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
890 statement = select(ModerationState)
892 if request.page_token:
893 page_token_id = int(request.page_token)
894 if request.newest_first:
895 statement = statement.where(ModerationState.id < page_token_id)
896 else:
897 statement = statement.where(ModerationState.id > page_token_id)
899 if request.author_user_id:
900 author_exists_clauses = []
901 for entry in get_moderated_models().values():
902 author_exists_clauses.append(
903 exists().where(
904 and_(
905 entry.moderation_state_id_column == ModerationState.id,
906 entry.author_column == request.author_user_id,
907 )
908 )
909 )
910 statement = statement.where(or_(*author_exists_clauses))
912 if request.newest_first:
913 statement = statement.order_by(ModerationState.id.desc())
914 else:
915 statement = statement.order_by(ModerationState.id.asc())
917 states = session.execute(statement.limit(page_size + 1)).scalars().all()
919 state_pbs = [moderation_state_to_pb(state, session) for state in states[:page_size]]
921 return moderation_pb2.ListModerationStatesRes(
922 moderation_states=state_pbs,
923 next_page_token=str(states[page_size - 1].id) if len(states) > page_size else None,
924 )
926 def GetModeratedObject(
927 self, request: moderation_pb2.GetModeratedObjectReq, context: CouchersContext, session: Session
928 ) -> moderation_pb2.GetModeratedObjectRes:
929 moderation_state = session.execute(
930 select(ModerationState).where(ModerationState.id == request.moderation_state_id)
931 ).scalar_one_or_none()
932 if moderation_state is None:
933 context.abort(grpc.StatusCode.NOT_FOUND, "Moderation state not found.")
935 return moderated_object_to_pb(moderation_state, session)