Coverage for app/backend/src/couchers/servicers/references.py: 96%

185 statements  

« prev     ^ index     » next       coverage.py v7.14.2, created at 2026-06-21 09:29 +0000

1""" 

2* Only one friend reference 

3* Multiple of the other types (one for each stay) 

4* Have 2 weeks to write a reference after hosting/surfing 

5* References become visible after min{2 weeks, both reciprocal references written} 

6""" 

7 

8from datetime import datetime 

9 

10import grpc 

11from google.protobuf import empty_pb2 

12from sqlalchemy import select 

13from sqlalchemy.orm import Session, aliased 

14from sqlalchemy.sql import and_, func, literal, or_, union_all 

15 

16from couchers.context import CouchersContext, make_notification_user_context 

17from couchers.db import are_friends 

18from couchers.event_log import log_event 

19from couchers.materialized_views import LiteUser 

20from couchers.models import HostRequest, ModerationObjectType, Reference, ReferenceType, User 

21from couchers.models.notifications import NotificationTopicAction 

22from couchers.moderation.utils import create_moderation 

23from couchers.notifications.notify import notify 

24from couchers.proto import notification_data_pb2, references_pb2, references_pb2_grpc 

25from couchers.servicers.api import user_model_to_pb 

26from couchers.sql import users_visible, where_moderated_content_visible, where_users_column_visible 

27from couchers.tasks import maybe_send_reference_report_email 

28from couchers.utils import Timestamp_from_datetime, now 

29 

30MAX_PAGINATION_LENGTH = 100 

31 

32reftype2sql = { 

33 references_pb2.ReferenceType.REFERENCE_TYPE_FRIEND: ReferenceType.friend, 

34 references_pb2.ReferenceType.REFERENCE_TYPE_SURFED: ReferenceType.surfed, 

35 references_pb2.ReferenceType.REFERENCE_TYPE_HOSTED: ReferenceType.hosted, 

36} 

37 

38reftype2api = { 

39 ReferenceType.friend: references_pb2.ReferenceType.REFERENCE_TYPE_FRIEND, 

40 ReferenceType.surfed: references_pb2.ReferenceType.REFERENCE_TYPE_SURFED, 

41 ReferenceType.hosted: references_pb2.ReferenceType.REFERENCE_TYPE_HOSTED, 

42} 

43 

44 

45def reference_to_pb(reference: Reference, context: CouchersContext) -> references_pb2.Reference: 

46 return references_pb2.Reference( 

47 reference_id=reference.id, 

48 from_user_id=reference.from_user_id, 

49 to_user_id=reference.to_user_id, 

50 reference_type=reftype2api[reference.reference_type], 

51 text=reference.text, 

52 written_time=Timestamp_from_datetime(reference.time.replace(hour=0, minute=0, second=0, microsecond=0)), 

53 host_request_id=( 

54 reference.host_request_id if context.user_id in [reference.from_user_id, reference.to_user_id] else None 

55 ), 

56 ) 

57 

58 

59def get_host_req_and_check_can_write_ref( 

60 session: Session, context: CouchersContext, host_request_id: int 

61) -> tuple[HostRequest, bool]: 

62 """ 

63 Checks that this can see the given host req and write a ref for it 

64 

65 Returns the host req and `surfed`, a boolean of if the user was the surfer or not 

66 """ 

67 query = select(HostRequest) 

68 query = where_users_column_visible(query, context, HostRequest.initiator_user_id) 

69 query = where_users_column_visible(query, context, HostRequest.recipient_user_id) 

70 query = where_moderated_content_visible(query, context, HostRequest, is_list_operation=False) 

71 query = query.where(HostRequest.conversation_id == host_request_id) 

72 query = query.where( 

73 or_(HostRequest.initiator_user_id == context.user_id, HostRequest.recipient_user_id == context.user_id) 

74 ) 

75 host_request = session.execute(query).scalar_one_or_none() 

76 

