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

143 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-12-14 00:52 +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 

8import grpc 

9from google.protobuf import empty_pb2 

10from sqlalchemy.orm import aliased 

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

12 

13from couchers.context import make_background_user_context 

14from couchers.db import are_friends 

15from couchers.materialized_views import LiteUser 

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

17from couchers.notifications.notify import notify 

18from couchers.proto import notification_data_pb2, references_pb2, references_pb2_grpc 

19from couchers.servicers.api import user_model_to_pb 

20from couchers.sql import couchers_select as select 

21from couchers.tasks import maybe_send_reference_report_email 

22from couchers.utils import Timestamp_from_datetime, now 

23 

24MAX_PAGINATION_LENGTH = 100 

25 

26reftype2sql = { 

27 references_pb2.ReferenceType.REFERENCE_TYPE_FRIEND: ReferenceType.friend, 

28 references_pb2.ReferenceType.REFERENCE_TYPE_SURFED: ReferenceType.surfed, 

29 references_pb2.ReferenceType.REFERENCE_TYPE_HOSTED: ReferenceType.hosted, 

30} 

31 

32reftype2api = { 

33 ReferenceType.friend: references_pb2.ReferenceType.REFERENCE_TYPE_FRIEND, 

34 ReferenceType.surfed: references_pb2.ReferenceType.REFERENCE_TYPE_SURFED, 

35 ReferenceType.hosted: references_pb2.ReferenceType.REFERENCE_TYPE_HOSTED, 

36} 

37 

38 

39def reference_to_pb(reference: Reference, context): 

40 return references_pb2.Reference( 

41 reference_id=reference.id, 

42 from_user_id=reference.from_user_id, 

43 to_user_id=reference.to_user_id, 

44 reference_type=reftype2api[reference.reference_type], 

45 text=reference.text, 

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

47 host_request_id=( 

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

49 ), 

50 ) 

51 

52 

53def get_host_req_and_check_can_write_ref(session, context, host_request_id): 

54 """ 

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

56 

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

58 """ 

59 host_request = session.execute( 

60 select(HostRequest) 

61 .where_users_column_visible(context, HostRequest.surfer_user_id) 

62 .where_users_column_visible(context, HostRequest.host_user_id) 

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

64 .where(HostRequest.conversation_id == host_request_id) 

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

66 ).scalar_one_or_none() 

67 

68 if not host_request: 

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

70 

71 if not host_request.can_write_reference: 

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

73 

74 if session.execute( 

75 select(Reference) 

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

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

78 ).scalar_one_or_none(): 

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

80 

81 surfed = host_request.surfer_user_id == context.user_id 

82 

83 if surfed: 

84 my_reason = host_request.surfer_reason_didnt_meetup 

85 else: 

86 my_reason = host_request.host_reason_didnt_meetup 

87 

88 if my_reason != None: 

89 context.abort_with_error_code( 

90 grpc.StatusCode.FAILED_PRECONDITION, "cant_write_reference_indicated_didnt_meetup" 

91 ) 

92 

93 return host_request, surfed 

94 

95 

96def check_valid_reference(request, context): 

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

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

99 

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

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

102 

103 

104def get_pending_references_to_write(session, context): 

105 q1 = ( 

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

107 .outerjoin( 

108 Reference, 

109 and_( 

110 Reference.host_request_id == HostRequest.conversation_id, 

111 Reference.from_user_id == context.user_id, 

112 ), 

113 ) 

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

115 .where_users_column_visible(context, HostRequest.host_user_id) 

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

117 .where(Reference.id == None) 

118 .where(HostRequest.can_write_reference) 

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

120 .where(HostRequest.surfer_reason_didnt_meetup == None) 

121 ) 

122 

123 q2 = ( 

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

125 .outerjoin( 

126 Reference, 

127 and_( 

128 Reference.host_request_id == HostRequest.conversation_id, 

129 Reference.from_user_id == context.user_id, 

130 ), 

131 ) 

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

133 .where_users_column_visible(context, HostRequest.surfer_user_id) 

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

135 .where(Reference.id == None) 

136 .where(HostRequest.can_write_reference) 

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

138 .where(HostRequest.host_reason_didnt_meetup == None) 

139 ) 

140 

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

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

143 host_request_references = session.execute(union).all() 

144 

