Coverage for app/backend/src/couchers/servicers/references.py: 96%
178 statements
« prev ^ index » next coverage.py v7.15.3, created at 2026-08-04 22:32 +0000
« prev ^ index » next coverage.py v7.15.3, created at 2026-08-04 22:32 +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_reference_user_visible, 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 statement = where_moderated_content_visible(select(Reference), context, Reference, is_list_operation=True)
182 if request.from_user_id:
183 # only interested if the recipient is visible
184 statement = where_reference_user_visible(statement, context, Reference.to_user_id).where(
185 Reference.from_user_id == request.from_user_id
186 )
187 if request.to_user_id:
188 # only interested if the writer is visible
189 statement = where_reference_user_visible(statement, context, Reference.from_user_id).where(
190 Reference.to_user_id == request.to_user_id
191 )
192 if len(request.reference_type_filter) > 0:
193 statement = statement.where(
194 Reference.reference_type.in_([reftype2sql[t] for t in request.reference_type_filter])
195 )
197 if next_reference_id:
198 statement = statement.where(Reference.id <= next_reference_id)
200 # Reference visibility logic (a reference is visible if any of the following apply):
201 # 1. It is a friend reference
202 # 2. Both references have been written
203 # 3. It has been over 2 weeks since the host request ended
204 # This must stay in sync with the reference count (get_num_references); both use the
205 # shared where_references_not_hidden_by_reciprocity() helper.
206 statement = where_references_not_hidden_by_reciprocity(statement)
208 statement = statement.order_by(Reference.id.desc()).limit(page_size + 1)
209 references = session.execute(statement).scalars().all()
211 return references_pb2.ListReferencesRes(
212 references=[reference_to_pb(reference, context) for reference in references[:page_size]],
213 next_page_token=str(references[-1].id) if len(references) > page_size else None,
214 )
216 def WriteFriendReference(
217 self, request: references_pb2.WriteFriendReferenceReq, context: CouchersContext, session: Session
218 ) -> references_pb2.Reference:
219 if context.user_id == request.to_user_id:
220 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "cant_refer_self")
222 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
224 check_valid_reference(request, context)
226 if not session.execute( 226 ↛ 229line 226 didn't jump to line 229 because the condition on line 226 was never true
227 select(User).where(users_visible(context)).where(User.id == request.to_user_id)
228 ).scalar_one_or_none():
229 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "user_not_found")
231 if not are_friends(session, context, request.to_user_id):
232 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "can_only_refer_friends")
234 if session.execute(
235 select(Reference)
236 .where(Reference.from_user_id == context.user_id)
237 .where(Reference.to_user_id == request.to_user_id)
238 .where(Reference.reference_type == ReferenceType.friend)
239 ).scalar_one_or_none():
240 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "reference_already_given")
242 reference_text = request.text.strip()
244 reference: Reference | None = None
246 def create_object(moderation_state_id: int) -> int:
247 nonlocal reference
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 moderation_state_id=moderation_state_id,
257 )
258 session.add(reference)
259 session.flush()
260 return reference.id
262 create_moderation(
263 session=session,
264 object_type=ModerationObjectType.reference,
265 object_id=create_object,
266 creator_user_id=context.user_id,
267 )
268 assert reference is not None
269 session.commit()
271 # send the recipient of the reference a reminder
272 notify(
273 session,
274 user_id=request.to_user_id,
275 topic_action=NotificationTopicAction.reference__receive_friend,
276 key=str(reference.id),
277 data=notification_data_pb2.ReferenceReceiveFriend(
278 from_user=user_model_to_pb(user, session, make_notification_user_context(user_id=request.to_user_id)),
279 text=reference_text,
280 ),
281 moderation_state_id=reference.moderation_state_id,
282 )
284 # possibly send out an alert to the mod team if the reference was bad
285 maybe_send_reference_report_email(session, reference)
287 log_event(
288 context,
289 session,
290 "reference.friend_written",
291 {
292 "to_user_id": request.to_user_id,
293 "rating": request.rating,
294 "was_appropriate": request.was_appropriate,
295 },
296 )
298 return reference_to_pb(reference, context)
300 def WriteHostRequestReference(
301 self, request: references_pb2.WriteHostRequestReferenceReq, context: CouchersContext, session: Session
302 ) -> references_pb2.Reference:
303 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
305 check_valid_reference(request, context)
307 host_request, surfed = get_host_req_and_check_can_write_ref(session, context, request.host_request_id)
309 reference_text = request.text.strip()
311 if surfed:
312 # we requested to surf with someone
313 reference_type = ReferenceType.surfed
314 to_user_id = host_request.recipient_user_id
315 assert context.user_id == host_request.initiator_user_id
316 else:
317 # we hosted someone
318 reference_type = ReferenceType.hosted
319 to_user_id = host_request.initiator_user_id
320 assert context.user_id == host_request.recipient_user_id
322 reference: Reference | None = None
324 def create_object(moderation_state_id: int) -> int:
325 nonlocal reference
326 reference = Reference(
327 from_user_id=context.user_id,
328 to_user_id=to_user_id,
329 host_request_id=host_request.conversation_id,
330 text=reference_text,
331 private_text=request.private_text.strip(),
332 rating=request.rating,
333 was_appropriate=request.was_appropriate,
334 reference_type=reference_type,
335 moderation_state_id=moderation_state_id,
336 )
337 session.add(reference)
338 session.flush()
339 return reference.id
341 create_moderation(
342 session=session,
343 object_type=ModerationObjectType.reference,
344 object_id=create_object,
345 creator_user_id=context.user_id,
346 )
347 assert reference is not None
348 session.commit()
350 other_reference = session.execute(
351 select(Reference)
352 .where(Reference.host_request_id == host_request.conversation_id)
353 .where(Reference.to_user_id == context.user_id)
354 ).scalar_one_or_none()
356 # send notification out
357 topic_action = (
358 NotificationTopicAction.reference__receive_surfed
359 if surfed
360 else NotificationTopicAction.reference__receive_hosted
361 )
362 notify(
363 session,
364 user_id=reference.to_user_id,
365 topic_action=topic_action,
366 key=str(host_request.conversation_id),
367 data=notification_data_pb2.ReferenceReceiveHostRequest(
368 host_request_id=host_request.conversation_id,
369 from_user=user_model_to_pb(user, session, make_notification_user_context(user_id=reference.to_user_id)),
370 text=reference_text if other_reference is not None else None,
371 ),
372 moderation_state_id=reference.moderation_state_id,
373 )
375 # possibly send out an alert to the mod team if the reference was bad
376 maybe_send_reference_report_email(session, reference)
378 log_event(
379 context,
380 session,
381 "reference.host_request_written",
382 {
383 "to_user_id": to_user_id,
384 "host_request_id": host_request.conversation_id,
385 "reference_type": reference_type.name,
386 "rating": request.rating,
387 "was_appropriate": request.was_appropriate,
388 },
389 )
391 return reference_to_pb(reference, context)
393 def HostRequestIndicateDidntMeetup(
394 self, request: references_pb2.HostRequestIndicateDidntMeetupReq, context: CouchersContext, session: Session
395 ) -> empty_pb2.Empty:
396 host_request, surfed = get_host_req_and_check_can_write_ref(session, context, request.host_request_id)
398 reason = request.reason_didnt_meetup.strip()
400 if surfed: 400 ↛ 401line 400 didn't jump to line 401 because the condition on line 400 was never true
401 host_request.initiator_reason_didnt_meetup = reason
402 else:
403 host_request.recipient_reason_didnt_meetup = reason
405 return empty_pb2.Empty()
407 def AvailableWriteReferences(
408 self, request: references_pb2.AvailableWriteReferencesReq, context: CouchersContext, session: Session
409 ) -> references_pb2.AvailableWriteReferencesRes:
410 # can't write anything for ourselves, but let's return empty so this can be used generically on profile page
411 if request.to_user_id == context.user_id:
412 return references_pb2.AvailableWriteReferencesRes()
414 if not session.execute(
415 select(User).where(users_visible(context)).where(User.id == request.to_user_id)
416 ).scalar_one_or_none():
417 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "user_not_found")
419 can_write_friend_reference = (
420 session.execute(
421 select(Reference)
422 .where(Reference.from_user_id == context.user_id)
423 .where(Reference.to_user_id == request.to_user_id)
424 .where(Reference.reference_type == ReferenceType.friend)
425 ).scalar_one_or_none()
426 ) is None
428 q1 = (
429 select(literal(True), HostRequest)
430 .outerjoin(
431 Reference,
432 and_(
433 Reference.host_request_id == HostRequest.conversation_id,
434 Reference.from_user_id == context.user_id,
435 ),
436 )
437 .where(Reference.id == None)
438 .where(HostRequest.can_write_reference)
439 .where(HostRequest.initiator_user_id == context.user_id)
440 .where(HostRequest.recipient_user_id == request.to_user_id)
441 .where(HostRequest.initiator_reason_didnt_meetup == None)
442 )
444 q2 = (
445 select(literal(False), HostRequest)
446 .outerjoin(
447 Reference,
448 and_(
449 Reference.host_request_id == HostRequest.conversation_id,
450 Reference.from_user_id == context.user_id,
451 ),
452 )
453 .where(Reference.id == None)
454 .where(HostRequest.can_write_reference)
455 .where(HostRequest.initiator_user_id == request.to_user_id)
456 .where(HostRequest.recipient_user_id == context.user_id)
457 .where(HostRequest.recipient_reason_didnt_meetup == None)
458 )
460 union = union_all(q1, q2).order_by(HostRequest.end_time_to_write_reference.asc()).subquery()
461 query = select(union.c[0].label("surfed"), aliased(HostRequest, union))
462 host_request_references = session.execute(query).all()
464 return references_pb2.AvailableWriteReferencesRes(
465 can_write_friend_reference=can_write_friend_reference,
466 available_write_references=[
467 references_pb2.AvailableWriteReferenceType(
468 host_request_id=host_request.conversation_id,
469 reference_type=reftype2api[ReferenceType.surfed if surfed else ReferenceType.hosted],
470 time_expires=Timestamp_from_datetime(host_request.end_time_to_write_reference),
471 )
472 for surfed, host_request in host_request_references
473 ],
474 )
476 def ListPendingReferencesToWrite(
477 self, request: empty_pb2.Empty, context: CouchersContext, session: Session
478 ) -> references_pb2.ListPendingReferencesToWriteRes:
479 return references_pb2.ListPendingReferencesToWriteRes(
480 pending_references=[
481 references_pb2.AvailableWriteReferenceType(
482 host_request_id=host_request_id,
483 reference_type=reftype2api[reference_type],
484 time_expires=Timestamp_from_datetime(end_time_to_write_reference),
485 )
486 for host_request_id, reference_type, end_time_to_write_reference, other_user in get_pending_references_to_write(
487 session, context
488 )
489 ],
490 )
492 def GetHostRequestReferenceStatus(
493 self, request: references_pb2.GetHostRequestReferenceStatusReq, context: CouchersContext, session: Session
494 ) -> references_pb2.GetHostRequestReferenceStatusRes:
495 # Compute has_given (whether current user already wrote a reference for this host request)
496 has_given = (
497 session.execute(
498 select(Reference)
499 .where(Reference.host_request_id == request.host_request_id)
500 .where(Reference.from_user_id == context.user_id)
501 ).scalar_one_or_none()
502 is not None
503 )
505 query = select(HostRequest)
506 query = where_moderated_content_visible(query, context, HostRequest, is_list_operation=False)
507 query = query.where(HostRequest.conversation_id == request.host_request_id)
508 query = query.where(
509 or_(HostRequest.initiator_user_id == context.user_id, HostRequest.recipient_user_id == context.user_id)
510 )
511 host_request = session.execute(query).scalar_one_or_none()
513 can_write = False
514 is_expired = False
515 didnt_stay = False
517 if host_request is not None:
518 # Compute expired from end_time_to_write_reference
519 if host_request.end_time_to_write_reference is not None: 519 ↛ 523line 519 didn't jump to line 523 because the condition on line 519 was always true
520 is_expired = host_request.end_time_to_write_reference < now()
522 # Block only if current user indicated didn't meet up
523 didnt_stay = (
524 (host_request.initiator_reason_didnt_meetup is not None)
525 if host_request.initiator_user_id == context.user_id
526 else (host_request.recipient_reason_didnt_meetup is not None)
527 )
529 # You can write only if: host_request allows it, you didn't already give one, and you didn't indicate didn't meet up
530 can_write = bool(host_request.can_write_reference) and (not has_given) and (not didnt_stay)
532 return references_pb2.GetHostRequestReferenceStatusRes(
533 has_given=has_given,
534 can_write=can_write,
535 is_expired=is_expired,
536 didnt_stay=didnt_stay,
537 )