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