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

126 statements  

« prev     ^ index     » next       coverage.py v7.6.10, created at 2025-01-22 06:42 +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 types import SimpleNamespace 

9 

10import grpc 

11from google.protobuf import empty_pb2 

12from sqlalchemy.orm import aliased 

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

14 

15from couchers import errors 

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 

24reftype2sql = { 

25 references_pb2.ReferenceType.REFERENCE_TYPE_FRIEND: ReferenceType.friend, 

26 references_pb2.ReferenceType.REFERENCE_TYPE_SURFED: ReferenceType.surfed, 

27 references_pb2.ReferenceType.REFERENCE_TYPE_HOSTED: ReferenceType.hosted, 

28} 

29 

30reftype2api = { 

31 ReferenceType.friend: references_pb2.ReferenceType.REFERENCE_TYPE_FRIEND, 

32 ReferenceType.surfed: references_pb2.ReferenceType.REFERENCE_TYPE_SURFED, 

33 ReferenceType.hosted: references_pb2.ReferenceType.REFERENCE_TYPE_HOSTED, 

34} 

35 

36 

37def reference_to_pb(reference: Reference, context): 

38 return references_pb2.Reference( 

39 reference_id=reference.id, 

40 from_user_id=reference.from_user_id, 

41 to_user_id=reference.to_user_id, 

42 reference_type=reftype2api[reference.reference_type], 

43 text=reference.text, 

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

45 host_request_id=( 

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

47 ), 

48 ) 

49 

50 

51def get_host_req_and_check_can_write_ref(session, context, host_request_id): 

52 """ 

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

54 

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

56 """ 

57 host_request = session.execute( 

58 select(HostRequest) 

59 .where_users_column_visible(context, HostRequest.surfer_user_id) 

60 .where_users_column_visible(context, HostRequest.host_user_id) 

61 .where(HostRequest.conversation_id == host_request_id) 

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

63 ).scalar_one_or_none() 

64 

65 if not host_request: 

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

67 

68 if not host_request.can_write_reference: 

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

70 

71 if session.execute( 

72 select(Reference) 

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

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

75 ).scalar_one_or_none(): 

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

77 

78 surfed = host_request.surfer_user_id == context.user_id 

79 

80 if surfed: 

81 my_reason = host_request.surfer_reason_didnt_meetup 

82 else: 

83 my_reason = host_request.host_reason_didnt_meetup 

84 

85 if my_reason != None: 

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

87 

88 return host_request, surfed 

89 

90 

91def check_valid_reference(request, context): 

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

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

94 

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

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

97 

98 

99MAX_PAGINATION_LENGTH = 100 

100 

101 

102class References(references_pb2_grpc.ReferencesServicer): 

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

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

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

106 

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

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

109 

110 to_users = aliased(User) 

111 from_users = aliased(User) 

112 statement = select(Reference) 

113 if request.from_user_id: 

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

