Coverage for app/backend/src/couchers/servicers/moderation.py: 94%

282 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-19 15:47 +0000

1import json 

2import logging 

3 

4import grpc 

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

6from sqlalchemy.orm import Session 

7 

8from couchers.context import CouchersContext 

9from couchers.jobs.enqueue import queue_job 

10from couchers.metrics import ( 

11 observe_moderation_action, 

12 observe_moderation_queue_item_created, 

13 observe_moderation_queue_item_resolved, 

14 observe_moderation_queue_resolution_time, 

15 observe_moderation_visibility_transition, 

16) 

17from couchers.models import ( 

18 AdminActionLevel, 

19 Comment, 

20 Discussion, 

21 Event, 

22 EventOccurrence, 

23 FriendRelationship, 

24 GroupChat, 

25 HostRequest, 

26 Message, 

27 MessageType, 

28 ModerationAction, 

29 ModerationLog, 

30 ModerationObjectType, 

31 ModerationQueueItem, 

32 ModerationState, 

33 ModerationTrigger, 

34 ModerationVisibility, 

35 Notification, 

36 NotificationDelivery, 

37 PublicTrip, 

38 Reference, 

39 Reply, 

40 User, 

41 get_moderated_models, 

42) 

43from couchers.proto import moderation_pb2, moderation_pb2_grpc 

44from couchers.proto.internal import jobs_pb2 

45from couchers.utils import Timestamp_from_datetime, not_none, now 

46 

47logger = logging.getLogger(__name__) 

48 

49MAX_PAGINATION_LENGTH = 1_000 

50 

51# Moderation enum mappings 

52moderationvisibility2api = { 

53 None: moderation_pb2.MODERATION_VISIBILITY_UNSPECIFIED, 

54 ModerationVisibility.hidden: moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

55 ModerationVisibility.shadowed: moderation_pb2.MODERATION_VISIBILITY_SHADOWED, 

56 ModerationVisibility.visible: moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

57 ModerationVisibility.unlisted: moderation_pb2.MODERATION_VISIBILITY_UNLISTED, 

58} 

59 

60moderationvisibility2sql = { 

61 moderation_pb2.MODERATION_VISIBILITY_UNSPECIFIED: None, 

62 moderation_pb2.MODERATION_VISIBILITY_HIDDEN: ModerationVisibility.hidden, 

63 moderation_pb2.MODERATION_VISIBILITY_SHADOWED: ModerationVisibility.shadowed, 

64 moderation_pb2.MODERATION_VISIBILITY_VISIBLE: ModerationVisibility.visible, 

65 moderation_pb2.MODERATION_VISIBILITY_UNLISTED: ModerationVisibility.unlisted, 

66} 

67 

68moderationtrigger2api = { 

69 None: moderation_pb2.MODERATION_TRIGGER_UNSPECIFIED, 

70 ModerationTrigger.initial_review: moderation_pb2.MODERATION_TRIGGER_INITIAL_REVIEW, 

71 ModerationTrigger.user_flag: moderation_pb2.MODERATION_TRIGGER_USER_FLAG, 

72 ModerationTrigger.machine_flag: moderation_pb2.MODERATION_TRIGGER_MACHINE_FLAG, 

73 ModerationTrigger.moderator_review: moderation_pb2.MODERATION_TRIGGER_MODERATOR_REVIEW, 

74} 

75 

76moderationtrigger2sql = { 

77 moderation_pb2.MODERATION_TRIGGER_UNSPECIFIED: None, 

78 moderation_pb2.MODERATION_TRIGGER_INITIAL_REVIEW: ModerationTrigger.initial_review, 

79 moderation_pb2.MODERATION_TRIGGER_USER_FLAG: ModerationTrigger.user_flag, 

80 moderation_pb2.MODERATION_TRIGGER_MACHINE_FLAG: ModerationTrigger.machine_flag, 

81 moderation_pb2.MODERATION_TRIGGER_MODERATOR_REVIEW: ModerationTrigger.moderator_review, 

82} 

83 

