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

140 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-11-29 16:55 +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.materialized_views import LiteUser 

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

16from couchers.notifications.notify import notify 

17from couchers.proto import notification_data_pb2, references_pb2, references_pb2_grpc 

18from couchers.servicers.api import user_model_to_pb 

19from couchers.sql import couchers_select as select 

20from couchers.tasks import maybe_send_reference_report_email 

21from couchers.utils import Timestamp_from_datetime, now 

22 

23MAX_PAGINATION_LENGTH = 100 

24 

25reftype2sql = { 

26 references_pb2.ReferenceType.REFERENCE_TYPE_FRIEND: ReferenceType.friend, 

27 references_pb2.ReferenceType.REFERENCE_TYPE_SURFED: ReferenceType.surfed, 

28 references_pb2.ReferenceType.REFERENCE_TYPE_HOSTED: ReferenceType.hosted, 

29} 

30 

31reftype2api = { 

32 ReferenceType.friend: references_pb2.ReferenceType.REFERENCE_TYPE_FRIEND, 

33 ReferenceType.surfed: references_pb2.ReferenceType.REFERENCE_TYPE_SURFED, 

34 ReferenceType.hosted: references_pb2.ReferenceType.REFERENCE_TYPE_HOSTED, 

35} 

36 

37 

38def reference_to_pb(reference: Reference, context): 

39 return references_pb2.Reference( 

40 reference_id=reference.id, 

41 from_user_id=reference.from_user_id, 

42 to_user_id=reference.to_user_id, 

43 reference_type=reftype2api[reference.reference_type], 

44 text=reference.text, 

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

46 host_request_id=( 

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

48 ), 

49 ) 

50 

51 

52def get_host_req_and_check_can_write_ref(session, context, host_request_id): 

53 """ 

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

55 

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

57 """ 

58 host_request = session.execute( 

59 select(HostRequest) 

60 .where_users_column_visible(context, HostRequest.surfer_user_id) 

61 .where_users_column_visible(context, HostRequest.host_user_id) 

62 .where(HostRequest.conversation_id == host_request_id) 

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

64 ).scalar_one_or_none() 

65 

66 if not host_request: 

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

68 

69 if not host_request.can_write_reference: 

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

71 

72 if session.execute( 

73 select(Reference) 

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

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

76 ).scalar_one_or_none(): 

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

78 

79 surfed = host_request.surfer_user_id == context.user_id 

80 

81 if surfed: 

82 my_reason = host_request.surfer_reason_didnt_meetup 

83 else: 

84 my_reason = host_request.host_reason_didnt_meetup 

85 

86 if my_reason != None: 

87 context.abort_with_error_code( 

88 grpc.StatusCode.FAILED_PRECONDITION, "cant_write_reference_indicated_didnt_meetup" 

89 ) 

90 

91 return host_request, surfed 

92 

93 

94def check_valid_reference(request, context): 

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

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

97 

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

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

100 

101 

102def get_pending_references_to_write(session, context): 

103 q1 = ( 

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

105 .outerjoin( 

106 Reference, 

107 and_( 

108 Reference.host_request_id == HostRequest.conversation_id, 

109 Reference.from_user_id == context.user_id, 

110 ), 

111 ) 

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

113 .where_users_column_visible(context, HostRequest.host_user_id) 

114 .where(Reference.id == None) 

115 .where(HostRequest.can_write_reference) 

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

117 .where(HostRequest.surfer_reason_didnt_meetup == None) 

118 ) 

119 

120 q2 = ( 

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

122 .outerjoin( 

123 Reference, 

124 and_( 

125 Reference.host_request_id == HostRequest.conversation_id, 

126 Reference.from_user_id == context.user_id, 

127 ), 

128 ) 

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

130 .where_users_column_visible(context, HostRequest.surfer_user_id) 

131 .where(Reference.id == None) 

132 .where(HostRequest.can_write_reference) 

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

134 .where(HostRequest.host_reason_didnt_meetup == None) 

135 ) 

