Coverage for app/backend/src/couchers/servicers/threads.py: 86%

185 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-21 21:52 +0000

1import logging 

2 

3import grpc 

4import sqlalchemy.exc 

5from google.protobuf import empty_pb2 

6from sqlalchemy import exists, select 

7from sqlalchemy.orm import Session 

8from sqlalchemy.sql import func 

9 

10from couchers.context import CouchersContext, make_background_user_context, make_notification_user_context 

11from couchers.db import session_scope 

12from couchers.helpers.completed_profile import has_completed_profile 

13from couchers.jobs.enqueue import queue_job 

14from couchers.models import ( 

15 Comment, 

16 Discussion, 

17 Event, 

18 EventOccurrence, 

19 ModerationObjectType, 

20 Reply, 

21 Thread, 

22 User, 

23) 

24from couchers.models.discussions import CommentVersion, ContentChangeType, ReplyVersion 

25from couchers.models.notifications import NotificationTopicAction 

26from couchers.moderation.utils import create_moderation 

27from couchers.notifications.notify import notify 

28from couchers.proto import notification_data_pb2, threads_pb2, threads_pb2_grpc 

29from couchers.proto.internal import jobs_pb2 

30from couchers.servicers.api import user_model_to_pb 

31from couchers.servicers.blocking import is_not_visible 

32from couchers.sql import where_moderated_content_visible, where_users_column_visible 

33from couchers.utils import Timestamp_from_datetime, now 

34 

35logger = logging.getLogger(__name__) 

36 

37 

38# Since the API exposes a single ID space regardless of nesting level, 

39# we construct the API id by appending the nesting level to the 

40# database ID. 

41 

42 

43def pack_thread_id(database_id: int, depth: int) -> int: 

44 return database_id * 10 + depth 

45 

46 

47def unpack_thread_id(thread_id: int) -> tuple[int, int]: 

48 """Returns (database_id, depth) tuple.""" 

49 return divmod(thread_id, 10) 

50 

51 

52def total_num_responses(session: Session, context: CouchersContext, database_id: int) -> int: 

53 """Return the total number of visible, non-deleted comments and replies to the thread with 

54 database id database_id. 

55 """ 

56 comments = where_moderated_content_visible( 

57 where_users_column_visible( 

58 select(func.count()) 

59 .select_from(Comment) 

60 .where(Comment.thread_id == database_id) 

61 .where(Comment.deleted == None), 

62 context, 

63 Comment.author_user_id, 

64 ), 

65 context, 

66 Comment, 

67 is_list_operation=True, 

68 ) 

69 replies = where_moderated_content_visible( 

70 where_users_column_visible( 

71 select(func.count()) 

72 .select_from(Reply) 

73 .join(Comment, Comment.id == Reply.comment_id) 

74 .where(Comment.thread_id == database_id) 

75 .where(Reply.deleted == None), 

76 context, 

77 Reply.author_user_id, 

78 ), 

79 context, 

80 Reply, 

81 is_list_operation=True, 

82 ) 

83 return session.execute(comments).scalar_one() + session.execute(replies).scalar_one() 

84 

85 

86def thread_to_pb(session: Session, context: CouchersContext, database_id: int) -> threads_pb2.Thread: 

87 return threads_pb2.Thread( 

88 thread_id=pack_thread_id(database_id, 0), 

89 num_responses=total_num_responses(session, context, database_id), 

90 ) 

91 

92 

93def generate_reply_notifications(payload: jobs_pb2.GenerateReplyNotificationsPayload) -> None: 

94 # Import here to avoid circular dependency 

95 from couchers.servicers.discussions import discussion_to_pb # noqa: PLC0415 

96 from couchers.servicers.events import event_to_pb # noqa: PLC0415 

97 

98 with session_scope() as session: 

99 database_id, depth = unpack_thread_id(payload.thread_id) 

100 if depth == 1: 

101 # this is a top-level Comment on a Thread attached to event, discussion, etc 

102 comment = session.execute(select(Comment).where(Comment.id == database_id)).scalar_one() 

103 thread = session.execute(select(Thread).where(Thread.id == comment.thread_id)).scalar_one() 

104 author_user = session.execute(select(User).where(User.id == comment.author_user_id)).scalar_one() 

105 # reply object for notif 

