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

172 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-03-19 14:14 +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.surfer_user_id) 

68 query = where_users_column_visible(query, context, HostRequest.host_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(or_(HostRequest.surfer_user_id == context.user_id, HostRequest.host_user_id == context.user_id)) 

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

73 

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

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

76 

77 if not host_request.can_write_reference: 

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

79 

80 if session.execute( 

81 select(Reference) 

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

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

84 ).scalar_one_or_none(): 

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

86 

87 surfed = host_request.surfer_user_id == context.user_id 

88 

89 if surfed: 

90 my_reason = host_request.surfer_reason_didnt_meetup 

91 else: 

92 my_reason = host_request.host_reason_didnt_meetup 

93 

94 if my_reason != None: 

95 context.abort_with_error_code( 

96 grpc.StatusCode.FAILED_PRECONDITION, "cant_write_reference_indicated_didnt_meetup" 

97 ) 

98 

99 return host_request, surfed 

100 

101 

102def check_valid_reference( 

103 request: references_pb2.WriteFriendReferenceReq | references_pb2.WriteHostRequestReferenceReq, 

104 context: CouchersContext, 

105) -> None: 

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

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

108 

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

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

111 

112 

113def get_pending_references_to_write( 

114 session: Session, context: CouchersContext 

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

116 q1 = ( 

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

118 .outerjoin( 

119 Reference, 

120 and_( 

121 Reference.host_request_id == HostRequest.conversation_id, 

122 Reference.from_user_id == context.user_id, 

123 ), 

124 ) 

125 .join(LiteUser, LiteUser.id == HostRequest.host_user_id) 

126 ) 

127 q1 = where_users_column_visible(q1, context, HostRequest.host_user_id) 

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

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

130 q1 = q1.where(HostRequest.can_write_reference) 

131 q1 = q1.where(HostRequest.surfer_user_id == context.user_id) 

132 q1 = q1.where(HostRequest.surfer_reason_didnt_meetup == None) 

133 

134 q2 = ( 

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

136 .outerjoin( 

137 Reference, 

138 and_( 

139 Reference.host_request_id == HostRequest.conversation_id, 

140 Reference.from_user_id == context.user_id, 

141 ), 

142 ) 

143 .join(LiteUser, LiteUser.id == HostRequest.surfer_user_id) 

144 ) 

145 q2 = where_users_column_visible(q2, context, HostRequest.surfer_user_id) 

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

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

148 q2 = q2.where(HostRequest.can_write_reference) 

149 q2 = q2.where(HostRequest.host_user_id == context.user_id) 

150 q2 = q2.where(HostRequest.host_reason_didnt_meetup == None) 

151 

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

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

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

155 

156 return [ 

157 ( 

158 host_request.conversation_id, 

159 ReferenceType.surfed if surfed else ReferenceType.hosted, 

160 host_request.end_time_to_write_reference, 

161 other_user, 

162 ) 

163 for surfed, host_request, other_user in host_request_references 

164 ] 

165 

166 

167class References(references_pb2_grpc.ReferencesServicer): 

168 def ListReferences( 

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

170 ) -> references_pb2.ListReferencesRes: 

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

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

173 

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

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

176 

177 to_users = aliased(User) 

178 from_users = aliased(User) 

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

180 if request.from_user_id: 

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

182 statement = ( 

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

184 .where( 

185 to_users.banned_at.is_(None) 

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

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

188 ) 

189 if request.to_user_id: 

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

191 statement = ( 

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

193 .where( 

194 from_users.banned_at.is_(None) 

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

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

197 ) 

198 if len(request.reference_type_filter) > 0: 

199 statement = statement.where( 

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

201 ) 

202 

203 if next_reference_id: 

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

205 

206 # Reference visibility logic: 

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

208 # 1. It is a friend reference 

209 # 2. Both references have been written 

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

211 

212 # we get the matching other references through this subquery 

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

214 Reference.reference_type != ReferenceType.friend 

215 ) 

216 if request.from_user_id: 

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

218 if request.to_user_id: 

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

220 

221 query = sub.subquery() 

222 statement = ( 

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

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

225 .where( 

226 or_( 

227 Reference.reference_type == ReferenceType.friend, 

228 query.c.sub_id != None, 

229 HostRequest.end_time_to_write_reference < func.now(), 

230 ) 

231 ) 

232 ) 

233 

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

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

236 

237 return references_pb2.ListReferencesRes( 

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

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

240 ) 

241 

242 def WriteFriendReference( 

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

244 ) -> references_pb2.Reference: 

245 if context.user_id == request.to_user_id: 

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

247 

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

249 

250 check_valid_reference(request, context) 

251 

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

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

254 ).scalar_one_or_none(): 

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

256 

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

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

259 

260 if session.execute( 

261 select(Reference) 

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

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

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

265 ).scalar_one_or_none(): 

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

267 

268 reference_text = request.text.strip() 

269 

270 reference = Reference( 

271 from_user_id=context.user_id, 

272 to_user_id=request.to_user_id, 

273 reference_type=ReferenceType.friend, 

274 text=reference_text, 

275 private_text=request.private_text.strip(), 

276 rating=request.rating, 

277 was_appropriate=request.was_appropriate, 

278 ) 

279 session.add(reference) 

280 session.commit() 

281 

282 # send the recipient of the reference a reminder 

283 notify( 

284 session, 

285 user_id=request.to_user_id, 

286 topic_action=NotificationTopicAction.reference__receive_friend, 

287 key=str(reference.id), 

288 data=notification_data_pb2.ReferenceReceiveFriend( 

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

290 text=reference_text, 

291 ), 

292 ) 

293 

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

295 maybe_send_reference_report_email(session, reference) 

296 

297 log_event( 

298 context, 

299 session, 

300 "reference.friend_written", 

301 { 

302 "to_user_id": request.to_user_id, 

303 "rating": request.rating, 

304 "was_appropriate": request.was_appropriate, 

305 }, 

306 ) 

307 

308 return reference_to_pb(reference, context) 

309 

310 def WriteHostRequestReference( 

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

312 ) -> references_pb2.Reference: 

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

314 

315 check_valid_reference(request, context) 

316 

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

318 

319 reference_text = request.text.strip() 

320 

321 if surfed: 

322 # we requested to surf with someone 

323 reference_type = ReferenceType.surfed 

324 to_user_id = host_request.host_user_id 

325 assert context.user_id == host_request.surfer_user_id 

326 else: 

327 # we hosted someone 

328 reference_type = ReferenceType.hosted 

329 to_user_id = host_request.surfer_user_id 

330 assert context.user_id == host_request.host_user_id 

331 

332 reference = Reference( 

333 from_user_id=context.user_id, 

334 to_user_id=to_user_id, 

335 host_request_id=host_request.conversation_id, 

336 text=reference_text, 

337 private_text=request.private_text.strip(), 

338 rating=request.rating, 

339 was_appropriate=request.was_appropriate, 

340 reference_type=reference_type, 

341 ) 

342 

343 session.add(reference) 

344 session.commit() 

345 

346 other_reference = session.execute( 

347 select(Reference) 

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

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

350 ).scalar_one_or_none() 

351 

352 # send notification out 

353 topic_action = ( 

354 NotificationTopicAction.reference__receive_surfed 

355 if surfed 

356 else NotificationTopicAction.reference__receive_hosted 

357 ) 

358 notify( 

359 session, 

360 user_id=reference.to_user_id, 

361 topic_action=topic_action, 

362 key=str(host_request.conversation_id), 

363 data=notification_data_pb2.ReferenceReceiveHostRequest( 

364 host_request_id=host_request.conversation_id, 

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

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

367 ), 

368 ) 

369 

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

371 maybe_send_reference_report_email(session, reference) 

372 

373 log_event( 

374 context, 

375 session, 

376 "reference.host_request_written", 

377 { 

378 "to_user_id": to_user_id, 

379 "host_request_id": host_request.conversation_id, 

380 "reference_type": reference_type.name, 

381 "rating": request.rating, 

382 "was_appropriate": request.was_appropriate, 

383 }, 

384 ) 

385 

386 return reference_to_pb(reference, context) 

387 

388 def HostRequestIndicateDidntMeetup( 

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

390 ) -> empty_pb2.Empty: 

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

392 

393 reason = request.reason_didnt_meetup.strip() 

394 

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

396 host_request.surfer_reason_didnt_meetup = reason 

397 else: 

398 host_request.host_reason_didnt_meetup = reason 

399 

400 return empty_pb2.Empty() 

401 

402 def AvailableWriteReferences( 

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

404 ) -> references_pb2.AvailableWriteReferencesRes: 

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

406 if request.to_user_id == context.user_id: 

407 return references_pb2.AvailableWriteReferencesRes() 

408 

409 if not session.execute( 

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

411 ).scalar_one_or_none(): 

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

413 

414 can_write_friend_reference = ( 

415 session.execute( 

416 select(Reference) 

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

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

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

420 ).scalar_one_or_none() 

421 ) is None 

422 

423 q1 = ( 

424 select(literal(True), HostRequest) 

425 .outerjoin( 

426 Reference, 

427 and_( 

428 Reference.host_request_id == HostRequest.conversation_id, 

429 Reference.from_user_id == context.user_id, 

430 ), 

431 ) 

432 .where(Reference.id == None) 

433 .where(HostRequest.can_write_reference) 

434 .where(HostRequest.surfer_user_id == context.user_id) 

435 .where(HostRequest.host_user_id == request.to_user_id) 

436 .where(HostRequest.surfer_reason_didnt_meetup == None) 

437 ) 

438 

439 q2 = ( 

440 select(literal(False), HostRequest) 

441 .outerjoin( 

442 Reference, 

443 and_( 

444 Reference.host_request_id == HostRequest.conversation_id, 

445 Reference.from_user_id == context.user_id, 

446 ), 

447 ) 

448 .where(Reference.id == None) 

449 .where(HostRequest.can_write_reference) 

450 .where(HostRequest.surfer_user_id == request.to_user_id) 

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

452 .where(HostRequest.host_reason_didnt_meetup == None) 

453 ) 

454 

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

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

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

458 

459 return references_pb2.AvailableWriteReferencesRes( 

460 can_write_friend_reference=can_write_friend_reference, 

461 available_write_references=[ 

462 references_pb2.AvailableWriteReferenceType( 

463 host_request_id=host_request.conversation_id, 

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

465 time_expires=Timestamp_from_datetime(host_request.end_time_to_write_reference), 

466 ) 

467 for surfed, host_request in host_request_references 

468 ], 

469 ) 

470 

471 def ListPendingReferencesToWrite( 

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

473 ) -> references_pb2.ListPendingReferencesToWriteRes: 

474 return references_pb2.ListPendingReferencesToWriteRes( 

475 pending_references=[ 

476 references_pb2.AvailableWriteReferenceType( 

477 host_request_id=host_request_id, 

478 reference_type=reftype2api[reference_type], 

479 time_expires=Timestamp_from_datetime(end_time_to_write_reference), 

480 ) 

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

482 session, context 

483 ) 

484 ], 

485 ) 

486 

487 def GetHostRequestReferenceStatus( 

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

489 ) -> references_pb2.GetHostRequestReferenceStatusRes: 

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

491 has_given = ( 

492 session.execute( 

493 select(Reference) 

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

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

496 ).scalar_one_or_none() 

497 is not None 

498 ) 

499 

500 query = select(HostRequest) 

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

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

503 query = query.where( 

504 or_(HostRequest.surfer_user_id == context.user_id, HostRequest.host_user_id == context.user_id) 

505 ) 

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

507 

508 can_write = False 

509 is_expired = False 

510 didnt_stay = False 

511 

512 if host_request is not None: 

513 # Compute expired from end_time_to_write_reference 

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

515 is_expired = host_request.end_time_to_write_reference < now() 

516 

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

518 didnt_stay = ( 

519 (host_request.surfer_reason_didnt_meetup is not None) 

520 if host_request.surfer_user_id == context.user_id 

521 else (host_request.host_reason_didnt_meetup is not None) 

522 ) 

523 

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

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

526 

527 return references_pb2.GetHostRequestReferenceStatusRes( 

528 has_given=has_given, 

529 can_write=can_write, 

530 is_expired=is_expired, 

531 didnt_stay=didnt_stay, 

532 )