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

169 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-11 00:39 +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.materialized_views import LiteUser 

19from couchers.models import HostRequest, Reference, ReferenceType, User 

20from couchers.models.notifications import NotificationTopicAction 

21from couchers.notifications.notify import notify 

22from couchers.proto import notification_data_pb2, references_pb2, references_pb2_grpc 

23from couchers.servicers.api import user_model_to_pb 

24from couchers.sql import users_visible, where_moderated_content_visible, where_users_column_visible 

25from couchers.tasks import maybe_send_reference_report_email 

26from couchers.utils import Timestamp_from_datetime, now 

27 

28MAX_PAGINATION_LENGTH = 100 

29 

30reftype2sql = { 

31 references_pb2.ReferenceType.REFERENCE_TYPE_FRIEND: ReferenceType.friend, 

32 references_pb2.ReferenceType.REFERENCE_TYPE_SURFED: ReferenceType.surfed, 

33 references_pb2.ReferenceType.REFERENCE_TYPE_HOSTED: ReferenceType.hosted, 

34} 

35 

36reftype2api = { 

37 ReferenceType.friend: references_pb2.ReferenceType.REFERENCE_TYPE_FRIEND, 

38 ReferenceType.surfed: references_pb2.ReferenceType.REFERENCE_TYPE_SURFED, 

39 ReferenceType.hosted: references_pb2.ReferenceType.REFERENCE_TYPE_HOSTED, 

40} 

41 

42 

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

44 return references_pb2.Reference( 

45 reference_id=reference.id, 

46 from_user_id=reference.from_user_id, 

47 to_user_id=reference.to_user_id, 

48 reference_type=reftype2api[reference.reference_type], 

49 text=reference.text, 

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

51 host_request_id=( 

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

53 ), 

54 ) 

55 

56 

57def get_host_req_and_check_can_write_ref( 

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

59) -> tuple[HostRequest, bool]: 

60 """ 

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

62 

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

64 """ 

65 query = select(HostRequest) 

66 query = where_users_column_visible(query, context, HostRequest.surfer_user_id) 

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

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

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

70 query = query.where(or_(HostRequest.surfer_user_id == context.user_id, HostRequest.host_user_id == context.user_id)) 

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

72 

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

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

75 

76 if not host_request.can_write_reference: 

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

78 

79 if session.execute( 

80 select(Reference) 

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

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

83 ).scalar_one_or_none(): 

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

85 

86 surfed = host_request.surfer_user_id == context.user_id 

87 

88 if surfed: 

89 my_reason = host_request.surfer_reason_didnt_meetup 

90 else: 

91 my_reason = host_request.host_reason_didnt_meetup 

92 

93 if my_reason != None: 

94 context.abort_with_error_code( 

95 grpc.StatusCode.FAILED_PRECONDITION, "cant_write_reference_indicated_didnt_meetup" 

96 ) 

97 

98 return host_request, surfed 

99 

100 

101def check_valid_reference( 

102 request: references_pb2.WriteFriendReferenceReq | references_pb2.WriteHostRequestReferenceReq, 

103 context: CouchersContext, 

104) -> None: 

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

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

107 

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

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

110 

111 