136 

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

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

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

140 

141 return [ 

142 ( 

143 host_request.conversation_id, 

144 ReferenceType.surfed if surfed else ReferenceType.hosted, 

145 host_request.end_time_to_write_reference, 

146 other_user, 

147 ) 

148 for surfed, host_request, other_user in host_request_references 

149 ] 

150 

151 

152class References(references_pb2_grpc.ReferencesServicer): 

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

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

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

156 

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

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

159 

160 to_users = aliased(User) 

161 from_users = aliased(User) 

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

163 if request.from_user_id: 

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

165 statement = ( 

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

167 .where( 

168 ~to_users.is_banned 

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

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

171 ) 

172 if request.to_user_id: 

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

174 statement = ( 

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

176 .where( 

177 ~from_users.is_banned 

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

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

180 ) 

181 if len(request.reference_type_filter) > 0: 

182 statement = statement.where( 

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

184 ) 

185 

186 if next_reference_id: 

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

188 

189 # Reference visibility logic: 

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

191 # 1. It is a friend reference 

192 # 2. Both references have been written 

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

194 

195 # we get the matching other references through this subquery 

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

197 Reference.reference_type != ReferenceType.friend 

198 ) 

199 if request.from_user_id: 

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

201 if request.to_user_id: 

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

203 

204 sub = sub.subquery() 

205 statement = ( 

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

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

208 .where( 

209 or_( 

210 Reference.reference_type == ReferenceType.friend, 

211 sub.c.sub_id != None, 

212 HostRequest.end_time_to_write_reference < func.now(), 

213 ) 

214 ) 

215 ) 

216 

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

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

219 

220 return references_pb2.ListReferencesRes( 

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

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

223 ) 

224 

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

226 if context.user_id == request.to_user_id: 

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

228 

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

230 

231 check_valid_reference(request, context) 

232 

233 if not session.execute( 

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

235 ).scalar_one_or_none(): 

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

237 

238 if session.execute( 

239 select(Reference) 

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

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

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

243 ).scalar_one_or_none(): 

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

245 

246 reference_text = request.text.strip() 

247 

248 reference = Reference( 

249 from_user_id=context.user_id, 

250 to_user_id=request.to_user_id, 

251 reference_type=ReferenceType.friend, 

252 text=reference_text, 

253 private_text=request.private_text.strip(), 

254 rating=request.rating, 

255 was_appropriate=request.was_appropriate, 

256 ) 

257 session.add(reference) 

258 session.commit() 

259 

260 # send the recipient of the reference a reminder 

261 notify( 

262 session, 

263 user_id=request.to_user_id, 

264 topic_action="reference:receive_friend", 

265 data=notification_data_pb2.ReferenceReceiveFriend( 

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

267 text=reference_text, 

268 ), 

269 ) 

270 

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

272 maybe_send_reference_report_email(session, reference) 

273 

274 return reference_to_pb(reference, context) 

275 

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

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

278 

279 check_valid_reference(request, context) 

280 

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

282 

283 reference_text = request.text.strip() 

284 

285 reference = Reference( 

286 from_user_id=context.user_id, 

287 host_request_id=host_request.conversation_id, 

288 text=reference_text, 

289 private_text=request.private_text.strip(), 

290 rating=request.rating, 

291 was_appropriate=request.was_appropriate, 

292 ) 

293 

294 if surfed: 

295 # we requested to surf with someone 

296 reference.reference_type = ReferenceType.surfed 

297 reference.to_user_id = host_request.host_user_id 

298 assert context.user_id == host_request.surfer_user_id 

299 else: 

300 # we hosted someone 

301 reference.reference_type = ReferenceType.hosted 

302 reference.to_user_id = host_request.surfer_user_id 

303 assert context.user_id == host_request.host_user_id 

304 

305 session.add(reference) 

306 session.commit() 

307 

308 other_reference = session.execute( 

309 select(Reference) 

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

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

312 ).scalar_one_or_none() 

313 

314 # send notification out 

315 notify( 

316 session, 

317 user_id=reference.to_user_id, 

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

319 data=notification_data_pb2.ReferenceReceiveHostRequest( 

320 host_request_id=host_request.conversation_id, 

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

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

323 ), 

324 ) 

325 

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

327 maybe_send_reference_report_email(session, reference) 

328 

329 return reference_to_pb(reference, context) 

330 

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

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

333 

334 reason = request.reason_didnt_meetup.strip() 

335 

336 if surfed: 

337 host_request.surfer_reason_didnt_meetup = reason 

338 else: 

339 host_request.host_reason_didnt_meetup = reason 

340 

341 return empty_pb2.Empty() 

342 

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

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

345 if request.to_user_id == context.user_id: 

346 return references_pb2.AvailableWriteReferencesRes() 

347 

348 if not session.execute( 

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

350 ).scalar_one_or_none(): 

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

352 

353 can_write_friend_reference = ( 

354 session.execute( 

355 select(Reference) 

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

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

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

359 ).scalar_one_or_none() 

360 ) is None 

361 

362 q1 = ( 

363 select(literal(True), HostRequest) 

364 .outerjoin( 

365 Reference, 

366 and_( 

367 Reference.host_request_id == HostRequest.conversation_id, 

368 Reference.from_user_id == context.user_id, 

369 ), 

370 ) 

371 .where(Reference.id == None) 

372 .where(HostRequest.can_write_reference) 

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

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

375 .where(HostRequest.surfer_reason_didnt_meetup == None) 

376 ) 

377 

378 q2 = ( 

379 select(literal(False), HostRequest) 

380 .outerjoin( 

381 Reference, 

382 and_( 

383 Reference.host_request_id == HostRequest.conversation_id, 

384 Reference.from_user_id == context.user_id, 

385 ), 

386 ) 

387 .where(Reference.id == None) 

388 .where(HostRequest.can_write_reference) 

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

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

391 .where(HostRequest.host_reason_didnt_meetup == None) 

392 ) 

393 

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

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

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

397 

398 return references_pb2.AvailableWriteReferencesRes( 

399 can_write_friend_reference=can_write_friend_reference, 

400 available_write_references=[ 

401 references_pb2.AvailableWriteReferenceType( 

402 host_request_id=host_request.conversation_id, 

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

404 time_expires=Timestamp_from_datetime(host_request.end_time_to_write_reference), 

405 ) 

406 for surfed, host_request in host_request_references 

407 ], 

408 ) 

409 

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

411 return references_pb2.ListPendingReferencesToWriteRes( 

412 pending_references=[ 

413 references_pb2.AvailableWriteReferenceType( 

414 host_request_id=host_request_id, 

415 reference_type=reftype2api[reference_type], 

416 time_expires=Timestamp_from_datetime(end_time_to_write_reference), 

417 ) 

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

419 session, context 

420 ) 

421 ], 

422 ) 

423 

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

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

426 has_given = ( 

427 session.execute( 

428 select(Reference) 

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

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

431 ).scalar_one_or_none() 

432 is not None 

433 ) 

434 

435 host_request = session.execute( 

436 select(HostRequest) 

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

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

439 ).scalar_one_or_none() 

440 

441 can_write = False 

442 is_expired = False 

443 didnt_stay = False 

444 

445 if host_request is not None: 

446 # Compute expired from end_time_to_write_reference 

447 if host_request.end_time_to_write_reference is not None: 

448 is_expired = host_request.end_time_to_write_reference < now() 

449 

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

451 didnt_stay = ( 

452 (host_request.surfer_reason_didnt_meetup is not None) 

453 if host_request.surfer_user_id == context.user_id 

454 else (host_request.host_reason_didnt_meetup is not None) 

455 ) 

456 

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

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

459 

460 return references_pb2.GetHostRequestReferenceStatusRes( 

461 has_given=has_given, 

462 can_write=can_write, 

463 is_expired=is_expired, 

464 didnt_stay=didnt_stay, 

465 )