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

125 statements  

« prev     ^ index     » next       coverage.py v7.6.10, created at 2025-06-01 15:07 +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.models import HostRequest, Reference, ReferenceType, User 

15from couchers.notifications.notify import notify 

16from couchers.servicers.api import user_model_to_pb 

17from couchers.sql import couchers_select as select 

18from couchers.tasks import maybe_send_reference_report_email 

19from couchers.utils import Timestamp_from_datetime, make_user_context 

20from proto import notification_data_pb2, references_pb2, references_pb2_grpc 

21 

22reftype2sql = { 

23 references_pb2.ReferenceType.REFERENCE_TYPE_FRIEND: ReferenceType.friend, 

24 references_pb2.ReferenceType.REFERENCE_TYPE_SURFED: ReferenceType.surfed, 

25 references_pb2.ReferenceType.REFERENCE_TYPE_HOSTED: ReferenceType.hosted, 

26} 

27 

28reftype2api = { 

29 ReferenceType.friend: references_pb2.ReferenceType.REFERENCE_TYPE_FRIEND, 

30 ReferenceType.surfed: references_pb2.ReferenceType.REFERENCE_TYPE_SURFED, 

31 ReferenceType.hosted: references_pb2.ReferenceType.REFERENCE_TYPE_HOSTED, 

32} 

33 

34 

35def reference_to_pb(reference: Reference, context): 

36 return references_pb2.Reference( 

37 reference_id=reference.id, 

38 from_user_id=reference.from_user_id, 

39 to_user_id=reference.to_user_id, 

40 reference_type=reftype2api[reference.reference_type], 

41 text=reference.text, 

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

43 host_request_id=( 

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

45 ), 

46 ) 

47 

48 

49def get_host_req_and_check_can_write_ref(session, context, host_request_id): 

50 """ 

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

52 

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

54 """ 

55 host_request = session.execute( 

56 select(HostRequest) 

57 .where_users_column_visible(context, HostRequest.surfer_user_id) 

58 .where_users_column_visible(context, HostRequest.host_user_id) 

59 .where(HostRequest.conversation_id == host_request_id) 

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

61 ).scalar_one_or_none() 

62 

63 if not host_request: 

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

65 

66 if not host_request.can_write_reference: 

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

68 

69 if session.execute( 

70 select(Reference) 

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

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

73 ).scalar_one_or_none(): 

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

75 

76 surfed = host_request.surfer_user_id == context.user_id 

77 

78 if surfed: 

79 my_reason = host_request.surfer_reason_didnt_meetup 

80 else: 

81 my_reason = host_request.host_reason_didnt_meetup 

82 

83 if my_reason != None: 

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

85 

86 return host_request, surfed 

87 

88 

89def check_valid_reference(request, context): 

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

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

92 

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

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

95 

96 

97MAX_PAGINATION_LENGTH = 100 

98 

99 

100class References(references_pb2_grpc.ReferencesServicer): 

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

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

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

104 

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

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

107 

108 to_users = aliased(User) 

109 from_users = aliased(User) 

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

111 if request.from_user_id: 

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

