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

110 statements  

« prev     ^ index     » next       coverage.py v7.5.0, created at 2024-12-02 20:27 +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 sqlalchemy.orm import aliased 

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

13 

14from couchers import errors 

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

16from couchers.notifications.notify import notify 

17from couchers.servicers.api import user_model_to_pb 

18from couchers.sql import couchers_select as select 

19from couchers.tasks import maybe_send_reference_report_email 

20from couchers.utils import Timestamp_from_datetime 

21from proto import notification_data_pb2, references_pb2, references_pb2_grpc 

22 

23reftype2sql = { 

24 references_pb2.ReferenceType.REFERENCE_TYPE_FRIEND: ReferenceType.friend, 

25 references_pb2.ReferenceType.REFERENCE_TYPE_SURFED: ReferenceType.surfed, 

26 references_pb2.ReferenceType.REFERENCE_TYPE_HOSTED: ReferenceType.hosted, 

27} 

28 

29reftype2api = { 

30 ReferenceType.friend: references_pb2.ReferenceType.REFERENCE_TYPE_FRIEND, 

31 ReferenceType.surfed: references_pb2.ReferenceType.REFERENCE_TYPE_SURFED, 

32 ReferenceType.hosted: references_pb2.ReferenceType.REFERENCE_TYPE_HOSTED, 

33} 

34 

35 

36def reference_to_pb(reference: Reference, context): 

37 return references_pb2.Reference( 

38 reference_id=reference.id, 

39 from_user_id=reference.from_user_id, 

40 to_user_id=reference.to_user_id, 

41 reference_type=reftype2api[reference.reference_type], 

42 text=reference.text, 

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

44 host_request_id=( 

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

46 ), 

47 ) 

48 

49 

50def check_valid_reference(request, context): 

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

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

53 

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

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

56 

57 

58MAX_PAGINATION_LENGTH = 100 

59 

60 

61class References(references_pb2_grpc.ReferencesServicer): 

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

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

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

65 

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

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

68 

69 to_users = aliased(User) 

70 from_users = aliased(User) 

71 statement = select(Reference) 

72 if request.from_user_id: 

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