84moderationaction2api = { 

85 None: moderation_pb2.MODERATION_ACTION_UNSPECIFIED, 

86 ModerationAction.create: moderation_pb2.MODERATION_ACTION_CREATE, 

87 ModerationAction.approve: moderation_pb2.MODERATION_ACTION_APPROVE, 

88 ModerationAction.hide: moderation_pb2.MODERATION_ACTION_HIDE, 

89 ModerationAction.flag: moderation_pb2.MODERATION_ACTION_FLAG, 

90 ModerationAction.unflag: moderation_pb2.MODERATION_ACTION_UNFLAG, 

91 ModerationAction.set_priority: moderation_pb2.MODERATION_ACTION_SET_PRIORITY, 

92 ModerationAction.bulk_set_visibility: moderation_pb2.MODERATION_ACTION_BULK_SET_VISIBILITY, 

93} 

94 

95moderationaction2sql = { 

96 moderation_pb2.MODERATION_ACTION_UNSPECIFIED: None, 

97 moderation_pb2.MODERATION_ACTION_CREATE: ModerationAction.create, 

98 moderation_pb2.MODERATION_ACTION_APPROVE: ModerationAction.approve, 

99 moderation_pb2.MODERATION_ACTION_HIDE: ModerationAction.hide, 

100 moderation_pb2.MODERATION_ACTION_FLAG: ModerationAction.flag, 

101 moderation_pb2.MODERATION_ACTION_UNFLAG: ModerationAction.unflag, 

102 moderation_pb2.MODERATION_ACTION_SET_PRIORITY: ModerationAction.set_priority, 

103 moderation_pb2.MODERATION_ACTION_BULK_SET_VISIBILITY: ModerationAction.bulk_set_visibility, 

104} 

105 

106moderationobjecttype2api = { 

107 None: moderation_pb2.MODERATION_OBJECT_TYPE_UNSPECIFIED, 

108 ModerationObjectType.host_request: moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST, 

109 ModerationObjectType.group_chat: moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT, 

110 ModerationObjectType.friend_request: moderation_pb2.MODERATION_OBJECT_TYPE_FRIEND_REQUEST, 

111 ModerationObjectType.event_occurrence: moderation_pb2.MODERATION_OBJECT_TYPE_EVENT_OCCURRENCE, 

112 ModerationObjectType.comment: moderation_pb2.MODERATION_OBJECT_TYPE_COMMENT, 

113 ModerationObjectType.reply: moderation_pb2.MODERATION_OBJECT_TYPE_REPLY, 

114 ModerationObjectType.discussion: moderation_pb2.MODERATION_OBJECT_TYPE_DISCUSSION, 

115 ModerationObjectType.reference: moderation_pb2.MODERATION_OBJECT_TYPE_REFERENCE, 

116 ModerationObjectType.public_trip: moderation_pb2.MODERATION_OBJECT_TYPE_PUBLIC_TRIP, 

117 ModerationObjectType.user: moderation_pb2.MODERATION_OBJECT_TYPE_USER, 

118} 

119 

120moderationobjecttype2sql = { 

121 moderation_pb2.MODERATION_OBJECT_TYPE_UNSPECIFIED: None, 

122 moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST: ModerationObjectType.host_request, 

123 moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT: ModerationObjectType.group_chat, 

124 moderation_pb2.MODERATION_OBJECT_TYPE_FRIEND_REQUEST: ModerationObjectType.friend_request, 

125 moderation_pb2.MODERATION_OBJECT_TYPE_EVENT_OCCURRENCE: ModerationObjectType.event_occurrence, 

126 moderation_pb2.MODERATION_OBJECT_TYPE_COMMENT: ModerationObjectType.comment, 

127 moderation_pb2.MODERATION_OBJECT_TYPE_REPLY: ModerationObjectType.reply, 

128 moderation_pb2.MODERATION_OBJECT_TYPE_DISCUSSION: ModerationObjectType.discussion, 

129 moderation_pb2.MODERATION_OBJECT_TYPE_REFERENCE: ModerationObjectType.reference, 

130 moderation_pb2.MODERATION_OBJECT_TYPE_PUBLIC_TRIP: ModerationObjectType.public_trip, 

131 moderation_pb2.MODERATION_OBJECT_TYPE_USER: ModerationObjectType.user, 

132} 

133 

134 

135def _resolve_queue_item( 

136 queue_item: ModerationQueueItem, 

137 log_entry: ModerationLog, 

138 action: ModerationAction, 

139 object_type: ModerationObjectType, 

140) -> None: 

141 """Resolve an open queue item against the given log entry and record metrics.""" 