113 statement = ( 

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

115 .where( 

116 ~to_users.is_banned 

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

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

119 ) 

120 if request.to_user_id: 

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

122 statement = ( 

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

124 .where( 

125 ~from_users.is_banned 

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

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

128 ) 

129 if len(request.reference_type_filter) > 0: 

130 statement = statement.where( 

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

132 ) 

133 

134 if next_reference_id: 

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

136 

137 # Reference visibility logic: 

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

139 # 1. It is a friend reference 

140 # 2. Both references have been written 

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

142 

143 # we get the matching other references through this subquery 

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

145 Reference.reference_type != ReferenceType.friend 

146 ) 

147 if request.from_user_id: 

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

149 if request.to_user_id: 

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

151 

152 sub = sub.subquery() 

153 statement = ( 

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

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

156 .where( 

157 or_( 

158 Reference.reference_type == ReferenceType.friend, 

159 sub.c.sub_id != None, 

160 HostRequest.end_time_to_write_reference < func.now(), 

161 ) 

162 ) 

163 ) 

164 

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

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

167 

168 return references_pb2.ListReferencesRes( 

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

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

171 ) 

172 

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

174 if context.user_id == request.to_user_id: 

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

176 

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

178 

179 check_valid_reference(request, context) 

180 

181 if not session.execute( 

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

183 ).scalar_one_or_none(): 

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

185 

186 if session.execute( 

187 select(Reference) 

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

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

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

191 ).scalar_one_or_none(): 

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

193 

194 reference_text = request.text.strip() 

195 

196 reference = Reference( 

197 from_user_id=context.user_id, 

198 to_user_id=request.to_user_id, 

199 reference_type=ReferenceType.friend, 

200 text=reference_text, 

201 private_text=request.private_text.strip(), 

202 rating=request.rating, 

203 was_appropriate=request.was_appropriate, 

204 ) 

205 session.add(reference) 

206 session.commit() 

207 

208 # send the recipient of the reference a reminder 

209 notify( 

210 session, 

211 user_id=request.to_user_id, 

212 topic_action="reference:receive_friend", 

213 data=notification_data_pb2.ReferenceReceiveFriend( 

214 from_user=user_model_to_pb(user, session, make_user_context(user_id=request.to_user_id)), 

215 text=reference_text, 

216 ), 

217 ) 

218 

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

220 maybe_send_reference_report_email(session, reference) 

221 

222 return reference_to_pb(reference, context) 

223 

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

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

226 

227 check_valid_reference(request, context) 

228 

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

230 

231 reference_text = request.text.strip() 

232 

233 reference = Reference( 

234 from_user_id=context.user_id, 

235 host_request_id=host_request.conversation_id, 

236 text=reference_text, 

237 private_text=request.private_text.strip(), 

238 rating=request.rating, 

239 was_appropriate=request.was_appropriate, 

240 ) 

241 

242 if surfed: 

243 # we requested to surf with someone 

244 reference.reference_type = ReferenceType.surfed 

245 reference.to_user_id = host_request.host_user_id 

246 assert context.user_id == host_request.surfer_user_id 

247 else: 

248 # we hosted someone 

249 reference.reference_type = ReferenceType.hosted 

250 reference.to_user_id = host_request.surfer_user_id 

251 assert context.user_id == host_request.host_user_id 

252 

253 session.add(reference) 

254 session.commit() 

255 

256 other_reference = session.execute( 

257 select(Reference) 

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

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

260 ).scalar_one_or_none() 

261 

262 # send notification out 

263 notify( 

264 session, 

265 user_id=reference.to_user_id, 

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

267 data=notification_data_pb2.ReferenceReceiveHostRequest( 

268 host_request_id=host_request.conversation_id, 

269 from_user=user_model_to_pb(user, session, make_user_context(user_id=reference.to_user_id)), 

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

271 ), 

272 ) 

273 

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

275 maybe_send_reference_report_email(session, reference) 

276 

277 return reference_to_pb(reference, context) 

278 

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

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

281 

282 reason = request.reason_didnt_meetup.strip() 

283 

284 if surfed: 

285 host_request.surfer_reason_didnt_meetup = reason 

286 else: 

287 host_request.host_reason_didnt_meetup = reason 

288 

289 return empty_pb2.Empty() 

290 

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

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

293 if request.to_user_id == context.user_id: 

294 return references_pb2.AvailableWriteReferencesRes() 

295 

296 if not session.execute( 

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

298 ).scalar_one_or_none(): 

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

300 

301 can_write_friend_reference = ( 

302 session.execute( 

303 select(Reference) 

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

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

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

307 ).scalar_one_or_none() 

308 ) is None 

309 

310 q1 = ( 

311 select(literal(True), HostRequest) 

312 .outerjoin( 

313 Reference, 

314 and_( 

315 Reference.host_request_id == HostRequest.conversation_id, 

316 Reference.from_user_id == context.user_id, 

317 ), 

318 ) 

319 .where(Reference.id == None) 

320 .where(HostRequest.can_write_reference) 

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

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

323 .where(HostRequest.surfer_reason_didnt_meetup == None) 

324 ) 

325 

326 q2 = ( 

327 select(literal(False), HostRequest) 

328 .outerjoin( 

329 Reference, 

330 and_( 

331 Reference.host_request_id == HostRequest.conversation_id, 

332 Reference.from_user_id == context.user_id, 

333 ), 

334 ) 

335 .where(Reference.id == None) 

336 .where(HostRequest.can_write_reference) 

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

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

339 .where(HostRequest.host_reason_didnt_meetup == None) 

340 ) 

341 

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

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

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

345 

346 return references_pb2.AvailableWriteReferencesRes( 

347 can_write_friend_reference=can_write_friend_reference, 

348 available_write_references=[ 

349 references_pb2.AvailableWriteReferenceType( 

350 host_request_id=host_request.conversation_id, 

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

352 time_expires=Timestamp_from_datetime(host_request.end_time_to_write_reference), 

353 ) 

354 for surfed, host_request in host_request_references 

355 ], 

356 ) 

357 

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

359 q1 = ( 

360 select(literal(True), HostRequest) 

361 .outerjoin( 

362 Reference, 

363 and_( 

364 Reference.host_request_id == HostRequest.conversation_id, 

365 Reference.from_user_id == context.user_id, 

366 ), 

367 ) 

368 .where_users_column_visible(context, HostRequest.host_user_id) 

369 .where(Reference.id == None) 

370 .where(HostRequest.can_write_reference) 

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

372 .where(HostRequest.surfer_reason_didnt_meetup == None) 

373 ) 

374 

375 q2 = ( 

376 select(literal(False), HostRequest) 

377 .outerjoin( 

378 Reference, 

379 and_( 

380 Reference.host_request_id == HostRequest.conversation_id, 

381 Reference.from_user_id == context.user_id, 

382 ), 

383 ) 

384 .where_users_column_visible(context, HostRequest.surfer_user_id) 

385 .where(Reference.id == None) 

386 .where(HostRequest.can_write_reference) 

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

388 .where(HostRequest.host_reason_didnt_meetup == None) 

389 ) 

390 

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

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

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

394 

395 return references_pb2.ListPendingReferencesToWriteRes( 

396 pending_references=[ 

397 references_pb2.AvailableWriteReferenceType( 

398 host_request_id=host_request.conversation_id, 

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

400 time_expires=Timestamp_from_datetime(host_request.end_time_to_write_reference), 

401 ) 

402 for surfed, host_request in host_request_references 

403 ], 

404 )