77 if not host_request: 77 ↛ 78line 77 didn't jump to line 78 because the condition on line 77 was never true

78 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "host_request_not_found") 

79 

80 if not host_request.can_write_reference: 

81 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "cant_write_reference_for_request") 

82 

83 if session.execute( 

84 select(Reference) 

85 .where(Reference.host_request_id == host_request.conversation_id) 

86 .where(Reference.from_user_id == context.user_id) 

87 ).scalar_one_or_none(): 

88 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "reference_already_given") 

89 

90 surfed = host_request.initiator_user_id == context.user_id 

91 

92 if surfed: 

93 my_reason = host_request.initiator_reason_didnt_meetup 

94 else: 

95 my_reason = host_request.recipient_reason_didnt_meetup 

96 

97 if my_reason != None: 

98 context.abort_with_error_code( 

99 grpc.StatusCode.FAILED_PRECONDITION, "cant_write_reference_indicated_didnt_meetup" 

100 ) 

101 

102 return host_request, surfed 

103 

104 

105def check_valid_reference( 

106 request: references_pb2.WriteFriendReferenceReq | references_pb2.WriteHostRequestReferenceReq, 

107 context: CouchersContext, 

108) -> None: 

109 if request.rating < 0 or request.rating > 1: 109 ↛ 110line 109 didn't jump to line 110 because the condition on line 109 was never true

110 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "reference_invalid_rating") 

111 

112 if request.text.strip() == "": 

113 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "reference_no_text") 

114 

115 

116def get_pending_references_to_write( 

117 session: Session, context: CouchersContext 

118) -> list[tuple[int, ReferenceType, datetime, LiteUser]]: 

119 q1 = ( 

120 select(literal(True), HostRequest, LiteUser) 

121 .outerjoin( 

122 Reference, 

123 and_( 

124 Reference.host_request_id == HostRequest.conversation_id, 

125 Reference.from_user_id == context.user_id, 

126 ), 

127 ) 

128 .join(LiteUser, LiteUser.id == HostRequest.recipient_user_id) 

129 ) 

130 q1 = where_users_column_visible(q1, context, HostRequest.recipient_user_id) 

131 q1 = where_moderated_content_visible(q1, context, HostRequest, is_list_operation=True) 

132 q1 = q1.where(Reference.id == None) 

133 q1 = q1.where(HostRequest.can_write_reference) 

134 q1 = q1.where(HostRequest.initiator_user_id == context.user_id) 

135 q1 = q1.where(HostRequest.initiator_reason_didnt_meetup == None) 

136 

137 q2 = ( 

138 select(literal(False), HostRequest, LiteUser) 

139 .outerjoin( 

140 Reference, 

141 and_( 

142 Reference.host_request_id == HostRequest.conversation_id, 

143 Reference.from_user_id == context.user_id, 

144 ), 

145 ) 

146 .join(LiteUser, LiteUser.id == HostRequest.initiator_user_id) 

147 ) 

148 q2 = where_users_column_visible(q2, context, HostRequest.initiator_user_id) 

149 q2 = where_moderated_content_visible(q2, context, HostRequest, is_list_operation=True) 

150 q2 = q2.where(Reference.id == None) 

151 q2 = q2.where(HostRequest.can_write_reference) 

152 q2 = q2.where(HostRequest.recipient_user_id == context.user_id) 

153 q2 = q2.where(HostRequest.recipient_reason_didnt_meetup == None) 

154 

155 union = union_all(q1, q2).order_by(HostRequest.end_time_to_write_reference.asc()).subquery() 

156 query = select(union.c[0].label("surfed"), aliased(HostRequest, union), aliased(LiteUser, union)) 

157 host_request_references = session.execute(query).all() 

158 

159 return [ 

160 ( 

161 host_request.conversation_id, 

162 ReferenceType.surfed if surfed else ReferenceType.hosted, 

163 host_request.end_time_to_write_reference, 

164 other_user, 

165 ) 

166 for surfed, host_request, other_user in host_request_references 

167 ] 

168 