142 queue_item.resolved_by_log_id = log_entry.id 

143 observe_moderation_queue_item_resolved(queue_item.trigger, action, object_type) 

144 observe_moderation_queue_resolution_time( 

145 queue_item.trigger, 

146 action, 

147 object_type, 

148 (now() - queue_item.time_created).total_seconds(), 

149 ) 

150 

151 

152def bulk_set_user_content_visibility( 

153 session: Session, 

154 user: User, 

155 new_visibility: ModerationVisibility, 

156 moderator_user_id: int, 

157 from_visibilities: set[ModerationVisibility] | None = None, 

158 reason: str | None = None, 

159) -> int: 

160 """Set visibility on every UMS-governed object authored by the user. Returns count of updated states.""" 

161 final_reason = reason or f"Bulk visibility update for user {user.id} to {new_visibility.name}" 

162 

163 author_exists_clauses = [] 

164 for entry in get_moderated_models().values(): 

165 if entry.has_own_visibility_mechanism: 

166 continue 

167 author_exists_clauses.append( 

168 exists().where(and_(entry.moderation_state_id_column == ModerationState.id, entry.author_column == user.id)) 

169 ) 

170 

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

172 

173 updated_count = 0 

174 for moderation_state in states: 

175 if from_visibilities and moderation_state.visibility not in from_visibilities: 

176 continue 

177 if moderation_state.visibility == new_visibility: 

178 continue 

179 

180 old_visibility = not_none(moderation_state.visibility) 

181 moderation_state.visibility = new_visibility 

182 moderation_state.updated = now() 

183 

184 log_entry = ModerationLog( 

185 moderation_state_id=moderation_state.id, 

186 action=ModerationAction.bulk_set_visibility, 

187 moderator_user_id=moderator_user_id, 

188 new_visibility=new_visibility, 

189 reason=final_reason, 

190 ) 

191 session.add(log_entry) 

192 session.flush() 

193 

194 open_items = ( 

195 session.execute( 

196 select(ModerationQueueItem) 

197 .where(ModerationQueueItem.moderation_state_id == moderation_state.id) 

198 .where(ModerationQueueItem.resolved_by_log_id.is_(None)) 

199 ) 

200 .scalars() 

201 .all() 

202 ) 

203 for queue_item in open_items: 

204 _resolve_queue_item( 

205 queue_item, log_entry, ModerationAction.bulk_set_visibility, moderation_state.object_type 

206 ) 

207 session.flush() 

208 

209 observe_moderation_action(ModerationAction.bulk_set_visibility, moderation_state.object_type) 

210 observe_moderation_visibility_transition(old_visibility, new_visibility, moderation_state.object_type) 

211 

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

213 _enqueue_pending_notifications(session, moderation_state.id) 

214 

215 updated_count += 1 

216 

217 return updated_count 

218 

219 

220def _enqueue_pending_notifications(session: Session, moderation_state_id: int) -> None: 

221 """Re-queue any pending notifications linked to the given moderation state whose deliveries were suppressed.""" 

222 pending_notifications = ( 

223 session.execute( 

224 select(Notification) 

225 .where(Notification.moderation_state_id == moderation_state_id) 

226 .where(not_(exists().where(NotificationDelivery.notification_id == Notification.id))) 

227 # they're delivered in the order they're queued, so the user reads them chronologically 

228 .order_by(Notification.id) 

229 ) 

230 .scalars() 

231 .all() 

232 ) 

233 

234 # Import here to avoid circular dependency 

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

236 

237 for notification in pending_notifications: 

238 queue_job( 

239 session, 

240 job=handle_notification, 

241 payload=jobs_pb2.HandleNotificationPayload(notification_id=notification.id), 

242 ) 

243 

244 

245def moderation_state_to_pb(state: ModerationState, session: Session) -> moderation_pb2.ModerationStateInfo: 

246 """Convert ModerationState model to proto message""" 

247 object_type = state.object_type 

248 object_id = state.object_id 

249 

250 # Get the author user ID and content based on object type 

251 if object_type == ModerationObjectType.host_request: 

252 author_user_id = session.execute( 

253 select(HostRequest.initiator_user_id).where(HostRequest.conversation_id == object_id) 

254 ).scalar_one() 

255 # Get the first text message for this conversation 

