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

180 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-23 04:10 +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_, 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.helpers.references import where_references_not_hidden_by_reciprocity 

20from couchers.materialized_views import LiteUser 

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

22from couchers.models.notifications import NotificationTopicAction 

23from couchers.moderation.utils import create_moderation 

24from couchers.notifications.notify import notify 

25from couchers.proto import notification_data_pb2, references_pb2, references_pb2_grpc 

26from couchers.servicers.api import user_model_to_pb 

27from couchers.sql import users_visible, where_moderated_content_visible, where_users_column_visible 

28from couchers.tasks import maybe_send_reference_report_email 

29from couchers.utils import Timestamp_from_datetime, now 

30 

31MAX_PAGINATION_LENGTH = 100 

32 

33reftype2sql = { 

34 references_pb2.ReferenceType.REFERENCE_TYPE_FRIEND: ReferenceType.friend, 

35 references_pb2.ReferenceType.REFERENCE_TYPE_SURFED: ReferenceType.surfed, 

36 references_pb2.ReferenceType.REFERENCE_TYPE_HOSTED: ReferenceType.hosted, 

37} 

38 

39reftype2api = { 

40 ReferenceType.friend: references_pb2.ReferenceType.REFERENCE_TYPE_FRIEND, 

41 ReferenceType.surfed: references_pb2.ReferenceType.REFERENCE_TYPE_SURFED, 

42 ReferenceType.hosted: references_pb2.ReferenceType.REFERENCE_TYPE_HOSTED, 

43} 

44 

45 

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

47 return references_pb2.Reference( 

48 reference_id=reference.id, 

49 from_user_id=reference.from_user_id, 

50 to_user_id=reference.to_user_id, 

51 reference_type=reftype2api[reference.reference_type], 

52 text=reference.text, 

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

54 host_request_id=( 

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

56 ), 

57 ) 

58 

59 

60def get_host_req_and_check_can_write_ref( 

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

62) -> tuple[HostRequest, bool]: 

63 """ 

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

65 

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

67 """ 

68 query = select(HostRequest) 

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

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

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

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

73 query = query.where( 

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

75 ) 

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

77 

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

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

80 

81 if not host_request.can_write_reference: 

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

83 

84 if session.execute( 

85 select(Reference) 

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

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

88 ).scalar_one_or_none(): 

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

90 

91 surfed = host_request.initiator_user_id == context.user_id 

92 

93 if surfed: 

94 my_reason = host_request.initiator_reason_didnt_meetup 

95 else: 

96 my_reason = host_request.recipient_reason_didnt_meetup 

97 

98 if my_reason != None: 

99 context.abort_with_error_code( 

100 grpc.StatusCode.FAILED_PRECONDITION, "cant_write_reference_indicated_didnt_meetup" 

101 ) 

102 

103 return host_request, surfed 

104 

105 

106def check_valid_reference( 

107 request: references_pb2.WriteFriendReferenceReq | references_pb2.WriteHostRequestReferenceReq, 

108 context: CouchersContext, 

109) -> None: 

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

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

112 

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

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

115 

116 