169 

170class References(references_pb2_grpc.ReferencesServicer): 

171 def ListReferences( 

172 self, request: references_pb2.ListReferencesReq, context: CouchersContext, session: Session 

173 ) -> references_pb2.ListReferencesRes: 

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

175 next_reference_id = int(request.page_token) if request.page_token else 0 

176 

177 if not request.from_user_id and not request.to_user_id: 

178 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "need_to_specify_at_least_one_user") 

179 

180 to_users = aliased(User) 

181 from_users = aliased(User) 

182 statement = where_moderated_content_visible(select(Reference), context, Reference, is_list_operation=True) 

183 if request.from_user_id: 

184 # join the to_users, because only interested if the recipient is visible 

185 statement = ( 

186 statement.join(to_users, Reference.to_user_id == to_users.id) 

187 .where( 

188 to_users.banned_at.is_(None) 

189 ) # instead of where_users_visible; if user is deleted or blocked, reference still visible 

190 .where(or_(to_users.shadowed_at.is_(None), to_users.id == context.user_id)) 

191 .where(Reference.from_user_id == request.from_user_id) 

192 ) 

193 if request.to_user_id: 

194 # join the from_users, because only interested if the writer is visible 

195 statement = ( 

196 statement.join(from_users, Reference.from_user_id == from_users.id) 

197 .where( 

198 from_users.banned_at.is_(None) 

199 ) # instead of where_users_visible; if user is deleted or blocked, reference still visible 

200 .where(or_(from_users.shadowed_at.is_(None), from_users.id == context.user_id)) 

201 .where(Reference.to_user_id == request.to_user_id) 

202 ) 

203 if len(request.reference_type_filter) > 0: 

204 statement = statement.where( 

205 Reference.reference_type.in_([reftype2sql[t] for t in request.reference_type_filter]) 

206 ) 

207 

208 if next_reference_id: 

209 statement = statement.where(Reference.id <= next_reference_id) 

210 

211 # Reference visibility logic: 

212 # A reference is visible if any of the following apply: 

213 # 1. It is a friend reference 

214 # 2. Both references have been written 

215 # 3. It has been over 2 weeks since the host request ended 

216 

217 # we get the matching other references through this subquery 

218 sub = select(Reference.id.label("sub_id"), Reference.host_request_id).where( 

219 Reference.reference_type != ReferenceType.friend 

220 ) 

221 if request.from_user_id: 

222 sub = sub.where(Reference.to_user_id == request.from_user_id) 

223 if request.to_user_id: 

224 sub = sub.where(Reference.from_user_id == request.to_user_id) 

225 

226 query = sub.subquery() 

227 statement = ( 

228 statement.outerjoin(query, query.c.host_request_id == Reference.host_request_id) 

229 .outerjoin(HostRequest, HostRequest.conversation_id == Reference.host_request_id) 

230 .where( 

231 or_( 

232 Reference.reference_type == ReferenceType.friend, 

233 query.c.sub_id != None, 

234 HostRequest.end_time_to_write_reference < func.now(), 

235 ) 

236 ) 

237 ) 

238 

239 statement = statement.order_by(Reference.id.desc()).limit(page_size + 1) 

240 references = session.execute(statement).scalars().all() 

241 

242 return references_pb2.ListReferencesRes( 

243 references=[reference_to_pb(reference, context) for reference in references[:page_size]], 

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

245 ) 

246 

247 def WriteFriendReference( 

248 self, request: references_pb2.WriteFriendReferenceReq, context: CouchersContext, session: Session 

249 ) -> references_pb2.Reference: 

250 if context.user_id == request.to_user_id: 

251 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "cant_refer_self") 

252 

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

254 

255 check_valid_reference(request, context) 

256 

257 if not session.execute( 257 ↛ 260line 257 didn't jump to line 260 because the condition on line 257 was never true

258 select(User).where(users_visible(context)).where(User.id == request.to_user_id) 

259 ).scalar_one_or_none(): 

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