256 content = session.execute( 

257 select(Message.text) 

258 .where(Message.conversation_id == object_id) 

259 .where(Message.message_type == MessageType.text) 

260 .order_by(Message.id.asc()) 

261 .limit(1) 

262 ).scalar_one_or_none() 

263 elif object_type == ModerationObjectType.group_chat: 

264 author_user_id = session.execute( 

265 select(GroupChat.creator_id).where(GroupChat.conversation_id == object_id) 

266 ).scalar_one() 

267 # Get the first text message for this conversation 

268 content = session.execute( 

269 select(Message.text) 

270 .where(Message.conversation_id == object_id) 

271 .where(Message.message_type == MessageType.text) 

272 .order_by(Message.id.asc()) 

273 .limit(1) 

274 ).scalar_one_or_none() 

275 elif object_type == ModerationObjectType.friend_request: 

276 author_user_id = session.execute( 

277 select(FriendRelationship.from_user_id).where(FriendRelationship.id == object_id) 

278 ).scalar_one() 

279 # Friend requests have no text content 

280 content = None 

281 elif object_type == ModerationObjectType.event_occurrence: 

282 author_user_id, title, description = session.execute( 

283 select(EventOccurrence.creator_user_id, Event.title, EventOccurrence.content) 

284 .join(Event, Event.id == EventOccurrence.event_id) 

285 .where(EventOccurrence.id == object_id) 

286 ).one() 

287 content = f"{title}\n\n{description}" 

288 elif object_type == ModerationObjectType.comment: 

289 author_user_id, content = session.execute( 

290 select(Comment.author_user_id, Comment.content).where(Comment.id == object_id) 

291 ).one() 

292 elif object_type == ModerationObjectType.reply: 

293 author_user_id, content = session.execute( 

294 select(Reply.author_user_id, Reply.content).where(Reply.id == object_id) 

295 ).one() 

296 elif object_type == ModerationObjectType.discussion: 

297 author_user_id, title, body = session.execute( 

298 select(Discussion.creator_user_id, Discussion.title, Discussion.content).where(Discussion.id == object_id) 

299 ).one() 

300 content = f"{title}\n\n{body}" 

301 elif object_type == ModerationObjectType.reference: 

302 author_user_id, content = session.execute( 

303 select(Reference.from_user_id, Reference.text).where(Reference.id == object_id) 

304 ).one() 

305 elif object_type == ModerationObjectType.public_trip: 

306 author_user_id, content = session.execute( 

307 select(PublicTrip.user_id, PublicTrip.description).where(PublicTrip.id == object_id) 

308 ).one() 

309 elif object_type == ModerationObjectType.user: 309 ↛ 314line 309 didn't jump to line 314 because the condition on line 309 was always true

310 author_user_id = object_id 

311 username = session.execute(select(User.username).where(User.id == object_id)).scalar_one() 

312 content = f"@{username} / {object_id}" 

313 else: 

314 raise ValueError(f"Unsupported moderation object type: {object_type}") 

315 

316 # Import here to avoid circular dependency 

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

318 

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

320 

321 state_pb = moderation_pb2.ModerationStateInfo( 

322 moderation_state_id=state.id, 

323 object_type=moderationobjecttype2api[state.object_type], 

324 object_id=state.object_id, 

325 visibility=moderationvisibility2api[state.visibility], 

326 created=Timestamp_from_datetime(state.created), 

327 updated=Timestamp_from_datetime(state.updated), 

328 author_user_id=author_user_id, 

329 author=_user_to_details(session, author), 

330 content=content or "", 

331 ) 

332 

333 return state_pb 

334 

335 

336class Moderation(moderation_pb2_grpc.ModerationServicer): 

337 def GetModerationQueue( 

338 self, request: moderation_pb2.GetModerationQueueReq, context: CouchersContext, session: Session 

339 ) -> moderation_pb2.GetModerationQueueRes: 

340 """Get moderation queue items with optional filtering""" 

341 

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

343 

344 # Build query 

345 statement = select(ModerationQueueItem) 

346 

347 # Apply page token filter based on ordering direction 

348 if request.page_token: 

349 page_token_id = int(request.page_token) 

350 if request.newest_first: 350 ↛ 355line 350 didn't jump to line 355 because the condition on line 350 was always true

351 # Descending order: get items with smaller IDs 

