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

129 statements  

« prev     ^ index     » next       coverage.py v7.6.10, created at 2025-08-28 14: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 import errors 

14from couchers.context import make_background_user_context 

15from couchers.materialized_views import LiteUser 

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

17from couchers.notifications.notify import notify 

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 

22from proto import notification_data_pb2, references_pb2, references_pb2_grpc 

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(HostRequest.conversation_id == host_request_id) 

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

65 ).scalar_one_or_none() 

66 

67 if not host_request: 

68 context.abort(grpc.StatusCode.NOT_FOUND, errors.HOST_REQUEST_NOT_FOUND) 

69 

70 if not host_request.can_write_reference: 

71 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.CANT_WRITE_REFERENCE_FOR_REQUEST) 

72 

73 if session.execute( 

74 select(Reference) 

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

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

77 ).scalar_one_or_none(): 

78 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.REFERENCE_ALREADY_GIVEN) 

79 

80 surfed = host_request.surfer_user_id == context.user_id 

81 

82 if surfed: 

83 my_reason = host_request.surfer_reason_didnt_meetup 

84 else: 

85 my_reason = host_request.host_reason_didnt_meetup 

86 

87 if my_reason != None: 

88 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.CANT_WRITE_REFERENCE_INDICATED_DIDNT_MEETUP) 

89 

90 return host_request, surfed 

91 

92 

93def check_valid_reference(request, context): 

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

95 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.REFERENCE_INVALID_RATING) 

96 

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

98 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.REFERENCE_NO_TEXT) 

99 

100 

101def get_pending_references_to_write(session, context): 

102 q1 = ( 

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

104 .outerjoin( 

105 Reference, 

106 and_( 

107 Reference.host_request_id == HostRequest.conversation_id, 

108 Reference.from_user_id == context.user_id, 

109 ), 

110 ) 

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

112 .where_users_column_visible(context, HostRequest.host_user_id) 

113 .where(Reference.id == None) 

114 .where(HostRequest.can_write_reference) 

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

116 .where(HostRequest.surfer_reason_didnt_meetup == None) 

117 ) 

118 

119 q2 = ( 

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

121 .outerjoin( 

122 Reference, 

123 and_( 

124 Reference.host_request_id == HostRequest.conversation_id, 

125 Reference.from_user_id == context.user_id, 

126 ), 

127 ) 

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

129 .where_users_column_visible(context, HostRequest.surfer_user_id) 

130 .where(Reference.id == None) 

131 .where(HostRequest.can_write_reference) 

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

133 .where(HostRequest.host_reason_didnt_meetup == None) 

134 ) 

135 

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

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

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

139 

140 return [ 

141 ( 

142 host_request.conversation_id, 

143 ReferenceType.surfed if surfed else ReferenceType.hosted, 

144 host_request.end_time_to_write_reference, 

145 other_user, 

146 ) 

147 for surfed, host_request, other_user in host_request_references 

148 ] 

149 

150 

151class References(references_pb2_grpc.ReferencesServicer): 

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

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

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

155 

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

157 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.NEED_TO_SPECIFY_AT_LEAST_ONE_USER) 

158 

159 to_users = aliased(User) 

160 from_users = aliased(User) 

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

162 if request.from_user_id: 

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

164 statement = ( 

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

166 .where( 

167 ~to_users.is_banned 

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

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

170 ) 

171 if request.to_user_id: 

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

173 statement = ( 

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

175 .where( 

176 ~from_users.is_banned 

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

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

179 ) 

180 if len(request.reference_type_filter) > 0: 

181 statement = statement.where( 

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

183 ) 

184 

185 if next_reference_id: 

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

187 

188 # Reference visibility logic: 

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

190 # 1. It is a friend reference 

191 # 2. Both references have been written 

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

193 

194 # we get the matching other references through this subquery 

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

196 Reference.reference_type != ReferenceType.friend 

197 ) 

198 if request.from_user_id: 

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

200 if request.to_user_id: 

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

202 

203 sub = sub.subquery() 

204 statement = ( 

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

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

207 .where( 

208 or_( 

209 Reference.reference_type == ReferenceType.friend, 

210 sub.c.sub_id != None, 

211 HostRequest.end_time_to_write_reference < func.now(), 

212 ) 

213 ) 

214 ) 

215 

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

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

218 

219 return references_pb2.ListReferencesRes( 

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

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

222 ) 

223 

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

225 if context.user_id == request.to_user_id: 

226 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.CANT_REFER_SELF) 

227 

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

229 

230 check_valid_reference(request, context) 

231 

232 if not session.execute( 

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

234 ).scalar_one_or_none(): 

235 context.abort(grpc.StatusCode.NOT_FOUND, errors.USER_NOT_FOUND) 

236 

237 if session.execute( 

238 select(Reference) 

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

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

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

242 ).scalar_one_or_none(): 

243 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.REFERENCE_ALREADY_GIVEN) 

244 

