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

172 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-05-05 09:44 +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_background_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, Reference, ReferenceType, User 

21from couchers.models.notifications import NotificationTopicAction 

22from couchers.notifications.notify import notify 

23from couchers.proto import notification_data_pb2, references_pb2, references_pb2_grpc 

24from couchers.servicers.api import user_model_to_pb 

25from couchers.sql import users_visible, where_moderated_content_visible, where_users_column_visible 

26from couchers.tasks import maybe_send_reference_report_email 

27from couchers.utils import Timestamp_from_datetime, now 

28 

29MAX_PAGINATION_LENGTH = 100 

30 

31reftype2sql = { 

32 references_pb2.ReferenceType.REFERENCE_TYPE_FRIEND: ReferenceType.friend, 

33 references_pb2.ReferenceType.REFERENCE_TYPE_SURFED: ReferenceType.surfed, 

34 references_pb2.ReferenceType.REFERENCE_TYPE_HOSTED: ReferenceType.hosted, 

35} 

36 

37reftype2api = { 

38 ReferenceType.friend: references_pb2.ReferenceType.REFERENCE_TYPE_FRIEND, 

39 ReferenceType.surfed: references_pb2.ReferenceType.REFERENCE_TYPE_SURFED, 

40 ReferenceType.hosted: references_pb2.ReferenceType.REFERENCE_TYPE_HOSTED, 

41} 

42 

43 

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

45 return references_pb2.Reference( 

46 reference_id=reference.id, 

47 from_user_id=reference.from_user_id, 

48 to_user_id=reference.to_user_id, 

49 reference_type=reftype2api[reference.reference_type], 

50 text=reference.text, 

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

52 host_request_id=( 

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

54 ), 

55 ) 

56 

57 

58def get_host_req_and_check_can_write_ref( 

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

60) -> tuple[HostRequest, bool]: 

61 """ 

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

63 

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

65 """ 

66 query = select(HostRequest) 

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

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

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

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

71 query = query.where( 

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

73 ) 

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

75 

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

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

78 

79 if not host_request.can_write_reference: 

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

81 

82 if session.execute( 

83 select(Reference) 

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

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

86 ).scalar_one_or_none(): 

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

88 

89 surfed = host_request.initiator_user_id == context.user_id 

90 

91 if surfed: 

92 my_reason = host_request.initiator_reason_didnt_meetup 

93 else: 

94 my_reason = host_request.recipient_reason_didnt_meetup 

95 

96 if my_reason != None: 

97 context.abort_with_error_code( 

98 grpc.StatusCode.FAILED_PRECONDITION, "cant_write_reference_indicated_didnt_meetup" 

99 ) 

100 

101 return host_request, surfed 

102 

103 

104def check_valid_reference( 

105 request: references_pb2.WriteFriendReferenceReq | references_pb2.WriteHostRequestReferenceReq, 

106 context: CouchersContext, 

107) -> None: 

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

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

110 

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

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

113 

114 

