Coverage for app / backend / src / couchers / servicers / references.py: 96%
172 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-05 09:44 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-05 09:44 +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_background_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, Reference, ReferenceType, User
21from couchers.models.notifications import NotificationTopicAction
22from couchers.notifications.notify import notify
23from couchers.proto import notification_data_pb2, references_pb2, references_pb2_grpc
24from couchers.servicers.api import user_model_to_pb
25from couchers.sql import users_visible, where_moderated_content_visible, where_users_column_visible
26from couchers.tasks import maybe_send_reference_report_email
27from couchers.utils import Timestamp_from_datetime, now
29MAX_PAGINATION_LENGTH = 100
31reftype2sql = {
32 references_pb2.ReferenceType.REFERENCE_TYPE_FRIEND: ReferenceType.friend,
33 references_pb2.ReferenceType.REFERENCE_TYPE_SURFED: ReferenceType.surfed,
34 references_pb2.ReferenceType.REFERENCE_TYPE_HOSTED: ReferenceType.hosted,
35}
37reftype2api = {
38 ReferenceType.friend: references_pb2.ReferenceType.REFERENCE_TYPE_FRIEND,
39 ReferenceType.surfed: references_pb2.ReferenceType.REFERENCE_TYPE_SURFED,
40 ReferenceType.hosted: references_pb2.ReferenceType.REFERENCE_TYPE_HOSTED,
41}
44def reference_to_pb(reference: Reference, context: CouchersContext) -> references_pb2.Reference:
45 return references_pb2.Reference(
46 reference_id=reference.id,
47 from_user_id=reference.from_user_id,
48 to_user_id=reference.to_user_id,
49 reference_type=reftype2api[reference.reference_type],
50 text=reference.text,
51 written_time=Timestamp_from_datetime(reference.time.replace(hour=0, minute=0, second=0, microsecond=0)),
52 host_request_id=(
53 reference.host_request_id if context.user_id in [reference.from_user_id, reference.to_user_id] else None
54 ),
55 )
58def get_host_req_and_check_can_write_ref(
59 session: Session, context: CouchersContext, host_request_id: int
60) -> tuple[HostRequest, bool]:
61 """
62 Checks that this can see the given host req and write a ref for it
64 Returns the host req and `surfed`, a boolean of if the user was the surfer or not
65 """
66 query = select(HostRequest)
67 query = where_users_column_visible(query, context, HostRequest.initiator_user_id)
68 query = where_users_column_visible(query, context, HostRequest.recipient_user_id)
69 query = where_moderated_content_visible(query, context, HostRequest, is_list_operation=False)
70 query = query.where(HostRequest.conversation_id == host_request_id)
71 query = query.where(
72 or_(HostRequest.initiator_user_id == context.user_id, HostRequest.recipient_user_id == context.user_id)
73 )
74 host_request = session.execute(query).scalar_one_or_none()
76 if not host_request: 76 ↛ 77line 76 didn't jump to line 77 because the condition on line 76 was never true
77 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "host_request_not_found")
79 if not host_request.can_write_reference:
80 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "cant_write_reference_for_request")
82 if session.execute(
83 select(Reference)
84 .where(Reference.host_request_id == host_request.conversation_id)
85 .where(Reference.from_user_id == context.user_id)
86 ).scalar_one_or_none():
87 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "reference_already_given")
89 surfed = host_request.initiator_user_id == context.user_id
91 if surfed:
92 my_reason = host_request.initiator_reason_didnt_meetup
93 else:
94 my_reason = host_request.recipient_reason_didnt_meetup
96 if my_reason != None:
97 context.abort_with_error_code(
98 grpc.StatusCode.FAILED_PRECONDITION, "cant_write_reference_indicated_didnt_meetup"
99 )
101 return host_request, surfed
104def check_valid_reference(
105 request: references_pb2.WriteFriendReferenceReq | references_pb2.WriteHostRequestReferenceReq,
106 context: CouchersContext,
107) -> None:
108 if request.rating < 0 or request.rating > 1: 108 ↛ 109line 108 didn't jump to line 109 because the condition on line 108 was never true
109 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "reference_invalid_rating")
111 if request.text.strip() == "":
112 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "reference_no_text")
115def get_pending_references_to_write(
116 session: Session, context: CouchersContext
117) -> list[tuple[int, ReferenceType, datetime, LiteUser]]:
118 q1 = (
119 select(literal(True), HostRequest, LiteUser)
120 .outerjoin(
121 Reference,
122 and_(
123 Reference.host_request_id == HostRequest.conversation_id,
124 Reference.from_user_id == context.user_id,
125 ),
126 )
127 .join(LiteUser, LiteUser.id == HostRequest.recipient_user_id)
128 )
129 q1 = where_users_column_visible(q1, context, HostRequest.recipient_user_id)
130 q1 = where_moderated_content_visible(q1, context, HostRequest, is_list_operation=True)
131 q1 = q1.where(Reference.id == None)
132 q1 = q1.where(HostRequest.can_write_reference)
133 q1 = q1.where(HostRequest.initiator_user_id == context.user_id)
134 q1 = q1.where(HostRequest.initiator_reason_didnt_meetup == None)
136 q2 = (
137 select(literal(False), HostRequest, LiteUser)
138 .outerjoin(
139 Reference,
140 and_(
141 Reference.host_request_id == HostRequest.conversation_id,
142 Reference.from_user_id == context.user_id,
143 ),
144 )
145 .join(LiteUser, LiteUser.id == HostRequest.initiator_user_id)
146 )
147 q2 = where_users_column_visible(q2, context, HostRequest.initiator_user_id)
148 q2 = where_moderated_content_visible(q2, context, HostRequest, is_list_operation=True)
149 q2 = q2.where(Reference.id == None)
150 q2 = q2.where(HostRequest.can_write_reference)
151 q2 = q2.where(HostRequest.recipient_user_id == context.user_id)
152 q2 = q2.where(HostRequest.recipient_reason_didnt_meetup == None)
154 union = union_all(q1, q2).order_by(HostRequest.end_time_to_write_reference.asc()).subquery()
155 query = select(union.c[0].label("surfed"), aliased(HostRequest, union), aliased(LiteUser, union))
156 host_request_references = session.execute(query).all()
158 return [
159 (
160 host_request.conversation_id,
161 ReferenceType.surfed if surfed else ReferenceType.hosted,
162 host_request.end_time_to_write_reference,
163 other_user,
164 )
165 for surfed, host_request, other_user in host_request_references
166 ]
169class References(references_pb2_grpc.ReferencesServicer):
170 def ListReferences(
171 self, request: references_pb2.ListReferencesReq, context: CouchersContext, session: Session
172 ) -> references_pb2.ListReferencesRes:
173 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
174 next_reference_id = int(request.page_token) if request.page_token else 0
176 if not request.from_user_id and not request.to_user_id:
177 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "need_to_specify_at_least_one_user")
179 to_users = aliased(User)
180 from_users = aliased(User)
181 statement = select(Reference).where(Reference.is_deleted == False)
182 if request.from_user_id:
183 # join the to_users, because only interested if the recipient is visible
184 statement = (
185 statement.join(to_users, Reference.to_user_id == to_users.id)
186 .where(
187 to_users.banned_at.is_(None)
188 ) # instead of where_users_visible; if user is deleted or blocked, reference still visible
189 .where(Reference.from_user_id == request.from_user_id)
190 )
191 if request.to_user_id:
192 # join the from_users, because only interested if the writer is visible
193 statement = (
194 statement.join(from_users, Reference.from_user_id == from_users.id)
195 .where(
196 from_users.banned_at.is_(None)
197 ) # instead of where_users_visible; if user is deleted or blocked, reference still visible
198 .where(Reference.to_user_id == request.to_user_id)
199 )
200 if len(request.reference_type_filter) > 0:
201 statement = statement.where(
202 Reference.reference_type.in_([reftype2sql[t] for t in request.reference_type_filter])
203 )
205 if next_reference_id:
206 statement = statement.where(Reference.id <= next_reference_id)
208 # Reference visibility logic:
209 # A reference is visible if any of the following apply:
210 # 1. It is a friend reference
211 # 2. Both references have been written
212 # 3. It has been over 2 weeks since the host request ended
214 # we get the matching other references through this subquery
215 sub = select(Reference.id.label("sub_id"), Reference.host_request_id).where(
216 Reference.reference_type != ReferenceType.friend
217 )
218 if request.from_user_id:
219 sub = sub.where(Reference.to_user_id == request.from_user_id)
220 if request.to_user_id:
221 sub = sub.where(Reference.from_user_id == request.to_user_id)
223 query = sub.subquery()
224 statement = (
225 statement.outerjoin(query, query.c.host_request_id == Reference.host_request_id)
226 .outerjoin(HostRequest, HostRequest.conversation_id == Reference.host_request_id)
227 .where(
228 or_(
229 Reference.reference_type == ReferenceType.friend,
230 query.c.sub_id != None,
231 HostRequest.end_time_to_write_reference < func.now(),
232 )
233 )
234 )
236 statement = statement.order_by(Reference.id.desc()).limit(page_size + 1)
237 references = session.execute(statement).scalars().all()
239 return references_pb2.ListReferencesRes(
240 references=[reference_to_pb(reference, context) for reference in references[:page_size]],
241 next_page_token=str(references[-1].id) if len(references) > page_size else None,
242 )
244 def WriteFriendReference(
245 self, request: references_pb2.WriteFriendReferenceReq, context: CouchersContext, session: Session
246 ) -> references_pb2.Reference:
247 if context.user_id == request.to_user_id:
248 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "cant_refer_self")
250 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
252 check_valid_reference(request, context)
254 if not session.execute( 254 ↛ 257line 254 didn't jump to line 257 because the condition on line 254 was never true
255 select(User).where(users_visible(context)).where(User.id == request.to_user_id)
256 ).scalar_one_or_none():
257 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "user_not_found")
259 if not are_friends(session, context, request.to_user_id):
260 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "can_only_refer_friends")
262 if session.execute(
263 select(Reference)
264 .where(Reference.from_user_id == context.user_id)
265 .where(Reference.to_user_id == request.to_user_id)
266 .where(Reference.reference_type == ReferenceType.friend)
267 ).scalar_one_or_none():
268 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "reference_already_given")
270 reference_text = request.text.strip()
272 reference = Reference(
273 from_user_id=context.user_id,
274 to_user_id=request.to_user_id,
275 reference_type=ReferenceType.friend,
276 text=reference_text,
277 private_text=request.private_text.strip(),
278 rating=request.rating,
279 was_appropriate=request.was_appropriate,
280 )
281 session.add(reference)
282 session.commit()
284 # send the recipient of the reference a reminder
285 notify(
286 session,
287 user_id=request.to_user_id,
288 topic_action=NotificationTopicAction.reference__receive_friend,
289 key=str(reference.id),
290 data=notification_data_pb2.ReferenceReceiveFriend(
291 from_user=user_model_to_pb(user, session, make_background_user_context(user_id=request.to_user_id)),
292 text=reference_text,
293 ),
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(
335 from_user_id=context.user_id,
336 to_user_id=to_user_id,
337 host_request_id=host_request.conversation_id,
338 text=reference_text,
339 private_text=request.private_text.strip(),
340 rating=request.rating,
341 was_appropriate=request.was_appropriate,
342 reference_type=reference_type,
343 )
345 session.add(reference)
346 session.commit()
348 other_reference = session.execute(
349 select(Reference)
350 .where(Reference.host_request_id == host_request.conversation_id)
351 .where(Reference.to_user_id == context.user_id)
352 ).scalar_one_or_none()
354 # send notification out
355 topic_action = (
356 NotificationTopicAction.reference__receive_surfed
357 if surfed
358 else NotificationTopicAction.reference__receive_hosted
359 )
360 notify(
361 session,
362 user_id=reference.to_user_id,
363 topic_action=topic_action,
364 key=str(host_request.conversation_id),
365 data=notification_data_pb2.ReferenceReceiveHostRequest(
366 host_request_id=host_request.conversation_id,
367 from_user=user_model_to_pb(user, session, make_background_user_context(user_id=reference.to_user_id)),
368 text=reference_text if other_reference is not None else None,
369 ),
370 )
372 # possibly send out an alert to the mod team if the reference was bad
373 maybe_send_reference_report_email(session, reference)
375 log_event(
376 context,
377 session,
378 "reference.host_request_written",
379 {
380 "to_user_id": to_user_id,
381 "host_request_id": host_request.conversation_id,
382 "reference_type": reference_type.name,
383 "rating": request.rating,
384 "was_appropriate": request.was_appropriate,
385 },
386 )
388 return reference_to_pb(reference, context)
390 def HostRequestIndicateDidntMeetup(
391 self, request: references_pb2.HostRequestIndicateDidntMeetupReq, context: CouchersContext, session: Session
392 ) -> empty_pb2.Empty:
393 host_request, surfed = get_host_req_and_check_can_write_ref(session, context, request.host_request_id)
395 reason = request.reason_didnt_meetup.strip()
397 if surfed: 397 ↛ 398line 397 didn't jump to line 398 because the condition on line 397 was never true
398 host_request.initiator_reason_didnt_meetup = reason
399 else:
400 host_request.recipient_reason_didnt_meetup = reason
402 return empty_pb2.Empty()
404 def AvailableWriteReferences(
405 self, request: references_pb2.AvailableWriteReferencesReq, context: CouchersContext, session: Session
406 ) -> references_pb2.AvailableWriteReferencesRes:
407 # can't write anything for ourselves, but let's return empty so this can be used generically on profile page
408 if request.to_user_id == context.user_id:
409 return references_pb2.AvailableWriteReferencesRes()
411 if not session.execute(
412 select(User).where(users_visible(context)).where(User.id == request.to_user_id)
413 ).scalar_one_or_none():
414 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "user_not_found")
416 can_write_friend_reference = (
417 session.execute(
418 select(Reference)
419 .where(Reference.from_user_id == context.user_id)
420 .where(Reference.to_user_id == request.to_user_id)
421 .where(Reference.reference_type == ReferenceType.friend)
422 ).scalar_one_or_none()
423 ) is None
425 q1 = (
426 select(literal(True), HostRequest)
427 .outerjoin(
428 Reference,
429 and_(
430 Reference.host_request_id == HostRequest.conversation_id,
431 Reference.from_user_id == context.user_id,
432 ),
433 )
434 .where(Reference.id == None)
435 .where(HostRequest.can_write_reference)
436 .where(HostRequest.initiator_user_id == context.user_id)
437 .where(HostRequest.recipient_user_id == request.to_user_id)
438 .where(HostRequest.initiator_reason_didnt_meetup == None)
439 )
441 q2 = (
442 select(literal(False), HostRequest)
443 .outerjoin(
444 Reference,
445 and_(
446 Reference.host_request_id == HostRequest.conversation_id,
447 Reference.from_user_id == context.user_id,
448 ),
449 )
450 .where(Reference.id == None)
451 .where(HostRequest.can_write_reference)
452 .where(HostRequest.initiator_user_id == request.to_user_id)
453 .where(HostRequest.recipient_user_id == context.user_id)
454 .where(HostRequest.recipient_reason_didnt_meetup == None)
455 )
457 union = union_all(q1, q2).order_by(HostRequest.end_time_to_write_reference.asc()).subquery()
458 query = select(union.c[0].label("surfed"), aliased(HostRequest, union))
459 host_request_references = session.execute(query).all()
461 return references_pb2.AvailableWriteReferencesRes(
462 can_write_friend_reference=can_write_friend_reference,
463 available_write_references=[
464 references_pb2.AvailableWriteReferenceType(
465 host_request_id=host_request.conversation_id,
466 reference_type=reftype2api[ReferenceType.surfed if surfed else ReferenceType.hosted],
467 time_expires=Timestamp_from_datetime(host_request.end_time_to_write_reference),
468 )
469 for surfed, host_request in host_request_references
470 ],
471 )
473 def ListPendingReferencesToWrite(
474 self, request: empty_pb2.Empty, context: CouchersContext, session: Session
475 ) -> references_pb2.ListPendingReferencesToWriteRes:
476 return references_pb2.ListPendingReferencesToWriteRes(
477 pending_references=[
478 references_pb2.AvailableWriteReferenceType(
479 host_request_id=host_request_id,
480 reference_type=reftype2api[reference_type],
481 time_expires=Timestamp_from_datetime(end_time_to_write_reference),
482 )
483 for host_request_id, reference_type, end_time_to_write_reference, other_user in get_pending_references_to_write(
484 session, context
485 )
486 ],
487 )
489 def GetHostRequestReferenceStatus(
490 self, request: references_pb2.GetHostRequestReferenceStatusReq, context: CouchersContext, session: Session
491 ) -> references_pb2.GetHostRequestReferenceStatusRes:
492 # Compute has_given (whether current user already wrote a reference for this host request)
493 has_given = (
494 session.execute(
495 select(Reference)
496 .where(Reference.host_request_id == request.host_request_id)
497 .where(Reference.from_user_id == context.user_id)
498 ).scalar_one_or_none()
499 is not None
500 )
502 query = select(HostRequest)
503 query = where_moderated_content_visible(query, context, HostRequest, is_list_operation=False)
504 query = query.where(HostRequest.conversation_id == request.host_request_id)
505 query = query.where(
506 or_(HostRequest.initiator_user_id == context.user_id, HostRequest.recipient_user_id == context.user_id)
507 )
508 host_request = session.execute(query).scalar_one_or_none()
510 can_write = False
511 is_expired = False
512 didnt_stay = False
514 if host_request is not None:
515 # Compute expired from end_time_to_write_reference
516 if host_request.end_time_to_write_reference is not None: 516 ↛ 520line 516 didn't jump to line 520 because the condition on line 516 was always true
517 is_expired = host_request.end_time_to_write_reference < now()
519 # Block only if current user indicated didn't meet up
520 didnt_stay = (
521 (host_request.initiator_reason_didnt_meetup is not None)
522 if host_request.initiator_user_id == context.user_id
523 else (host_request.recipient_reason_didnt_meetup is not None)
524 )
526 # You can write only if: host_request allows it, you didn't already give one, and you didn't indicate didn't meet up
527 can_write = bool(host_request.can_write_reference) and (not has_given) and (not didnt_stay)
529 return references_pb2.GetHostRequestReferenceStatusRes(
530 has_given=has_given,
531 can_write=can_write,
532 is_expired=is_expired,
533 didnt_stay=didnt_stay,
534 )