352 statement = statement.where(ModerationQueueItem.id < page_token_id) 

353 else: 

354 # Ascending order: get items with larger IDs 

355 statement = statement.where(ModerationQueueItem.id > page_token_id) 

356 

357 # Apply filters 

358 if request.triggers: 

359 internal_triggers = [moderationtrigger2sql[t] for t in request.triggers] 

360 statement = statement.where(ModerationQueueItem.trigger.in_(internal_triggers)) 

361 

362 if request.object_type and request.object_type != moderation_pb2.MODERATION_OBJECT_TYPE_UNSPECIFIED: 362 ↛ 363line 362 didn't jump to line 363 because the condition on line 362 was never true

363 internal_object_type = moderationobjecttype2sql[request.object_type] 

364 if internal_object_type: 

365 statement = statement.join(ModerationState).where(ModerationState.object_type == internal_object_type) 

366 

367 if request.unresolved_only: 

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

369 

370 if request.HasField("priority_min"): 

371 statement = statement.where(ModerationQueueItem.priority >= request.priority_min) 

372 

373 if request.HasField("priority_max"): 

374 statement = statement.where(ModerationQueueItem.priority <= request.priority_max) 

375 

376 if request.HasField("created_before"): 

377 created_before = request.created_before.ToDatetime() 

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

379 

380 if request.HasField("created_after"): 

381 created_after = request.created_after.ToDatetime() 

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

383 

384 if request.item_author_user_id: 

385 author_user_id = request.item_author_user_id 

386 

387 # Use EXISTS for efficient author filtering 

388 author_exists_clauses = [] 

389 for entry in get_moderated_models().values(): 

390 author_exists_clauses.append( 

391 exists().where( 

392 and_( 

393 entry.moderation_state_id_column == ModerationQueueItem.moderation_state_id, 

394 entry.author_column == author_user_id, 

395 ) 

396 ) 

397 ) 

398 statement = statement.where(or_(*author_exists_clauses)) 

399 

400 if request.newest_first: 

401 statement = statement.order_by(ModerationQueueItem.id.desc()) 

402 else: 

403 statement = statement.order_by(ModerationQueueItem.id.asc()) 

404 

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

406 

407 # Convert to proto 

408 queue_items_pb = [] 

409 for item in queue_items[:page_size]: 

410 # Fetch the moderation state for this queue item 

411 mod_state = session.execute( 

412 select(ModerationState).where(ModerationState.id == item.moderation_state_id) 

413 ).scalar_one() 

414 

415 queue_item_pb = moderation_pb2.ModerationQueueItemInfo( 

416 queue_item_id=item.id, 

417 moderation_state_id=item.moderation_state_id, 

418 time_created=Timestamp_from_datetime(item.time_created), 

419 trigger=moderationtrigger2api[item.trigger], 

420 reason=item.reason, 

421 is_resolved=item.resolved_by_log_id is not None, 

422 resolved_by_log_id=item.resolved_by_log_id or 0, 

423 moderation_state=moderation_state_to_pb(mod_state, session), 

424 priority=item.priority, 

425 data=json.dumps(item.data) if item.data is not None else "", 

426 ) 

427 

428 queue_items_pb.append(queue_item_pb) 

429 

430 return moderation_pb2.GetModerationQueueRes( 

431 queue_items=queue_items_pb, 

432 # Use the ID of the last returned item (not the extra fetched item) as the cursor 

433 next_page_token=str(queue_items[page_size - 1].id) if len(queue_items) > page_size else None, 

434 ) 

435 

436 def GetModerationState( 

437 self, request: moderation_pb2.GetModerationStateReq, context: CouchersContext, session: Session 

438 ) -> moderation_pb2.GetModerationStateRes: 

439 """Get moderation state by object type and object ID""" 

440 object_type = moderationobjecttype2sql[request.object_type] 

441 if object_type is None: 

442 context.abort(grpc.StatusCode.INVALID_ARGUMENT, "Object type must be specified.") 

443 

444 moderation_state = session.execute( 

445 select(ModerationState) 

446 .where(ModerationState.object_type == object_type) 

447 .where(ModerationState.object_id == request.object_id) 

448 ).scalar_one_or_none() 

449 if moderation_state is None: 

450 context.abort(grpc.StatusCode.NOT_FOUND, "Moderation state not found.") 