115 statement = ( 

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

117 .where( 

118 ~to_users.is_banned 

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

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

121 ) 

122 if request.to_user_id: 

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

124 statement = ( 

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

126 .where( 

127 ~from_users.is_banned 

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

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

130 ) 

131 if len(request.reference_type_filter) > 0: 

132 statement = statement.where( 

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

134 ) 

135 

136 if next_reference_id: 

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

138 

139 # Reference visibility logic: 

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

141 # 1. It is a friend reference 

142 # 2. Both references have been written 

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

144 

145 # we get the matching other references through this subquery 

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

147 Reference.reference_type != ReferenceType.friend 

148 ) 

149 if request.from_user_id: 

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

151 if request.to_user_id: 

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

153 

154 sub = sub.subquery() 

155 statement = ( 

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

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

158 .where( 

159 or_( 

160 Reference.reference_type == ReferenceType.friend, 

161 sub.c.sub_id != None, 

162 HostRequest.end_time_to_write_reference < func.now(), 

163 ) 

164 ) 

165 ) 

166 

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

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

169 

170 return references_pb2.ListReferencesRes( 

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

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

173 ) 

174 

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

176 if context.user_id == request.to_user_id: 

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

178 

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

180 

181 check_valid_reference(request, context) 

182 

183 if not session.execute( 

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

185 ).scalar_one_or_none(): 

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

187 

188 if session.execute( 

189 select(Reference) 

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

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

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

193 ).scalar_one_or_none(): 

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

195 

196 reference_text = request.text.strip() 

197 

198 reference = Reference( 

199 from_user_id=context.user_id, 

200 to_user_id=request.to_user_id, 

201 reference_type=ReferenceType.friend, 

202 text=reference_text, 

203 private_text=request.private_text.strip(), 

204 rating=request.rating, 

205 was_appropriate=request.was_appropriate, 

206 ) 

207 session.add(reference) 

208 session.commit() 

209 

210 # send the recipient of the reference a reminder 

211 notify( 

212 session, 

213 user_id=request.to_user_id, 

214 topic_action="reference:receive_friend", 

215 data=notification_data_pb2.ReferenceReceiveFriend( 

216 from_user=user_model_to_pb(user, session, SimpleNamespace(user_id=request.to_user_id)), 

217 text=reference_text, 

218 ), 

219 ) 

220 

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

222 maybe_send_reference_report_email(session, reference) 

223 

224 return reference_to_pb(reference, context) 

225 

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

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

228 

229 check_valid_reference(request, context) 

230 

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

232 

233 reference_text = request.text.strip() 

234 

235 reference = Reference( 

236 from_user_id=context.user_id, 

237 host_request_id=host_request.conversation_id, 

238 text=reference_text, 

239 private_text=request.private_text.strip(), 

240 rating=request.rating, 

241 was_appropriate=request.was_appropriate, 

242 ) 

243 

244 if surfed: 

245 # we requested to surf with someone 

246 reference.reference_type = ReferenceType.surfed 

247 reference.to_user_id = host_request.host_user_id 

248 assert context.user_id == host_request.surfer_user_id 

249 else: 

250 # we hosted someone 

251 reference.reference_type = ReferenceType.hosted 

252 reference.to_user_id = host_request.surfer_user_id 

253 assert context.user_id == host_request.host_user_id 

254 

255 session.add(reference) 

256 session.commit() 

257 

258 other_reference = session.execute( 

259 select(Reference) 

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

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

262 ).scalar_one_or_none() 

263 

264 # send notification out 

265 notify( 

266 session, 

267 user_id=reference.to_user_id, 

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

269 data=notification_data_pb2.ReferenceReceiveHostRequest( 

270 host_request_id=host_request.conversation_id, 

271 from_user=user_model_to_pb(user, session, SimpleNamespace(user_id=reference.to_user_id)), 

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

273 ), 

274 ) 

275 

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

277 maybe_send_reference_report_email(session, reference) 

278 

279 return reference_to_pb(reference, context) 

280 

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

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

283 

284 reason = request.reason_didnt_meetup.strip() 

285 

286 if surfed: 

287 host_request.surfer_reason_didnt_meetup = reason 

288 else: 

289 host_request.host_reason_didnt_meetup = reason 

290 

291 return empty_pb2.Empty() 

292 

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

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

295 if request.to_user_id == context.user_id: 

296 return references_pb2.AvailableWriteReferencesRes() 

297 

298 if not session.execute( 

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

300 ).scalar_one_or_none(): 

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

302 

303 can_write_friend_reference = ( 

304 session.execute( 

305 select(Reference) 

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

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

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

309 ).scalar_one_or_none() 

310 ) is None 

311 

312 q1 = ( 

313 select(literal(True), HostRequest) 

314 .outerjoin( 

315 Reference, 

316 and_( 

317 Reference.host_request_id == HostRequest.conversation_id, 

318 Reference.from_user_id == context.user_id, 

319 ), 

320 ) 

321 .where(Reference.id == None) 

322 .where(HostRequest.can_write_reference) 

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

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

325 .where(HostRequest.surfer_reason_didnt_meetup == None) 

326 ) 

327 

328 q2 = ( 

329 select(literal(False), HostRequest) 

330 .outerjoin( 

331 Reference, 

332 and_( 

333 Reference.host_request_id == HostRequest.conversation_id, 

334 Reference.from_user_id == context.user_id, 

335 ), 

336 ) 

337 .where(Reference.id == None) 

338 .where(HostRequest.can_write_reference) 

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

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

341 .where(HostRequest.host_reason_didnt_meetup == None) 

342 ) 

343 

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

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

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

347 

348 return references_pb2.AvailableWriteReferencesRes( 

349 can_write_friend_reference=can_write_friend_reference, 

350 available_write_references=[ 

351 references_pb2.AvailableWriteReferenceType( 

352 host_request_id=host_request.conversation_id, 

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

354 time_expires=Timestamp_from_datetime(host_request.end_time_to_write_reference), 

355 ) 

356 for surfed, host_request in host_request_references 

357 ], 

358 ) 

359 

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

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_users_column_visible(context, HostRequest.host_user_id) 

371 .where(Reference.id == None) 

372 .where(HostRequest.can_write_reference) 

373 .where(HostRequest.surfer_user_id == context.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_users_column_visible(context, HostRequest.surfer_user_id) 

387 .where(Reference.id == None) 

388 .where(HostRequest.can_write_reference) 

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.ListPendingReferencesToWriteRes( 

398 pending_references=[ 

399 references_pb2.AvailableWriteReferenceType( 

400 host_request_id=host_request.conversation_id, 

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

402 time_expires=Timestamp_from_datetime(host_request.end_time_to_write_reference), 

403 ) 

404 for surfed, host_request in host_request_references 

405 ], 

406 )