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
« 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"""
8from types import SimpleNamespace
10import grpc
11from google.protobuf import empty_pb2
12from sqlalchemy.orm import aliased
13from sqlalchemy.sql import and_, func, literal, or_, union_all
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
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}
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}
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 )
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
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()
65 if not host_request:
66 context.abort(grpc.StatusCode.NOT_FOUND, errors.HOST_REQUEST_NOT_FOUND)
68 if not host_request.can_write_reference:
69 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.CANT_WRITE_REFERENCE_FOR_REQUEST)
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)
78 surfed = host_request.surfer_user_id == context.user_id
80 if surfed:
81 my_reason = host_request.surfer_reason_didnt_meetup
82 else:
83 my_reason = host_request.host_reason_didnt_meetup
85 if my_reason != None:
86 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.CANT_WRITE_REFERENCE_INDICATED_DIDNT_MEETUP)
88 return host_request, surfed
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)
95 if request.text.strip() == "":
96 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.REFERENCE_NO_TEXT)
99MAX_PAGINATION_LENGTH = 100
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
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)
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 )
136 if next_reference_id:
137 statement = statement.where(Reference.id <= next_reference_id)
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
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)
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 )
167 statement = statement.order_by(Reference.id.desc()).limit(page_size + 1)
168 references = session.execute(statement).scalars().all()
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 )
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)
179 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
181 check_valid_reference(request, context)
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)
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)
196 reference_text = request.text.strip()
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()
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 )
221 # possibly send out an alert to the mod team if the reference was bad
222 maybe_send_reference_report_email(session, reference)
224 return reference_to_pb(reference, context)
226 def WriteHostRequestReference(self, request, context, session):
227 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
229 check_valid_reference(request, context)
231 host_request, surfed = get_host_req_and_check_can_write_ref(session, context, request.host_request_id)
233 reference_text = request.text.strip()
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 )
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
255 session.add(reference)
256 session.commit()
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()
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 )
276 # possibly send out an alert to the mod team if the reference was bad
277 maybe_send_reference_report_email(session, reference)
279 return reference_to_pb(reference, context)
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)
284 reason = request.reason_didnt_meetup.strip()
286 if surfed:
287 host_request.surfer_reason_didnt_meetup = reason
288 else:
289 host_request.host_reason_didnt_meetup = reason
291 return empty_pb2.Empty()
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()
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)
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
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 )
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 )
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()
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 )
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 )
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 )
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()
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 )