451 

452 return moderation_pb2.GetModerationStateRes( 

453 moderation_state=moderation_state_to_pb(moderation_state, session), 

454 ) 

455 

456 def GetModerationLog( 

457 self, request: moderation_pb2.GetModerationLogReq, context: CouchersContext, session: Session 

458 ) -> moderation_pb2.GetModerationLogRes: 

459 """Get moderation log for a specific moderation state""" 

460 # Get the moderation state 

461 moderation_state = session.execute( 

462 select(ModerationState).where(ModerationState.id == request.moderation_state_id) 

463 ).scalar_one_or_none() 

464 if moderation_state is None: 

465 context.abort(grpc.StatusCode.NOT_FOUND, "Moderation state not found.") 

466 

467 # Get all log entries for this state, ordered by time (most recent first) 

468 log_entries = ( 

469 session.execute( 

470 select(ModerationLog) 

471 .where(ModerationLog.moderation_state_id == request.moderation_state_id) 

472 .order_by(ModerationLog.time.desc(), ModerationLog.id.desc()) 

473 ) 

474 .scalars() 

475 .all() 

476 ) 

477 

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

479 moderation_state_pb = moderation_state_to_pb(moderation_state, session) 

480 

481 # Convert to proto 

482 log_entries_pb = [] 

483 for entry in log_entries: 

484 log_entry_pb = moderation_pb2.ModerationLogEntryInfo( 

485 log_entry_id=entry.id, 

486 moderation_state_id=entry.moderation_state_id, 

487 time=Timestamp_from_datetime(entry.time), 

488 action=moderationaction2api[entry.action], 

489 moderator_user_id=entry.moderator_user_id, 

490 reason=entry.reason, 

491 ) 

492 

493 # Only include changed fields 

494 if entry.new_visibility is not None: 

495 log_entry_pb.new_visibility = moderationvisibility2api[entry.new_visibility] 

496 if entry.new_priority is not None: 496 ↛ 497line 496 didn't jump to line 497 because the condition on line 496 was never true

497 log_entry_pb.new_priority = entry.new_priority 

498 if entry.queue_item_id is not None: 

499 log_entry_pb.queue_item_id = entry.queue_item_id 

500 

501 log_entries_pb.append(log_entry_pb) 

502 

503 return moderation_pb2.GetModerationLogRes( 

504 log_entries=log_entries_pb, 

505 moderation_state=moderation_state_pb, 

506 ) 

507 

508 def ModerateContent( 

509 self, request: moderation_pb2.ModerateContentReq, context: CouchersContext, session: Session 

510 ) -> moderation_pb2.ModerateContentRes: 

511 """Single moderation entrypoint, dispatching on action. 

512 

513 APPROVE/HIDE act on the state's visibility; FLAG/SET_PRIORITY/UNFLAG act on a single 

514 queue item. Every action appends a ModerationLog row. 

515 """ 

516 

517 moderation_state = session.execute( 

518 select(ModerationState).where(ModerationState.id == request.moderation_state_id) 

519 ).scalar_one_or_none() 

520 if moderation_state is None: 

521 context.abort(grpc.StatusCode.NOT_FOUND, "Moderation state not found.") 

522 

523 action = moderationaction2sql[request.action] 

524 if action is None: 524 ↛ 525line 524 didn't jump to line 525 because the condition on line 524 was never true

525 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "admin:action_must_be_specified") 

526 

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

528 object_type = moderation_state.object_type 

529 

530 data = None 

531 if request.data.strip(): 

532 if action != ModerationAction.flag: 

533 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "admin:data_only_allowed_on_flag") 

534 try: 

535 data = json.loads(request.data) 

536 except json.JSONDecodeError: 

537 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "admin:data_must_be_valid_json") 

538 

539 if action in (ModerationAction.approve, ModerationAction.hide): 

540 if get_moderated_models()[object_type].has_own_visibility_mechanism: 

541 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "admin:cannot_set_visibility_on_state") 

542 

543 new_visibility = moderationvisibility2sql[request.visibility] 

544 if new_visibility is None: 544 ↛ 545line 544 didn't jump to line 545 because the condition on line 544 was never true

545 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "admin:visibility_must_be_specified") 

546 

547 old_visibility = not_none(moderation_state.visibility) 

548 moderation_state.visibility = new_visibility 

