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

144 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-12-25 10:58 +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.orm import Session, aliased 

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

14 

15from couchers.context import CouchersContext, make_background_user_context 

16from couchers.db import are_friends 

17from couchers.materialized_views import LiteUser 

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

19from couchers.notifications.notify import notify 

20from couchers.proto import notification_data_pb2, references_pb2, references_pb2_grpc 

21from couchers.servicers.api import user_model_to_pb 

22from couchers.sql import couchers_select as select 

23from couchers.tasks import maybe_send_reference_report_email 

24from couchers.utils import Timestamp_from_datetime, now 

25 

26MAX_PAGINATION_LENGTH = 100 

27 

28reftype2sql = { 

29 references_pb2.ReferenceType.REFERENCE_TYPE_FRIEND: ReferenceType.friend, 

30 references_pb2.ReferenceType.REFERENCE_TYPE_SURFED: ReferenceType.surfed, 

31 references_pb2.ReferenceType.REFERENCE_TYPE_HOSTED: ReferenceType.hosted, 

32} 

33 

34reftype2api = { 

35 ReferenceType.friend: references_pb2.ReferenceType.REFERENCE_TYPE_FRIEND, 

36 ReferenceType.surfed: references_pb2.ReferenceType.REFERENCE_TYPE_SURFED, 

37 ReferenceType.hosted: references_pb2.ReferenceType.REFERENCE_TYPE_HOSTED, 

38} 

39 

40 

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

42 return references_pb2.Reference( 

43 reference_id=reference.id, 

44 from_user_id=reference.from_user_id, 

45 to_user_id=reference.to_user_id, 

46 reference_type=reftype2api[reference.reference_type], 

47 text=reference.text, 

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

49 host_request_id=( 

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

51 ), 

52 ) 

53 

54 

55def get_host_req_and_check_can_write_ref( 

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

57) -> tuple[HostRequest, bool]: 

58 """ 

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

60 

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

62 """ 

63 host_request = session.execute( 

64 select(HostRequest) 

65 .where_users_column_visible(context, HostRequest.surfer_user_id) 

66 .where_users_column_visible(context, HostRequest.host_user_id) 

67 .where_moderated_content_visible(context, HostRequest, is_list_operation=False) 

68 .where(HostRequest.conversation_id == host_request_id) 

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

70 ).scalar_one_or_none() 

71 

72 if not host_request: 

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

74 

75 if not host_request.can_write_reference: 

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

77 

78 if session.execute( 

79 select(Reference) 

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

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

82 ).scalar_one_or_none(): 

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

84 

85 surfed = host_request.surfer_user_id == context.user_id 

86 

87 if surfed: 

88 my_reason = host_request.surfer_reason_didnt_meetup 

89 else: 

90 my_reason = host_request.host_reason_didnt_meetup 

91 

92 if my_reason != None: 

93 context.abort_with_error_code( 

94 grpc.StatusCode.FAILED_PRECONDITION, "cant_write_reference_indicated_didnt_meetup" 

95 ) 

96 

97 return host_request, surfed 

98 

99 

100def check_valid_reference( 

101 request: references_pb2.WriteFriendReferenceReq | references_pb2.WriteHostRequestReferenceReq, 

102 context: CouchersContext, 

103) -> None: 

104 if request.rating < 0 or request.rating > 1: 

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

106 

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

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

109 

110 

