Coverage for app/backend/src/couchers/servicers/references.py: 96%
185 statements
« prev ^ index » next coverage.py v7.14.2, created at 2026-06-21 09:29 +0000
« prev ^ index » next coverage.py v7.14.2, created at 2026-06-21 09:29 +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_, func, 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.materialized_views import LiteUser
20from couchers.models import HostRequest, ModerationObjectType, Reference, ReferenceType, User
21from couchers.models.notifications import NotificationTopicAction
22from couchers.moderation.utils import create_moderation
23from couchers.notifications.notify import notify
24from couchers.proto import notification_data_pb2, references_pb2, references_pb2_grpc
25from couchers.servicers.api import user_model_to_pb
26from couchers.sql import users_visible, where_moderated_content_visible, where_users_column_visible
27from couchers.tasks import maybe_send_reference_report_email
28from couchers.utils import Timestamp_from_datetime, now
30MAX_PAGINATION_LENGTH = 100
32reftype2sql = {
33 references_pb2.ReferenceType.REFERENCE_TYPE_FRIEND: ReferenceType.friend,
34 references_pb2.ReferenceType.REFERENCE_TYPE_SURFED: ReferenceType.surfed,
35 references_pb2.ReferenceType.REFERENCE_TYPE_HOSTED: ReferenceType.hosted,
36}
38reftype2api = {
39 ReferenceType.friend: references_pb2.ReferenceType.REFERENCE_TYPE_FRIEND,
40 ReferenceType.surfed: references_pb2.ReferenceType.REFERENCE_TYPE_SURFED,
41 ReferenceType.hosted: references_pb2.ReferenceType.REFERENCE_TYPE_HOSTED,
42}
45def reference_to_pb(reference: Reference, context: CouchersContext) -> references_pb2.Reference:
46 return references_pb2.Reference(
47 reference_id=reference.id,
48 from_user_id=reference.from_user_id,
49 to_user_id=reference.to_user_id,
50 reference_type=reftype2api[reference.reference_type],
51 text=reference.text,
52 written_time=Timestamp_from_datetime(reference.time.replace(hour=0, minute=0, second=0, microsecond=0)),
53 host_request_id=(
54 reference.host_request_id if context.user_id in [reference.from_user_id, reference.to_user_id] else None
55 ),
56 )
59def get_host_req_and_check_can_write_ref(
60 session: Session, context: CouchersContext, host_request_id: int
61) -> tuple[HostRequest, bool]:
62 """
63 Checks that this can see the given host req and write a ref for it
65 Returns the host req and `surfed`, a boolean of if the user was the surfer or not
66 """
67 query = select(HostRequest)
68 query = where_users_column_visible(query, context, HostRequest.initiator_user_id)
69 query = where_users_column_visible(query, context, HostRequest.recipient_user_id)
70 query = where_moderated_content_visible(query, context, HostRequest, is_list_operation=False)
71 query = query.where(HostRequest.conversation_id == host_request_id)
72 query = query.where(
73 or_(HostRequest.initiator_user_id == context.user_id, HostRequest.recipient_user_id == context.user_id)
74 )
75 host_request = session.execute(query).scalar_one_or_none()
77 if not host_request: 77 ↛ 78line 77 didn't jump to line 78 because the condition on line 77 was never true
78 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "host_request_not_found")
80 if not host_request.can_write_reference:
81 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "cant_write_reference_for_request")
83 if session.execute(
84 select(Reference)
85 .where(Reference.host_request_id == host_request.conversation_id)
86 .where(Reference.from_user_id == context.user_id)
87 ).scalar_one_or_none():
88 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "reference_already_given")
90 surfed = host_request.initiator_user_id == context.user_id
92 if surfed:
93 my_reason = host_request.initiator_reason_didnt_meetup
94 else:
95 my_reason = host_request.recipient_reason_didnt_meetup
97 if my_reason != None:
98 context.abort_with_error_code(
99 grpc.StatusCode.FAILED_PRECONDITION, "cant_write_reference_indicated_didnt_meetup"
100 )
102 return host_request, surfed
105def check_valid_reference(
106 request: references_pb2.WriteFriendReferenceReq | references_pb2.WriteHostRequestReferenceReq,
107 context: CouchersContext,
108) -> None:
109 if request.rating < 0 or request.rating > 1: 109 ↛ 110line 109 didn't jump to line 110 because the condition on line 109 was never true
110 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "reference_invalid_rating")
112 if request.text.strip() == "":
113 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "reference_no_text")
116def get_pending_references_to_write(
117 session: Session, context: CouchersContext
118) -> list[tuple[int, ReferenceType, datetime, LiteUser]]:
119 q1 = (
120 select(literal(True), HostRequest, LiteUser)
121 .outerjoin(
122 Reference,
123 and_(
124 Reference.host_request_id == HostRequest.conversation_id,
125 Reference.from_user_id == context.user_id,
126 ),
127 )
128 .join(LiteUser, LiteUser.id == HostRequest.recipient_user_id)
129 )
130 q1 = where_users_column_visible(q1, context, HostRequest.recipient_user_id)
131 q1 = where_moderated_content_visible(q1, context, HostRequest, is_list_operation=True)
132 q1 = q1.where(Reference.id == None)
133 q1 = q1.where(HostRequest.can_write_reference)
134 q1 = q1.where(HostRequest.initiator_user_id == context.user_id)
135 q1 = q1.where(HostRequest.initiator_reason_didnt_meetup == None)
137 q2 = (
138 select(literal(False), HostRequest, LiteUser)
139 .outerjoin(
140 Reference,
141 and_(
142 Reference.host_request_id == HostRequest.conversation_id,
143 Reference.from_user_id == context.user_id,
144 ),
145 )
146 .join(LiteUser, LiteUser.id == HostRequest.initiator_user_id)
147 )
148 q2 = where_users_column_visible(q2, context, HostRequest.initiator_user_id)
149 q2 = where_moderated_content_visible(q2, context, HostRequest, is_list_operation=True)
150 q2 = q2.where(Reference.id == None)
151 q2 = q2.where(HostRequest.can_write_reference)
152 q2 = q2.where(HostRequest.recipient_user_id == context.user_id)
153 q2 = q2.where(HostRequest.recipient_reason_didnt_meetup == None)
155 union = union_all(q1, q2).order_by(HostRequest.end_time_to_write_reference.asc()).subquery()
156 query = select(union.c[0].label("surfed"), aliased(HostRequest, union), aliased(LiteUser, union))
157 host_request_references = session.execute(query).all()
159 return [
160 (
161 host_request.conversation_id,
162 ReferenceType.surfed if surfed else ReferenceType.hosted,
163 host_request.end_time_to_write_reference,
164 other_user,
165 )
166 for surfed, host_request, other_user in host_request_references
167 ]
170class References(references_pb2_grpc.ReferencesServicer):
171 def ListReferences(
172 self, request: references_pb2.ListReferencesReq, context: CouchersContext, session: Session
173 ) -> references_pb2.ListReferencesRes:
174 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
175 next_reference_id = int(request.page_token) if request.page_token else 0
177 if not request.from_user_id and not request.to_user_id:
178 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "need_to_specify_at_least_one_user")
180 to_users = aliased(User)
181 from_users = aliased(User)
182 statement = where_moderated_content_visible(select(Reference), context, Reference, is_list_operation=True)
183 if request.from_user_id:
184 # join the to_users, because only interested if the recipient is visible
185 statement = (
186 statement.join(to_users, Reference.to_user_id == to_users.id)
187 .where(
188 to_users.banned_at.is_(None)
189 ) # instead of where_users_visible; if user is deleted or blocked, reference still visible
190 .where(or_(to_users.shadowed_at.is_(None), to_users.id == context.user_id))
191 .where(Reference.from_user_id == request.from_user_id)
192 )
193 if request.to_user_id:
194 # join the from_users, because only interested if the writer is visible
195 statement = (
196 statement.join(from_users, Reference.from_user_id == from_users.id)
197 .where(
198 from_users.banned_at.is_(None)
199 ) # instead of where_users_visible; if user is deleted or blocked, reference still visible
200 .where(or_(from_users.shadowed_at.is_(None), from_users.id == context.user_id))
201 .where(Reference.to_user_id == request.to_user_id)
202 )
203 if len(request.reference_type_filter) > 0:
204 statement = statement.where(
205 Reference.reference_type.in_([reftype2sql[t] for t in request.reference_type_filter])
206 )
208 if next_reference_id:
209 statement = statement.where(Reference.id <= next_reference_id)
211 # Reference visibility logic:
212 # 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
217 # we get the matching other references through this subquery
218 sub = select(Reference.id.label("sub_id"), Reference.host_request_id).where(
219 Reference.reference_type != ReferenceType.friend
220 )
221 if request.from_user_id:
222 sub = sub.where(Reference.to_user_id == request.from_user_id)
223 if request.to_user_id:
224 sub = sub.where(Reference.from_user_id == request.to_user_id)
226 query = sub.subquery()
227 statement = (
228 statement.outerjoin(query, query.c.host_request_id == Reference.host_request_id)
229 .outerjoin(HostRequest, HostRequest.conversation_id == Reference.host_request_id)
230 .where(
231 or_(
232 Reference.reference_type == ReferenceType.friend,
233 query.c.sub_id != None,
234 HostRequest.end_time_to_write_reference < func.now(),
235 )
236 )
237 )
239 statement = statement.order_by(Reference.id.desc()).limit(page_size + 1)
240 references = session.execute(statement).scalars().all()
242 return references_pb2.ListReferencesRes(
243 references=[reference_to_pb(reference, context) for reference in references[:page_size]],
244 next_page_token=str(references[-1].id) if len(references) > page_size else None,
245 )
247 def WriteFriendReference(
248 self, request: references_pb2.WriteFriendReferenceReq, context: CouchersContext, session: Session
249 ) -> references_pb2.Reference:
250 if context.user_id == request.to_user_id:
251 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "cant_refer_self")
253 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
255 check_valid_reference(request, context)
257 if not session.execute( 257 ↛ 260line 257 didn't jump to line 260 because the condition on line 257 was never true
258 select(User).where(users_visible(context)).where(User.id == request.to_user_id)
259 ).scalar_one_or_none():
260 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "user_not_found")
262 if not are_friends(session, context, request.to_user_id):
263 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "can_only_refer_friends")
265 if session.execute(
266 select(Reference)
267 .where(Reference.from_user_id == context.user_id)
268 .where(Reference.to_user_id == request.to_user_id)
269 .where(Reference.reference_type == ReferenceType.friend)
270 ).scalar_one_or_none():
271 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "reference_already_given")
273 reference_text = request.text.strip()
275 reference: Reference | None = None
277 def create_object(moderation_state_id: int) -> int:
278 nonlocal reference
279 reference = Reference(
280 from_user_id=context.user_id,
281 to_user_id=request.to_user_id,
282 reference_type=ReferenceType.friend,
283 text=reference_text,
284 private_text=request.private_text.strip(),
285 rating=request.rating,
286 was_appropriate=request.was_appropriate,
287 moderation_state_id=moderation_state_id,
288 )
289 session.add(reference)
290 session.flush()
291 return reference.id
293 create_moderation(
294 session=session,
295 object_type=ModerationObjectType.reference,
296 object_id=create_object,
297 creator_user_id=context.user_id,
298 )
299 assert reference is not None
300 session.commit()
302 # send the recipient of the reference a reminder
303 notify(
304 session,
305 user_id=request.to_user_id,
306 topic_action=NotificationTopicAction.reference__receive_friend,
307 key=str(reference.id),
308 data=notification_data_pb2.ReferenceReceiveFriend(
309 from_user=user_model_to_pb(user, session, make_notification_user_context(user_id=request.to_user_id)),
310 text=reference_text,
311 ),
312 moderation_state_id=reference.moderation_state_id,
313 )
315 # possibly send out an alert to the mod team if the reference was bad
316 maybe_send_reference_report_email(session, reference)
318 log_event(
319 context,
320 session,
321 "reference.friend_written",
322 {
323 "to_user_id": request.to_user_id,
324 "rating": request.rating,
325 "was_appropriate": request.was_appropriate,
326 },
327 )
329 return reference_to_pb(reference, context)
331 def WriteHostRequestReference(
332 self, request: references_pb2.WriteHostRequestReferenceReq, context: CouchersContext, session: Session
333 ) -> references_pb2.Reference:
334 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
336 check_valid_reference(request, context)
338 host_request, surfed = get_host_req_and_check_can_write_ref(session, context, request.host_request_id)
340 reference_text = request.text.strip()
342 if surfed:
343 # we requested to surf with someone
344 reference_type = ReferenceType.surfed
345 to_user_id = host_request.recipient_user_id
346 assert context.user_id == host_request.initiator_user_id
347 else:
348 # we hosted someone
349 reference_type = ReferenceType.hosted
350 to_user_id = host_request.initiator_user_id
351 assert context.user_id == host_request.recipient_user_id
353 reference: Reference | None = None
355 def create_object(moderation_state_id: int) -> int:
356 nonlocal reference
357 reference = Reference(
358 from_user_id=context.user_id,
359 to_user_id=to_user_id,
360 host_request_id=host_request.conversation_id,
361 text=reference_text,
362 private_text=request.private_text.strip(),
363 rating=request.rating,
364 was_appropriate=request.was_appropriate,
365 reference_type=reference_type,
366 moderation_state_id=moderation_state_id,
367 )
368 session.add(reference)
369 session.flush()
370 return reference.id
372 create_moderation(
373 session=session,
374 object_type=ModerationObjectType.reference,
375 object_id=create_object,
376 creator_user_id=context.user_id,
377 )
378 assert reference is not None
379 session.commit()
381 other_reference = session.execute(
382 select(Reference)
383 .where(Reference.host_request_id == host_request.conversation_id)
384 .where(Reference.to_user_id == context.user_id)
385 ).scalar_one_or_none()
387 # send notification out
388 topic_action = (
389 NotificationTopicAction.reference__receive_surfed
390 if surfed
391 else NotificationTopicAction.reference__receive_hosted
392 )
393 notify(
394 session,
395 user_id=reference.to_user_id,
396 topic_action=topic_action,
397 key=str(host_request.conversation_id),
398 data=notification_data_pb2.ReferenceReceiveHostRequest(
399 host_request_id=host_request.conversation_id,
400 from_user=user_model_to_pb(user, session, make_notification_user_context(user_id=reference.to_user_id)),
401 text=reference_text if other_reference is not None else None,
402 ),
403 moderation_state_id=reference.moderation_state_id,
404 )
406 # possibly send out an alert to the mod team if the reference was bad
407 maybe_send_reference_report_email(session, reference)
409 log_event(
410 context,
411 session,
412 "reference.host_request_written",
413 {
414 "to_user_id": to_user_id,
415 "host_request_id": host_request.conversation_id,
416 "reference_type": reference_type.name,
417 "rating": request.rating,
418 "was_appropriate": request.was_appropriate,
419 },
420 )
422 return reference_to_pb(reference, context)
424 def HostRequestIndicateDidntMeetup(
425 self, request: references_pb2.HostRequestIndicateDidntMeetupReq, context: CouchersContext, session: Session
426 ) -> empty_pb2.Empty:
427 host_request, surfed = get_host_req_and_check_can_write_ref(session, context, request.host_request_id)
429 reason = request.reason_didnt_meetup.strip()
431 if surfed: 431 ↛ 432line 431 didn't jump to line 432 because the condition on line 431 was never true
432 host_request.initiator_reason_didnt_meetup = reason
433 else:
434 host_request.recipient_reason_didnt_meetup = reason
436 return empty_pb2.Empty()
438 def AvailableWriteReferences(
439 self, request: references_pb2.AvailableWriteReferencesReq, context: CouchersContext, session: Session
440 ) -> references_pb2.AvailableWriteReferencesRes:
441 # can't write anything for ourselves, but let's return empty so this can be used generically on profile page
442 if request.to_user_id == context.user_id:
443 return references_pb2.AvailableWriteReferencesRes()
445 if not session.execute(
446 select(User).where(users_visible(context)).where(User.id == request.to_user_id)
447 ).scalar_one_or_none():
448 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "user_not_found")
450 can_write_friend_reference = (
451 session.execute(
452 select(Reference)
453 .where(Reference.from_user_id == context.user_id)
454 .where(Reference.to_user_id == request.to_user_id)
455 .where(Reference.reference_type == ReferenceType.friend)
456 ).scalar_one_or_none()
457 ) is None
459 q1 = (
460 select(literal(True), HostRequest)
461 .outerjoin(
462 Reference,
463 and_(
464 Reference.host_request_id == HostRequest.conversation_id,
465 Reference.from_user_id == context.user_id,
466 ),
467 )
468 .where(Reference.id == None)
469 .where(HostRequest.can_write_reference)
470 .where(HostRequest.initiator_user_id == context.user_id)
471 .where(HostRequest.recipient_user_id == request.to_user_id)
472 .where(HostRequest.initiator_reason_didnt_meetup == None)
473 )
475 q2 = (
476 select(literal(False), HostRequest)
477 .outerjoin(
478 Reference,
479 and_(
480 Reference.host_request_id == HostRequest.conversation_id,
481 Reference.from_user_id == context.user_id,
482 ),
483 )
484 .where(Reference.id == None)
485 .where(HostRequest.can_write_reference)
486 .where(HostRequest.initiator_user_id == request.to_user_id)
487 .where(HostRequest.recipient_user_id == context.user_id)
488 .where(HostRequest.recipient_reason_didnt_meetup == None)
489 )
491 union = union_all(q1, q2).order_by(HostRequest.end_time_to_write_reference.asc()).subquery()
492 query = select(union.c[0].label("surfed"), aliased(HostRequest, union))
493 host_request_references = session.execute(query).all()
495 return references_pb2.AvailableWriteReferencesRes(
496 can_write_friend_reference=can_write_friend_reference,
497 available_write_references=[
498 references_pb2.AvailableWriteReferenceType(
499 host_request_id=host_request.conversation_id,
500 reference_type=reftype2api[ReferenceType.surfed if surfed else ReferenceType.hosted],
501 time_expires=Timestamp_from_datetime(host_request.end_time_to_write_reference),
502 )
503 for surfed, host_request in host_request_references
504 ],
505 )
507 def ListPendingReferencesToWrite(
508 self, request: empty_pb2.Empty, context: CouchersContext, session: Session
509 ) -> references_pb2.ListPendingReferencesToWriteRes:
510 return references_pb2.ListPendingReferencesToWriteRes(
511 pending_references=[
512 references_pb2.AvailableWriteReferenceType(
513 host_request_id=host_request_id,
514 reference_type=reftype2api[reference_type],
515 time_expires=Timestamp_from_datetime(end_time_to_write_reference),
516 )
517 for host_request_id, reference_type, end_time_to_write_reference, other_user in get_pending_references_to_write(
518 session, context
519 )
520 ],
521 )
523 def GetHostRequestReferenceStatus(
524 self, request: references_pb2.GetHostRequestReferenceStatusReq, context: CouchersContext, session: Session
525 ) -> references_pb2.GetHostRequestReferenceStatusRes:
526 # Compute has_given (whether current user already wrote a reference for this host request)
527 has_given = (
528 session.execute(
529 select(Reference)
530 .where(Reference.host_request_id == request.host_request_id)
531 .where(Reference.from_user_id == context.user_id)
532 ).scalar_one_or_none()
533 is not None
534 )
536 query = select(HostRequest)
537 query = where_moderated_content_visible(query, context, HostRequest, is_list_operation=False)
538 query = query.where(HostRequest.conversation_id == request.host_request_id)
539 query = query.where(
540 or_(HostRequest.initiator_user_id == context.user_id, HostRequest.recipient_user_id == context.user_id)
541 )
542 host_request = session.execute(query).scalar_one_or_none()
544 can_write = False
545 is_expired = False
546 didnt_stay = False
548 if host_request is not None:
549 # Compute expired from end_time_to_write_reference
550 if host_request.end_time_to_write_reference is not None: 550 ↛ 554line 550 didn't jump to line 554 because the condition on line 550 was always true
551 is_expired = host_request.end_time_to_write_reference < now()
553 # Block only if current user indicated didn't meet up
554 didnt_stay = (
555 (host_request.initiator_reason_didnt_meetup is not None)
556 if host_request.initiator_user_id == context.user_id
557 else (host_request.recipient_reason_didnt_meetup is not None)
558 )
560 # You can write only if: host_request allows it, you didn't already give one, and you didn't indicate didn't meet up
561 can_write = bool(host_request.can_write_reference) and (not has_given) and (not didnt_stay)
563 return references_pb2.GetHostRequestReferenceStatusRes(
564 has_given=has_given,
565 can_write=can_write,
566 is_expired=is_expired,
567 didnt_stay=didnt_stay,
568 )