549 moderation_state.updated = now() 

550 

551 log_entry = ModerationLog( 

552 moderation_state_id=moderation_state.id, 

553 action=action, 

554 moderator_user_id=context.user_id, 

555 new_visibility=new_visibility, 

556 reason=reason, 

557 ) 

558 session.add(log_entry) 

559 session.flush() 

560 

561 if request.clear_flags: 

562 open_items = ( 

563 session.execute( 

564 select(ModerationQueueItem) 

565 .where(ModerationQueueItem.moderation_state_id == moderation_state.id) 

566 .where(ModerationQueueItem.resolved_by_log_id.is_(None)) 

567 ) 

568 .scalars() 

569 .all() 

570 ) 

571 for queue_item in open_items: 

572 _resolve_queue_item(queue_item, log_entry, action, object_type) 

573 session.flush() 

574 

575 observe_moderation_action(action, object_type) 

576 observe_moderation_visibility_transition(old_visibility, new_visibility, object_type) 

577 

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

579 _enqueue_pending_notifications(session, moderation_state.id) 

580 

581 elif action == ModerationAction.flag: 

582 trigger = moderationtrigger2sql[request.trigger] 

583 if trigger is None: 

584 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "admin:trigger_must_be_specified") 

585 

586 queue_item = ModerationQueueItem( 

587 moderation_state_id=moderation_state.id, 

588 trigger=trigger, 

589 reason=reason, 

590 priority=request.priority, 

591 data=data, 

592 ) 

593 session.add(queue_item) 

594 session.flush() 

595 

596 log_entry = ModerationLog( 

597 moderation_state_id=moderation_state.id, 

598 action=ModerationAction.flag, 

599 moderator_user_id=context.user_id, 

600 queue_item_id=queue_item.id, 

601 reason=reason, 

602 ) 

603 session.add(log_entry) 

604 session.flush() 

605 

606 if request.supersede_queue_item_id: 

607 superseded = session.execute( 

608 select(ModerationQueueItem) 

609 .where(ModerationQueueItem.id == request.supersede_queue_item_id) 

610 .where(ModerationQueueItem.moderation_state_id == moderation_state.id) 

611 ).scalar_one_or_none() 

612 if superseded is None: 612 ↛ 613line 612 didn't jump to line 613 because the condition on line 612 was never true

613 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "admin:queue_item_not_found") 

614 if superseded.resolved_by_log_id is None: 614 ↛ 618line 614 didn't jump to line 618 because the condition on line 614 was always true

615 _resolve_queue_item(superseded, log_entry, ModerationAction.flag, object_type) 

616 session.flush() 

617 

618 observe_moderation_action(ModerationAction.flag, object_type) 

619 observe_moderation_queue_item_created(trigger, object_type) 

620 

621 elif action == ModerationAction.set_priority: 

622 queue_item = self._get_queue_item_for_state(request, context, session, moderation_state.id) 

623 queue_item.priority = request.priority 

624 

625 log_entry = ModerationLog( 

626 moderation_state_id=moderation_state.id, 

627 action=ModerationAction.set_priority, 

628 moderator_user_id=context.user_id, 

629 queue_item_id=queue_item.id, 

630 new_priority=request.priority, 

631 reason=reason, 

632 ) 

633 session.add(log_entry) 

634 session.flush() 

635 

636 observe_moderation_action(ModerationAction.set_priority, object_type) 

637 

638 elif action == ModerationAction.unflag: 638 ↛ 659line 638 didn't jump to line 659 because the condition on line 638 was always true

639 queue_item = self._get_queue_item_for_state(request, context, session, moderation_state.id) 

640 moderation_state.updated = now() 

641 

642 log_entry = ModerationLog( 

643 moderation_state_id=moderation_state.id, 

644 action=ModerationAction.unflag, 

645 moderator_user_id=context.user_id, 

646 queue_item_id=queue_item.id, 

647 reason=reason, 

648 ) 

649 session.add(log_entry) 

650 session.flush() 

651 

652 if queue_item.resolved_by_log_id is None: 652 ↛ 656line 652 didn't jump to line 656 because the condition on line 652 was always true

653 _resolve_queue_item(queue_item, log_entry, ModerationAction.unflag, object_type) 

654 session.flush() 

655 

656 observe_moderation_action(ModerationAction.unflag, object_type) 