111def get_pending_references_to_write( 

112 session: Session, context: CouchersContext 

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

114 q1 = ( 

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

116 .outerjoin( 

117 Reference, 

118 and_( 

119 Reference.host_request_id == HostRequest.conversation_id, 

120 Reference.from_user_id == context.user_id, 

121 ), 

122 ) 

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

124 .where_users_column_visible(context, HostRequest.host_user_id) 

125 .where_moderated_content_visible(context, HostRequest, is_list_operation=True) 

126 .where(Reference.id == None) 

127 .where(HostRequest.can_write_reference) 

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

129 .where(HostRequest.surfer_reason_didnt_meetup == None) 

130 ) 

131 

132 q2 = ( 

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

134 .outerjoin( 

135 Reference, 

136 and_( 

137 Reference.host_request_id == HostRequest.conversation_id, 

138 Reference.from_user_id == context.user_id, 

139 ), 

140 ) 

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

142 .where_users_column_visible(context, HostRequest.surfer_user_id) 

143 .where_moderated_content_visible(context, HostRequest, is_list_operation=True) 

144 .where(Reference.id == None) 

145 .where(HostRequest.can_write_reference) 

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

147 .where(HostRequest.host_reason_didnt_meetup == None) 

148 ) 

149 

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

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

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

153 

154 return [ 

155 ( 

156 host_request.conversation_id, 

157 ReferenceType.surfed if surfed else ReferenceType.hosted, 

158 host_request.end_time_to_write_reference, 

159 other_user, 

160 ) 

161 for surfed, host_request, other_user in host_request_references 

162 ] 

163 

164 

165class References(references_pb2_grpc.ReferencesServicer): 

166 def ListReferences( 

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

168 ) -> references_pb2.ListReferencesRes: 

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

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

171 

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

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

174 

175 to_users = aliased(User) 

176 from_users = aliased(User) 

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

178 if request.from_user_id: 

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

180 statement = ( 

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

182 .where( 

183 ~to_users.is_banned 

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

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

186 ) 

187 if request.to_user_id: 

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

189 statement = ( 

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

191 .where( 

192 ~from_users.is_banned 

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

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

195 ) 

196 if len(request.reference_type_filter) > 0: 

197 statement = statement.where( 

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

199 ) 

200 

201 if next_reference_id: 

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

203 

204 # Reference visibility logic: 

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

206 # 1. It is a friend reference 

207 # 2. Both references have been written 

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

209 

210 # we get the matching other references through this subquery 

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

212 Reference.reference_type != ReferenceType.friend 

213 ) 

214 if request.from_user_id: 

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

216 if request.to_user_id: 

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

218 

219 query = sub.subquery() 

220 statement = ( 

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

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

223 .where( 

224 or_( 

225 Reference.reference_type == ReferenceType.friend, 

226 query.c.sub_id != None, 

227 HostRequest.end_time_to_write_reference < func.now(), 

228 ) 

229 ) 

230 ) 

231 

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

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

234 

235 return references_pb2.ListReferencesRes( 

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

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

238 ) 

239 

240 def WriteFriendReference( 

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

242 ) -> references_pb2.Reference: 

243 if context.user_id == request.to_user_id: 

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

245 

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

247 

248 check_valid_reference(request, context) 

249 

250 if not session.execute( 

251 select(User).where_users_visible(context).where(User.id == request.to_user_id) 

252 ).scalar_one_or_none(): 

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

254 

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

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

257 

258 if session.execute( 

259 select(Reference) 

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

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

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

263 ).scalar_one_or_none(): 

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

265 

266 reference_text = request.text.strip() 

267 

268 reference = Reference( 

269 from_user_id=context.user_id, 

270 to_user_id=request.to_user_id, 

271 reference_type=ReferenceType.friend, 

272 text=reference_text, 

273 private_text=request.private_text.strip(), 

274 rating=request.rating, 

275 was_appropriate=request.was_appropriate, 

276 ) 

277 session.add(reference) 

278 session.commit() 

279 

280 # send the recipient of the reference a reminder 

281 notify( 

282 session, 

283 user_id=request.to_user_id, 

284 topic_action="reference:receive_friend", 

285 key=str(reference.id), 

286 data=notification_data_pb2.ReferenceReceiveFriend( 

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

288 text=reference_text, 

289 ), 

290 ) 

291 

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

293 maybe_send_reference_report_email(session, reference) 

294 

295 return reference_to_pb(reference, context) 

296 

297 def WriteHostRequestReference( 

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

299 ) -> references_pb2.Reference: 

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

301 

302 check_valid_reference(request, context) 

303 

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

305 

306 reference_text = request.text.strip() 

307 

308 reference = Reference( 

309 from_user_id=context.user_id, 

310 host_request_id=host_request.conversation_id, 

311 text=reference_text, 

312 private_text=request.private_text.strip(), 

313 rating=request.rating, 

314 was_appropriate=request.was_appropriate, 

315 ) 

316 

317 if surfed: 

318 # we requested to surf with someone 

319 reference.reference_type = ReferenceType.surfed 

320 reference.to_user_id = host_request.host_user_id 

321 assert context.user_id == host_request.surfer_user_id 

322 else: 

323 # we hosted someone 

324 reference.reference_type = ReferenceType.hosted 

325 reference.to_user_id = host_request.surfer_user_id 

326 assert context.user_id == host_request.host_user_id 

327 

328 session.add(reference) 

329 session.commit() 

330 

331 other_reference = session.execute( 

332 select(Reference) 

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

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

335 ).scalar_one_or_none() 

336 

337 # send notification out 

338 notify( 

339 session, 

340 user_id=reference.to_user_id, 

341 topic_action="reference:receive_surfed" if surfed else "reference:receive_hosted", 

342 key=str(host_request.conversation_id), 

343 data=notification_data_pb2.ReferenceReceiveHostRequest( 

344 host_request_id=host_request.conversation_id, 

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

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

347 ), 

348 ) 

349 

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

351 maybe_send_reference_report_email(session, reference) 

352 

353 return reference_to_pb(reference, context) 

354 

355 def HostRequestIndicateDidntMeetup( 

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

357 ) -> empty_pb2.Empty: 

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

359 

360 reason = request.reason_didnt_meetup.strip() 

361 

362 if surfed: 

363 host_request.surfer_reason_didnt_meetup = reason 

364 else: 

365 host_request.host_reason_didnt_meetup = reason 

366 

367 return empty_pb2.Empty() 

368 

369 def AvailableWriteReferences( 

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

371 ) -> references_pb2.AvailableWriteReferencesRes: 

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

373 if request.to_user_id == context.user_id: 

374 return references_pb2.AvailableWriteReferencesRes() 

375 

376 if not session.execute( 

377 select(User).where_users_visible(context).where(User.id == request.to_user_id) 

378 ).scalar_one_or_none(): 

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

380 

381 can_write_friend_reference = ( 

382 session.execute( 

383 select(Reference) 

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

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

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

387 ).scalar_one_or_none() 

388 ) is None 

389 

390 q1 = ( 

391 select(literal(True), HostRequest) 

392 .outerjoin( 

393 Reference, 

394 and_( 

395 Reference.host_request_id == HostRequest.conversation_id, 

396 Reference.from_user_id == context.user_id, 

397 ), 

398 ) 

399 .where(Reference.id == None) 

400 .where(HostRequest.can_write_reference) 

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

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

403 .where(HostRequest.surfer_reason_didnt_meetup == None) 

404 ) 

405 

406 q2 = ( 

407 select(literal(False), HostRequest) 

408 .outerjoin( 

409 Reference, 

410 and_( 

411 Reference.host_request_id == HostRequest.conversation_id, 

412 Reference.from_user_id == context.user_id, 

413 ), 

414 ) 

415 .where(Reference.id == None) 

416 .where(HostRequest.can_write_reference) 

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

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

419 .where(HostRequest.host_reason_didnt_meetup == None) 

420 ) 

421 

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

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

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

425 

426 return references_pb2.AvailableWriteReferencesRes( 

427 can_write_friend_reference=can_write_friend_reference, 

428 available_write_references=[ 

429 references_pb2.AvailableWriteReferenceType( 

430 host_request_id=host_request.conversation_id, 

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

432 time_expires=Timestamp_from_datetime(host_request.end_time_to_write_reference), 

433 ) 

434 for surfed, host_request in host_request_references 

435 ], 

436 ) 

437 

438 def ListPendingReferencesToWrite( 

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

440 ) -> references_pb2.ListPendingReferencesToWriteRes: 

441 return references_pb2.ListPendingReferencesToWriteRes( 

442 pending_references=[ 

443 references_pb2.AvailableWriteReferenceType( 

444 host_request_id=host_request_id, 

445 reference_type=reftype2api[reference_type], 

446 time_expires=Timestamp_from_datetime(end_time_to_write_reference), 

447 ) 

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

449 session, context 

450 ) 

451 ], 

452 ) 

453 

454 def GetHostRequestReferenceStatus( 

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

456 ) -> references_pb2.GetHostRequestReferenceStatusRes: 

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

458 has_given = ( 

459 session.execute( 

460 select(Reference) 

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

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

463 ).scalar_one_or_none() 

464 is not None 

465 ) 

466 

467 host_request = session.execute( 

468 select(HostRequest) 

469 .where_moderated_content_visible(context, HostRequest, is_list_operation=False) 

470 .where(HostRequest.conversation_id == request.host_request_id) 

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

472 ).scalar_one_or_none() 

473 

474 can_write = False 

475 is_expired = False 

476 didnt_stay = False 

477 

478 if host_request is not None: 

479 # Compute expired from end_time_to_write_reference 

480 if host_request.end_time_to_write_reference is not None: 

481 is_expired = host_request.end_time_to_write_reference < now() 

482 

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

484 didnt_stay = ( 

485 (host_request.surfer_reason_didnt_meetup is not None) 

486 if host_request.surfer_user_id == context.user_id 

487 else (host_request.host_reason_didnt_meetup is not None) 

488 ) 

489 

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

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

492 

493 return references_pb2.GetHostRequestReferenceStatusRes( 

494 has_given=has_given, 

495 can_write=can_write, 

496 is_expired=is_expired, 

497 didnt_stay=didnt_stay, 

498 )