115def get_pending_references_to_write( 

116 session: Session, context: CouchersContext 

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

118 q1 = ( 

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

120 .outerjoin( 

121 Reference, 

122 and_( 

123 Reference.host_request_id == HostRequest.conversation_id, 

124 Reference.from_user_id == context.user_id, 

125 ), 

126 ) 

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

128 ) 

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

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

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

132 q1 = q1.where(HostRequest.can_write_reference) 

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

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

135 

136 q2 = ( 

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

138 .outerjoin( 

139 Reference, 

140 and_( 

141 Reference.host_request_id == HostRequest.conversation_id, 

142 Reference.from_user_id == context.user_id, 

143 ), 

144 ) 

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

146 ) 

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

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

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

150 q2 = q2.where(HostRequest.can_write_reference) 

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

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

153 

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

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

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

157 

158 return [ 

159 ( 

160 host_request.conversation_id, 

161 ReferenceType.surfed if surfed else ReferenceType.hosted, 

162 host_request.end_time_to_write_reference, 

163 other_user, 

164 ) 

165 for surfed, host_request, other_user in host_request_references 

166 ] 

167 

168 

169class References(references_pb2_grpc.ReferencesServicer): 

170 def ListReferences( 

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

172 ) -> references_pb2.ListReferencesRes: 

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

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

175 

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

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

178 

179 to_users = aliased(User) 

180 from_users = aliased(User) 

181 statement = select(Reference).where(Reference.is_deleted == False) 

182 if request.from_user_id: 

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

184 statement = ( 

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

186 .where( 

187 to_users.banned_at.is_(None) 

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

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

190 ) 

191 if request.to_user_id: 

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

193 statement = ( 

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

195 .where( 

196 from_users.banned_at.is_(None) 

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

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

199 ) 

200 if len(request.reference_type_filter) > 0: 

201 statement = statement.where( 

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

203 ) 

204 

205 if next_reference_id: 

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

207 

208 # Reference visibility logic: 

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

210 # 1. It is a friend reference 

211 # 2. Both references have been written 

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

213 

214 # we get the matching other references through this subquery 

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

216 Reference.reference_type != ReferenceType.friend 

217 ) 

218 if request.from_user_id: 

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

220 if request.to_user_id: 

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

222 

223 query = sub.subquery() 

224 statement = ( 

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

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

227 .where( 

228 or_( 

229 Reference.reference_type == ReferenceType.friend, 

230 query.c.sub_id != None, 

231 HostRequest.end_time_to_write_reference < func.now(), 

232 ) 

233 ) 

234 ) 

235 

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

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

238 

239 return references_pb2.ListReferencesRes( 

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

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

242 ) 

243 

244 def WriteFriendReference( 

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

246 ) -> references_pb2.Reference: 

247 if context.user_id == request.to_user_id: 

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

249 

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

251 

252 check_valid_reference(request, context) 

253 

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

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

256 ).scalar_one_or_none(): 

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

258 

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

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

261 

262 if session.execute( 

263 select(Reference) 

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

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

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

267 ).scalar_one_or_none(): 

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

269 

270 reference_text = request.text.strip() 

271 

272 reference = Reference( 

273 from_user_id=context.user_id, 

274 to_user_id=request.to_user_id, 

275 reference_type=ReferenceType.friend, 

276 text=reference_text, 

277 private_text=request.private_text.strip(), 

278 rating=request.rating, 

279 was_appropriate=request.was_appropriate, 

280 ) 

281 session.add(reference) 

282 session.commit() 

283 

284 # send the recipient of the reference a reminder 

285 notify( 

286 session, 

287 user_id=request.to_user_id, 

288 topic_action=NotificationTopicAction.reference__receive_friend, 

289 key=str(reference.id), 

290 data=notification_data_pb2.ReferenceReceiveFriend( 

291 from_user=user_model_to_pb(user, session, make_background_user_context(user_id=request.to_user_id)), 

292 text=reference_text, 

293 ), 

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( 

335 from_user_id=context.user_id, 

336 to_user_id=to_user_id, 

337 host_request_id=host_request.conversation_id, 

338 text=reference_text, 

339 private_text=request.private_text.strip(), 

340 rating=request.rating, 

341 was_appropriate=request.was_appropriate, 

342 reference_type=reference_type, 

343 ) 

344 

345 session.add(reference) 

346 session.commit() 

347 

348 other_reference = session.execute( 

349 select(Reference) 

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

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

352 ).scalar_one_or_none() 

353 

354 # send notification out 

355 topic_action = ( 

356 NotificationTopicAction.reference__receive_surfed 

357 if surfed 

358 else NotificationTopicAction.reference__receive_hosted 

359 ) 

360 notify( 

361 session, 

362 user_id=reference.to_user_id, 

363 topic_action=topic_action, 

364 key=str(host_request.conversation_id), 

365 data=notification_data_pb2.ReferenceReceiveHostRequest( 

366 host_request_id=host_request.conversation_id, 

367 from_user=user_model_to_pb(user, session, make_background_user_context(user_id=reference.to_user_id)), 

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

369 ), 

370 ) 

371 

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

373 maybe_send_reference_report_email(session, reference) 

374 

375 log_event( 

376 context, 

377 session, 

378 "reference.host_request_written", 

379 { 

380 "to_user_id": to_user_id, 

381 "host_request_id": host_request.conversation_id, 

382 "reference_type": reference_type.name, 

383 "rating": request.rating, 

384 "was_appropriate": request.was_appropriate, 

385 }, 

386 ) 

387 

388 return reference_to_pb(reference, context) 

389 

390 def HostRequestIndicateDidntMeetup( 

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

392 ) -> empty_pb2.Empty: 

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

394 

395 reason = request.reason_didnt_meetup.strip() 

396 

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

398 host_request.initiator_reason_didnt_meetup = reason 

399 else: 

400 host_request.recipient_reason_didnt_meetup = reason 

401 

402 return empty_pb2.Empty() 

403 

404 def AvailableWriteReferences( 

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

406 ) -> references_pb2.AvailableWriteReferencesRes: 

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

408 if request.to_user_id == context.user_id: 

409 return references_pb2.AvailableWriteReferencesRes() 

410 

411 if not session.execute( 

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

413 ).scalar_one_or_none(): 

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

415 

416 can_write_friend_reference = ( 

417 session.execute( 

418 select(Reference) 

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

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

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

422 ).scalar_one_or_none() 

423 ) is None 

424 

425 q1 = ( 

426 select(literal(True), HostRequest) 

427 .outerjoin( 

428 Reference, 

429 and_( 

430 Reference.host_request_id == HostRequest.conversation_id, 

431 Reference.from_user_id == context.user_id, 

432 ), 

433 ) 

434 .where(Reference.id == None) 

435 .where(HostRequest.can_write_reference) 

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

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

438 .where(HostRequest.initiator_reason_didnt_meetup == None) 

439 ) 

440 

441 q2 = ( 

442 select(literal(False), HostRequest) 

443 .outerjoin( 

444 Reference, 

445 and_( 

446 Reference.host_request_id == HostRequest.conversation_id, 

447 Reference.from_user_id == context.user_id, 

448 ), 

449 ) 

450 .where(Reference.id == None) 

451 .where(HostRequest.can_write_reference) 

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

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

454 .where(HostRequest.recipient_reason_didnt_meetup == None) 

455 ) 

456 

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

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

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

460 

461 return references_pb2.AvailableWriteReferencesRes( 

462 can_write_friend_reference=can_write_friend_reference, 

463 available_write_references=[ 

464 references_pb2.AvailableWriteReferenceType( 

465 host_request_id=host_request.conversation_id, 

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

467 time_expires=Timestamp_from_datetime(host_request.end_time_to_write_reference), 

468 ) 

469 for surfed, host_request in host_request_references 

470 ], 

471 ) 

472 

473 def ListPendingReferencesToWrite( 

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

475 ) -> references_pb2.ListPendingReferencesToWriteRes: 

476 return references_pb2.ListPendingReferencesToWriteRes( 

477 pending_references=[ 

478 references_pb2.AvailableWriteReferenceType( 

479 host_request_id=host_request_id, 

480 reference_type=reftype2api[reference_type], 

481 time_expires=Timestamp_from_datetime(end_time_to_write_reference), 

482 ) 

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

484 session, context 

485 ) 

486 ], 

487 ) 

488 

489 def GetHostRequestReferenceStatus( 

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

491 ) -> references_pb2.GetHostRequestReferenceStatusRes: 

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

493 has_given = ( 

494 session.execute( 

495 select(Reference) 

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

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

498 ).scalar_one_or_none() 

499 is not None 

500 ) 

501 

502 query = select(HostRequest) 

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

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

505 query = query.where( 

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

507 ) 

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

509 

510 can_write = False 

511 is_expired = False 

512 didnt_stay = False 

513 

514 if host_request is not None: 

515 # Compute expired from end_time_to_write_reference 

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

517 is_expired = host_request.end_time_to_write_reference < now() 

518 

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

520 didnt_stay = ( 

521 (host_request.initiator_reason_didnt_meetup is not None) 

522 if host_request.initiator_user_id == context.user_id 

523 else (host_request.recipient_reason_didnt_meetup is not None) 

524 ) 

525 

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

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

528 

529 return references_pb2.GetHostRequestReferenceStatusRes( 

530 has_given=has_given, 

531 can_write=can_write, 

532 is_expired=is_expired, 

533 didnt_stay=didnt_stay, 

534 )