245 reference_text = request.text.strip() 

246 

247 reference = Reference( 

248 from_user_id=context.user_id, 

249 to_user_id=request.to_user_id, 

250 reference_type=ReferenceType.friend, 

251 text=reference_text, 

252 private_text=request.private_text.strip(), 

253 rating=request.rating, 

254 was_appropriate=request.was_appropriate, 

255 ) 

256 session.add(reference) 

257 session.commit() 

258 

259 # send the recipient of the reference a reminder 

260 notify( 

261 session, 

262 user_id=request.to_user_id, 

263 topic_action="reference:receive_friend", 

264 data=notification_data_pb2.ReferenceReceiveFriend( 

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

266 text=reference_text, 

267 ), 

268 ) 

269 

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

271 maybe_send_reference_report_email(session, reference) 

272 

273 return reference_to_pb(reference, context) 

274 

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

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

277 

278 check_valid_reference(request, context) 

279 

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

281 

282 reference_text = request.text.strip() 

283 

284 reference = Reference( 

285 from_user_id=context.user_id, 

286 host_request_id=host_request.conversation_id, 

287 text=reference_text, 

288 private_text=request.private_text.strip(), 

289 rating=request.rating, 

290 was_appropriate=request.was_appropriate, 

291 ) 

292 

293 if surfed: 

294 # we requested to surf with someone 

295 reference.reference_type = ReferenceType.surfed 

296 reference.to_user_id = host_request.host_user_id 

297 assert context.user_id == host_request.surfer_user_id 

298 else: 

299 # we hosted someone 

300 reference.reference_type = ReferenceType.hosted 

301 reference.to_user_id = host_request.surfer_user_id 

302 assert context.user_id == host_request.host_user_id 

303 

304 session.add(reference) 

305 session.commit() 

306 

307 other_reference = session.execute( 

308 select(Reference) 

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

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

311 ).scalar_one_or_none() 

312 

313 # send notification out 

314 notify( 

315 session, 

316 user_id=reference.to_user_id, 

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

318 data=notification_data_pb2.ReferenceReceiveHostRequest( 

319 host_request_id=host_request.conversation_id, 

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

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

322 ), 

323 ) 

324 

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

326 maybe_send_reference_report_email(session, reference) 

327 

328 return reference_to_pb(reference, context) 

329 

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

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

332 

333 reason = request.reason_didnt_meetup.strip() 

334 

335 if surfed: 

336 host_request.surfer_reason_didnt_meetup = reason 

337 else: 

338 host_request.host_reason_didnt_meetup = reason 

339 

340 return empty_pb2.Empty() 

341 

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

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

344 if request.to_user_id == context.user_id: 

345 return references_pb2.AvailableWriteReferencesRes() 

346 

347 if not session.execute( 

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

349 ).scalar_one_or_none(): 

350 context.abort(grpc.StatusCode.NOT_FOUND, errors.USER_NOT_FOUND) 

351 

352 can_write_friend_reference = ( 

353 session.execute( 

354 select(Reference) 

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

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

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

358 ).scalar_one_or_none() 

359 ) is None 

360 

361 q1 = ( 

362 select(literal(True), HostRequest) 

363 .outerjoin( 

364 Reference, 

365 and_( 

366 Reference.host_request_id == HostRequest.conversation_id, 

367 Reference.from_user_id == context.user_id, 

368 ), 

369 ) 

370 .where(Reference.id == None) 

371 .where(HostRequest.can_write_reference) 

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

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

374 .where(HostRequest.surfer_reason_didnt_meetup == None) 

375 ) 

376 

377 q2 = ( 

378 select(literal(False), HostRequest) 

379 .outerjoin( 

380 Reference, 

381 and_( 

382 Reference.host_request_id == HostRequest.conversation_id, 

383 Reference.from_user_id == context.user_id, 

384 ), 

385 ) 

386 .where(Reference.id == None) 

387 .where(HostRequest.can_write_reference) 

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

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

390 .where(HostRequest.host_reason_didnt_meetup == None) 

391 ) 

392 

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

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

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

396 

397 return references_pb2.AvailableWriteReferencesRes( 

398 can_write_friend_reference=can_write_friend_reference, 

399 available_write_references=[ 

400 references_pb2.AvailableWriteReferenceType( 

401 host_request_id=host_request.conversation_id, 

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

403 time_expires=Timestamp_from_datetime(host_request.end_time_to_write_reference), 

404 ) 

405 for surfed, host_request in host_request_references 

406 ], 

407 ) 

408 

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

410 return references_pb2.ListPendingReferencesToWriteRes( 

411 pending_references=[ 

412 references_pb2.AvailableWriteReferenceType( 

413 host_request_id=host_request_id, 

414 reference_type=reftype2api[reference_type], 

415 time_expires=Timestamp_from_datetime(end_time_to_write_reference), 

416 ) 

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

418 session, context 

419 ) 

420 ], 

421 )