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

1import logging 

2 

3import grpc 

4from sqlalchemy import and_, exists, not_, or_, select 

5from sqlalchemy.orm import Session 

6 

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 

44 

45logger = logging.getLogger(__name__) 

46 

47MAX_PAGINATION_LENGTH = 1_000 

48 

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} 

57 

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} 

65 

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} 

73 

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} 

81 

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} 

91 

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} 

101 

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} 

113 

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} 

125 

126 

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}" 

137 

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 ) 

143 

144 states = session.execute(select(ModerationState).where(or_(*author_exists_clauses))).scalars().all() 

145 

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 

152 

153 old_visibility = moderation_state.visibility 

154 moderation_state.visibility = new_visibility 

155 moderation_state.updated = now() 

156 

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() 

166 

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() 

176 

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) 

179 

180 if new_visibility in (ModerationVisibility.visible, ModerationVisibility.unlisted): 

181 _enqueue_pending_notifications(session, moderation_state.id) 

182 

183 updated_count += 1 

184 

185 return updated_count 

186 

187 

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 ) 

199 

200 # Import here to avoid circular dependency 

201 from couchers.notifications.background import handle_notification # noqa: PLC0415 

202 

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 ) 

209 

210 

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 

215 

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}") 

273 

274 # Import here to avoid circular dependency 

275 from couchers.servicers.admin import _user_to_details # noqa: PLC0415 

276 

277 author = session.execute(select(User).where(User.id == author_user_id)).scalar_one() 

278 

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 ) 

290 

291 return state_pb 

292 

293 

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""" 

299 

300 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH) 

301 

302 # Build query 

303 statement = select(ModerationQueueItem) 

304 

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) 

314 

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)) 

319 

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) 

324 

325 if request.unresolved_only: 

326 statement = statement.where(ModerationQueueItem.resolved_by_log_id.is_(None)) 

327 

328 if request.HasField("created_before"): 

329 created_before = request.created_before.ToDatetime() 

330 statement = statement.where(ModerationQueueItem.time_created < created_before) 

331 

332 if request.HasField("created_after"): 

333 created_after = request.created_after.ToDatetime() 

334 statement = statement.where(ModerationQueueItem.time_created > created_after) 

335 

336 if request.item_author_user_id: 

337 author_user_id = request.item_author_user_id 

338 

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)) 

351 

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()) 

357 

358 queue_items = session.execute(statement.limit(page_size + 1)).scalars().all() 

359 

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() 

367 

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 ) 

378 

379 queue_items_pb.append(queue_item_pb) 

380 

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 ) 

386 

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.") 

394 

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.") 

402 

403 return moderation_pb2.GetModerationStateRes( 

404 moderation_state=moderation_state_to_pb(moderation_state, session), 

405 ) 

406 

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.") 

417 

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 ) 

428 

429 # Convert moderation state to proto first (while still in session) 

430 moderation_state_pb = moderation_state_to_pb(moderation_state, session) 

431 

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 ) 

443 

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] 

447 

448 log_entries_pb.append(log_entry_pb) 

449 

450 return moderation_pb2.GetModerationLogRes( 

451 log_entries=log_entries_pb, 

452 moderation_state=moderation_state_pb, 

453 ) 

454 

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""" 

459 

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.") 

465 

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") 

470 

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") 

474 

475 reason = request.reason or "Moderated by admin" 

476 

477 # Track old visibility for metrics 

478 old_visibility = moderation_state.visibility 

479 

480 # Update visibility 

481 moderation_state.visibility = new_visibility 

482 moderation_state.updated = now() 

483 

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() 

494 

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() 

502 

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 ) 

513 

514 observe_moderation_action(action, moderation_state.object_type) 

515 observe_moderation_visibility_transition(old_visibility, new_visibility, moderation_state.object_type) 

516 

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) 

520 

521 return moderation_pb2.ModerateContentRes( 

522 moderation_state=moderation_state_to_pb(moderation_state, session), 

523 ) 

524 

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""" 

529 

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") 

535 

536 trigger = moderationtrigger2sql[request.trigger] or ModerationTrigger.initial_review 

537 reason = request.reason or "Flagged by admin for review" 

538 

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() 

547 

548 observe_moderation_action(ModerationAction.flag, moderation_state.object_type) 

549 observe_moderation_queue_item_created(trigger, moderation_state.object_type) 

550 

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 ) 

561 

562 return moderation_pb2.FlagContentForReviewRes(queue_item=queue_item_pb) 

563 

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""" 

568 

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") 

574 

575 reason = request.reason or "Unflagged by admin" 

576 

577 # Update moderation state (inline moderate_content logic) 

578 moderation_state.updated = now() 

579 

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() 

590 

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() 

598 

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 ) 

611 

612 observe_moderation_action(ModerationAction.unflag, moderation_state.object_type) 

613 

614 return moderation_pb2.UnflagContentRes( 

615 moderation_state=moderation_state_to_pb(moderation_state, session), 

616 ) 

617 

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. 

622 

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") 

628 

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 

635 

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") 

639 

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 ) 

648 

649 # Import here to avoid circular dependency 

650 from couchers.servicers.admin import log_admin_action # noqa: PLC0415 

651 

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 ) 

661 

662 return moderation_pb2.SetUserContentVisibilityRes(updated_count=updated_count) 

663 

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) 

669 

670 statement = select(ModerationState) 

671 

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) 

678 

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)) 

691 

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()) 

696 

697 states = session.execute(statement.limit(page_size + 1)).scalars().all() 

698 

699 state_pbs = [moderation_state_to_pb(state, session) for state in states[:page_size]] 

700 

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 )