106 reply = threads_pb2.Reply( 

107 thread_id=payload.thread_id, 

108 content=comment.content, 

109 author_user_id=comment.author_user_id, 

110 created_time=Timestamp_from_datetime(comment.created), 

111 num_replies=0, 

112 ) 

113 # figure out if the thread is related to an event or discussion 

114 event = session.execute(select(Event).where(Event.thread_id == thread.id)).scalar_one_or_none() 

115 discussion = session.execute( 

116 select(Discussion).where(Discussion.thread_id == thread.id) 

117 ).scalar_one_or_none() 

118 if event: 

119 # thread is an event thread 

120 occurrence = event.occurrences.order_by(EventOccurrence.id.desc()).limit(1).one() 

121 subscribed_user_ids = [user.id for user in event.subscribers] 

122 attending_user_ids = [user.user_id for user in occurrence.attendances] 

123 

124 for user_id in set(subscribed_user_ids + attending_user_ids): 

125 if is_not_visible(session, user_id, comment.author_user_id): 125 ↛ 126line 125 didn't jump to line 126 because the condition on line 125 was never true

126 continue 

127 if user_id == comment.author_user_id: 127 ↛ 128line 127 didn't jump to line 128 because the condition on line 127 was never true

128 continue 

129 context = make_notification_user_context(user_id=user_id) 

130 notify( 

131 session, 

132 user_id=user_id, 

133 topic_action=NotificationTopicAction.event__comment, 

134 key=str(occurrence.id), 

135 data=notification_data_pb2.EventComment( 

136 reply=reply, 

137 event=event_to_pb(session, occurrence, context), 

138 author=user_model_to_pb(author_user, session, context), 

139 ), 

140 moderation_state_id=comment.moderation_state_id, 

141 ) 

142 elif discussion: 142 ↛ 169line 142 didn't jump to line 169 because the condition on line 142 was always true

143 # community discussion thread 

144 cluster = discussion.owner_cluster 

145 

146 if not cluster.is_official_cluster: 146 ↛ 147line 146 didn't jump to line 147 because the condition on line 146 was never true

147 raise NotImplementedError("Shouldn't have discussions under groups, only communities") 

148 

149 for user_id in [discussion.creator_user_id]: 

150 if is_not_visible(session, user_id, comment.author_user_id): 150 ↛ 151line 150 didn't jump to line 151 because the condition on line 150 was never true

151 continue 

152 if user_id == comment.author_user_id: 152 ↛ 153line 152 didn't jump to line 153 because the condition on line 152 was never true

153 continue 

154 

155 context = make_notification_user_context(user_id=user_id) 

156 notify( 

157 session, 

158 user_id=user_id, 

159 topic_action=NotificationTopicAction.discussion__comment, 

160 key=str(discussion.id), 

161 data=notification_data_pb2.DiscussionComment( 

162 reply=reply, 

163 discussion=discussion_to_pb(session, discussion, context), 

164 author=user_model_to_pb(author_user, session, context), 

165 ), 

166 moderation_state_id=comment.moderation_state_id, 

167 ) 

168 else: 

169 raise NotImplementedError("I can only do event and discussion threads for now") 

170 elif depth == 2: 170 ↛ 245line 170 didn't jump to line 245 because the condition on line 170 was always true

171 # this is a second-level reply to a comment 

172 db_reply = session.execute(select(Reply).where(Reply.id == database_id)).scalar_one() 

173 # the comment we're replying to 

174 parent_comment = session.execute(select(Comment).where(Comment.id == db_reply.comment_id)).scalar_one() 

175 context = make_background_user_context(user_id=db_reply.author_user_id) 

176 thread_replies_author_user_ids = ( 

177 session.execute( 

178 where_users_column_visible( 

179 select(Reply.author_user_id).where(Reply.comment_id == parent_comment.id), 

180 context, 

181 Reply.author_user_id, 

182 ) 

183 ) 

184 .scalars() 

185 .all() 

186 ) 

187 thread_user_ids = set(thread_replies_author_user_ids) 

188 if not is_not_visible(session, parent_comment.author_user_id, db_reply.author_user_id): 188 ↛ 191line 188 didn't jump to line 191 because the condition on line 188 was always true

189 thread_user_ids.add(parent_comment.author_user_id) 

190 

191 author_user = session.execute(select(User).where(User.id == db_reply.author_user_id)).scalar_one() 

192 

193 user_ids_to_notify = set(thread_user_ids) - {db_reply.author_user_id} 

