Coverage for src/couchers/servicers/references.py: 97%
140 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-29 16:55 +0000
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-29 16:55 +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"""
8import grpc
9from google.protobuf import empty_pb2
10from sqlalchemy.orm import aliased
11from sqlalchemy.sql import and_, func, literal, or_, union_all
13from couchers.context import make_background_user_context
14from couchers.materialized_views import LiteUser
15from couchers.models import HostRequest, Reference, ReferenceType, User
16from couchers.notifications.notify import notify
17from couchers.proto import notification_data_pb2, references_pb2, references_pb2_grpc
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, now
23MAX_PAGINATION_LENGTH = 100
25reftype2sql = {
26 references_pb2.ReferenceType.REFERENCE_TYPE_FRIEND: ReferenceType.friend,
27 references_pb2.ReferenceType.REFERENCE_TYPE_SURFED: ReferenceType.surfed,
28 references_pb2.ReferenceType.REFERENCE_TYPE_HOSTED: ReferenceType.hosted,
29}
31reftype2api = {
32 ReferenceType.friend: references_pb2.ReferenceType.REFERENCE_TYPE_FRIEND,
33 ReferenceType.surfed: references_pb2.ReferenceType.REFERENCE_TYPE_SURFED,
34 ReferenceType.hosted: references_pb2.ReferenceType.REFERENCE_TYPE_HOSTED,
35}
38def reference_to_pb(reference: Reference, context):
39 return references_pb2.Reference(
40 reference_id=reference.id,
41 from_user_id=reference.from_user_id,
42 to_user_id=reference.to_user_id,
43 reference_type=reftype2api[reference.reference_type],
44 text=reference.text,
45 written_time=Timestamp_from_datetime(reference.time.replace(hour=0, minute=0, second=0, microsecond=0)),
46 host_request_id=(
47 reference.host_request_id if context.user_id in [reference.from_user_id, reference.to_user_id] else None
48 ),
49 )
52def get_host_req_and_check_can_write_ref(session, context, host_request_id):
53 """
54 Checks that this can see the given host req and write a ref for it
56 Returns the host req and `surfed`, a boolean of if the user was the surfer or not
57 """
58 host_request = session.execute(
59 select(HostRequest)
60 .where_users_column_visible(context, HostRequest.surfer_user_id)
61 .where_users_column_visible(context, HostRequest.host_user_id)
62 .where(HostRequest.conversation_id == host_request_id)
63 .where(or_(HostRequest.surfer_user_id == context.user_id, HostRequest.host_user_id == context.user_id))
64 ).scalar_one_or_none()
66 if not host_request:
67 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "host_request_not_found")
69 if not host_request.can_write_reference:
70 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "cant_write_reference_for_request")
72 if session.execute(
73 select(Reference)
74 .where(Reference.host_request_id == host_request.conversation_id)
75 .where(Reference.from_user_id == context.user_id)
76 ).scalar_one_or_none():
77 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "reference_already_given")
79 surfed = host_request.surfer_user_id == context.user_id
81 if surfed:
82 my_reason = host_request.surfer_reason_didnt_meetup
83 else:
84 my_reason = host_request.host_reason_didnt_meetup
86 if my_reason != None:
87 context.abort_with_error_code(
88 grpc.StatusCode.FAILED_PRECONDITION, "cant_write_reference_indicated_didnt_meetup"
89 )
91 return host_request, surfed
94def check_valid_reference(request, context):
95 if request.rating < 0 or request.rating > 1:
96 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "reference_invalid_rating")
98 if request.text.strip() == "":
99 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "reference_no_text")
102def get_pending_references_to_write(session, context):
103 q1 = (
104 select(literal(True), HostRequest, LiteUser)
105 .outerjoin(
106 Reference,
107 and_(
108 Reference.host_request_id == HostRequest.conversation_id,
109 Reference.from_user_id == context.user_id,
110 ),
111 )
112 .join(LiteUser, LiteUser.id == HostRequest.host_user_id)
113 .where_users_column_visible(context, HostRequest.host_user_id)
114 .where(Reference.id == None)
115 .where(HostRequest.can_write_reference)
116 .where(HostRequest.surfer_user_id == context.user_id)
117 .where(HostRequest.surfer_reason_didnt_meetup == None)
118 )
120 q2 = (
121 select(literal(False), HostRequest, LiteUser)
122 .outerjoin(
123 Reference,
124 and_(
125 Reference.host_request_id == HostRequest.conversation_id,
126 Reference.from_user_id == context.user_id,
127 ),
128 )
129 .join(LiteUser, LiteUser.id == HostRequest.surfer_user_id)
130 .where_users_column_visible(context, HostRequest.surfer_user_id)
131 .where(Reference.id == None)
132 .where(HostRequest.can_write_reference)
133 .where(HostRequest.host_user_id == context.user_id)
134 .where(HostRequest.host_reason_didnt_meetup == None)
135 )
137 union = union_all(q1, q2).order_by(HostRequest.end_time_to_write_reference.asc()).subquery()
138 union = select(union.c[0].label("surfed"), aliased(HostRequest, union), aliased(LiteUser, union))
139 host_request_references = session.execute(union).all()
141 return [
142 (
143 host_request.conversation_id,
144 ReferenceType.surfed if surfed else ReferenceType.hosted,
145 host_request.end_time_to_write_reference,
146 other_user,
147 )
148 for surfed, host_request, other_user in host_request_references
149 ]
152class References(references_pb2_grpc.ReferencesServicer):
153 def ListReferences(self, request, context, session):
154 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
155 next_reference_id = int(request.page_token) if request.page_token else 0
157 if not request.from_user_id and not request.to_user_id:
158 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "need_to_specify_at_least_one_user")
160 to_users = aliased(User)
161 from_users = aliased(User)
162 statement = select(Reference).where(Reference.is_deleted == False)
163 if request.from_user_id:
164 # join the to_users, because only interested if the recipient is visible
165 statement = (
166 statement.join(to_users, Reference.to_user_id == to_users.id)
167 .where(
168 ~to_users.is_banned
169 ) # instead of where_users_visible; if user is deleted or blocked, reference still visible
170 .where(Reference.from_user_id == request.from_user_id)
171 )
172 if request.to_user_id:
173 # join the from_users, because only interested if the writer is visible
174 statement = (
175 statement.join(from_users, Reference.from_user_id == from_users.id)
176 .where(
177 ~from_users.is_banned
178 ) # instead of where_users_visible; if user is deleted or blocked, reference still visible
179 .where(Reference.to_user_id == request.to_user_id)
180 )
181 if len(request.reference_type_filter) > 0:
182 statement = statement.where(
183 Reference.reference_type.in_([reftype2sql[t] for t in request.reference_type_filter])
184 )
186 if next_reference_id:
187 statement = statement.where(Reference.id <= next_reference_id)
189 # Reference visibility logic:
190 # A reference is visible if any of the following apply:
191 # 1. It is a friend reference
192 # 2. Both references have been written
193 # 3. It has been over 2 weeks since the host request ended
195 # we get the matching other references through this subquery
196 sub = select(Reference.id.label("sub_id"), Reference.host_request_id).where(
197 Reference.reference_type != ReferenceType.friend
198 )
199 if request.from_user_id:
200 sub = sub.where(Reference.to_user_id == request.from_user_id)
201 if request.to_user_id:
202 sub = sub.where(Reference.from_user_id == request.to_user_id)
204 sub = sub.subquery()
205 statement = (
206 statement.outerjoin(sub, sub.c.host_request_id == Reference.host_request_id)
207 .outerjoin(HostRequest, HostRequest.conversation_id == Reference.host_request_id)
208 .where(
209 or_(
210 Reference.reference_type == ReferenceType.friend,
211 sub.c.sub_id != None,
212 HostRequest.end_time_to_write_reference < func.now(),
213 )
214 )
215 )
217 statement = statement.order_by(Reference.id.desc()).limit(page_size + 1)
218 references = session.execute(statement).scalars().all()
220 return references_pb2.ListReferencesRes(
221 references=[reference_to_pb(reference, context) for reference in references[:page_size]],
222 next_page_token=str(references[-1].id) if len(references) > page_size else None,
223 )
225 def WriteFriendReference(self, request, context, session):
226 if context.user_id == request.to_user_id:
227 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "cant_refer_self")
229 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
231 check_valid_reference(request, context)
233 if not session.execute(
234 select(User).where_users_visible(context).where(User.id == request.to_user_id)
235 ).scalar_one_or_none():
236 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "user_not_found")
238 if session.execute(
239 select(Reference)
240 .where(Reference.from_user_id == context.user_id)
241 .where(Reference.to_user_id == request.to_user_id)
242 .where(Reference.reference_type == ReferenceType.friend)
243 ).scalar_one_or_none():
244 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "reference_already_given")
246 reference_text = request.text.strip()
248 reference = Reference(
249 from_user_id=context.user_id,
250 to_user_id=request.to_user_id,
251 reference_type=ReferenceType.friend,
252 text=reference_text,
253 private_text=request.private_text.strip(),
254 rating=request.rating,
255 was_appropriate=request.was_appropriate,
256 )
257 session.add(reference)
258 session.commit()
260 # send the recipient of the reference a reminder
261 notify(
262 session,
263 user_id=request.to_user_id,
264 topic_action="reference:receive_friend",
265 data=notification_data_pb2.ReferenceReceiveFriend(
266 from_user=user_model_to_pb(user, session, make_background_user_context(user_id=request.to_user_id)),
267 text=reference_text,
268 ),
269 )
271 # possibly send out an alert to the mod team if the reference was bad
272 maybe_send_reference_report_email(session, reference)
274 return reference_to_pb(reference, context)
276 def WriteHostRequestReference(self, request, context, session):
277 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
279 check_valid_reference(request, context)
281 host_request, surfed = get_host_req_and_check_can_write_ref(session, context, request.host_request_id)
283 reference_text = request.text.strip()
285 reference = Reference(
286 from_user_id=context.user_id,
287 host_request_id=host_request.conversation_id,
288 text=reference_text,
289 private_text=request.private_text.strip(),
290 rating=request.rating,
291 was_appropriate=request.was_appropriate,
292 )
294 if surfed:
295 # we requested to surf with someone
296 reference.reference_type = ReferenceType.surfed
297 reference.to_user_id = host_request.host_user_id
298 assert context.user_id == host_request.surfer_user_id
299 else:
300 # we hosted someone
301 reference.reference_type = ReferenceType.hosted
302 reference.to_user_id = host_request.surfer_user_id
303 assert context.user_id == host_request.host_user_id
305 session.add(reference)
306 session.commit()
308 other_reference = session.execute(
309 select(Reference)
310 .where(Reference.host_request_id == host_request.conversation_id)
311 .where(Reference.to_user_id == context.user_id)
312 ).scalar_one_or_none()
314 # send notification out
315 notify(
316 session,
317 user_id=reference.to_user_id,
318 topic_action="reference:receive_surfed" if surfed else "reference:receive_hosted",
319 data=notification_data_pb2.ReferenceReceiveHostRequest(
320 host_request_id=host_request.conversation_id,
321 from_user=user_model_to_pb(user, session, make_background_user_context(user_id=reference.to_user_id)),
322 text=reference_text if other_reference is not None else None,
323 ),
324 )
326 # possibly send out an alert to the mod team if the reference was bad
327 maybe_send_reference_report_email(session, reference)
329 return reference_to_pb(reference, context)
331 def HostRequestIndicateDidntMeetup(self, request, context, session):
332 host_request, surfed = get_host_req_and_check_can_write_ref(session, context, request.host_request_id)
334 reason = request.reason_didnt_meetup.strip()
336 if surfed:
337 host_request.surfer_reason_didnt_meetup = reason
338 else:
339 host_request.host_reason_didnt_meetup = reason
341 return empty_pb2.Empty()
343 def AvailableWriteReferences(self, request, context, session):
344 # can't write anything for ourselves, but let's return empty so this can be used generically on profile page
345 if request.to_user_id == context.user_id:
346 return references_pb2.AvailableWriteReferencesRes()
348 if not session.execute(
349 select(User).where_users_visible(context).where(User.id == request.to_user_id)
350 ).scalar_one_or_none():
351 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "user_not_found")
353 can_write_friend_reference = (
354 session.execute(
355 select(Reference)
356 .where(Reference.from_user_id == context.user_id)
357 .where(Reference.to_user_id == request.to_user_id)
358 .where(Reference.reference_type == ReferenceType.friend)
359 ).scalar_one_or_none()
360 ) is None
362 q1 = (
363 select(literal(True), HostRequest)
364 .outerjoin(
365 Reference,
366 and_(
367 Reference.host_request_id == HostRequest.conversation_id,
368 Reference.from_user_id == context.user_id,
369 ),
370 )
371 .where(Reference.id == None)
372 .where(HostRequest.can_write_reference)
373 .where(HostRequest.surfer_user_id == context.user_id)
374 .where(HostRequest.host_user_id == request.to_user_id)
375 .where(HostRequest.surfer_reason_didnt_meetup == None)
376 )
378 q2 = (
379 select(literal(False), HostRequest)
380 .outerjoin(
381 Reference,
382 and_(
383 Reference.host_request_id == HostRequest.conversation_id,
384 Reference.from_user_id == context.user_id,
385 ),
386 )
387 .where(Reference.id == None)
388 .where(HostRequest.can_write_reference)
389 .where(HostRequest.surfer_user_id == request.to_user_id)
390 .where(HostRequest.host_user_id == context.user_id)
391 .where(HostRequest.host_reason_didnt_meetup == None)
392 )
394 union = union_all(q1, q2).order_by(HostRequest.end_time_to_write_reference.asc()).subquery()
395 union = select(union.c[0].label("surfed"), aliased(HostRequest, union))
396 host_request_references = session.execute(union).all()
398 return references_pb2.AvailableWriteReferencesRes(
399 can_write_friend_reference=can_write_friend_reference,
400 available_write_references=[
401 references_pb2.AvailableWriteReferenceType(
402 host_request_id=host_request.conversation_id,
403 reference_type=reftype2api[ReferenceType.surfed if surfed else ReferenceType.hosted],
404 time_expires=Timestamp_from_datetime(host_request.end_time_to_write_reference),
405 )
406 for surfed, host_request in host_request_references
407 ],
408 )
410 def ListPendingReferencesToWrite(self, request, context, session):
411 return references_pb2.ListPendingReferencesToWriteRes(
412 pending_references=[
413 references_pb2.AvailableWriteReferenceType(
414 host_request_id=host_request_id,
415 reference_type=reftype2api[reference_type],
416 time_expires=Timestamp_from_datetime(end_time_to_write_reference),
417 )
418 for host_request_id, reference_type, end_time_to_write_reference, other_user in get_pending_references_to_write(
419 session, context
420 )
421 ],
422 )
424 def GetHostRequestReferenceStatus(self, request, context, session):
425 # Compute has_given (whether current user already wrote a reference for this host request)
426 has_given = (
427 session.execute(
428 select(Reference)
429 .where(Reference.host_request_id == request.host_request_id)
430 .where(Reference.from_user_id == context.user_id)
431 ).scalar_one_or_none()
432 is not None
433 )
435 host_request = session.execute(
436 select(HostRequest)
437 .where(HostRequest.conversation_id == request.host_request_id)
438 .where(or_(HostRequest.surfer_user_id == context.user_id, HostRequest.host_user_id == context.user_id))
439 ).scalar_one_or_none()
441 can_write = False
442 is_expired = False
443 didnt_stay = False
445 if host_request is not None:
446 # Compute expired from end_time_to_write_reference
447 if host_request.end_time_to_write_reference is not None:
448 is_expired = host_request.end_time_to_write_reference < now()
450 # Block only if current user indicated didn't meet up
451 didnt_stay = (
452 (host_request.surfer_reason_didnt_meetup is not None)
453 if host_request.surfer_user_id == context.user_id
454 else (host_request.host_reason_didnt_meetup is not None)
455 )
457 # You can write only if: host_request allows it, you didn't already give one, and you didn't indicate didn't meet up
458 can_write = bool(host_request.can_write_reference) and (not has_given) and (not didnt_stay)
460 return references_pb2.GetHostRequestReferenceStatusRes(
461 has_given=has_given,
462 can_write=can_write,
463 is_expired=is_expired,
464 didnt_stay=didnt_stay,
465 )