261 

262 if not are_friends(session, context, request.to_user_id): 

263 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "can_only_refer_friends") 

264 

265 if session.execute( 

266 select(Reference) 

267 .where(Reference.from_user_id == context.user_id) 

268 .where(Reference.to_user_id == request.to_user_id) 

269 .where(Reference.reference_type == ReferenceType.friend) 

270 ).scalar_one_or_none(): 

271 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "reference_already_given") 

272 

273 reference_text = request.text.strip() 

274 

275 reference: Reference | None = None 

276 

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

278 nonlocal reference 

279 reference = Reference( 

280 from_user_id=context.user_id, 

281 to_user_id=request.to_user_id, 

282 reference_type=ReferenceType.friend, 

283 text=reference_text, 

284 private_text=request.private_text.strip(), 

285 rating=request.rating, 

286 was_appropriate=request.was_appropriate, 

287 moderation_state_id=moderation_state_id, 

288 ) 

289 session.add(reference) 

290 session.flush() 

291 return reference.id 

292 

293 create_moderation( 

294 session=session, 

295 object_type=ModerationObjectType.reference, 

296 object_id=create_object, 

297 creator_user_id=context.user_id, 

298 ) 

299 assert reference is not None 

300 session.commit() 

301 

302 # send the recipient of the reference a reminder 

303 notify( 

304 session, 

305 user_id=request.to_user_id, 

306 topic_action=NotificationTopicAction.reference__receive_friend, 

307 key=str(reference.id), 

308 data=notification_data_pb2.ReferenceReceiveFriend( 

309 from_user=user_model_to_pb(user, session, make_notification_user_context(user_id=request.to_user_id)), 

310 text=reference_text, 

311 ), 

312 moderation_state_id=reference.moderation_state_id, 

313 ) 

314 

315 # possibly send out an alert to the mod team if the reference was bad 

316 maybe_send_reference_report_email(session, reference) 

317 

318 log_event( 

319 context, 

320 session, 

321 "reference.friend_written", 

322 { 

323 "to_user_id": request.to_user_id, 

324 "rating": request.rating, 

325 "was_appropriate": request.was_appropriate, 

326 }, 

327 ) 

328 

329 return reference_to_pb(reference, context) 

330 

331 def WriteHostRequestReference( 

332 self, request: references_pb2.WriteHostRequestReferenceReq, context: CouchersContext, session: Session 

333 ) -> references_pb2.Reference: 

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

335 

336 check_valid_reference(request, context) 

337 

338 host_request, surfed = get_host_req_and_check_can_write_ref(session, context, request.host_request_id) 

339 

340 reference_text = request.text.strip() 

341 

342 if surfed: 

343 # we requested to surf with someone 

344 reference_type = ReferenceType.surfed 

345 to_user_id = host_request.recipient_user_id 

346 assert context.user_id == host_request.initiator_user_id 

347 else: 

348 # we hosted someone 

349 reference_type = ReferenceType.hosted 

350 to_user_id = host_request.initiator_user_id 

351 assert context.user_id == host_request.recipient_user_id 

352 

353 reference: Reference | None = None 

354 

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

356 nonlocal reference 

357 reference = Reference( 

358 from_user_id=context.user_id, 

359 to_user_id=to_user_id, 

360 host_request_id=host_request.conversation_id, 

361 text=reference_text, 

362 private_text=request.private_text.strip(), 

363 rating=request.rating, 

364 was_appropriate=request.was_appropriate, 

365 reference_type=reference_type, 

366 moderation_state_id=moderation_state_id, 

367 ) 

368 session.add(reference) 

369 session.flush() 

370 return reference.id 

371 

372 create_moderation( 

373 session=session, 

374 object_type=ModerationObjectType.reference, 

375 object_id=create_object, 

376 creator_user_id=context.user_id, 

377 ) 

378 assert reference is not None 

379 session.commit() 

380 