194 

195 reply = threads_pb2.Reply( 

196 thread_id=payload.thread_id, 

197 content=db_reply.content, 

198 author_user_id=db_reply.author_user_id, 

199 created_time=Timestamp_from_datetime(db_reply.created), 

200 num_replies=0, 

201 ) 

202 

203 event = session.execute( 

204 select(Event).where(Event.thread_id == parent_comment.thread_id) 

205 ).scalar_one_or_none() 

206 discussion = session.execute( 

207 select(Discussion).where(Discussion.thread_id == parent_comment.thread_id) 

208 ).scalar_one_or_none() 

209 if event: 

210 # thread is an event thread 

211 occurrence = event.occurrences.order_by(EventOccurrence.id.desc()).limit(1).one() 

212 for user_id in user_ids_to_notify: 

213 context = make_notification_user_context(user_id=user_id) 

214 notify( 

215 session, 

216 user_id=user_id, 

217 topic_action=NotificationTopicAction.thread__reply, 

218 key=str(occurrence.id), 

219 data=notification_data_pb2.ThreadReply( 

220 reply=reply, 

221 event=event_to_pb(session, occurrence, context), 

222 author=user_model_to_pb(author_user, session, context), 

223 ), 

224 moderation_state_id=db_reply.moderation_state_id, 

225 ) 

226 elif discussion: 226 ↛ 243line 226 didn't jump to line 243 because the condition on line 226 was always true

227 # community discussion thread 

228 for user_id in user_ids_to_notify: 

229 context = make_notification_user_context(user_id=user_id) 

230 notify( 

231 session, 

232 user_id=user_id, 

233 topic_action=NotificationTopicAction.thread__reply, 

234 key=str(discussion.id), 

235 data=notification_data_pb2.ThreadReply( 

236 reply=reply, 

237 discussion=discussion_to_pb(session, discussion, context), 

238 author=user_model_to_pb(author_user, session, context), 

239 ), 

240 moderation_state_id=db_reply.moderation_state_id, 

241 ) 

242 else: 

243 raise NotImplementedError("I can only do event and discussion threads for now") 

244 else: 

245 raise Exception("Unknown depth") 

246 

247 

248class Threads(threads_pb2_grpc.ThreadsServicer): 

249 def GetThread( 

250 self, request: threads_pb2.GetThreadReq, context: CouchersContext, session: Session 

251 ) -> threads_pb2.GetThreadRes: 

252 database_id, depth = unpack_thread_id(request.thread_id) 

253 page_size = request.page_size if 0 < request.page_size < 100000 else 1000 

254 page_start = unpack_thread_id(int(request.page_token))[0] if request.page_token else 2**50 

255 

256 if depth == 0: 

257 if not session.execute(select(Thread).where(Thread.id == database_id)).scalar_one_or_none(): 257 ↛ 258line 257 didn't jump to line 258 because the condition on line 257 was never true

258 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "thread_not_found") 

259 

260 has_replies = exists().where((Reply.comment_id == Comment.id) & (Reply.deleted == None)).correlate(Comment) 

261 visible_reply_count = ( 

262 where_moderated_content_visible( 

263 where_users_column_visible( 

264 select(func.count(Reply.id)).where(Reply.comment_id == Comment.id).where(Reply.deleted == None), 

265 context, 

266 Reply.author_user_id, 

267 ), 

268 context, 

269 Reply, 

270 is_list_operation=True, 

271 ) 

272 .correlate(Comment) 

273 .scalar_subquery() 

274 ) 

275 

276 res = session.execute( 

277 where_moderated_content_visible( 

278 where_users_column_visible( 

279 select(Comment, visible_reply_count) 

280 .where(Comment.thread_id == database_id) 

281 .where((Comment.deleted == None) | has_replies) 

282 .where(Comment.id < page_start) 

283 .order_by(Comment.created.desc()) 

284 .limit(page_size + 1), 

285 context, 

286 Comment.author_user_id, 

287 ), 

288 context, 

289 Comment, 

290 is_list_operation=True, 

291 ) 

292 ).all() 

293 # Deleted comments are shown as stubs (thread_id, deleted, num_replies only) 

294 # to preserve thread structure, but content and author are stripped. 

295 replies = [] 

296 for r, n in res[:page_size]: 

297 if r.deleted is not None: 