657 

658 else: 

659 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "admin:unsupported_action") 

660 

661 return moderation_pb2.ModerateContentRes( 

662 moderation_state=moderation_state_to_pb(moderation_state, session), 

663 ) 

664 

665 def _get_queue_item_for_state( 

666 self, 

667 request: moderation_pb2.ModerateContentReq, 

668 context: CouchersContext, 

669 session: Session, 

670 moderation_state_id: int, 

671 ) -> ModerationQueueItem: 

672 """Resolve the request's target queue item, asserting it belongs to the given state.""" 

673 if not request.queue_item_id: 

674 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "admin:queue_item_id_must_be_specified") 

675 queue_item = session.execute( 

676 select(ModerationQueueItem) 

677 .where(ModerationQueueItem.id == request.queue_item_id) 

678 .where(ModerationQueueItem.moderation_state_id == moderation_state_id) 

679 ).scalar_one_or_none() 

680 if queue_item is None: 680 ↛ 681line 680 didn't jump to line 681 because the condition on line 680 was never true

681 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "admin:queue_item_not_found") 

682 return queue_item 

683 

684 def SetUserContentVisibility( 

685 self, request: moderation_pb2.SetUserContentVisibilityReq, context: CouchersContext, session: Session 

686 ) -> moderation_pb2.SetUserContentVisibilityRes: 

687 """Bulk-set visibility on every UMS-governed object authored by the given user. 

688 

689 If from_visibility is non-empty, only states currently at one of those visibilities are swept. 

690 """ 

691 new_visibility = moderationvisibility2sql[request.visibility] 

692 if new_visibility is None: 

693 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "admin:visibility_must_be_specified") 

694 

695 raw_from_visibilities = {moderationvisibility2sql.get(v) for v in request.from_visibility} 

696 if None in raw_from_visibilities: 

697 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "admin:visibility_must_be_specified") 

698 from_visibilities: set[ModerationVisibility] | None = { 

699 v for v in raw_from_visibilities if v is not None 

700 } or None 

701 

702 user = session.execute(select(User).where(User.id == request.user_id)).scalar_one_or_none() 

703 if not user: 

704 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "user_not_found") 

705 

706 updated_count = bulk_set_user_content_visibility( 

707 session=session, 

708 user=user, 

709 new_visibility=new_visibility, 

710 moderator_user_id=context.user_id, 

711 from_visibilities=from_visibilities, 

712 reason=request.reason or None, 

713 ) 

714 

715 # Import here to avoid circular dependency 

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

717 

718 log_admin_action( 

719 session, 

720 context, 

721 user, 

722 "set_user_content_visibility", 

723 note=request.reason or None, 

724 tag=new_visibility.name, 

725 level=AdminActionLevel.high, 

726 ) 

727 

728 return moderation_pb2.SetUserContentVisibilityRes(updated_count=updated_count) 

729 

730 def ListModerationStates( 

731 self, request: moderation_pb2.ListModerationStatesReq, context: CouchersContext, session: Session 

732 ) -> moderation_pb2.ListModerationStatesRes: 

733 """Paginated list of ModerationState rows in id order. Optional author_user_id filter.""" 

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

735 

736 statement = select(ModerationState) 

737 

738 if request.page_token: 

739 page_token_id = int(request.page_token) 

740 if request.newest_first: 

741 statement = statement.where(ModerationState.id < page_token_id) 

742 else: 

743 statement = statement.where(ModerationState.id > page_token_id) 

744 

745 if request.author_user_id: 

746 author_exists_clauses = [] 

747 for entry in get_moderated_models().values(): 

748 author_exists_clauses.append( 

749 exists().where( 

750 and_( 

751 entry.moderation_state_id_column == ModerationState.id, 

752 entry.author_column == request.author_user_id, 

753 ) 

754 ) 

755 ) 

756 statement = statement.where(or_(*author_exists_clauses)) 

757 

758 if request.newest_first: 

759 statement = statement.order_by(ModerationState.id.desc()) 

760 else: 

761 statement = statement.order_by(ModerationState.id.asc()) 

762 

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

764 

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

766 

767 return moderation_pb2.ListModerationStatesRes( 

768 moderation_states=state_pbs, 

769 next_page_token=str(states[page_size - 1].id) if len(states) > page_size else None, 

770 )