381 other_reference = session.execute( 

382 select(Reference) 

383 .where(Reference.host_request_id == host_request.conversation_id) 

384 .where(Reference.to_user_id == context.user_id) 

385 ).scalar_one_or_none() 

386 

387 # send notification out 

388 topic_action = ( 

389 NotificationTopicAction.reference__receive_surfed 

390 if surfed 

391 else NotificationTopicAction.reference__receive_hosted 

392 ) 

393 notify( 

394 session, 

395 user_id=reference.to_user_id, 

396 topic_action=topic_action, 

397 key=str(host_request.conversation_id), 

398 data=notification_data_pb2.ReferenceReceiveHostRequest( 

399 host_request_id=host_request.conversation_id, 

400 from_user=user_model_to_pb(user, session, make_notification_user_context(user_id=reference.to_user_id)), 

401 text=reference_text if other_reference is not None else None, 

402 ), 

403 moderation_state_id=reference.moderation_state_id, 

404 ) 

405 

406 # possibly send out an alert to the mod team if the reference was bad 

407 maybe_send_reference_report_email(session, reference) 

408 

409 log_event( 

410 context, 

411 session, 

412 "reference.host_request_written", 

413 { 

414 "to_user_id": to_user_id, 

415 "host_request_id": host_request.conversation_id, 

416 "reference_type": reference_type.name, 

417 "rating": request.rating, 

418 "was_appropriate": request.was_appropriate, 

419 }, 

420 ) 

421 

422 return reference_to_pb(reference, context) 

423 

424 def HostRequestIndicateDidntMeetup( 

425 self, request: references_pb2.HostRequestIndicateDidntMeetupReq, context: CouchersContext, session: Session 

426 ) -> empty_pb2.Empty: 

427 host_request, surfed = get_host_req_and_check_can_write_ref(session, context, request.host_request_id) 

428 

429 reason = request.reason_didnt_meetup.strip() 

430 

431 if surfed: 431 ↛ 432line 431 didn't jump to line 432 because the condition on line 431 was never true

432 host_request.initiator_reason_didnt_meetup = reason 

433 else: 

434 host_request.recipient_reason_didnt_meetup = reason 

435 

436 return empty_pb2.Empty() 

437 

438 def AvailableWriteReferences( 

439 self, request: references_pb2.AvailableWriteReferencesReq, context: CouchersContext, session: Session 

440 ) -> references_pb2.AvailableWriteReferencesRes: 

441 # can't write anything for ourselves, but let's return empty so this can be used generically on profile page 

442 if request.to_user_id == context.user_id: 

443 return references_pb2.AvailableWriteReferencesRes() 

444 

445 if not session.execute( 

446 select(User).where(users_visible(context)).where(User.id == request.to_user_id) 

447 ).scalar_one_or_none(): 

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

449 

450 can_write_friend_reference = ( 

451 session.execute( 

452 select(Reference) 

453 .where(Reference.from_user_id == context.user_id) 

454 .where(Reference.to_user_id == request.to_user_id) 

455 .where(Reference.reference_type == ReferenceType.friend) 

456 ).scalar_one_or_none() 

457 ) is None 

458 

459 q1 = ( 

460 select(literal(True), HostRequest) 

461 .outerjoin( 

462 Reference, 

463 and_( 

464 Reference.host_request_id == HostRequest.conversation_id, 

465 Reference.from_user_id == context.user_id, 

466 ), 

467 ) 

468 .where(Reference.id == None) 

469 .where(HostRequest.can_write_reference) 

470 .where(HostRequest.initiator_user_id == context.user_id) 

471 .where(HostRequest.recipient_user_id == request.to_user_id) 

472 .where(HostRequest.initiator_reason_didnt_meetup == None) 

473 ) 

474 

475 q2 = ( 

476 select(literal(False), HostRequest) 

477 .outerjoin( 

478 Reference, 

479 and_( 

480 Reference.host_request_id == HostRequest.conversation_id, 

481 Reference.from_user_id == context.user_id, 

482 ), 

483 ) 

484 .where(Reference.id == None) 

485 .where(HostRequest.can_write_reference) 

486 .where(HostRequest.initiator_user_id == request.to_user_id) 

487 .where(HostRequest.recipient_user_id == context.user_id) 

488 .where(HostRequest.recipient_reason_didnt_meetup == None) 

489 ) 