145 return [ 

146 ( 

147 host_request.conversation_id, 

148 ReferenceType.surfed if surfed else ReferenceType.hosted, 

149 host_request.end_time_to_write_reference, 

150 other_user, 

151 ) 

152 for surfed, host_request, other_user in host_request_references 

153 ] 

154 

155 

156class References(references_pb2_grpc.ReferencesServicer): 

157 def ListReferences(self, request, context, session): 

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

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

160 

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

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

163 

164 to_users = aliased(User) 

165 from_users = aliased(User) 

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

167 if request.from_user_id: 

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

169 statement = ( 

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

171 .where( 

172 ~to_users.is_banned 

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

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

175 ) 

176 if request.to_user_id: 

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

178 statement = ( 

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

180 .where( 

181 ~from_users.is_banned 

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

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

184 ) 

185 if len(request.reference_type_filter) > 0: 

186 statement = statement.where( 

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

188 ) 

189 

190 if next_reference_id: 

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

192 

193 # Reference visibility logic: 

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

195 # 1. It is a friend reference 

196 # 2. Both references have been written 

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

198 

199 # we get the matching other references through this subquery 

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

201 Reference.reference_type != ReferenceType.friend 

202 ) 

203 if request.from_user_id: 

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

205 if request.to_user_id: 

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

207 

208 sub = sub.subquery() 

209 statement = ( 

210 statement.outerjoin(sub, sub.c.host_request_id == Reference.host_request_id) 

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

212 .where( 

213 or_( 

214 Reference.reference_type == ReferenceType.friend, 

215 sub.c.sub_id != None, 

216 HostRequest.end_time_to_write_reference < func.now(), 

217 ) 

218 ) 

219 ) 

220 

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

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

223 

224 return references_pb2.ListReferencesRes( 

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

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

227 ) 

228 

229 def WriteFriendReference(self, request, context, session): 

230 if context.user_id == request.to_user_id: 

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

232 

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

234 

235 check_valid_reference(request, context) 

236 

237 if not session.execute( 

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

239 ).scalar_one_or_none(): 

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

241 

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

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

244 

245 if session.execute( 

246 select(Reference) 

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

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

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

250 ).scalar_one_or_none(): 

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

252 

253 reference_text = request.text.strip() 

254 

255 reference = Reference( 

256 from_user_id=context.user_id, 

257 to_user_id=request.to_user_id, 

258 reference_type=ReferenceType.friend, 

259 text=reference_text, 

260 private_text=request.private_text.strip(), 

261 rating=request.rating, 

262 was_appropriate=request.was_appropriate, 

263 ) 

264 session.add(reference) 

265 session.commit() 

266 

267 # send the recipient of the reference a reminder 

268 notify( 

269 session, 

270 user_id=request.to_user_id, 

271 topic_action="reference:receive_friend", 

272 key=str(reference.id), 

273 data=notification_data_pb2.ReferenceReceiveFriend( 

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

275 text=reference_text, 

276 ), 

277 ) 

278 

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

280 maybe_send_reference_report_email(session, reference) 

281 

282 return reference_to_pb(reference, context) 

283 

284 def WriteHostRequestReference(self, request, context, session): 

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

286 

287 check_valid_reference(request, context) 

288 

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

290 

291 reference_text = request.text.strip() 

292 

293 reference = Reference( 

294 from_user_id=context.user_id, 

295 host_request_id=host_request.conversation_id, 

296 text=reference_text, 

297 private_text=request.private_text.strip(), 

298 rating=request.rating, 

299 was_appropriate=request.was_appropriate, 

300 ) 

301 

302 if surfed: 

303 # we requested to surf with someone 

304 reference.reference_type = ReferenceType.surfed 

305 reference.to_user_id = host_request.host_user_id 

306 assert context.user_id == host_request.surfer_user_id 

307 else: 

308 # we hosted someone 

309 reference.reference_type = ReferenceType.hosted 

310 reference.to_user_id = host_request.surfer_user_id 

311 assert context.user_id == host_request.host_user_id 

312 

313 session.add(reference) 

314 session.commit() 

315 

316 other_reference = session.execute( 

317 select(Reference) 

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

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

320 ).scalar_one_or_none() 

321 

322 # send notification out 

323 notify( 

324 session, 

325 user_id=reference.to_user_id, 

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

327 key=str(host_request.conversation_id), 

328 data=notification_data_pb2.ReferenceReceiveHostRequest( 

329 host_request_id=host_request.conversation_id, 

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

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

332 ), 

333 ) 

334 

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

336 maybe_send_reference_report_email(session, reference) 

337 

338 return reference_to_pb(reference, context) 

339 

340 def HostRequestIndicateDidntMeetup(self, request, context, session): 

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

342 

343 reason = request.reason_didnt_meetup.strip() 

344 

345 if surfed: 

346 host_request.surfer_reason_didnt_meetup = reason 

347 else: 

348 host_request.host_reason_didnt_meetup = reason 

349 

350 return empty_pb2.Empty() 

351 

352 def AvailableWriteReferences(self, request, context, session): 

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

354 if request.to_user_id == context.user_id: 

355 return references_pb2.AvailableWriteReferencesRes() 

356 

357 if not session.execute( 

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

359 ).scalar_one_or_none(): 

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

361 

362 can_write_friend_reference = ( 

363 session.execute( 

364 select(Reference) 

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

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

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

368 ).scalar_one_or_none() 

369 ) is None 

370 

371 q1 = ( 

372 select(literal(True), HostRequest) 

373 .outerjoin( 

374 Reference, 

375 and_( 

376 Reference.host_request_id == HostRequest.conversation_id, 

377 Reference.from_user_id == context.user_id, 

378 ), 

379 ) 

380 .where(Reference.id == None) 

381 .where(HostRequest.can_write_reference) 

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

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

384 .where(HostRequest.surfer_reason_didnt_meetup == None) 

385 ) 

386 

387 q2 = ( 

388 select(literal(False), HostRequest) 

389 .outerjoin( 

390 Reference, 

391 and_( 

392 Reference.host_request_id == HostRequest.conversation_id, 

393 Reference.from_user_id == context.user_id, 

394 ), 

395 ) 

396 .where(Reference.id == None) 

397 .where(HostRequest.can_write_reference) 

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

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

400 .where(HostRequest.host_reason_didnt_meetup == None) 

401 ) 

402 

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

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

405 host_request_references = session.execute(union).all() 

406 

407 return references_pb2.AvailableWriteReferencesRes( 

408 can_write_friend_reference=can_write_friend_reference, 

409 available_write_references=[ 

410 references_pb2.AvailableWriteReferenceType( 

411 host_request_id=host_request.conversation_id, 

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

413 time_expires=Timestamp_from_datetime(host_request.end_time_to_write_reference), 

414 ) 

415 for surfed, host_request in host_request_references 

416 ], 

417 ) 

418 

419 def ListPendingReferencesToWrite(self, request, context, session): 

420 return references_pb2.ListPendingReferencesToWriteRes( 

421 pending_references=[ 

422 references_pb2.AvailableWriteReferenceType( 

423 host_request_id=host_request_id, 

424 reference_type=reftype2api[reference_type], 

425 time_expires=Timestamp_from_datetime(end_time_to_write_reference), 

426 ) 

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

428 session, context 

429 ) 

430 ], 

431 ) 

432 

433 def GetHostRequestReferenceStatus(self, request, context, session): 

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

435 has_given = ( 

436 session.execute( 

437 select(Reference) 

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

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

440 ).scalar_one_or_none() 

441 is not None 

442 ) 

443 

444 host_request = session.execute( 

445 select(HostRequest) 

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

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

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

449 ).scalar_one_or_none() 

450 

451 can_write = False 

452 is_expired = False 

453 didnt_stay = False 

454 

455 if host_request is not None: 

456 # Compute expired from end_time_to_write_reference 

457 if host_request.end_time_to_write_reference is not None: 

458 is_expired = host_request.end_time_to_write_reference < now() 

459 

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

461 didnt_stay = ( 

462 (host_request.surfer_reason_didnt_meetup is not None) 

463 if host_request.surfer_user_id == context.user_id 

464 else (host_request.host_reason_didnt_meetup is not None) 

465 ) 

466 

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

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

469 

470 return references_pb2.GetHostRequestReferenceStatusRes( 

471 has_given=has_given, 

472 can_write=can_write, 

473 is_expired=is_expired, 

474 didnt_stay=didnt_stay, 

475 )