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

116 statements  

« prev     ^ index     » next       coverage.py v7.5.0, created at 2024-07-22 17:19 +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.db import session_scope 

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 check_valid_reference(request, context): 

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

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

54 

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

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

57 

58 

59MAX_PAGINATION_LENGTH = 100 

60 

61 

62class References(references_pb2_grpc.ReferencesServicer): 

63 def ListReferences(self, request, context): 

64 with session_scope() as session: 

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

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

67 

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

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

70 

71 to_users = aliased(User) 

72 from_users = aliased(User) 

73 statement = select(Reference) 

74 if request.from_user_id: 

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

76 statement = ( 

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

78 .where( 

79 ~to_users.is_banned 

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

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

82 ) 

83 if request.to_user_id: 

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

85 statement = ( 

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

87 .where( 

88 ~from_users.is_banned 

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

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

91 ) 

92 if len(request.reference_type_filter) > 0: 

93 statement = statement.where( 

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

95 ) 

96 

97 if next_reference_id: 

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

99 

100 # Reference visibility logic: 

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

102 # 1. It is a friend reference 

103 # 2. Both references have been written 

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

105 

106 # we get the matching other references through this subquery 

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

108 Reference.reference_type != ReferenceType.friend 

109 ) 

110 if request.from_user_id: 

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

112 if request.to_user_id: 

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

114 

115 sub = sub.subquery() 

116 statement = ( 

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

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

119 .where( 

120 or_( 

121 Reference.reference_type == ReferenceType.friend, 

122 sub.c.sub_id != None, 

123 HostRequest.end_time_to_write_reference < func.now(), 

124 ) 

125 ) 

126 ) 

127 

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

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

130 

131 return references_pb2.ListReferencesRes( 

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

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

134 ) 

135 

136 def WriteFriendReference(self, request, context): 

137 if context.user_id == request.to_user_id: 

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

139 

140 with session_scope() as session: 

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

142 

143 check_valid_reference(request, context) 

144 

145 if not session.execute( 

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

147 ).scalar_one_or_none(): 

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

149 

150 if session.execute( 

151 select(Reference) 

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

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

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

155 ).scalar_one_or_none(): 

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

157 

158 reference_text = request.text.strip() 

159 

160 reference = Reference( 

161 from_user_id=context.user_id, 

162 to_user_id=request.to_user_id, 

163 reference_type=ReferenceType.friend, 

164 text=reference_text, 

165 private_text=request.private_text.strip(), 

166 rating=request.rating, 

167 was_appropriate=request.was_appropriate, 

168 ) 

169 session.add(reference) 

170 session.commit() 

171 

172 # send the recipient of the reference a reminder 

173 notify( 

174 user_id=request.to_user_id, 

175 topic_action="reference:receive_friend", 

176 data=notification_data_pb2.ReferenceReceiveFriend( 

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

178 text=reference_text, 

179 ), 

180 ) 

181 

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

183 maybe_send_reference_report_email(reference) 

184 

185 return reference_to_pb(reference, context) 

186 

187 def WriteHostRequestReference(self, request, context): 

188 with session_scope() as session: 

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

190 

191 check_valid_reference(request, context) 

192 

193 host_request = session.execute( 

194 select(HostRequest) 

195 .where_users_column_visible(context, HostRequest.surfer_user_id) 

196 .where_users_column_visible(context, HostRequest.host_user_id) 

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

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

199 ).scalar_one_or_none() 

200 

201 if not host_request: 

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

203 

204 if not host_request.can_write_reference: 

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

206 

207 if session.execute( 

208 select(Reference) 

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

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

211 ).scalar_one_or_none(): 

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

213 

214 other_reference = session.execute( 

215 select(Reference) 

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

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

218 ).scalar_one_or_none() 

219 

220 reference_text = request.text.strip() 

221 

222 reference = Reference( 

223 from_user_id=context.user_id, 

224 host_request_id=host_request.conversation_id, 

225 text=reference_text, 

226 private_text=request.private_text.strip(), 

227 rating=request.rating, 

228 was_appropriate=request.was_appropriate, 

229 ) 

230 

231 surfed = host_request.surfer_user_id == context.user_id 

232 

233 if surfed: 

234 # we requested to surf with someone 

235 reference.reference_type = ReferenceType.surfed 

236 reference.to_user_id = host_request.host_user_id 

237 assert context.user_id == host_request.surfer_user_id 

238 else: 

239 # we hosted someone 

240 reference.reference_type = ReferenceType.hosted 

241 reference.to_user_id = host_request.surfer_user_id 

242 assert context.user_id == host_request.host_user_id 

243 

244 session.add(reference) 

245 session.commit() 

246 

247 # send notification out 

248 notify( 

249 user_id=reference.to_user_id, 

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

251 data=notification_data_pb2.ReferenceReceiveHostRequest( 

252 host_request_id=host_request.conversation_id, 

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

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

255 ), 

256 ) 

257 

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

259 maybe_send_reference_report_email(reference) 

260 

261 return reference_to_pb(reference, context) 

262 

263 def AvailableWriteReferences(self, request, context): 

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

265 if request.to_user_id == context.user_id: 

266 return references_pb2.AvailableWriteReferencesRes() 

267 

268 with session_scope() as session: 

269 if not session.execute( 

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

271 ).scalar_one_or_none(): 

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

273 

274 can_write_friend_reference = ( 

275 session.execute( 

276 select(Reference) 

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

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

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

280 ).scalar_one_or_none() 

281 ) is None 

282 

283 q1 = ( 

284 select(literal(True), HostRequest) 

285 .outerjoin( 

286 Reference, 

287 and_( 

288 Reference.host_request_id == HostRequest.conversation_id, 

289 Reference.from_user_id == context.user_id, 

290 ), 

291 ) 

292 .where(Reference.id == None) 

293 .where(HostRequest.can_write_reference) 

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

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

296 ) 

297 

298 q2 = ( 

299 select(literal(False), HostRequest) 

300 .outerjoin( 

301 Reference, 

302 and_( 

303 Reference.host_request_id == HostRequest.conversation_id, 

304 Reference.from_user_id == context.user_id, 

305 ), 

306 ) 

307 .where(Reference.id == None) 

308 .where(HostRequest.can_write_reference) 

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

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

311 ) 

312 

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

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

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

316 

317 return references_pb2.AvailableWriteReferencesRes( 

318 can_write_friend_reference=can_write_friend_reference, 

319 available_write_references=[ 

320 references_pb2.AvailableWriteReferenceType( 

321 host_request_id=host_request.conversation_id, 

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

323 time_expires=Timestamp_from_datetime(host_request.end_time_to_write_reference), 

324 ) 

325 for surfed, host_request in host_request_references 

326 ], 

327 ) 

328 

329 def ListPendingReferencesToWrite(self, request, context): 

330 with session_scope() as session: 

331 q1 = ( 

332 select(literal(True), HostRequest) 

333 .outerjoin( 

334 Reference, 

335 and_( 

336 Reference.host_request_id == HostRequest.conversation_id, 

337 Reference.from_user_id == context.user_id, 

338 ), 

339 ) 

340 .where_users_column_visible(context, HostRequest.host_user_id) 

341 .where(Reference.id == None) 

342 .where(HostRequest.can_write_reference) 

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

344 ) 

345 

346 q2 = ( 

347 select(literal(False), HostRequest) 

348 .outerjoin( 

349 Reference, 

350 and_( 

351 Reference.host_request_id == HostRequest.conversation_id, 

352 Reference.from_user_id == context.user_id, 

353 ), 

354 ) 

355 .where_users_column_visible(context, HostRequest.surfer_user_id) 

356 .where(Reference.id == None) 

357 .where(HostRequest.can_write_reference) 

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

359 ) 

360 

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

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

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

364 

365 return references_pb2.ListPendingReferencesToWriteRes( 

366 pending_references=[ 

367 references_pb2.AvailableWriteReferenceType( 

368 host_request_id=host_request.conversation_id, 

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

370 time_expires=Timestamp_from_datetime(host_request.end_time_to_write_reference), 

371 ) 

372 for surfed, host_request in host_request_references 

373 ], 

374 )