Coverage for app / backend / src / couchers / servicers / references.py: 96%
172 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-19 14:14 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-19 14:14 +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.surfer_user_id)
68 query = where_users_column_visible(query, context, HostRequest.host_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(or_(HostRequest.surfer_user_id == context.user_id, HostRequest.host_user_id == context.user_id))
72 host_request = session.execute(query).scalar_one_or_none()
74 if not host_request: 74 ↛ 75line 74 didn't jump to line 75 because the condition on line 74 was never true
75 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "host_request_not_found")
77 if not host_request.can_write_reference:
78 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "cant_write_reference_for_request")
80 if session.execute(
81 select(Reference)
82 .where(Reference.host_request_id == host_request.conversation_id)
83 .where(Reference.from_user_id == context.user_id)
84 ).scalar_one_or_none():
85 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "reference_already_given")
87 surfed = host_request.surfer_user_id == context.user_id
89 if surfed:
90 my_reason = host_request.surfer_reason_didnt_meetup
91 else:
92 my_reason = host_request.host_reason_didnt_meetup
94 if my_reason != None:
95 context.abort_with_error_code(
96 grpc.StatusCode.FAILED_PRECONDITION, "cant_write_reference_indicated_didnt_meetup"
97 )
99 return host_request, surfed
102def check_valid_reference(
103 request: references_pb2.WriteFriendReferenceReq | references_pb2.WriteHostRequestReferenceReq,
104 context: CouchersContext,
105) -> None:
106 if request.rating < 0 or request.rating > 1: 106 ↛ 107line 106 didn't jump to line 107 because the condition on line 106 was never true
107 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "reference_invalid_rating")
109 if request.text.strip() == "":
110 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "reference_no_text")
113def get_pending_references_to_write(
114 session: Session, context: CouchersContext
115) -> list[tuple[int, ReferenceType, datetime, LiteUser]]:
116 q1 = (
117 select(literal(True), HostRequest, LiteUser)
118 .outerjoin(
119 Reference,
120 and_(
121 Reference.host_request_id == HostRequest.conversation_id,
122 Reference.from_user_id == context.user_id,
123 ),
124 )
125 .join(LiteUser, LiteUser.id == HostRequest.host_user_id)
126 )
127 q1 = where_users_column_visible(q1, context, HostRequest.host_user_id)
128 q1 = where_moderated_content_visible(q1, context, HostRequest, is_list_operation=True)
129 q1 = q1.where(Reference.id == None)
130 q1 = q1.where(HostRequest.can_write_reference)
131 q1 = q1.where(HostRequest.surfer_user_id == context.user_id)
132 q1 = q1.where(HostRequest.surfer_reason_didnt_meetup == None)
134 q2 = (
135 select(literal(False), HostRequest, LiteUser)
136 .outerjoin(
137 Reference,
138 and_(
139 Reference.host_request_id == HostRequest.conversation_id,
140 Reference.from_user_id == context.user_id,
141 ),
142 )
143 .join(LiteUser, LiteUser.id == HostRequest.surfer_user_id)
144 )
145 q2 = where_users_column_visible(q2, context, HostRequest.surfer_user_id)
146 q2 = where_moderated_content_visible(q2, context, HostRequest, is_list_operation=True)
147 q2 = q2.where(Reference.id == None)
148 q2 = q2.where(HostRequest.can_write_reference)
149 q2 = q2.where(HostRequest.host_user_id == context.user_id)
150 q2 = q2.where(HostRequest.host_reason_didnt_meetup == None)
152 union = union_all(q1, q2).order_by(HostRequest.end_time_to_write_reference.asc()).subquery()
153 query = select(union.c[0].label("surfed"), aliased(HostRequest, union), aliased(LiteUser, union))
154 host_request_references = session.execute(query).all()
156 return [
157 (
158 host_request.conversation_id,
159 ReferenceType.surfed if surfed else ReferenceType.hosted,
160 host_request.end_time_to_write_reference,
161 other_user,
162 )
163 for surfed, host_request, other_user in host_request_references
164 ]
167class References(references_pb2_grpc.ReferencesServicer):
168 def ListReferences(
169 self, request: references_pb2.ListReferencesReq, context: CouchersContext, session: Session
170 ) -> references_pb2.ListReferencesRes:
171 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
172 next_reference_id = int(request.page_token) if request.page_token else 0
174 if not request.from_user_id and not request.to_user_id:
175 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "need_to_specify_at_least_one_user")
177 to_users = aliased(User)
178 from_users = aliased(User)
179 statement = select(Reference).where(Reference.is_deleted == False)
180 if request.from_user_id:
181 # join the to_users, because only interested if the recipient is visible
182 statement = (
183 statement.join(to_users, Reference.to_user_id == to_users.id)
184 .where(
185 to_users.banned_at.is_(None)
186 ) # instead of where_users_visible; if user is deleted or blocked, reference still visible
187 .where(Reference.from_user_id == request.from_user_id)
188 )
189 if request.to_user_id:
190 # join the from_users, because only interested if the writer is visible
191 statement = (
192 statement.join(from_users, Reference.from_user_id == from_users.id)
193 .where(
194 from_users.banned_at.is_(None)
195 ) # instead of where_users_visible; if user is deleted or blocked, reference still visible
196 .where(Reference.to_user_id == request.to_user_id)
197 )
198 if len(request.reference_type_filter) > 0:
199 statement = statement.where(
200 Reference.reference_type.in_([reftype2sql[t] for t in request.reference_type_filter])
201 )
203 if next_reference_id:
204 statement = statement.where(Reference.id <= next_reference_id)
206 # Reference visibility logic:
207 # A reference is visible if any of the following apply:
208 # 1. It is a friend reference
209 # 2. Both references have been written
210 # 3. It has been over 2 weeks since the host request ended
212 # we get the matching other references through this subquery
213 sub = select(Reference.id.label("sub_id"), Reference.host_request_id).where(
214 Reference.reference_type != ReferenceType.friend
215 )
216 if request.from_user_id:
217 sub = sub.where(Reference.to_user_id == request.from_user_id)
218 if request.to_user_id:
219 sub = sub.where(Reference.from_user_id == request.to_user_id)
221 query = sub.subquery()
222 statement = (
223 statement.outerjoin(query, query.c.host_request_id == Reference.host_request_id)
224 .outerjoin(HostRequest, HostRequest.conversation_id == Reference.host_request_id)
225 .where(
226 or_(
227 Reference.reference_type == ReferenceType.friend,
228 query.c.sub_id != None,
229 HostRequest.end_time_to_write_reference < func.now(),
230 )
231 )
232 )
234 statement = statement.order_by(Reference.id.desc()).limit(page_size + 1)
235 references = session.execute(statement).scalars().all()
237 return references_pb2.ListReferencesRes(
238 references=[reference_to_pb(reference, context) for reference in references[:page_size]],
239 next_page_token=str(references[-1].id) if len(references) > page_size else None,
240 )
242 def WriteFriendReference(
243 self, request: references_pb2.WriteFriendReferenceReq, context: CouchersContext, session: Session
244 ) -> references_pb2.Reference:
245 if context.user_id == request.to_user_id:
246 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "cant_refer_self")
248 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
250 check_valid_reference(request, context)
252 if not session.execute( 252 ↛ 255line 252 didn't jump to line 255 because the condition on line 252 was never true
253 select(User).where(users_visible(context)).where(User.id == request.to_user_id)
254 ).scalar_one_or_none():
255 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "user_not_found")
257 if not are_friends(session, context, request.to_user_id):
258 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "can_only_refer_friends")
260 if session.execute(
261 select(Reference)
262 .where(Reference.from_user_id == context.user_id)
263 .where(Reference.to_user_id == request.to_user_id)
264 .where(Reference.reference_type == ReferenceType.friend)
265 ).scalar_one_or_none():
266 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "reference_already_given")
268 reference_text = request.text.strip()
270 reference = Reference(
271 from_user_id=context.user_id,
272 to_user_id=request.to_user_id,
273 reference_type=ReferenceType.friend,
274 text=reference_text,
275 private_text=request.private_text.strip(),
276 rating=request.rating,
277 was_appropriate=request.was_appropriate,
278 )
279 session.add(reference)
280 session.commit()
282 # send the recipient of the reference a reminder
283 notify(
284 session,
285 user_id=request.to_user_id,
286 topic_action=NotificationTopicAction.reference__receive_friend,
287 key=str(reference.id),
288 data=notification_data_pb2.ReferenceReceiveFriend(
289 from_user=user_model_to_pb(user, session, make_background_user_context(user_id=request.to_user_id)),
290 text=reference_text,
291 ),
292 )
294 # possibly send out an alert to the mod team if the reference was bad
295 maybe_send_reference_report_email(session, reference)
297 log_event(
298 context,
299 session,
300 "reference.friend_written",
301 {
302 "to_user_id": request.to_user_id,
303 "rating": request.rating,
304 "was_appropriate": request.was_appropriate,
305 },
306 )
308 return reference_to_pb(reference, context)
310 def WriteHostRequestReference(
311 self, request: references_pb2.WriteHostRequestReferenceReq, context: CouchersContext, session: Session
312 ) -> references_pb2.Reference:
313 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
315 check_valid_reference(request, context)
317 host_request, surfed = get_host_req_and_check_can_write_ref(session, context, request.host_request_id)
319 reference_text = request.text.strip()
321 if surfed:
322 # we requested to surf with someone
323 reference_type = ReferenceType.surfed
324 to_user_id = host_request.host_user_id
325 assert context.user_id == host_request.surfer_user_id
326 else:
327 # we hosted someone
328 reference_type = ReferenceType.hosted
329 to_user_id = host_request.surfer_user_id
330 assert context.user_id == host_request.host_user_id
332 reference = Reference(
333 from_user_id=context.user_id,
334 to_user_id=to_user_id,
335 host_request_id=host_request.conversation_id,
336 text=reference_text,
337 private_text=request.private_text.strip(),
338 rating=request.rating,
339 was_appropriate=request.was_appropriate,
340 reference_type=reference_type,
341 )
343 session.add(reference)
344 session.commit()
346 other_reference = session.execute(
347 select(Reference)
348 .where(Reference.host_request_id == host_request.conversation_id)
349 .where(Reference.to_user_id == context.user_id)
350 ).scalar_one_or_none()
352 # send notification out
353 topic_action = (
354 NotificationTopicAction.reference__receive_surfed
355 if surfed
356 else NotificationTopicAction.reference__receive_hosted
357 )
358 notify(
359 session,
360 user_id=reference.to_user_id,
361 topic_action=topic_action,
362 key=str(host_request.conversation_id),
363 data=notification_data_pb2.ReferenceReceiveHostRequest(
364 host_request_id=host_request.conversation_id,
365 from_user=user_model_to_pb(user, session, make_background_user_context(user_id=reference.to_user_id)),
366 text=reference_text if other_reference is not None else None,
367 ),
368 )
370 # possibly send out an alert to the mod team if the reference was bad
371 maybe_send_reference_report_email(session, reference)
373 log_event(
374 context,
375 session,
376 "reference.host_request_written",
377 {
378 "to_user_id": to_user_id,
379 "host_request_id": host_request.conversation_id,
380 "reference_type": reference_type.name,
381 "rating": request.rating,
382 "was_appropriate": request.was_appropriate,
383 },
384 )
386 return reference_to_pb(reference, context)
388 def HostRequestIndicateDidntMeetup(
389 self, request: references_pb2.HostRequestIndicateDidntMeetupReq, context: CouchersContext, session: Session
390 ) -> empty_pb2.Empty:
391 host_request, surfed = get_host_req_and_check_can_write_ref(session, context, request.host_request_id)
393 reason = request.reason_didnt_meetup.strip()
395 if surfed: 395 ↛ 396line 395 didn't jump to line 396 because the condition on line 395 was never true
396 host_request.surfer_reason_didnt_meetup = reason
397 else:
398 host_request.host_reason_didnt_meetup = reason
400 return empty_pb2.Empty()
402 def AvailableWriteReferences(
403 self, request: references_pb2.AvailableWriteReferencesReq, context: CouchersContext, session: Session
404 ) -> references_pb2.AvailableWriteReferencesRes:
405 # can't write anything for ourselves, but let's return empty so this can be used generically on profile page
406 if request.to_user_id == context.user_id:
407 return references_pb2.AvailableWriteReferencesRes()
409 if not session.execute(
410 select(User).where(users_visible(context)).where(User.id == request.to_user_id)
411 ).scalar_one_or_none():
412 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "user_not_found")
414 can_write_friend_reference = (
415 session.execute(
416 select(Reference)
417 .where(Reference.from_user_id == context.user_id)
418 .where(Reference.to_user_id == request.to_user_id)
419 .where(Reference.reference_type == ReferenceType.friend)
420 ).scalar_one_or_none()
421 ) is None
423 q1 = (
424 select(literal(True), HostRequest)
425 .outerjoin(
426 Reference,
427 and_(
428 Reference.host_request_id == HostRequest.conversation_id,
429 Reference.from_user_id == context.user_id,
430 ),
431 )
432 .where(Reference.id == None)
433 .where(HostRequest.can_write_reference)
434 .where(HostRequest.surfer_user_id == context.user_id)
435 .where(HostRequest.host_user_id == request.to_user_id)
436 .where(HostRequest.surfer_reason_didnt_meetup == None)
437 )
439 q2 = (
440 select(literal(False), HostRequest)
441 .outerjoin(
442 Reference,
443 and_(
444 Reference.host_request_id == HostRequest.conversation_id,
445 Reference.from_user_id == context.user_id,
446 ),
447 )
448 .where(Reference.id == None)
449 .where(HostRequest.can_write_reference)
450 .where(HostRequest.surfer_user_id == request.to_user_id)
451 .where(HostRequest.host_user_id == context.user_id)
452 .where(HostRequest.host_reason_didnt_meetup == None)
453 )
455 union = union_all(q1, q2).order_by(HostRequest.end_time_to_write_reference.asc()).subquery()
456 query = select(union.c[0].label("surfed"), aliased(HostRequest, union))
457 host_request_references = session.execute(query).all()
459 return references_pb2.AvailableWriteReferencesRes(
460 can_write_friend_reference=can_write_friend_reference,
461 available_write_references=[
462 references_pb2.AvailableWriteReferenceType(
463 host_request_id=host_request.conversation_id,
464 reference_type=reftype2api[ReferenceType.surfed if surfed else ReferenceType.hosted],
465 time_expires=Timestamp_from_datetime(host_request.end_time_to_write_reference),
466 )
467 for surfed, host_request in host_request_references
468 ],
469 )
471 def ListPendingReferencesToWrite(
472 self, request: empty_pb2.Empty, context: CouchersContext, session: Session
473 ) -> references_pb2.ListPendingReferencesToWriteRes:
474 return references_pb2.ListPendingReferencesToWriteRes(
475 pending_references=[
476 references_pb2.AvailableWriteReferenceType(
477 host_request_id=host_request_id,
478 reference_type=reftype2api[reference_type],
479 time_expires=Timestamp_from_datetime(end_time_to_write_reference),
480 )
481 for host_request_id, reference_type, end_time_to_write_reference, other_user in get_pending_references_to_write(
482 session, context
483 )
484 ],
485 )
487 def GetHostRequestReferenceStatus(
488 self, request: references_pb2.GetHostRequestReferenceStatusReq, context: CouchersContext, session: Session
489 ) -> references_pb2.GetHostRequestReferenceStatusRes:
490 # Compute has_given (whether current user already wrote a reference for this host request)
491 has_given = (
492 session.execute(
493 select(Reference)
494 .where(Reference.host_request_id == request.host_request_id)
495 .where(Reference.from_user_id == context.user_id)
496 ).scalar_one_or_none()
497 is not None
498 )
500 query = select(HostRequest)
501 query = where_moderated_content_visible(query, context, HostRequest, is_list_operation=False)
502 query = query.where(HostRequest.conversation_id == request.host_request_id)
503 query = query.where(
504 or_(HostRequest.surfer_user_id == context.user_id, HostRequest.host_user_id == context.user_id)
505 )
506 host_request = session.execute(query).scalar_one_or_none()
508 can_write = False
509 is_expired = False
510 didnt_stay = False
512 if host_request is not None:
513 # Compute expired from end_time_to_write_reference
514 if host_request.end_time_to_write_reference is not None: 514 ↛ 518line 514 didn't jump to line 518 because the condition on line 514 was always true
515 is_expired = host_request.end_time_to_write_reference < now()
517 # Block only if current user indicated didn't meet up
518 didnt_stay = (
519 (host_request.surfer_reason_didnt_meetup is not None)
520 if host_request.surfer_user_id == context.user_id
521 else (host_request.host_reason_didnt_meetup is not None)
522 )
524 # You can write only if: host_request allows it, you didn't already give one, and you didn't indicate didn't meet up
525 can_write = bool(host_request.can_write_reference) and (not has_given) and (not didnt_stay)
527 return references_pb2.GetHostRequestReferenceStatusRes(
528 has_given=has_given,
529 can_write=can_write,
530 is_expired=is_expired,
531 didnt_stay=didnt_stay,
532 )