74 statement = ( 

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

76 .where( 

77 ~to_users.is_banned 

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

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

80 ) 

81 if request.to_user_id: 

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

83 statement = ( 

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

85 .where( 

86 ~from_users.is_banned 

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

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

89 ) 

90 if len(request.reference_type_filter) > 0: 

91 statement = statement.where( 

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

93 ) 

94 

95 if next_reference_id: 

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

97 

98 # Reference visibility logic: 

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

100 # 1. It is a friend reference 

101 # 2. Both references have been written 

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

103 

104 # we get the matching other references through this subquery 

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

106 Reference.reference_type != ReferenceType.friend 

107 ) 

108 if request.from_user_id: 

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

110 if request.to_user_id: 

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

112 

113 sub = sub.subquery() 

114 statement = ( 

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

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

117 .where( 

118 or_( 

119 Reference.reference_type == ReferenceType.friend, 

120 sub.c.sub_id != None, 

121 HostRequest.end_time_to_write_reference < func.now(), 

122 ) 

123 ) 

124 ) 

125 

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

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

128 

129 return references_pb2.ListReferencesRes( 

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

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

132 ) 

133 

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

135 if context.user_id == request.to_user_id: 

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

137 

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

139 

140 check_valid_reference(request, context) 

141 

142 if not session.execute( 

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

144 ).scalar_one_or_none(): 

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

146 

147 if session.execute( 

148 select(Reference) 

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

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

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

152 ).scalar_one_or_none(): 

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

154 

155 reference_text = request.text.strip() 

156 

157 reference = Reference( 

158 from_user_id=context.user_id, 

159 to_user_id=request.to_user_id, 

160 reference_type=ReferenceType.friend, 

161 text=reference_text, 

162 private_text=request.private_text.strip(), 

163 rating=request.rating, 

164 was_appropriate=request.was_appropriate, 

165 ) 

166 session.add(reference) 

167 session.commit() 

168 

169 # send the recipient of the reference a reminder 

170 notify( 

171 session, 

172 user_id=request.to_user_id, 

173 topic_action="reference:receive_friend", 

174 data=notification_data_pb2.ReferenceReceiveFriend( 

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

176 text=reference_text, 

177 ), 

178 ) 

179 

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

181 maybe_send_reference_report_email(session, reference) 

182 

183 return reference_to_pb(reference, context) 

184 

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

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

187 

188 check_valid_reference(request, context) 

189 

190 host_request = session.execute( 

191 select(HostRequest) 

192 .where_users_column_visible(context, HostRequest.surfer_user_id) 

193 .where_users_column_visible(context, HostRequest.host_user_id) 

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

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

196 ).scalar_one_or_none() 

197 

198 if not host_request: 

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

200 

201 if not host_request.can_write_reference: 

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

203 

204 if session.execute( 

205 select(Reference) 

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

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

208 ).scalar_one_or_none(): 

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

210 

211 other_reference = session.execute( 

212 select(Reference) 

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

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

215 ).scalar_one_or_none() 

216 

217 reference_text = request.text.strip() 

218 

219 reference = Reference( 

220 from_user_id=context.user_id, 

221 host_request_id=host_request.conversation_id, 

222 text=reference_text, 

223 private_text=request.private_text.strip(), 

224 rating=request.rating, 

225 was_appropriate=request.was_appropriate, 

226 ) 

227 

228 surfed = host_request.surfer_user_id == context.user_id 

229 

230 if surfed: 

231 # we requested to surf with someone 

232 reference.reference_type = ReferenceType.surfed 

233 reference.to_user_id = host_request.host_user_id 

234 assert context.user_id == host_request.surfer_user_id 

235 else: 

236 # we hosted someone 

237 reference.reference_type = ReferenceType.hosted 

238 reference.to_user_id = host_request.surfer_user_id 

239 assert context.user_id == host_request.host_user_id 

240 

241 session.add(reference) 

242 session.commit() 

243 

244 # send notification out 

245 notify( 

246 session, 

247 user_id=reference.to_user_id, 

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

249 data=notification_data_pb2.ReferenceReceiveHostRequest( 

250 host_request_id=host_request.conversation_id, 

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

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

253 ), 

254 ) 

255 

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

257 maybe_send_reference_report_email(session, reference) 

258 

259 return reference_to_pb(reference, context) 

260 

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

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

263 if request.to_user_id == context.user_id: 

264 return references_pb2.AvailableWriteReferencesRes() 

265 

266 if not session.execute( 

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

268 ).scalar_one_or_none(): 

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

270 

271 can_write_friend_reference = ( 

272 session.execute( 

273 select(Reference) 

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

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

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

277 ).scalar_one_or_none() 

278 ) is None 

279 

280 q1 = ( 

281 select(literal(True), HostRequest) 

282 .outerjoin( 

283 Reference, 

284 and_( 

285 Reference.host_request_id == HostRequest.conversation_id, 

286 Reference.from_user_id == context.user_id, 

287 ), 

288 ) 

289 .where(Reference.id == None) 

290 .where(HostRequest.can_write_reference) 

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

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

293 ) 

294 

295 q2 = ( 

296 select(literal(False), HostRequest) 

297 .outerjoin( 

298 Reference, 

299 and_( 

300 Reference.host_request_id == HostRequest.conversation_id, 

301 Reference.from_user_id == context.user_id, 

302 ), 

303 ) 

304 .where(Reference.id == None) 

305 .where(HostRequest.can_write_reference) 

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

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

308 ) 

309 

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

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

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

313 

314 return references_pb2.AvailableWriteReferencesRes( 

315 can_write_friend_reference=can_write_friend_reference, 

316 available_write_references=[ 

317 references_pb2.AvailableWriteReferenceType( 

318 host_request_id=host_request.conversation_id, 

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

320 time_expires=Timestamp_from_datetime(host_request.end_time_to_write_reference), 

321 ) 

322 for surfed, host_request in host_request_references 

323 ], 

324 ) 

325 

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

327 q1 = ( 

328 select(literal(True), HostRequest) 

329 .outerjoin( 

330 Reference, 

331 and_( 

332 Reference.host_request_id == HostRequest.conversation_id, 

333 Reference.from_user_id == context.user_id, 

334 ), 

335 ) 

336 .where_users_column_visible(context, HostRequest.host_user_id) 

337 .where(Reference.id == None) 

338 .where(HostRequest.can_write_reference) 

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

340 ) 

341 

342 q2 = ( 

343 select(literal(False), HostRequest) 

344 .outerjoin( 

345 Reference, 

346 and_( 

347 Reference.host_request_id == HostRequest.conversation_id, 

348 Reference.from_user_id == context.user_id, 

349 ), 

350 ) 

351 .where_users_column_visible(context, HostRequest.surfer_user_id) 

352 .where(Reference.id == None) 

353 .where(HostRequest.can_write_reference) 

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

355 ) 

356 

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

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

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

360 

361 return references_pb2.ListPendingReferencesToWriteRes( 

362 pending_references=[ 

363 references_pb2.AvailableWriteReferenceType( 

364 host_request_id=host_request.conversation_id, 

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

366 time_expires=Timestamp_from_datetime(host_request.end_time_to_write_reference), 

367 ) 

368 for surfed, host_request in host_request_references 

369 ], 

370 )