298 replies.append( 

299 threads_pb2.Reply( 

300 thread_id=pack_thread_id(r.id, 1), 

301 deleted=True, 

302 num_replies=n, 

303 ) 

304 ) 

305 else: 

306 replies.append( 

307 threads_pb2.Reply( 

308 thread_id=pack_thread_id(r.id, 1), 

309 content=r.content, 

310 author_user_id=r.author_user_id, 

311 created_time=Timestamp_from_datetime(r.created), 

312 num_replies=n, 

313 can_edit=(context.user_id == r.author_user_id), 

314 last_edited=Timestamp_from_datetime(r.last_edited) if r.last_edited else None, 

315 ) 

316 ) 

317 

318 elif depth == 1: 

319 if not session.execute( 

320 where_moderated_content_visible( 

321 where_users_column_visible( 

322 select(Comment).where(Comment.id == database_id), 

323 context, 

324 Comment.author_user_id, 

325 ), 

326 context, 

327 Comment, 

328 ) 

329 ).scalar_one_or_none(): 

330 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "thread_not_found") 

331 

332 res = ( 

333 session.execute( # type: ignore[assignment] 

334 where_moderated_content_visible( 

335 where_users_column_visible( 

336 select(Reply) 

337 .where(Reply.comment_id == database_id) 

338 .where(Reply.deleted == None) 

339 .where(Reply.id < page_start) 

340 .order_by(Reply.created.desc()) 

341 .limit(page_size + 1), 

342 context, 

343 Reply.author_user_id, 

344 ), 

345 context, 

346 Reply, 

347 is_list_operation=True, 

348 ) 

349 ) 

350 .scalars() 

351 .all() 

352 ) 

353 replies = [ 

354 threads_pb2.Reply( 

355 thread_id=pack_thread_id(r.id, 2), 

356 content=r.content, 

357 author_user_id=r.author_user_id, 

358 created_time=Timestamp_from_datetime(r.created), 

359 num_replies=0, 

360 can_edit=(context.user_id == r.author_user_id), 

361 last_edited=Timestamp_from_datetime(r.last_edited) if r.last_edited else None, 

362 ) 

363 for r in res[:page_size] 

364 ] 

365 

366 else: 

367 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "thread_not_found") 

368 

369 if len(res) > page_size: 

370 # There's more! 

371 next_page_token = str(replies[-1].thread_id) 

372 else: 

373 next_page_token = "" 

374 

375 return threads_pb2.GetThreadRes(replies=replies, next_page_token=next_page_token) 

376 

377 def PostReply( 

378 self, request: threads_pb2.PostReplyReq, context: CouchersContext, session: Session 

379 ) -> threads_pb2.PostReplyRes: 

380 content = request.content.strip() 

381 

382 if content == "": 

383 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_comment") 

384 

385 database_id, depth = unpack_thread_id(request.thread_id) 

386 if depth not in (0, 1): 

387 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "thread_not_found") 

388 

389 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one() 

390 if not has_completed_profile(session, user): 

391 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "incomplete_profile_post_comment") 

392 

393 object_to_add: Comment | Reply | None = None 

394 

395 def create_object(moderation_state_id: int) -> int: 

396 nonlocal object_to_add 

397 if depth == 0: 

398 object_to_add = Comment( 

399 thread_id=database_id, 

400 author_user_id=context.user_id, 

401 content=content, 

402 moderation_state_id=moderation_state_id, 

403 ) 

404 else: 

405 object_to_add = Reply( 

406 comment_id=database_id, 

407 author_user_id=context.user_id, 

408 content=content, 

409 moderation_state_id=moderation_state_id, 

410 ) 

411 session.add(object_to_add) 

412 try: 

413 session.flush() 

414 except sqlalchemy.exc.IntegrityError: 

415 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "thread_not_found") 

416 return object_to_add.id 

417 

418 create_moderation( 

419 session=session, 

420 object_type=ModerationObjectType.comment if depth == 0 else ModerationObjectType.reply, 

421 object_id=create_object, 

422 creator_user_id=context.user_id, 

423 ) 

424 

425 assert object_to_add is not None 

426 thread_id = pack_thread_id(object_to_add.id, depth + 1) 

427 

428 queue_job( 

429 session, 

430 job=generate_reply_notifications, 

431 payload=jobs_pb2.GenerateReplyNotificationsPayload( 

432 thread_id=thread_id, 

433 ), 

434 ) 