117def get_pending_references_to_write( 

118 session: Session, context: CouchersContext 

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

120 q1 = ( 

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

122 .outerjoin( 

123 Reference, 

124 and_( 

125 Reference.host_request_id == HostRequest.conversation_id, 

126 Reference.from_user_id == context.user_id, 

127 ), 

128 ) 

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

130 ) 

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

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

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

134 q1 = q1.where(HostRequest.can_write_reference) 

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

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

137 

138 q2 = ( 

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

140 .outerjoin( 

141 Reference, 

142 and_( 

143 Reference.host_request_id == HostRequest.conversation_id, 

144 Reference.from_user_id == context.user_id, 

145 ), 

146 ) 

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

148 ) 

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

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

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

152 q2 = q2.where(HostRequest.can_write_reference) 

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

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

155 

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

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

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

159 

160 return [ 

161 ( 

162 host_request.conversation_id, 

163 ReferenceType.surfed if surfed else ReferenceType.hosted, 

164 host_request.end_time_to_write_reference, 

165 other_user, 

166 ) 

167 for surfed, host_request, other_user in host_request_references 

168 ] 

169 

170 

171class References(references_pb2_grpc.ReferencesServicer): 

172 def ListReferences( 

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

174 ) -> references_pb2.ListReferencesRes: 

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

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

177 

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

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

180 

181 to_users = aliased(User) 

182 from_users = aliased(User) 

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

184 if request.from_user_id: 

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

186 statement = ( 

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

188 .where( 

189 to_users.banned_at.is_(None) 

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

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

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

193 ) 

194 if request.to_user_id: 

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

196 statement = ( 

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

198 .where( 

199 from_users.banned_at.is_(None) 

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

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

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

203 ) 

204 if len(request.reference_type_filter) > 0: 

205 statement = statement.where( 

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

207 ) 

208 

209 if next_reference_id: 

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

211 

212 # Reference visibility logic (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 # This must stay in sync with the reference count (get_num_references); both use the 

217 # shared where_references_not_hidden_by_reciprocity() helper. 

218 statement = where_references_not_hidden_by_reciprocity(statement) 

219 

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

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

222 

223 return references_pb2.ListReferencesRes( 

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

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

226 ) 

227 

228 def WriteFriendReference( 

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

230 ) -> references_pb2.Reference: 

231 if context.user_id == request.to_user_id: 

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

233 

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

235 

236 check_valid_reference(request, context) 

237 

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

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

240 ).scalar_one_or_none(): 

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

242 

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

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

245 

246 if session.execute( 

247 select(Reference) 

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

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

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

251 ).scalar_one_or_none(): 

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

253 

254 reference_text = request.text.strip() 

255 

256 reference: Reference | None = None 

257 

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

259 nonlocal reference 

260 reference = Reference( 

261 from_user_id=context.user_id, 

262 to_user_id=request.to_user_id, 

263 reference_type=ReferenceType.friend, 

264 text=reference_text, 

265 private_text=request.private_text.strip(), 

266 rating=request.rating, 

267 was_appropriate=request.was_appropriate, 

268 moderation_state_id=moderation_state_id, 

269 ) 

270 session.add(reference) 

271 session.flush() 

272 return reference.id 

273 

274 create_moderation( 

275 session=session, 

276 object_type=ModerationObjectType.reference, 

277 object_id=create_object, 

278 creator_user_id=context.user_id, 

279 ) 

280 assert reference is not None 

281 session.commit() 

282 

283 # send the recipient of the reference a reminder 

284 notify( 

285 session, 

286 user_id=request.to_user_id, 

287 topic_action=NotificationTopicAction.reference__receive_friend, 

288 key=str(reference.id), 

289 data=notification_data_pb2.ReferenceReceiveFriend( 

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

291 text=reference_text, 

292 ), 

293 moderation_state_id=reference.moderation_state_id, 

294 ) 

295 

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

297 maybe_send_reference_report_email(session, reference) 

298 

299 log_event( 

300 context, 

301 session, 

302 "reference.friend_written", 

303 { 

304 "to_user_id": request.to_user_id, 

305 "rating": request.rating, 

306 "was_appropriate": request.was_appropriate, 

307 }, 

308 ) 

309 

310 return reference_to_pb(reference, context) 

311 

312 def WriteHostRequestReference( 

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

314 ) -> references_pb2.Reference: 

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

316 

317 check_valid_reference(request, context) 

318 

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

320 

321 reference_text = request.text.strip() 

322 

323 if surfed: 

324 # we requested to surf with someone 

325 reference_type = ReferenceType.surfed 

326 to_user_id = host_request.recipient_user_id 

327 assert context.user_id == host_request.initiator_user_id 

328 else: 

329 # we hosted someone 

330 reference_type = ReferenceType.hosted 

331 to_user_id = host_request.initiator_user_id 

332 assert context.user_id == host_request.recipient_user_id 

333 

334 reference: Reference | None = None 

335 

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

337 nonlocal reference 

338 reference = Reference( 

339 from_user_id=context.user_id, 

340 to_user_id=to_user_id, 

341 host_request_id=host_request.conversation_id, 

342 text=reference_text, 

343 private_text=request.private_text.strip(), 

344 rating=request.rating, 

345 was_appropriate=request.was_appropriate, 

346 reference_type=reference_type, 

347 moderation_state_id=moderation_state_id, 

348 ) 

349 session.add(reference) 

350 session.flush() 

351 return reference.id 

352 

353 create_moderation( 

354 session=session, 

355 object_type=ModerationObjectType.reference, 

356 object_id=create_object, 

357 creator_user_id=context.user_id, 

358 ) 

359 assert reference is not None 

360 session.commit() 

361 

362 other_reference = session.execute( 

363 select(Reference) 

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

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

366 ).scalar_one_or_none() 

367 

368 # send notification out 

369 topic_action = ( 

370 NotificationTopicAction.reference__receive_surfed 

371 if surfed 

372 else NotificationTopicAction.reference__receive_hosted 

373 ) 

374 notify( 

375 session, 

376 user_id=reference.to_user_id, 

377 topic_action=topic_action, 

378 key=str(host_request.conversation_id), 

379 data=notification_data_pb2.ReferenceReceiveHostRequest( 

380 host_request_id=host_request.conversation_id, 

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

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

383 ), 

384 moderation_state_id=reference.moderation_state_id, 

385 ) 

386 

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

388 maybe_send_reference_report_email(session, reference) 

389 

390 log_event( 

391 context, 

392 session, 

393 "reference.host_request_written", 

394 { 

395 "to_user_id": to_user_id, 

396 "host_request_id": host_request.conversation_id, 

397 "reference_type": reference_type.name, 

398 "rating": request.rating, 

399 "was_appropriate": request.was_appropriate, 

400 }, 

401 ) 

402 

403 return reference_to_pb(reference, context) 

404 

405 def HostRequestIndicateDidntMeetup( 

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

407 ) -> empty_pb2.Empty: 

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

409 

410 reason = request.reason_didnt_meetup.strip() 

411 

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

413 host_request.initiator_reason_didnt_meetup = reason 

414 else: 

415 host_request.recipient_reason_didnt_meetup = reason 

416 

417 return empty_pb2.Empty() 

418 

419 def AvailableWriteReferences( 

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

421 ) -> references_pb2.AvailableWriteReferencesRes: 

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

423 if request.to_user_id == context.user_id: 

424 return references_pb2.AvailableWriteReferencesRes() 

425 

426 if not session.execute( 

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

428 ).scalar_one_or_none(): 

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

430 

431 can_write_friend_reference = ( 

432 session.execute( 

433 select(Reference) 

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

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

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

437 ).scalar_one_or_none() 

438 ) is None 

439 

440 q1 = ( 

441 select(literal(True), HostRequest) 

442 .outerjoin( 

443 Reference, 

444 and_( 

445 Reference.host_request_id == HostRequest.conversation_id, 

446 Reference.from_user_id == context.user_id, 

447 ), 

448 ) 

449 .where(Reference.id == None) 

450 .where(HostRequest.can_write_reference) 

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

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

453 .where(HostRequest.initiator_reason_didnt_meetup == None) 

454 ) 

455 

456 q2 = ( 

457 select(literal(False), HostRequest) 

458 .outerjoin( 

459 Reference, 

460 and_( 

461 Reference.host_request_id == HostRequest.conversation_id, 

462 Reference.from_user_id == context.user_id, 

463 ), 

464 ) 

465 .where(Reference.id == None) 

466 .where(HostRequest.can_write_reference) 

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

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

469 .where(HostRequest.recipient_reason_didnt_meetup == None) 

470 ) 

471 

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

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

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

475 

476 return references_pb2.AvailableWriteReferencesRes( 

477 can_write_friend_reference=can_write_friend_reference, 

478 available_write_references=[ 

479 references_pb2.AvailableWriteReferenceType( 

480 host_request_id=host_request.conversation_id, 

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

482 time_expires=Timestamp_from_datetime(host_request.end_time_to_write_reference), 

483 ) 

484 for surfed, host_request in host_request_references 

485 ], 

486 ) 

487 

488 def ListPendingReferencesToWrite( 

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

490 ) -> references_pb2.ListPendingReferencesToWriteRes: 

491 return references_pb2.ListPendingReferencesToWriteRes( 

492 pending_references=[ 

493 references_pb2.AvailableWriteReferenceType( 

494 host_request_id=host_request_id, 

495 reference_type=reftype2api[reference_type], 

496 time_expires=Timestamp_from_datetime(end_time_to_write_reference), 

497 ) 

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

499 session, context 

500 ) 

501 ], 

502 ) 

503 

504 def GetHostRequestReferenceStatus( 

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

506 ) -> references_pb2.GetHostRequestReferenceStatusRes: 

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

508 has_given = ( 

509 session.execute( 

510 select(Reference) 

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

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

513 ).scalar_one_or_none() 

514 is not None 

515 ) 

516 

517 query = select(HostRequest) 

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

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

520 query = query.where( 

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

522 ) 

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

524 

525 can_write = False 

526 is_expired = False 

527 didnt_stay = False 

528 

529 if host_request is not None: 

530 # Compute expired from end_time_to_write_reference 

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

532 is_expired = host_request.end_time_to_write_reference < now() 

533 

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

535 didnt_stay = ( 

536 (host_request.initiator_reason_didnt_meetup is not None) 

537 if host_request.initiator_user_id == context.user_id 

538 else (host_request.recipient_reason_didnt_meetup is not None) 

539 ) 

540 

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

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

543 

544 return references_pb2.GetHostRequestReferenceStatusRes( 

545 has_given=has_given, 

546 can_write=can_write, 

547 is_expired=is_expired, 

548 didnt_stay=didnt_stay, 

549 )