Coverage for app/backend/src/couchers/servicers/references.py: 96%
178 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-19 15:47 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-19 15:47 +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_, 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.surfer_user_id == context.user_id
93 if host_request.initiator_user_id == context.user_id:
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 # the two halves split on the conversation role, since that's the axis the didnt_meetup columns live on
121 surfed_col = (HostRequest.surfer_user_id == context.user_id).label("surfed")
123 q1 = (
124 select(surfed_col, 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.recipient_user_id)
133 )
134 q1 = where_users_column_visible(q1, context, HostRequest.recipient_user_id)
135 q1 = where_moderated_content_visible(q1, context, HostRequest, is_list_operation=True)
136 q1 = q1.where(Reference.id == None)
137 q1 = q1.where(HostRequest.can_write_reference)
138 q1 = q1.where(HostRequest.initiator_user_id == context.user_id)
139 q1 = q1.where(HostRequest.initiator_reason_didnt_meetup == None)
141 q2 = (
142 select(surfed_col, HostRequest, LiteUser)
143 .outerjoin(
144 Reference,
145 and_(
146 Reference.host_request_id == HostRequest.conversation_id,
147 Reference.from_user_id == context.user_id,
148 ),
149 )
150 .join(LiteUser, LiteUser.id == HostRequest.initiator_user_id)
151 )
152 q2 = where_users_column_visible(q2, context, HostRequest.initiator_user_id)
153 q2 = where_moderated_content_visible(q2, context, HostRequest, is_list_operation=True)
154 q2 = q2.where(Reference.id == None)
155 q2 = q2.where(HostRequest.can_write_reference)
156 q2 = q2.where(HostRequest.recipient_user_id == context.user_id)
157 q2 = q2.where(HostRequest.recipient_reason_didnt_meetup == None)
159 union = union_all(q1, q2).order_by(HostRequest.end_time_to_write_reference.asc()).subquery()
160 query = select(union.c[0].label("surfed"), aliased(HostRequest, union), aliased(LiteUser, union))
161 host_request_references = session.execute(query).all()
163 return [
164 (
165 host_request.conversation_id,
166 ReferenceType.surfed if surfed else ReferenceType.hosted,
167 host_request.end_time_to_write_reference,
168 other_user,
169 )
170 for surfed, host_request, other_user in host_request_references
171 ]
174class References(references_pb2_grpc.ReferencesServicer):
175 def ListReferences(
176 self, request: references_pb2.ListReferencesReq, context: CouchersContext, session: Session
177 ) -> references_pb2.ListReferencesRes:
178 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
179 next_reference_id = int(request.page_token) if request.page_token else 0
181 if not request.from_user_id and not request.to_user_id:
182 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "need_to_specify_at_least_one_user")
184 statement = where_moderated_content_visible(select(Reference), context, Reference, is_list_operation=True)
185 if request.from_user_id:
186 # only interested if the recipient is visible
187 statement = where_reference_user_visible(statement, context, Reference.to_user_id).where(
188 Reference.from_user_id == request.from_user_id
189 )
190 if request.to_user_id:
191 # only interested if the writer is visible
192 statement = where_reference_user_visible(statement, context, Reference.from_user_id).where(
193 Reference.to_user_id == request.to_user_id
194 )
195 if len(request.reference_type_filter) > 0:
196 statement = statement.where(
197 Reference.reference_type.in_([reftype2sql[t] for t in request.reference_type_filter])
198 )
200 if next_reference_id:
201 statement = statement.where(Reference.id <= next_reference_id)
203 # Reference visibility logic (a reference is visible if any of the following apply):
204 # 1. It is a friend reference
205 # 2. Both references have been written
206 # 3. It has been over 2 weeks since the host request ended
207 # This must stay in sync with the reference count (get_num_references); both use the
208 # shared where_references_not_hidden_by_reciprocity() helper.
209 statement = where_references_not_hidden_by_reciprocity(statement)
211 statement = statement.order_by(Reference.id.desc()).limit(page_size + 1)
212 references = session.execute(statement).scalars().all()
214 return references_pb2.ListReferencesRes(
215 references=[reference_to_pb(reference, context) for reference in references[:page_size]],
216 next_page_token=str(references[-1].id) if len(references) > page_size else None,
217 )
219 def WriteFriendReference(
220 self, request: references_pb2.WriteFriendReferenceReq, context: CouchersContext, session: Session
221 ) -> references_pb2.Reference:
222 if context.user_id == request.to_user_id:
223 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "cant_refer_self")
225 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
227 check_valid_reference(request, context)
229 if not session.execute( 229 ↛ 232line 229 didn't jump to line 232 because the condition on line 229 was never true
230 select(User).where(users_visible(context)).where(User.id == request.to_user_id)
231 ).scalar_one_or_none():
232 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "user_not_found")
234 if not are_friends(session, context, request.to_user_id):
235 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "can_only_refer_friends")
237 if session.execute(
238 select(Reference)
239 .where(Reference.from_user_id == context.user_id)
240 .where(Reference.to_user_id == request.to_user_id)
241 .where(Reference.reference_type == ReferenceType.friend)
242 ).scalar_one_or_none():
243 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "reference_already_given")
245 reference_text = request.text.strip()
247 reference: Reference | None = None
249 def create_object(moderation_state_id: int) -> int:
250 nonlocal reference
251 reference = Reference(
252 from_user_id=context.user_id,
253 to_user_id=request.to_user_id,
254 reference_type=ReferenceType.friend,
255 text=reference_text,
256 private_text=request.private_text.strip(),
257 rating=request.rating,
258 was_appropriate=request.was_appropriate,
259 moderation_state_id=moderation_state_id,
260 )
261 session.add(reference)
262 session.flush()
263 return reference.id
265 create_moderation(
266 session=session,
267 object_type=ModerationObjectType.reference,
268 object_id=create_object,
269 creator_user_id=context.user_id,
270 )
271 assert reference is not None
272 session.commit()
274 # send the recipient of the reference a reminder
275 notify(
276 session,
277 user_id=request.to_user_id,
278 topic_action=NotificationTopicAction.reference__receive_friend,
279 key=str(reference.id),
280 data=notification_data_pb2.ReferenceReceiveFriend(
281 from_user=user_model_to_pb(user, session, make_notification_user_context(user_id=request.to_user_id)),
282 text=reference_text,
283 ),
284 moderation_state_id=reference.moderation_state_id,
285 )
287 # possibly send out an alert to the mod team if the reference was bad
288 maybe_send_reference_report_email(session, reference)
290 log_event(
291 context,
292 session,
293 "reference.friend_written",
294 {
295 "to_user_id": request.to_user_id,
296 "rating": request.rating,
297 "was_appropriate": request.was_appropriate,
298 },
299 )
301 return reference_to_pb(reference, context)
303 def WriteHostRequestReference(
304 self, request: references_pb2.WriteHostRequestReferenceReq, context: CouchersContext, session: Session
305 ) -> references_pb2.Reference:
306 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
308 check_valid_reference(request, context)
310 host_request, surfed = get_host_req_and_check_can_write_ref(session, context, request.host_request_id)
312 reference_text = request.text.strip()
314 if surfed:
315 # we surfed with someone
316 reference_type = ReferenceType.surfed
317 to_user_id = host_request.host_user_id
318 else:
319 # we hosted someone
320 reference_type = ReferenceType.hosted
321 to_user_id = host_request.surfer_user_id
323 reference: Reference | None = None
325 def create_object(moderation_state_id: int) -> int:
326 nonlocal reference
327 reference = Reference(
328 from_user_id=context.user_id,
329 to_user_id=to_user_id,
330 host_request_id=host_request.conversation_id,
331 text=reference_text,
332 private_text=request.private_text.strip(),
333 rating=request.rating,
334 was_appropriate=request.was_appropriate,
335 reference_type=reference_type,
336 moderation_state_id=moderation_state_id,
337 )
338 session.add(reference)
339 session.flush()
340 return reference.id
342 create_moderation(
343 session=session,
344 object_type=ModerationObjectType.reference,
345 object_id=create_object,
346 creator_user_id=context.user_id,
347 )
348 assert reference is not None
349 session.commit()
351 other_reference = session.execute(
352 select(Reference)
353 .where(Reference.host_request_id == host_request.conversation_id)
354 .where(Reference.to_user_id == context.user_id)
355 ).scalar_one_or_none()
357 # send notification out
358 topic_action = (
359 NotificationTopicAction.reference__receive_surfed
360 if surfed
361 else NotificationTopicAction.reference__receive_hosted
362 )
363 notify(
364 session,
365 user_id=reference.to_user_id,
366 topic_action=topic_action,
367 key=str(host_request.conversation_id),
368 data=notification_data_pb2.ReferenceReceiveHostRequest(
369 host_request_id=host_request.conversation_id,
370 from_user=user_model_to_pb(user, session, make_notification_user_context(user_id=reference.to_user_id)),
371 text=reference_text if other_reference is not None else None,
372 ),
373 moderation_state_id=reference.moderation_state_id,
374 )
376 # possibly send out an alert to the mod team if the reference was bad
377 maybe_send_reference_report_email(session, reference)
379 log_event(
380 context,
381 session,
382 "reference.host_request_written",
383 {
384 "to_user_id": to_user_id,
385 "host_request_id": host_request.conversation_id,
386 "reference_type": reference_type.name,
387 "rating": request.rating,
388 "was_appropriate": request.was_appropriate,
389 },
390 )
392 return reference_to_pb(reference, context)
394 def HostRequestIndicateDidntMeetup(
395 self, request: references_pb2.HostRequestIndicateDidntMeetupReq, context: CouchersContext, session: Session
396 ) -> empty_pb2.Empty:
397 host_request, surfed = get_host_req_and_check_can_write_ref(session, context, request.host_request_id)
399 reason = request.reason_didnt_meetup.strip()
401 if surfed: 401 ↛ 402line 401 didn't jump to line 402 because the condition on line 401 was never true
402 host_request.initiator_reason_didnt_meetup = reason
403 else:
404 host_request.recipient_reason_didnt_meetup = reason
406 return empty_pb2.Empty()
408 def AvailableWriteReferences(
409 self, request: references_pb2.AvailableWriteReferencesReq, context: CouchersContext, session: Session
410 ) -> references_pb2.AvailableWriteReferencesRes:
411 # can't write anything for ourselves, but let's return empty so this can be used generically on profile page
412 if request.to_user_id == context.user_id:
413 return references_pb2.AvailableWriteReferencesRes()
415 if not session.execute(
416 select(User).where(users_visible(context)).where(User.id == request.to_user_id)
417 ).scalar_one_or_none():
418 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "user_not_found")
420 can_write_friend_reference = (
421 session.execute(
422 select(Reference)
423 .where(Reference.from_user_id == context.user_id)
424 .where(Reference.to_user_id == request.to_user_id)
425 .where(Reference.reference_type == ReferenceType.friend)
426 ).scalar_one_or_none()
427 ) is None
429 # the two halves split on the conversation role, since that's the axis the didnt_meetup columns live on
430 surfed_col = (HostRequest.surfer_user_id == context.user_id).label("surfed")
432 q1 = (
433 select(surfed_col, HostRequest)
434 .outerjoin(
435 Reference,
436 and_(
437 Reference.host_request_id == HostRequest.conversation_id,
438 Reference.from_user_id == context.user_id,
439 ),
440 )
441 .where(Reference.id == None)
442 .where(HostRequest.can_write_reference)
443 .where(HostRequest.initiator_user_id == context.user_id)
444 .where(HostRequest.recipient_user_id == request.to_user_id)
445 .where(HostRequest.initiator_reason_didnt_meetup == None)
446 )
448 q2 = (
449 select(surfed_col, HostRequest)
450 .outerjoin(
451 Reference,
452 and_(
453 Reference.host_request_id == HostRequest.conversation_id,
454 Reference.from_user_id == context.user_id,
455 ),
456 )
457 .where(Reference.id == None)
458 .where(HostRequest.can_write_reference)
459 .where(HostRequest.initiator_user_id == request.to_user_id)
460 .where(HostRequest.recipient_user_id == context.user_id)
461 .where(HostRequest.recipient_reason_didnt_meetup == None)
462 )
464 union = union_all(q1, q2).order_by(HostRequest.end_time_to_write_reference.asc()).subquery()
465 query = select(union.c[0].label("surfed"), aliased(HostRequest, union))
466 host_request_references = session.execute(query).all()
468 return references_pb2.AvailableWriteReferencesRes(
469 can_write_friend_reference=can_write_friend_reference,
470 available_write_references=[
471 references_pb2.AvailableWriteReferenceType(
472 host_request_id=host_request.conversation_id,
473 reference_type=reftype2api[ReferenceType.surfed if surfed else ReferenceType.hosted],
474 time_expires=Timestamp_from_datetime(host_request.end_time_to_write_reference),
475 )
476 for surfed, host_request in host_request_references
477 ],
478 )
480 def ListPendingReferencesToWrite(
481 self, request: empty_pb2.Empty, context: CouchersContext, session: Session
482 ) -> references_pb2.ListPendingReferencesToWriteRes:
483 return references_pb2.ListPendingReferencesToWriteRes(
484 pending_references=[
485 references_pb2.AvailableWriteReferenceType(
486 host_request_id=host_request_id,
487 reference_type=reftype2api[reference_type],
488 time_expires=Timestamp_from_datetime(end_time_to_write_reference),
489 )
490 for host_request_id, reference_type, end_time_to_write_reference, other_user in get_pending_references_to_write(
491 session, context
492 )
493 ],
494 )
496 def GetHostRequestReferenceStatus(
497 self, request: references_pb2.GetHostRequestReferenceStatusReq, context: CouchersContext, session: Session
498 ) -> references_pb2.GetHostRequestReferenceStatusRes:
499 # Compute has_given (whether current user already wrote a reference for this host request)
500 has_given = (
501 session.execute(
502 select(Reference)
503 .where(Reference.host_request_id == request.host_request_id)
504 .where(Reference.from_user_id == context.user_id)
505 ).scalar_one_or_none()
506 is not None
507 )
509 query = select(HostRequest)
510 query = where_moderated_content_visible(query, context, HostRequest, is_list_operation=False)
511 query = query.where(HostRequest.conversation_id == request.host_request_id)
512 query = query.where(
513 or_(HostRequest.initiator_user_id == context.user_id, HostRequest.recipient_user_id == context.user_id)
514 )
515 host_request = session.execute(query).scalar_one_or_none()
517 can_write = False
518 is_expired = False
519 didnt_stay = False
521 if host_request is not None:
522 # Compute expired from end_time_to_write_reference
523 if host_request.end_time_to_write_reference is not None: 523 ↛ 527line 523 didn't jump to line 527 because the condition on line 523 was always true
524 is_expired = host_request.end_time_to_write_reference < now()
526 # Block only if current user indicated didn't meet up
527 didnt_stay = (
528 (host_request.initiator_reason_didnt_meetup is not None)
529 if host_request.initiator_user_id == context.user_id
530 else (host_request.recipient_reason_didnt_meetup is not None)
531 )
533 # You can write only if: host_request allows it, you didn't already give one, and you didn't indicate didn't meet up
534 can_write = bool(host_request.can_write_reference) and (not has_given) and (not didnt_stay)
536 return references_pb2.GetHostRequestReferenceStatusRes(
537 has_given=has_given,
538 can_write=can_write,
539 is_expired=is_expired,
540 didnt_stay=didnt_stay,
541 )