435 

436 return threads_pb2.PostReplyRes(thread_id=thread_id) 

437 

438 def UpdateReply( 

439 self, request: threads_pb2.UpdateReplyReq, context: CouchersContext, session: Session 

440 ) -> threads_pb2.Reply: 

441 content = request.content.strip() 

442 if not content: 442 ↛ 443line 442 didn't jump to line 443 because the condition on line 442 was never true

443 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_comment") 

444 

445 database_id, depth = unpack_thread_id(request.thread_id) 

446 if depth == 1: 

447 obj: Comment | Reply | None = session.execute( 

448 select(Comment).where(Comment.id == database_id) 

449 ).scalar_one_or_none() 

450 elif depth == 2: 450 ↛ 453line 450 didn't jump to line 453 because the condition on line 450 was always true

451 obj = session.execute(select(Reply).where(Reply.id == database_id)).scalar_one_or_none() 

452 else: 

453 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "thread_not_found") 

454 

455 if not obj: 455 ↛ 456line 455 didn't jump to line 456 because the condition on line 455 was never true

456 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "thread_not_found") 

457 if obj.deleted is not None: 457 ↛ 458line 457 didn't jump to line 458 because the condition on line 457 was never true

458 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "reply_deleted") 

459 if obj.author_user_id != context.user_id: 459 ↛ 460line 459 didn't jump to line 460 because the condition on line 459 was never true

460 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "reply_edit_permission_denied") 

461 

462 old_content = obj.content 

463 

464 if depth == 1: 

465 session.add( 

466 CommentVersion( 

467 comment_id=database_id, 

468 editor_user_id=context.user_id, 

469 change_type=ContentChangeType.edit, 

470 old_content=old_content, 

471 new_content=content, 

472 ) 

473 ) 

474 else: 

475 session.add( 

476 ReplyVersion( 

477 reply_id=database_id, 

478 editor_user_id=context.user_id, 

479 change_type=ContentChangeType.edit, 

480 old_content=old_content, 

481 new_content=content, 

482 ) 

483 ) 

484 

485 obj.content = content 

486 obj.last_edited = now() 

487 

488 return threads_pb2.Reply( 

489 thread_id=request.thread_id, 

490 content=obj.content, 

491 author_user_id=obj.author_user_id, 

492 created_time=Timestamp_from_datetime(obj.created), 

493 num_replies=0, 

494 can_edit=True, 

495 last_edited=Timestamp_from_datetime(obj.last_edited), 

496 ) 

497 

498 def DeleteReply( 

499 self, request: threads_pb2.DeleteReplyReq, context: CouchersContext, session: Session 

500 ) -> empty_pb2.Empty: 

501 database_id, depth = unpack_thread_id(request.thread_id) 

502 if depth == 1: 

503 obj: Comment | Reply | None = session.execute( 

504 select(Comment).where(Comment.id == database_id) 

505 ).scalar_one_or_none() 

506 elif depth == 2: 506 ↛ 509line 506 didn't jump to line 509 because the condition on line 506 was always true

507 obj = session.execute(select(Reply).where(Reply.id == database_id)).scalar_one_or_none() 

508 else: 

509 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "thread_not_found") 

510 

511 if not obj: 511 ↛ 512line 511 didn't jump to line 512 because the condition on line 511 was never true

512 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "thread_not_found") 

513 if obj.deleted is not None: 513 ↛ 514line 513 didn't jump to line 514 because the condition on line 513 was never true

514 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "reply_deleted") 

515 if obj.author_user_id != context.user_id: 515 ↛ 516line 515 didn't jump to line 516 because the condition on line 515 was never true

516 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "reply_delete_permission_denied") 

517 

518 if depth == 1: 

519 session.add( 

520 CommentVersion( 

521 comment_id=database_id, 

522 editor_user_id=context.user_id, 

523 change_type=ContentChangeType.delete, 

524 old_content=obj.content, 

525 new_content=None, 

526 ) 

527 ) 

528 else: 

529 session.add( 

530 ReplyVersion( 

531 reply_id=database_id, 

532 editor_user_id=context.user_id, 

533 change_type=ContentChangeType.delete, 

534 old_content=obj.content, 

535 new_content=None, 

536 ) 

537 ) 

538 

539 obj.deleted = now() 

540 

541 return empty_pb2.Empty()