490 

491 union = union_all(q1, q2).order_by(HostRequest.end_time_to_write_reference.asc()).subquery() 

492 query = select(union.c[0].label("surfed"), aliased(HostRequest, union)) 

493 host_request_references = session.execute(query).all() 

494 

495 return references_pb2.AvailableWriteReferencesRes( 

496 can_write_friend_reference=can_write_friend_reference, 

497 available_write_references=[ 

498 references_pb2.AvailableWriteReferenceType( 

499 host_request_id=host_request.conversation_id, 

500 reference_type=reftype2api[ReferenceType.surfed if surfed else ReferenceType.hosted], 

501 time_expires=Timestamp_from_datetime(host_request.end_time_to_write_reference), 

502 ) 

503 for surfed, host_request in host_request_references 

504 ], 

505 ) 

506 

507 def ListPendingReferencesToWrite( 

508 self, request: empty_pb2.Empty, context: CouchersContext, session: Session 

509 ) -> references_pb2.ListPendingReferencesToWriteRes: 

510 return references_pb2.ListPendingReferencesToWriteRes( 

511 pending_references=[ 

512 references_pb2.AvailableWriteReferenceType( 

513 host_request_id=host_request_id, 

514 reference_type=reftype2api[reference_type], 

515 time_expires=Timestamp_from_datetime(end_time_to_write_reference), 

516 ) 

517 for host_request_id, reference_type, end_time_to_write_reference, other_user in get_pending_references_to_write( 

518 session, context 

519 ) 

520 ], 

521 ) 

522 

523 def GetHostRequestReferenceStatus( 

524 self, request: references_pb2.GetHostRequestReferenceStatusReq, context: CouchersContext, session: Session 

525 ) -> references_pb2.GetHostRequestReferenceStatusRes: 

526 # Compute has_given (whether current user already wrote a reference for this host request) 

527 has_given = ( 

528 session.execute( 

529 select(Reference) 

530 .where(Reference.host_request_id == request.host_request_id) 

531 .where(Reference.from_user_id == context.user_id) 

532 ).scalar_one_or_none() 

533 is not None 

534 ) 

535 

536 query = select(HostRequest) 

537 query = where_moderated_content_visible(query, context, HostRequest, is_list_operation=False) 

538 query = query.where(HostRequest.conversation_id == request.host_request_id) 

539 query = query.where( 

540 or_(HostRequest.initiator_user_id == context.user_id, HostRequest.recipient_user_id == context.user_id) 

541 ) 

542 host_request = session.execute(query).scalar_one_or_none() 

543 

544 can_write = False 

545 is_expired = False 

546 didnt_stay = False 

547 

548 if host_request is not None: 

549 # Compute expired from end_time_to_write_reference 

550 if host_request.end_time_to_write_reference is not None: 550 ↛ 554line 550 didn't jump to line 554 because the condition on line 550 was always true

551 is_expired = host_request.end_time_to_write_reference < now() 

552 

553 # Block only if current user indicated didn't meet up 

554 didnt_stay = ( 

555 (host_request.initiator_reason_didnt_meetup is not None) 

556 if host_request.initiator_user_id == context.user_id 

557 else (host_request.recipient_reason_didnt_meetup is not None) 

558 ) 

559 

560 # You can write only if: host_request allows it, you didn't already give one, and you didn't indicate didn't meet up 

561 can_write = bool(host_request.can_write_reference) and (not has_given) and (not didnt_stay) 

562 

563 return references_pb2.GetHostRequestReferenceStatusRes( 

564 has_given=has_given, 

565 can_write=can_write, 

566 is_expired=is_expired, 

567 didnt_stay=didnt_stay, 

568 )