112def get_pending_references_to_write( 

113 session: Session, context: CouchersContext 

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

115 q1 = ( 

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

117 .outerjoin( 

118 Reference, 

119 and_( 

120 Reference.host_request_id == HostRequest.conversation_id, 

121 Reference.from_user_id == context.user_id, 

122 ), 

123 ) 

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

125 ) 

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

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

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

129 q1 = q1.where(HostRequest.can_write_reference) 

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

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

132 

133 q2 = ( 

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

135 .outerjoin( 

136 Reference, 

137 and_( 

138 Reference.host_request_id == HostRequest.conversation_id, 

139 Reference.from_user_id == context.user_id, 

140 ), 

141 ) 

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

143 ) 

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

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

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

147 q2 = q2.where(HostRequest.can_write_reference) 

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

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

150 

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

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

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

154 

155 return [ 

156 ( 

157 host_request.conversation_id, 

158 ReferenceType.surfed if surfed else ReferenceType.hosted, 

159 host_request.end_time_to_write_reference, 

160 other_user, 

161 ) 

162 for surfed, host_request, other_user in host_request_references 

163 ] 

164 

165 

166class References(references_pb2_grpc.ReferencesServicer): 

167 def ListReferences( 

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

169 ) -> references_pb2.ListReferencesRes: 

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

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

172 

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

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

175 

176 to_users = aliased(User) 

177 from_users = aliased(User) 

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

179 if request.from_user_id: 

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

181 statement = ( 

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

183 .where( 

184 ~to_users.is_banned 

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

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

187 ) 

188 if request.to_user_id: 

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

190 statement = ( 

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

192 .where( 

193 ~from_users.is_banned 

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

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

196 ) 

197 if len(request.reference_type_filter) > 0: 

198 statement = statement.where( 

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

200 ) 

201 

202 if next_reference_id: 

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

204 

205 # Reference visibility logic: 

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

207 # 1. It is a friend reference 

208 # 2. Both references have been written 

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

210 

211 # we get the matching other references through this subquery 

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

213 Reference.reference_type != ReferenceType.friend 

214 ) 

215 if request.from_user_id: 

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

217 if request.to_user_id: 

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

219 

220 query = sub.subquery() 

221 statement = ( 

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

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

224 .where( 

225 or_( 

226 Reference.reference_type == ReferenceType.friend, 

227 query.c.sub_id != None, 

228 HostRequest.end_time_to_write_reference < func.now(), 

229 ) 

230 ) 

231 ) 

232 

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

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

235 

236 return references_pb2.ListReferencesRes( 

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

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

239 ) 

240 

241 def WriteFriendReference( 

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

243 ) -> references_pb2.Reference: 

244 if context.user_id == request.to_user_id: 

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

246 

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

248 

249 check_valid_reference(request, context) 

250 

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

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

253 ).scalar_one_or_none(): 

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

255 

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

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

258 

259 if session.execute( 

260 select(Reference) 

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

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

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

264 ).scalar_one_or_none(): 

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

266 

267 reference_text = request.text.strip() 

268 

269 reference = Reference( 

270 from_user_id=context.user_id, 

271 to_user_id=request.to_user_id, 

272 reference_type=ReferenceType.friend, 

273 text=reference_text, 

274 private_text=request.private_text.strip(), 

275 rating=request.rating, 

276 was_appropriate=request.was_appropriate, 

277 ) 

278 session.add(reference) 

279 session.commit() 

280 

281 # send the recipient of the reference a reminder 

282 notify( 

283 session, 

284 user_id=request.to_user_id, 

285 topic_action=NotificationTopicAction.reference__receive_friend, 

286 key=str(reference.id), 

287 data=notification_data_pb2.ReferenceReceiveFriend( 

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

289 text=reference_text, 

290 ), 

291 ) 

292 

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

294 maybe_send_reference_report_email(session, reference) 

295 

296 return reference_to_pb(reference, context) 

297 

298 def WriteHostRequestReference( 

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

300 ) -> references_pb2.Reference: 

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

302 

303 check_valid_reference(request, context) 

304 

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

306 

307 reference_text = request.text.strip() 

308 

309 if surfed: 

310 # we requested to surf with someone 

311 reference_type = ReferenceType.surfed 

312 to_user_id = host_request.host_user_id 

313 assert context.user_id == host_request.surfer_user_id 

314 else: 

315 # we hosted someone 

316 reference_type = ReferenceType.hosted 

317 to_user_id = host_request.surfer_user_id 

318 assert context.user_id == host_request.host_user_id 

319 

320 reference = Reference( 

321 from_user_id=context.user_id, 

322 to_user_id=to_user_id, 

323 host_request_id=host_request.conversation_id, 

324 text=reference_text, 

325 private_text=request.private_text.strip(), 

326 rating=request.rating, 

327 was_appropriate=request.was_appropriate, 

328 reference_type=reference_type, 

329 ) 

330 

331 session.add(reference) 

332 session.commit() 

333 

334 other_reference = session.execute( 

335 select(Reference) 

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

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

338 ).scalar_one_or_none() 

339 

340 # send notification out 

341 topic_action = ( 

342 NotificationTopicAction.reference__receive_surfed 

343 if surfed 

344 else NotificationTopicAction.reference__receive_hosted 

345 ) 

346 notify( 

347 session, 

348 user_id=reference.to_user_id, 

349 topic_action=topic_action, 

350 key=str(host_request.conversation_id), 

351 data=notification_data_pb2.ReferenceReceiveHostRequest( 

352 host_request_id=host_request.conversation_id, 

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

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

355 ), 

356 ) 

357 

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

359 maybe_send_reference_report_email(session, reference) 

360 

361 return reference_to_pb(reference, context) 

362 

363 def HostRequestIndicateDidntMeetup( 

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

365 ) -> empty_pb2.Empty: 

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

367 

368 reason = request.reason_didnt_meetup.strip() 

369 

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

371 host_request.surfer_reason_didnt_meetup = reason 

372 else: 

373 host_request.host_reason_didnt_meetup = reason 

374 

375 return empty_pb2.Empty() 

376 

377 def AvailableWriteReferences( 

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

379 ) -> references_pb2.AvailableWriteReferencesRes: 

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

381 if request.to_user_id == context.user_id: 

382 return references_pb2.AvailableWriteReferencesRes() 

383 

384 if not session.execute( 

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

386 ).scalar_one_or_none(): 

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

388 

389 can_write_friend_reference = ( 

390 session.execute( 

391 select(Reference) 

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

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

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

395 ).scalar_one_or_none() 

396 ) is None 

397 

398 q1 = ( 

399 select(literal(True), HostRequest) 

400 .outerjoin( 

401 Reference, 

402 and_( 

403 Reference.host_request_id == HostRequest.conversation_id, 

404 Reference.from_user_id == context.user_id, 

405 ), 

406 ) 

407 .where(Reference.id == None) 

408 .where(HostRequest.can_write_reference) 

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

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

411 .where(HostRequest.surfer_reason_didnt_meetup == None) 

412 ) 

413 

414 q2 = ( 

415 select(literal(False), HostRequest) 

416 .outerjoin( 

417 Reference, 

418 and_( 

419 Reference.host_request_id == HostRequest.conversation_id, 

420 Reference.from_user_id == context.user_id, 

421 ), 

422 ) 

423 .where(Reference.id == None) 

424 .where(HostRequest.can_write_reference) 

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

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

427 .where(HostRequest.host_reason_didnt_meetup == None) 

428 ) 

429 

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

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

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

433 

434 return references_pb2.AvailableWriteReferencesRes( 

435 can_write_friend_reference=can_write_friend_reference, 

436 available_write_references=[ 

437 references_pb2.AvailableWriteReferenceType( 

438 host_request_id=host_request.conversation_id, 

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

440 time_expires=Timestamp_from_datetime(host_request.end_time_to_write_reference), 

441 ) 

442 for surfed, host_request in host_request_references 

443 ], 

444 ) 

445 

446 def ListPendingReferencesToWrite( 

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

448 ) -> references_pb2.ListPendingReferencesToWriteRes: 

449 return references_pb2.ListPendingReferencesToWriteRes( 

450 pending_references=[ 

451 references_pb2.AvailableWriteReferenceType( 

452 host_request_id=host_request_id, 

453 reference_type=reftype2api[reference_type], 

454 time_expires=Timestamp_from_datetime(end_time_to_write_reference), 

455 ) 

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

457 session, context 

458 ) 

459 ], 

460 ) 

461 

462 def GetHostRequestReferenceStatus( 

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

464 ) -> references_pb2.GetHostRequestReferenceStatusRes: 

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

466 has_given = ( 

467 session.execute( 

468 select(Reference) 

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

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

471 ).scalar_one_or_none() 

472 is not None 

473 ) 

474 

475 query = select(HostRequest) 

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

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

478 query = query.where( 

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

480 ) 

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

482 

483 can_write = False 

484 is_expired = False 

485 didnt_stay = False 

486 

487 if host_request is not None: 

488 # Compute expired from end_time_to_write_reference 

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

490 is_expired = host_request.end_time_to_write_reference < now() 

491 

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

493 didnt_stay = ( 

494 (host_request.surfer_reason_didnt_meetup is not None) 

495 if host_request.surfer_user_id == context.user_id 

496 else (host_request.host_reason_didnt_meetup is not None) 

497 ) 

498 

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

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

501 

502 return references_pb2.GetHostRequestReferenceStatusRes( 

503 has_given=has_given, 

504 can_write=can_write, 

505 is_expired=is_expired, 

506 didnt_stay=didnt_stay, 

507 )