Coverage for app/backend/src/couchers/servicers/moderation.py: 88%
236 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-14 11:44 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-14 11:44 +0000
1import logging
3import grpc
4from sqlalchemy import and_, exists, not_, or_, select
5from sqlalchemy.orm import Session
7from couchers.context import CouchersContext
8from couchers.jobs.enqueue import queue_job
9from couchers.metrics import (
10 observe_moderation_action,
11 observe_moderation_queue_item_created,
12 observe_moderation_queue_item_resolved,
13 observe_moderation_queue_resolution_time,
14 observe_moderation_visibility_transition,
15)
16from couchers.models import (
17 AdminActionLevel,
18 Comment,
19 Discussion,
20 Event,
21 EventOccurrence,
22 FriendRelationship,
23 GroupChat,
24 HostRequest,
25 Message,
26 MessageType,
27 ModerationAction,
28 ModerationLog,
29 ModerationObjectType,
30 ModerationQueueItem,
31 ModerationState,
32 ModerationTrigger,
33 ModerationVisibility,
34 Notification,
35 NotificationDelivery,
36 Reference,
37 Reply,
38 User,
39 get_moderated_models,
40)
41from couchers.proto import moderation_pb2, moderation_pb2_grpc
42from couchers.proto.internal import jobs_pb2
43from couchers.utils import Timestamp_from_datetime, now
45logger = logging.getLogger(__name__)
47MAX_PAGINATION_LENGTH = 1_000
49# Moderation enum mappings
50moderationvisibility2api = {
51 None: moderation_pb2.MODERATION_VISIBILITY_UNSPECIFIED,
52 ModerationVisibility.hidden: moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
53 ModerationVisibility.shadowed: moderation_pb2.MODERATION_VISIBILITY_SHADOWED,
54 ModerationVisibility.visible: moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
55 ModerationVisibility.unlisted: moderation_pb2.MODERATION_VISIBILITY_UNLISTED,
56}
58moderationvisibility2sql = {
59 moderation_pb2.MODERATION_VISIBILITY_UNSPECIFIED: None,
60 moderation_pb2.MODERATION_VISIBILITY_HIDDEN: ModerationVisibility.hidden,
61 moderation_pb2.MODERATION_VISIBILITY_SHADOWED: ModerationVisibility.shadowed,
62 moderation_pb2.MODERATION_VISIBILITY_VISIBLE: ModerationVisibility.visible,
63 moderation_pb2.MODERATION_VISIBILITY_UNLISTED: ModerationVisibility.unlisted,
64}
66moderationtrigger2api = {
67 None: moderation_pb2.MODERATION_TRIGGER_UNSPECIFIED,
68 ModerationTrigger.initial_review: moderation_pb2.MODERATION_TRIGGER_INITIAL_REVIEW,
69 ModerationTrigger.user_flag: moderation_pb2.MODERATION_TRIGGER_USER_FLAG,
70 ModerationTrigger.machine_flag: moderation_pb2.MODERATION_TRIGGER_MACHINE_FLAG,
71 ModerationTrigger.moderator_review: moderation_pb2.MODERATION_TRIGGER_MODERATOR_REVIEW,
72}
74moderationtrigger2sql = {
75 moderation_pb2.MODERATION_TRIGGER_UNSPECIFIED: None,
76 moderation_pb2.MODERATION_TRIGGER_INITIAL_REVIEW: ModerationTrigger.initial_review,
77 moderation_pb2.MODERATION_TRIGGER_USER_FLAG: ModerationTrigger.user_flag,
78 moderation_pb2.MODERATION_TRIGGER_MACHINE_FLAG: ModerationTrigger.machine_flag,
79 moderation_pb2.MODERATION_TRIGGER_MODERATOR_REVIEW: ModerationTrigger.moderator_review,
80}
82moderationaction2api = {
83 None: moderation_pb2.MODERATION_ACTION_UNSPECIFIED,
84 ModerationAction.create: moderation_pb2.MODERATION_ACTION_CREATE,
85 ModerationAction.approve: moderation_pb2.MODERATION_ACTION_APPROVE,
86 ModerationAction.hide: moderation_pb2.MODERATION_ACTION_HIDE,
87 ModerationAction.flag: moderation_pb2.MODERATION_ACTION_FLAG,
88 ModerationAction.unflag: moderation_pb2.MODERATION_ACTION_UNFLAG,
89 ModerationAction.bulk_set_visibility: moderation_pb2.MODERATION_ACTION_BULK_SET_VISIBILITY,
90}
92moderationaction2sql = {
93 moderation_pb2.MODERATION_ACTION_UNSPECIFIED: None,
94 moderation_pb2.MODERATION_ACTION_CREATE: ModerationAction.create,
95 moderation_pb2.MODERATION_ACTION_APPROVE: ModerationAction.approve,
96 moderation_pb2.MODERATION_ACTION_HIDE: ModerationAction.hide,
97 moderation_pb2.MODERATION_ACTION_FLAG: ModerationAction.flag,
98 moderation_pb2.MODERATION_ACTION_UNFLAG: ModerationAction.unflag,
99 moderation_pb2.MODERATION_ACTION_BULK_SET_VISIBILITY: ModerationAction.bulk_set_visibility,
100}
102moderationobjecttype2api = {
103 None: moderation_pb2.MODERATION_OBJECT_TYPE_UNSPECIFIED,
104 ModerationObjectType.host_request: moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST,
105 ModerationObjectType.group_chat: moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT,
106 ModerationObjectType.friend_request: moderation_pb2.MODERATION_OBJECT_TYPE_FRIEND_REQUEST,
107 ModerationObjectType.event_occurrence: moderation_pb2.MODERATION_OBJECT_TYPE_EVENT_OCCURRENCE,
108 ModerationObjectType.comment: moderation_pb2.MODERATION_OBJECT_TYPE_COMMENT,
109 ModerationObjectType.reply: moderation_pb2.MODERATION_OBJECT_TYPE_REPLY,
110 ModerationObjectType.discussion: moderation_pb2.MODERATION_OBJECT_TYPE_DISCUSSION,
111 ModerationObjectType.reference: moderation_pb2.MODERATION_OBJECT_TYPE_REFERENCE,
112}
114moderationobjecttype2sql = {
115 moderation_pb2.MODERATION_OBJECT_TYPE_UNSPECIFIED: None,
116 moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST: ModerationObjectType.host_request,
117 moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT: ModerationObjectType.group_chat,
118 moderation_pb2.MODERATION_OBJECT_TYPE_FRIEND_REQUEST: ModerationObjectType.friend_request,
119 moderation_pb2.MODERATION_OBJECT_TYPE_EVENT_OCCURRENCE: ModerationObjectType.event_occurrence,
120 moderation_pb2.MODERATION_OBJECT_TYPE_COMMENT: ModerationObjectType.comment,
121 moderation_pb2.MODERATION_OBJECT_TYPE_REPLY: ModerationObjectType.reply,
122 moderation_pb2.MODERATION_OBJECT_TYPE_DISCUSSION: ModerationObjectType.discussion,
123 moderation_pb2.MODERATION_OBJECT_TYPE_REFERENCE: ModerationObjectType.reference,
124}
127def bulk_set_user_content_visibility(
128 session: Session,
129 user: User,
130 new_visibility: ModerationVisibility,
131 moderator_user_id: int,
132 from_visibilities: set[ModerationVisibility] | None = None,
133 reason: str | None = None,
134) -> int:
135 """Set visibility on every UMS-governed object authored by the user. Returns count of updated states."""
136 final_reason = reason or f"Bulk visibility update for user {user.id} to {new_visibility.name}"
138 author_exists_clauses = []
139 for entry in get_moderated_models().values():
140 author_exists_clauses.append(
141 exists().where(and_(entry.moderation_state_id_column == ModerationState.id, entry.author_column == user.id))
142 )
144 states = session.execute(select(ModerationState).where(or_(*author_exists_clauses))).scalars().all()
146 updated_count = 0
147 for moderation_state in states:
148 if from_visibilities and moderation_state.visibility not in from_visibilities:
149 continue
150 if moderation_state.visibility == new_visibility:
151 continue
153 old_visibility = moderation_state.visibility
154 moderation_state.visibility = new_visibility
155 moderation_state.updated = now()
157 log_entry = ModerationLog(
158 moderation_state_id=moderation_state.id,
159 action=ModerationAction.bulk_set_visibility,
160 moderator_user_id=moderator_user_id,
161 new_visibility=new_visibility,
162 reason=final_reason,
163 )
164 session.add(log_entry)
165 session.flush()
167 queue_item = session.execute(
168 select(ModerationQueueItem)
169 .where(ModerationQueueItem.moderation_state_id == moderation_state.id)
170 .where(ModerationQueueItem.resolved_by_log_id.is_(None))
171 .order_by(ModerationQueueItem.time_created.desc())
172 ).scalar_one_or_none()
173 if queue_item:
174 queue_item.resolved_by_log_id = log_entry.id
175 session.flush()
177 observe_moderation_action(ModerationAction.bulk_set_visibility, moderation_state.object_type)
178 observe_moderation_visibility_transition(old_visibility, new_visibility, moderation_state.object_type)
180 if new_visibility in (ModerationVisibility.visible, ModerationVisibility.unlisted):
181 _enqueue_pending_notifications(session, moderation_state.id)
183 updated_count += 1
185 return updated_count
188def _enqueue_pending_notifications(session: Session, moderation_state_id: int) -> None:
189 """Re-queue any pending notifications linked to the given moderation state whose deliveries were suppressed."""
190 pending_notifications = (
191 session.execute(
192 select(Notification)
193 .where(Notification.moderation_state_id == moderation_state_id)
194 .where(not_(exists().where(NotificationDelivery.notification_id == Notification.id)))
195 )
196 .scalars()
197 .all()
198 )
200 # Import here to avoid circular dependency
201 from couchers.notifications.background import handle_notification # noqa: PLC0415
203 for notification in pending_notifications:
204 queue_job(
205 session,
206 job=handle_notification,
207 payload=jobs_pb2.HandleNotificationPayload(notification_id=notification.id),
208 )
211def moderation_state_to_pb(state: ModerationState, session: Session) -> moderation_pb2.ModerationStateInfo:
212 """Convert ModerationState model to proto message"""
213 object_type = state.object_type
214 object_id = state.object_id
216 # Get the author user ID and content based on object type
217 if object_type == ModerationObjectType.host_request:
218 author_user_id = session.execute(
219 select(HostRequest.initiator_user_id).where(HostRequest.conversation_id == object_id)
220 ).scalar_one()
221 # Get the first text message for this conversation
222 content = session.execute(
223 select(Message.text)
224 .where(Message.conversation_id == object_id)
225 .where(Message.message_type == MessageType.text)
226 .order_by(Message.id.asc())
227 .limit(1)
228 ).scalar_one_or_none()
229 elif object_type == ModerationObjectType.group_chat:
230 author_user_id = session.execute(
231 select(GroupChat.creator_id).where(GroupChat.conversation_id == object_id)
232 ).scalar_one()
233 # Get the first text message for this conversation
234 content = session.execute(
235 select(Message.text)
236 .where(Message.conversation_id == object_id)
237 .where(Message.message_type == MessageType.text)
238 .order_by(Message.id.asc())
239 .limit(1)
240 ).scalar_one_or_none()
241 elif object_type == ModerationObjectType.friend_request:
242 author_user_id = session.execute(
243 select(FriendRelationship.from_user_id).where(FriendRelationship.id == object_id)
244 ).scalar_one()
245 # Friend requests have no text content
246 content = None
247 elif object_type == ModerationObjectType.event_occurrence:
248 author_user_id, title, description = session.execute(
249 select(EventOccurrence.creator_user_id, Event.title, EventOccurrence.content)
250 .join(Event, Event.id == EventOccurrence.event_id)
251 .where(EventOccurrence.id == object_id)
252 ).one()
253 content = f"{title}\n\n{description}"
254 elif object_type == ModerationObjectType.comment:
255 author_user_id, content = session.execute(
256 select(Comment.author_user_id, Comment.content).where(Comment.id == object_id)
257 ).one()
258 elif object_type == ModerationObjectType.reply:
259 author_user_id, content = session.execute(
260 select(Reply.author_user_id, Reply.content).where(Reply.id == object_id)
261 ).one()
262 elif object_type == ModerationObjectType.discussion:
263 author_user_id, title, body = session.execute(
264 select(Discussion.creator_user_id, Discussion.title, Discussion.content).where(Discussion.id == object_id)
265 ).one()
266 content = f"{title}\n\n{body}"
267 elif object_type == ModerationObjectType.reference: 267 ↛ 272line 267 didn't jump to line 272 because the condition on line 267 was always true
268 author_user_id, content = session.execute(
269 select(Reference.from_user_id, Reference.text).where(Reference.id == object_id)
270 ).one()
271 else:
272 raise ValueError(f"Unsupported moderation object type: {object_type}")
274 # Import here to avoid circular dependency
275 from couchers.servicers.admin import _user_to_details # noqa: PLC0415
277 author = session.execute(select(User).where(User.id == author_user_id)).scalar_one()
279 state_pb = moderation_pb2.ModerationStateInfo(
280 moderation_state_id=state.id,
281 object_type=moderationobjecttype2api[state.object_type],
282 object_id=state.object_id,
283 visibility=moderationvisibility2api[state.visibility],
284 created=Timestamp_from_datetime(state.created),
285 updated=Timestamp_from_datetime(state.updated),
286 author_user_id=author_user_id,
287 author=_user_to_details(session, author),
288 content=content or "",
289 )
291 return state_pb
294class Moderation(moderation_pb2_grpc.ModerationServicer):
295 def GetModerationQueue(
296 self, request: moderation_pb2.GetModerationQueueReq, context: CouchersContext, session: Session
297 ) -> moderation_pb2.GetModerationQueueRes:
298 """Get moderation queue items with optional filtering"""
300 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
302 # Build query
303 statement = select(ModerationQueueItem)
305 # Apply page token filter based on ordering direction
306 if request.page_token:
307 page_token_id = int(request.page_token)
308 if request.newest_first: 308 ↛ 313line 308 didn't jump to line 313 because the condition on line 308 was always true
309 # Descending order: get items with smaller IDs
310 statement = statement.where(ModerationQueueItem.id < page_token_id)
311 else:
312 # Ascending order: get items with larger IDs
313 statement = statement.where(ModerationQueueItem.id > page_token_id)
315 # Apply filters
316 if request.triggers:
317 internal_triggers = [moderationtrigger2sql[t] for t in request.triggers]
318 statement = statement.where(ModerationQueueItem.trigger.in_(internal_triggers))
320 if request.object_type and request.object_type != moderation_pb2.MODERATION_OBJECT_TYPE_UNSPECIFIED: 320 ↛ 321line 320 didn't jump to line 321 because the condition on line 320 was never true
321 internal_object_type = moderationobjecttype2sql[request.object_type]
322 if internal_object_type:
323 statement = statement.join(ModerationState).where(ModerationState.object_type == internal_object_type)
325 if request.unresolved_only:
326 statement = statement.where(ModerationQueueItem.resolved_by_log_id.is_(None))
328 if request.HasField("created_before"):
329 created_before = request.created_before.ToDatetime()
330 statement = statement.where(ModerationQueueItem.time_created < created_before)
332 if request.HasField("created_after"):
333 created_after = request.created_after.ToDatetime()
334 statement = statement.where(ModerationQueueItem.time_created > created_after)
336 if request.item_author_user_id:
337 author_user_id = request.item_author_user_id
339 # Use EXISTS for efficient author filtering
340 author_exists_clauses = []
341 for entry in get_moderated_models().values():
342 author_exists_clauses.append(
343 exists().where(
344 and_(
345 entry.moderation_state_id_column == ModerationQueueItem.moderation_state_id,
346 entry.author_column == author_user_id,
347 )
348 )
349 )
350 statement = statement.where(or_(*author_exists_clauses))
352 # Order by time created
353 if request.newest_first:
354 statement = statement.order_by(ModerationQueueItem.time_created.desc(), ModerationQueueItem.id.desc())
355 else:
356 statement = statement.order_by(ModerationQueueItem.time_created.asc(), ModerationQueueItem.id.asc())
358 queue_items = session.execute(statement.limit(page_size + 1)).scalars().all()
360 # Convert to proto
361 queue_items_pb = []
362 for item in queue_items[:page_size]:
363 # Fetch the moderation state for this queue item
364 mod_state = session.execute(
365 select(ModerationState).where(ModerationState.id == item.moderation_state_id)
366 ).scalar_one()
368 queue_item_pb = moderation_pb2.ModerationQueueItemInfo(
369 queue_item_id=item.id,
370 moderation_state_id=item.moderation_state_id,
371 time_created=Timestamp_from_datetime(item.time_created),
372 trigger=moderationtrigger2api[item.trigger],
373 reason=item.reason,
374 is_resolved=item.resolved_by_log_id is not None,
375 resolved_by_log_id=item.resolved_by_log_id or 0,
376 moderation_state=moderation_state_to_pb(mod_state, session),
377 )
379 queue_items_pb.append(queue_item_pb)
381 return moderation_pb2.GetModerationQueueRes(
382 queue_items=queue_items_pb,
383 # Use the ID of the last returned item (not the extra fetched item) as the cursor
384 next_page_token=str(queue_items[page_size - 1].id) if len(queue_items) > page_size else None,
385 )
387 def GetModerationState(
388 self, request: moderation_pb2.GetModerationStateReq, context: CouchersContext, session: Session
389 ) -> moderation_pb2.GetModerationStateRes:
390 """Get moderation state by object type and object ID"""
391 object_type = moderationobjecttype2sql[request.object_type]
392 if object_type is None:
393 context.abort(grpc.StatusCode.INVALID_ARGUMENT, "Object type must be specified.")
395 moderation_state = session.execute(
396 select(ModerationState)
397 .where(ModerationState.object_type == object_type)
398 .where(ModerationState.object_id == request.object_id)
399 ).scalar_one_or_none()
400 if moderation_state is None:
401 context.abort(grpc.StatusCode.NOT_FOUND, "Moderation state not found.")
403 return moderation_pb2.GetModerationStateRes(
404 moderation_state=moderation_state_to_pb(moderation_state, session),
405 )
407 def GetModerationLog(
408 self, request: moderation_pb2.GetModerationLogReq, context: CouchersContext, session: Session
409 ) -> moderation_pb2.GetModerationLogRes:
410 """Get moderation log for a specific moderation state"""
411 # Get the moderation state
412 moderation_state = session.execute(
413 select(ModerationState).where(ModerationState.id == request.moderation_state_id)
414 ).scalar_one_or_none()
415 if moderation_state is None:
416 context.abort(grpc.StatusCode.NOT_FOUND, "Moderation state not found.")
418 # Get all log entries for this state, ordered by time (most recent first)
419 log_entries = (
420 session.execute(
421 select(ModerationLog)
422 .where(ModerationLog.moderation_state_id == request.moderation_state_id)
423 .order_by(ModerationLog.time.desc(), ModerationLog.id.desc())
424 )
425 .scalars()
426 .all()
427 )
429 # Convert moderation state to proto first (while still in session)
430 moderation_state_pb = moderation_state_to_pb(moderation_state, session)
432 # Convert to proto
433 log_entries_pb = []
434 for entry in log_entries:
435 log_entry_pb = moderation_pb2.ModerationLogEntryInfo(
436 log_entry_id=entry.id,
437 moderation_state_id=entry.moderation_state_id,
438 time=Timestamp_from_datetime(entry.time),
439 action=moderationaction2api[entry.action],
440 moderator_user_id=entry.moderator_user_id,
441 reason=entry.reason,
442 )
444 # Only include changed fields
445 if entry.new_visibility is not None: 445 ↛ 448line 445 didn't jump to line 448 because the condition on line 445 was always true
446 log_entry_pb.new_visibility = moderationvisibility2api[entry.new_visibility]
448 log_entries_pb.append(log_entry_pb)
450 return moderation_pb2.GetModerationLogRes(
451 log_entries=log_entries_pb,
452 moderation_state=moderation_state_pb,
453 )
455 def ModerateContent(
456 self, request: moderation_pb2.ModerateContentReq, context: CouchersContext, session: Session
457 ) -> moderation_pb2.ModerateContentRes:
458 """Unified moderation action - takes both action and visibility explicitly"""
460 moderation_state = session.execute(
461 select(ModerationState).where(ModerationState.id == request.moderation_state_id)
462 ).scalar_one_or_none()
463 if moderation_state is None:
464 context.abort(grpc.StatusCode.NOT_FOUND, "Moderation state not found.")
466 # Convert proto enums to internal enums
467 action = moderationaction2sql[request.action]
468 if action is None: 468 ↛ 469line 468 didn't jump to line 469 because the condition on line 468 was never true
469 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "action_must_be_specified")
471 new_visibility = moderationvisibility2sql[request.visibility]
472 if new_visibility is None: 472 ↛ 473line 472 didn't jump to line 473 because the condition on line 472 was never true
473 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "visibility_must_be_specified")
475 reason = request.reason or "Moderated by admin"
477 # Track old visibility for metrics
478 old_visibility = moderation_state.visibility
480 # Update visibility
481 moderation_state.visibility = new_visibility
482 moderation_state.updated = now()
484 # Log the action
485 log_entry = ModerationLog(
486 moderation_state_id=moderation_state.id,
487 action=action,
488 moderator_user_id=context.user_id,
489 new_visibility=new_visibility,
490 reason=reason,
491 )
492 session.add(log_entry)
493 session.flush()
495 # Resolve any pending queue items
496 queue_item = session.execute(
497 select(ModerationQueueItem)
498 .where(ModerationQueueItem.moderation_state_id == moderation_state.id)
499 .where(ModerationQueueItem.resolved_by_log_id.is_(None))
500 .order_by(ModerationQueueItem.time_created.desc())
501 ).scalar_one_or_none()
503 if queue_item:
504 queue_item.resolved_by_log_id = log_entry.id
505 session.flush()
506 observe_moderation_queue_item_resolved(queue_item.trigger, action, moderation_state.object_type)
507 observe_moderation_queue_resolution_time(
508 queue_item.trigger,
509 action,
510 moderation_state.object_type,
511 (now() - queue_item.time_created).total_seconds(),
512 )
514 observe_moderation_action(action, moderation_state.object_type)
515 observe_moderation_visibility_transition(old_visibility, new_visibility, moderation_state.object_type)
517 # If visibility becomes VISIBLE or UNLISTED, trigger pending notifications
518 if new_visibility in (ModerationVisibility.visible, ModerationVisibility.unlisted):
519 _enqueue_pending_notifications(session, moderation_state.id)
521 return moderation_pb2.ModerateContentRes(
522 moderation_state=moderation_state_to_pb(moderation_state, session),
523 )
525 def FlagContentForReview(
526 self, request: moderation_pb2.FlagContentForReviewReq, context: CouchersContext, session: Session
527 ) -> moderation_pb2.FlagContentForReviewRes:
528 """Flag content for review by adding it to the moderation queue"""
530 moderation_state = session.execute(
531 select(ModerationState).where(ModerationState.id == request.moderation_state_id)
532 ).scalar_one_or_none()
533 if not moderation_state: 533 ↛ 534line 533 didn't jump to line 534 because the condition on line 533 was never true
534 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "moderation_state_not_found")
536 trigger = moderationtrigger2sql[request.trigger] or ModerationTrigger.initial_review
537 reason = request.reason or "Flagged by admin for review"
539 # Add to moderation queue
540 queue_item = ModerationQueueItem(
541 moderation_state_id=request.moderation_state_id,
542 trigger=trigger,
543 reason=reason,
544 )
545 session.add(queue_item)
546 session.flush()
548 observe_moderation_action(ModerationAction.flag, moderation_state.object_type)
549 observe_moderation_queue_item_created(trigger, moderation_state.object_type)
551 queue_item_pb = moderation_pb2.ModerationQueueItemInfo(
552 queue_item_id=queue_item.id,
553 moderation_state_id=queue_item.moderation_state_id,
554 time_created=Timestamp_from_datetime(queue_item.time_created),
555 trigger=moderationtrigger2api[queue_item.trigger],
556 reason=queue_item.reason,
557 is_resolved=False,
558 resolved_by_log_id=0,
559 moderation_state=moderation_state_to_pb(moderation_state, session),
560 )
562 return moderation_pb2.FlagContentForReviewRes(queue_item=queue_item_pb)
564 def UnflagContent(
565 self, request: moderation_pb2.UnflagContentReq, context: CouchersContext, session: Session
566 ) -> moderation_pb2.UnflagContentRes:
567 """Unflag content by resolving pending queue items"""
569 moderation_state = session.execute(
570 select(ModerationState).where(ModerationState.id == request.moderation_state_id)
571 ).scalar_one_or_none()
572 if not moderation_state:
573 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "moderation_state_not_found")
575 reason = request.reason or "Unflagged by admin"
577 # Update moderation state (inline moderate_content logic)
578 moderation_state.updated = now()
580 # Log the unflag action
581 log_entry = ModerationLog(
582 moderation_state_id=moderation_state.id,
583 action=ModerationAction.unflag,
584 moderator_user_id=context.user_id,
585 new_visibility=None,
586 reason=reason,
587 )
588 session.add(log_entry)
589 session.flush()
591 # Resolve any pending queue items (inline resolve_queue_item logic)
592 queue_item = session.execute(
593 select(ModerationQueueItem)
594 .where(ModerationQueueItem.moderation_state_id == moderation_state.id)
595 .where(ModerationQueueItem.resolved_by_log_id.is_(None))
596 .order_by(ModerationQueueItem.time_created.desc())
597 ).scalar_one_or_none()
599 if queue_item:
600 queue_item.resolved_by_log_id = log_entry.id
601 session.flush()
602 observe_moderation_queue_item_resolved(
603 queue_item.trigger, ModerationAction.unflag, moderation_state.object_type
604 )
605 observe_moderation_queue_resolution_time(
606 queue_item.trigger,
607 ModerationAction.unflag,
608 moderation_state.object_type,
609 (now() - queue_item.time_created).total_seconds(),
610 )
612 observe_moderation_action(ModerationAction.unflag, moderation_state.object_type)
614 return moderation_pb2.UnflagContentRes(
615 moderation_state=moderation_state_to_pb(moderation_state, session),
616 )
618 def SetUserContentVisibility(
619 self, request: moderation_pb2.SetUserContentVisibilityReq, context: CouchersContext, session: Session
620 ) -> moderation_pb2.SetUserContentVisibilityRes:
621 """Bulk-set visibility on every UMS-governed object authored by the given user.
623 If from_visibility is non-empty, only states currently at one of those visibilities are swept.
624 """
625 new_visibility = moderationvisibility2sql[request.visibility]
626 if new_visibility is None:
627 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "visibility_must_be_specified")
629 raw_from_visibilities = {moderationvisibility2sql.get(v) for v in request.from_visibility}
630 if None in raw_from_visibilities:
631 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "visibility_must_be_specified")
632 from_visibilities: set[ModerationVisibility] | None = {
633 v for v in raw_from_visibilities if v is not None
634 } or None
636 user = session.execute(select(User).where(User.id == request.user_id)).scalar_one_or_none()
637 if not user:
638 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "user_not_found")
640 updated_count = bulk_set_user_content_visibility(
641 session=session,
642 user=user,
643 new_visibility=new_visibility,
644 moderator_user_id=context.user_id,
645 from_visibilities=from_visibilities,
646 reason=request.reason or None,
647 )
649 # Import here to avoid circular dependency
650 from couchers.servicers.admin import log_admin_action # noqa: PLC0415
652 log_admin_action(
653 session,
654 context,
655 user,
656 "set_user_content_visibility",
657 note=request.reason or None,
658 tag=new_visibility.name,
659 level=AdminActionLevel.high,
660 )
662 return moderation_pb2.SetUserContentVisibilityRes(updated_count=updated_count)
664 def ListModerationStates(
665 self, request: moderation_pb2.ListModerationStatesReq, context: CouchersContext, session: Session
666 ) -> moderation_pb2.ListModerationStatesRes:
667 """Chronological, paginated list of ModerationState rows. Optional author_user_id filter."""
668 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
670 statement = select(ModerationState)
672 if request.page_token:
673 page_token_id = int(request.page_token)
674 if request.newest_first: 674 ↛ 675line 674 didn't jump to line 675 because the condition on line 674 was never true
675 statement = statement.where(ModerationState.id < page_token_id)
676 else:
677 statement = statement.where(ModerationState.id > page_token_id)
679 if request.author_user_id:
680 author_exists_clauses = []
681 for entry in get_moderated_models().values():
682 author_exists_clauses.append(
683 exists().where(
684 and_(
685 entry.moderation_state_id_column == ModerationState.id,
686 entry.author_column == request.author_user_id,
687 )
688 )
689 )
690 statement = statement.where(or_(*author_exists_clauses))
692 if request.newest_first:
693 statement = statement.order_by(ModerationState.created.desc(), ModerationState.id.desc())
694 else:
695 statement = statement.order_by(ModerationState.created.asc(), ModerationState.id.asc())
697 states = session.execute(statement.limit(page_size + 1)).scalars().all()
699 state_pbs = [moderation_state_to_pb(state, session) for state in states[:page_size]]
701 return moderation_pb2.ListModerationStatesRes(
702 moderation_states=state_pbs,
703 next_page_token=str(states[page_size - 1].id) if len(states) > page_size else None,
704 )