Coverage for app/backend/src/couchers/servicers/references.py: 96%
180 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-23 04:10 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-23 04:10 +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 datetime import datetime
10import grpc
11from google.protobuf import empty_pb2
12from sqlalchemy import select
13from sqlalchemy.orm import Session, aliased
14from sqlalchemy.sql import and_, literal, or_, union_all
16from couchers.context import CouchersContext, make_notification_user_context
17from couchers.db import are_friends
18from couchers.event_log import log_event
19from couchers.helpers.references import where_references_not_hidden_by_reciprocity
20from couchers.materialized_views import LiteUser
21from couchers.models import HostRequest, ModerationObjectType, Reference, ReferenceType, User
22from couchers.models.notifications import NotificationTopicAction
23from couchers.moderation.utils import create_moderation
24from couchers.notifications.notify import notify
25from couchers.proto import notification_data_pb2, references_pb2, references_pb2_grpc
26from couchers.servicers.api import user_model_to_pb
27from couchers.sql import users_visible, where_moderated_content_visible, where_users_column_visible
28from couchers.tasks import maybe_send_reference_report_email
29from couchers.utils import Timestamp_from_datetime, now
31MAX_PAGINATION_LENGTH = 100
33reftype2sql = {
34 references_pb2.ReferenceType.REFERENCE_TYPE_FRIEND: ReferenceType.friend,
35 references_pb2.ReferenceType.REFERENCE_TYPE_SURFED: ReferenceType.surfed,
36 references_pb2.ReferenceType.REFERENCE_TYPE_HOSTED: ReferenceType.hosted,
37}
39reftype2api = {
40 ReferenceType.friend: references_pb2.ReferenceType.REFERENCE_TYPE_FRIEND,
41 ReferenceType.surfed: references_pb2.ReferenceType.REFERENCE_TYPE_SURFED,
42 ReferenceType.hosted: references_pb2.ReferenceType.REFERENCE_TYPE_HOSTED,
43}
46def reference_to_pb(reference: Reference, context: CouchersContext) -> references_pb2.Reference:
47 return references_pb2.Reference(
48 reference_id=reference.id,
49 from_user_id=reference.from_user_id,
50 to_user_id=reference.to_user_id,
51 reference_type=reftype2api[reference.reference_type],
52 text=reference.text,
53 written_time=Timestamp_from_datetime(reference.time.replace(hour=0, minute=0, second=0, microsecond=0)),
54 host_request_id=(
55 reference.host_request_id if context.user_id in [reference.from_user_id, reference.to_user_id] else None
56 ),
57 )
60def get_host_req_and_check_can_write_ref(
61 session: Session, context: CouchersContext, host_request_id: int
62) -> tuple[HostRequest, bool]:
63 """
64 Checks that this can see the given host req and write a ref for it
66 Returns the host req and `surfed`, a boolean of if the user was the surfer or not
67 """
68 query = select(HostRequest)
69 query = where_users_column_visible(query, context, HostRequest.initiator_user_id)
70 query = where_users_column_visible(query, context, HostRequest.recipient_user_id)
71 query = where_moderated_content_visible(query, context, HostRequest, is_list_operation=False)
72 query = query.where(HostRequest.conversation_id == host_request_id)
73 query = query.where(
74 or_(HostRequest.initiator_user_id == context.user_id, HostRequest.recipient_user_id == context.user_id)
75 )
76 host_request = session.execute(query).scalar_one_or_none()
78 if not host_request: 78 ↛ 79line 78 didn't jump to line 79 because the condition on line 78 was never true
79 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "host_request_not_found")
81 if not host_request.can_write_reference:
82 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "cant_write_reference_for_request")
84 if session.execute(
85 select(Reference)
86 .where(Reference.host_request_id == host_request.conversation_id)
87 .where(Reference.from_user_id == context.user_id)
88 ).scalar_one_or_none():
89 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "reference_already_given")
91 surfed = host_request.initiator_user_id == context.user_id
93 if surfed:
94 my_reason = host_request.initiator_reason_didnt_meetup
95 else:
96 my_reason = host_request.recipient_reason_didnt_meetup
98 if my_reason != None:
99 context.abort_with_error_code(
100 grpc.StatusCode.FAILED_PRECONDITION, "cant_write_reference_indicated_didnt_meetup"
101 )
103 return host_request, surfed
106def check_valid_reference(
107 request: references_pb2.WriteFriendReferenceReq | references_pb2.WriteHostRequestReferenceReq,
108 context: CouchersContext,
109) -> None:
110 if request.rating < 0 or request.rating > 1: 110 ↛ 111line 110 didn't jump to line 111 because the condition on line 110 was never true
111 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "reference_invalid_rating")
113 if request.text.strip() == "":
114 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "reference_no_text")
117def get_pending_references_to_write(
118 session: Session, context: CouchersContext
119) -> list[tuple[int, ReferenceType, datetime, LiteUser]]:
120 q1 = (
121 select(literal(True), 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.recipient_user_id)
130 )
131 q1 = where_users_column_visible(q1, context, HostRequest.recipient_user_id)
132 q1 = where_moderated_content_visible(q1, context, HostRequest, is_list_operation=True)
133 q1 = q1.where(Reference.id == None)
134 q1 = q1.where(HostRequest.can_write_reference)
135 q1 = q1.where(HostRequest.initiator_user_id == context.user_id)
136 q1 = q1.where(HostRequest.initiator_reason_didnt_meetup == None)
138 q2 = (
139 select(literal(False), HostRequest, LiteUser)
140 .outerjoin(
141 Reference,
142 and_(
143 Reference.host_request_id == HostRequest.conversation_id,
144 Reference.from_user_id == context.user_id,
145 ),
146 )
147 .join(LiteUser, LiteUser.id == HostRequest.initiator_user_id)
148 )
149 q2 = where_users_column_visible(q2, context, HostRequest.initiator_user_id)
150 q2 = where_moderated_content_visible(q2, context, HostRequest, is_list_operation=True)
151 q2 = q2.where(Reference.id == None)
152 q2 = q2.where(HostRequest.can_write_reference)
153 q2 = q2.where(HostRequest.recipient_user_id == context.user_id)
154 q2 = q2.where(HostRequest.recipient_reason_didnt_meetup == None)
156 union = union_all(q1, q2).order_by(HostRequest.end_time_to_write_reference.asc()).subquery()
157 query = select(union.c[0].label("surfed"), aliased(HostRequest, union), aliased(LiteUser, union))
158 host_request_references = session.execute(query).all()
160 return [
161 (
162 host_request.conversation_id,
163 ReferenceType.surfed if surfed else ReferenceType.hosted,
164 host_request.end_time_to_write_reference,
165 other_user,
166 )
167 for surfed, host_request, other_user in host_request_references
168 ]
171class References(references_pb2_grpc.ReferencesServicer):
172 def ListReferences(
173 self, request: references_pb2.ListReferencesReq, context: CouchersContext, session: Session
174 ) -> references_pb2.ListReferencesRes:
175 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
176 next_reference_id = int(request.page_token) if request.page_token else 0
178 if not request.from_user_id and not request.to_user_id:
179 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "need_to_specify_at_least_one_user")
181 to_users = aliased(User)
182 from_users = aliased(User)
183 statement = where_moderated_content_visible(select(Reference), context, Reference, is_list_operation=True)
184 if request.from_user_id:
185 # join the to_users, because only interested if the recipient is visible
186 statement = (
187 statement.join(to_users, Reference.to_user_id == to_users.id)
188 .where(
189 to_users.banned_at.is_(None)
190 ) # instead of where_users_visible; if user is deleted or blocked, reference still visible
191 .where(or_(to_users.shadowed_at.is_(None), to_users.id == context.user_id))
192 .where(Reference.from_user_id == request.from_user_id)
193 )
194 if request.to_user_id:
195 # join the from_users, because only interested if the writer is visible
196 statement = (
197 statement.join(from_users, Reference.from_user_id == from_users.id)
198 .where(
199 from_users.banned_at.is_(None)
200 ) # instead of where_users_visible; if user is deleted or blocked, reference still visible
201 .where(or_(from_users.shadowed_at.is_(None), from_users.id == context.user_id))
202 .where(Reference.to_user_id == request.to_user_id)
203 )
204 if len(request.reference_type_filter) > 0:
205 statement = statement.where(
206 Reference.reference_type.in_([reftype2sql[t] for t in request.reference_type_filter])
207 )
209 if next_reference_id:
210 statement = statement.where(Reference.id <= next_reference_id)
212 # Reference visibility logic (a reference is visible if any of the following apply):
213 # 1. It is a friend reference
214 # 2. Both references have been written
215 # 3. It has been over 2 weeks since the host request ended
216 # This must stay in sync with the reference count (get_num_references); both use the
217 # shared where_references_not_hidden_by_reciprocity() helper.
218 statement = where_references_not_hidden_by_reciprocity(statement)
220 statement = statement.order_by(Reference.id.desc()).limit(page_size + 1)
221 references = session.execute(statement).scalars().all()
223 return references_pb2.ListReferencesRes(
224 references=[reference_to_pb(reference, context) for reference in references[:page_size]],
225 next_page_token=str(references[-1].id) if len(references) > page_size else None,
226 )
228 def WriteFriendReference(
229 self, request: references_pb2.WriteFriendReferenceReq, context: CouchersContext, session: Session
230 ) -> references_pb2.Reference:
231 if context.user_id == request.to_user_id:
232 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "cant_refer_self")
234 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
236 check_valid_reference(request, context)
238 if not session.execute( 238 ↛ 241line 238 didn't jump to line 241 because the condition on line 238 was never true
239 select(User).where(users_visible(context)).where(User.id == request.to_user_id)
240 ).scalar_one_or_none():
241 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "user_not_found")
243 if not are_friends(session, context, request.to_user_id):
244 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "can_only_refer_friends")
246 if session.execute(
247 select(Reference)
248 .where(Reference.from_user_id == context.user_id)
249 .where(Reference.to_user_id == request.to_user_id)
250 .where(Reference.reference_type == ReferenceType.friend)
251 ).scalar_one_or_none():
252 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "reference_already_given")
254 reference_text = request.text.strip()
256 reference: Reference | None = None
258 def create_object(moderation_state_id: int) -> int:
259 nonlocal reference
260 reference = Reference(
261 from_user_id=context.user_id,
262 to_user_id=request.to_user_id,
263 reference_type=ReferenceType.friend,
264 text=reference_text,
265 private_text=request.private_text.strip(),
266 rating=request.rating,
267 was_appropriate=request.was_appropriate,
268 moderation_state_id=moderation_state_id,
269 )
270 session.add(reference)
271 session.flush()
272 return reference.id
274 create_moderation(
275 session=session,
276 object_type=ModerationObjectType.reference,
277 object_id=create_object,
278 creator_user_id=context.user_id,
279 )
280 assert reference is not None
281 session.commit()
283 # send the recipient of the reference a reminder
284 notify(
285 session,
286 user_id=request.to_user_id,
287 topic_action=NotificationTopicAction.reference__receive_friend,
288 key=str(reference.id),
289 data=notification_data_pb2.ReferenceReceiveFriend(
290 from_user=user_model_to_pb(user, session, make_notification_user_context(user_id=request.to_user_id)),
291 text=reference_text,
292 ),
293 moderation_state_id=reference.moderation_state_id,
294 )
296 # possibly send out an alert to the mod team if the reference was bad
297 maybe_send_reference_report_email(session, reference)
299 log_event(
300 context,
301 session,
302 "reference.friend_written",
303 {
304 "to_user_id": request.to_user_id,
305 "rating": request.rating,
306 "was_appropriate": request.was_appropriate,
307 },
308 )
310 return reference_to_pb(reference, context)
312 def WriteHostRequestReference(
313 self, request: references_pb2.WriteHostRequestReferenceReq, context: CouchersContext, session: Session
314 ) -> references_pb2.Reference:
315 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
317 check_valid_reference(request, context)
319 host_request, surfed = get_host_req_and_check_can_write_ref(session, context, request.host_request_id)
321 reference_text = request.text.strip()
323 if surfed:
324 # we requested to surf with someone
325 reference_type = ReferenceType.surfed
326 to_user_id = host_request.recipient_user_id
327 assert context.user_id == host_request.initiator_user_id
328 else:
329 # we hosted someone
330 reference_type = ReferenceType.hosted
331 to_user_id = host_request.initiator_user_id
332 assert context.user_id == host_request.recipient_user_id
334 reference: Reference | None = None
336 def create_object(moderation_state_id: int) -> int:
337 nonlocal reference
338 reference = Reference(
339 from_user_id=context.user_id,
340 to_user_id=to_user_id,
341 host_request_id=host_request.conversation_id,
342 text=reference_text,
343 private_text=request.private_text.strip(),
344 rating=request.rating,
345 was_appropriate=request.was_appropriate,
346 reference_type=reference_type,
347 moderation_state_id=moderation_state_id,
348 )
349 session.add(reference)
350 session.flush()
351 return reference.id
353 create_moderation(
354 session=session,
355 object_type=ModerationObjectType.reference,
356 object_id=create_object,
357 creator_user_id=context.user_id,
358 )
359 assert reference is not None
360 session.commit()
362 other_reference = session.execute(
363 select(Reference)
364 .where(Reference.host_request_id == host_request.conversation_id)
365 .where(Reference.to_user_id == context.user_id)
366 ).scalar_one_or_none()
368 # send notification out
369 topic_action = (
370 NotificationTopicAction.reference__receive_surfed
371 if surfed
372 else NotificationTopicAction.reference__receive_hosted
373 )
374 notify(
375 session,
376 user_id=reference.to_user_id,
377 topic_action=topic_action,
378 key=str(host_request.conversation_id),
379 data=notification_data_pb2.ReferenceReceiveHostRequest(
380 host_request_id=host_request.conversation_id,
381 from_user=user_model_to_pb(user, session, make_notification_user_context(user_id=reference.to_user_id)),
382 text=reference_text if other_reference is not None else None,
383 ),
384 moderation_state_id=reference.moderation_state_id,
385 )
387 # possibly send out an alert to the mod team if the reference was bad
388 maybe_send_reference_report_email(session, reference)
390 log_event(
391 context,
392 session,
393 "reference.host_request_written",
394 {
395 "to_user_id": to_user_id,
396 "host_request_id": host_request.conversation_id,
397 "reference_type": reference_type.name,
398 "rating": request.rating,
399 "was_appropriate": request.was_appropriate,
400 },
401 )
403 return reference_to_pb(reference, context)
405 def HostRequestIndicateDidntMeetup(
406 self, request: references_pb2.HostRequestIndicateDidntMeetupReq, context: CouchersContext, session: Session
407 ) -> empty_pb2.Empty:
408 host_request, surfed = get_host_req_and_check_can_write_ref(session, context, request.host_request_id)
410 reason = request.reason_didnt_meetup.strip()
412 if surfed: 412 ↛ 413line 412 didn't jump to line 413 because the condition on line 412 was never true
413 host_request.initiator_reason_didnt_meetup = reason
414 else:
415 host_request.recipient_reason_didnt_meetup = reason
417 return empty_pb2.Empty()
419 def AvailableWriteReferences(
420 self, request: references_pb2.AvailableWriteReferencesReq, context: CouchersContext, session: Session
421 ) -> references_pb2.AvailableWriteReferencesRes:
422 # can't write anything for ourselves, but let's return empty so this can be used generically on profile page
423 if request.to_user_id == context.user_id:
424 return references_pb2.AvailableWriteReferencesRes()
426 if not session.execute(
427 select(User).where(users_visible(context)).where(User.id == request.to_user_id)
428 ).scalar_one_or_none():
429 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "user_not_found")
431 can_write_friend_reference = (
432 session.execute(
433 select(Reference)
434 .where(Reference.from_user_id == context.user_id)
435 .where(Reference.to_user_id == request.to_user_id)
436 .where(Reference.reference_type == ReferenceType.friend)
437 ).scalar_one_or_none()
438 ) is None
440 q1 = (
441 select(literal(True), HostRequest)
442 .outerjoin(
443 Reference,
444 and_(
445 Reference.host_request_id == HostRequest.conversation_id,
446 Reference.from_user_id == context.user_id,
447 ),
448 )
449 .where(Reference.id == None)
450 .where(HostRequest.can_write_reference)
451 .where(HostRequest.initiator_user_id == context.user_id)
452 .where(HostRequest.recipient_user_id == request.to_user_id)
453 .where(HostRequest.initiator_reason_didnt_meetup == None)
454 )
456 q2 = (
457 select(literal(False), HostRequest)
458 .outerjoin(
459 Reference,
460 and_(
461 Reference.host_request_id == HostRequest.conversation_id,
462 Reference.from_user_id == context.user_id,
463 ),
464 )
465 .where(Reference.id == None)
466 .where(HostRequest.can_write_reference)
467 .where(HostRequest.initiator_user_id == request.to_user_id)
468 .where(HostRequest.recipient_user_id == context.user_id)
469 .where(HostRequest.recipient_reason_didnt_meetup == None)
470 )
472 union = union_all(q1, q2).order_by(HostRequest.end_time_to_write_reference.asc()).subquery()
473 query = select(union.c[0].label("surfed"), aliased(HostRequest, union))
474 host_request_references = session.execute(query).all()
476 return references_pb2.AvailableWriteReferencesRes(
477 can_write_friend_reference=can_write_friend_reference,
478 available_write_references=[
479 references_pb2.AvailableWriteReferenceType(
480 host_request_id=host_request.conversation_id,
481 reference_type=reftype2api[ReferenceType.surfed if surfed else ReferenceType.hosted],
482 time_expires=Timestamp_from_datetime(host_request.end_time_to_write_reference),
483 )
484 for surfed, host_request in host_request_references
485 ],
486 )
488 def ListPendingReferencesToWrite(
489 self, request: empty_pb2.Empty, context: CouchersContext, session: Session
490 ) -> references_pb2.ListPendingReferencesToWriteRes:
491 return references_pb2.ListPendingReferencesToWriteRes(
492 pending_references=[
493 references_pb2.AvailableWriteReferenceType(
494 host_request_id=host_request_id,
495 reference_type=reftype2api[reference_type],
496 time_expires=Timestamp_from_datetime(end_time_to_write_reference),
497 )
498 for host_request_id, reference_type, end_time_to_write_reference, other_user in get_pending_references_to_write(
499 session, context
500 )
501 ],
502 )
504 def GetHostRequestReferenceStatus(
505 self, request: references_pb2.GetHostRequestReferenceStatusReq, context: CouchersContext, session: Session
506 ) -> references_pb2.GetHostRequestReferenceStatusRes:
507 # Compute has_given (whether current user already wrote a reference for this host request)
508 has_given = (
509 session.execute(
510 select(Reference)
511 .where(Reference.host_request_id == request.host_request_id)
512 .where(Reference.from_user_id == context.user_id)
513 ).scalar_one_or_none()
514 is not None
515 )
517 query = select(HostRequest)
518 query = where_moderated_content_visible(query, context, HostRequest, is_list_operation=False)
519 query = query.where(HostRequest.conversation_id == request.host_request_id)
520 query = query.where(
521 or_(HostRequest.initiator_user_id == context.user_id, HostRequest.recipient_user_id == context.user_id)
522 )
523 host_request = session.execute(query).scalar_one_or_none()
525 can_write = False
526 is_expired = False
527 didnt_stay = False
529 if host_request is not None:
530 # Compute expired from end_time_to_write_reference
531 if host_request.end_time_to_write_reference is not None: 531 ↛ 535line 531 didn't jump to line 535 because the condition on line 531 was always true
532 is_expired = host_request.end_time_to_write_reference < now()
534 # Block only if current user indicated didn't meet up
535 didnt_stay = (
536 (host_request.initiator_reason_didnt_meetup is not None)
537 if host_request.initiator_user_id == context.user_id
538 else (host_request.recipient_reason_didnt_meetup is not None)
539 )
541 # You can write only if: host_request allows it, you didn't already give one, and you didn't indicate didn't meet up
542 can_write = bool(host_request.can_write_reference) and (not has_given) and (not didnt_stay)
544 return references_pb2.GetHostRequestReferenceStatusRes(
545 has_given=has_given,
546 can_write=can_write,
547 is_expired=is_expired,
548 didnt_stay=didnt_stay,
549 )