Coverage for src / couchers / servicers / references.py: 96%
169 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-07 13:46 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-07 13:46 +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.materialized_views import LiteUser
19from couchers.models import HostRequest, Reference, ReferenceType, User
20from couchers.models.notifications import NotificationTopicAction
21from couchers.notifications.notify import notify
22from couchers.proto import notification_data_pb2, references_pb2, references_pb2_grpc
23from couchers.servicers.api import user_model_to_pb
24from couchers.sql import users_visible, where_moderated_content_visible, where_users_column_visible
25from couchers.tasks import maybe_send_reference_report_email
26from couchers.utils import Timestamp_from_datetime, now
28MAX_PAGINATION_LENGTH = 100
30reftype2sql = {
31 references_pb2.ReferenceType.REFERENCE_TYPE_FRIEND: ReferenceType.friend,
32 references_pb2.ReferenceType.REFERENCE_TYPE_SURFED: ReferenceType.surfed,
33 references_pb2.ReferenceType.REFERENCE_TYPE_HOSTED: ReferenceType.hosted,
34}
36reftype2api = {
37 ReferenceType.friend: references_pb2.ReferenceType.REFERENCE_TYPE_FRIEND,
38 ReferenceType.surfed: references_pb2.ReferenceType.REFERENCE_TYPE_SURFED,
39 ReferenceType.hosted: references_pb2.ReferenceType.REFERENCE_TYPE_HOSTED,
40}
43def reference_to_pb(reference: Reference, context: CouchersContext) -> references_pb2.Reference:
44 return references_pb2.Reference(
45 reference_id=reference.id,
46 from_user_id=reference.from_user_id,
47 to_user_id=reference.to_user_id,
48 reference_type=reftype2api[reference.reference_type],
49 text=reference.text,
50 written_time=Timestamp_from_datetime(reference.time.replace(hour=0, minute=0, second=0, microsecond=0)),
51 host_request_id=(
52 reference.host_request_id if context.user_id in [reference.from_user_id, reference.to_user_id] else None
53 ),
54 )
57def get_host_req_and_check_can_write_ref(
58 session: Session, context: CouchersContext, host_request_id: int
59) -> tuple[HostRequest, bool]:
60 """
61 Checks that this can see the given host req and write a ref for it
63 Returns the host req and `surfed`, a boolean of if the user was the surfer or not
64 """
65 query = select(HostRequest)
66 query = where_users_column_visible(query, context, HostRequest.surfer_user_id)
67 query = where_users_column_visible(query, context, HostRequest.host_user_id)
68 query = where_moderated_content_visible(query, context, HostRequest, is_list_operation=False)
69 query = query.where(HostRequest.conversation_id == host_request_id)
70 query = query.where(or_(HostRequest.surfer_user_id == context.user_id, HostRequest.host_user_id == context.user_id))
71 host_request = session.execute(query).scalar_one_or_none()
73 if not host_request: 73 ↛ 74line 73 didn't jump to line 74 because the condition on line 73 was never true
74 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "host_request_not_found")
76 if not host_request.can_write_reference:
77 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "cant_write_reference_for_request")
79 if session.execute(
80 select(Reference)
81 .where(Reference.host_request_id == host_request.conversation_id)
82 .where(Reference.from_user_id == context.user_id)
83 ).scalar_one_or_none():
84 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "reference_already_given")
86 surfed = host_request.surfer_user_id == context.user_id
88 if surfed:
89 my_reason = host_request.surfer_reason_didnt_meetup
90 else:
91 my_reason = host_request.host_reason_didnt_meetup
93 if my_reason != None:
94 context.abort_with_error_code(
95 grpc.StatusCode.FAILED_PRECONDITION, "cant_write_reference_indicated_didnt_meetup"
96 )
98 return host_request, surfed
101def check_valid_reference(
102 request: references_pb2.WriteFriendReferenceReq | references_pb2.WriteHostRequestReferenceReq,
103 context: CouchersContext,
104) -> None:
105 if request.rating < 0 or request.rating > 1: 105 ↛ 106line 105 didn't jump to line 106 because the condition on line 105 was never true
106 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "reference_invalid_rating")
108 if request.text.strip() == "":
109 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "reference_no_text")
112def get_pending_references_to_write(
113 session: Session, context: CouchersContext
114) -> list[tuple[int, ReferenceType, datetime, LiteUser]]:
115 q1 = (
116 select(literal(True), HostRequest, LiteUser)
117 .outerjoin(
118 Reference,
119 and_(
120 Reference.host_request_id == HostRequest.conversation_id,
121 Reference.from_user_id == context.user_id,
122 ),
123 )
124 .join(LiteUser, LiteUser.id == HostRequest.host_user_id)
125 )
126 q1 = where_users_column_visible(q1, context, HostRequest.host_user_id)
127 q1 = where_moderated_content_visible(q1, context, HostRequest, is_list_operation=True)
128 q1 = q1.where(Reference.id == None)
129 q1 = q1.where(HostRequest.can_write_reference)
130 q1 = q1.where(HostRequest.surfer_user_id == context.user_id)
131 q1 = q1.where(HostRequest.surfer_reason_didnt_meetup == None)
133 q2 = (
134 select(literal(False), HostRequest, LiteUser)
135 .outerjoin(
136 Reference,
137 and_(
138 Reference.host_request_id == HostRequest.conversation_id,
139 Reference.from_user_id == context.user_id,
140 ),
141 )
142 .join(LiteUser, LiteUser.id == HostRequest.surfer_user_id)
143 )
144 q2 = where_users_column_visible(q2, context, HostRequest.surfer_user_id)
145 q2 = where_moderated_content_visible(q2, context, HostRequest, is_list_operation=True)
146 q2 = q2.where(Reference.id == None)
147 q2 = q2.where(HostRequest.can_write_reference)
148 q2 = q2.where(HostRequest.host_user_id == context.user_id)
149 q2 = q2.where(HostRequest.host_reason_didnt_meetup == None)
151 union = union_all(q1, q2).order_by(HostRequest.end_time_to_write_reference.asc()).subquery()
152 query = select(union.c[0].label("surfed"), aliased(HostRequest, union), aliased(LiteUser, union))
153 host_request_references = session.execute(query).all()
155 return [
156 (
157 host_request.conversation_id,
158 ReferenceType.surfed if surfed else ReferenceType.hosted,
159 host_request.end_time_to_write_reference,
160 other_user,
161 )
162 for surfed, host_request, other_user in host_request_references
163 ]
166class References(references_pb2_grpc.ReferencesServicer):
167 def ListReferences(
168 self, request: references_pb2.ListReferencesReq, context: CouchersContext, session: Session
169 ) -> references_pb2.ListReferencesRes:
170 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
171 next_reference_id = int(request.page_token) if request.page_token else 0
173 if not request.from_user_id and not request.to_user_id:
174 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "need_to_specify_at_least_one_user")
176 to_users = aliased(User)
177 from_users = aliased(User)
178 statement = select(Reference).where(Reference.is_deleted == False)
179 if request.from_user_id:
180 # join the to_users, because only interested if the recipient is visible
181 statement = (
182 statement.join(to_users, Reference.to_user_id == to_users.id)
183 .where(
184 ~to_users.is_banned
185 ) # instead of where_users_visible; if user is deleted or blocked, reference still visible
186 .where(Reference.from_user_id == request.from_user_id)
187 )
188 if request.to_user_id:
189 # join the from_users, because only interested if the writer is visible
190 statement = (
191 statement.join(from_users, Reference.from_user_id == from_users.id)
192 .where(
193 ~from_users.is_banned
194 ) # instead of where_users_visible; if user is deleted or blocked, reference still visible
195 .where(Reference.to_user_id == request.to_user_id)
196 )
197 if len(request.reference_type_filter) > 0:
198 statement = statement.where(
199 Reference.reference_type.in_([reftype2sql[t] for t in request.reference_type_filter])
200 )
202 if next_reference_id:
203 statement = statement.where(Reference.id <= next_reference_id)
205 # Reference visibility logic:
206 # A reference is visible if any of the following apply:
207 # 1. It is a friend reference
208 # 2. Both references have been written
209 # 3. It has been over 2 weeks since the host request ended
211 # we get the matching other references through this subquery
212 sub = select(Reference.id.label("sub_id"), Reference.host_request_id).where(
213 Reference.reference_type != ReferenceType.friend
214 )
215 if request.from_user_id:
216 sub = sub.where(Reference.to_user_id == request.from_user_id)
217 if request.to_user_id:
218 sub = sub.where(Reference.from_user_id == request.to_user_id)
220 query = sub.subquery()
221 statement = (
222 statement.outerjoin(query, query.c.host_request_id == Reference.host_request_id)
223 .outerjoin(HostRequest, HostRequest.conversation_id == Reference.host_request_id)
224 .where(
225 or_(
226 Reference.reference_type == ReferenceType.friend,
227 query.c.sub_id != None,
228 HostRequest.end_time_to_write_reference < func.now(),
229 )
230 )
231 )
233 statement = statement.order_by(Reference.id.desc()).limit(page_size + 1)
234 references = session.execute(statement).scalars().all()
236 return references_pb2.ListReferencesRes(
237 references=[reference_to_pb(reference, context) for reference in references[:page_size]],
238 next_page_token=str(references[-1].id) if len(references) > page_size else None,
239 )
241 def WriteFriendReference(
242 self, request: references_pb2.WriteFriendReferenceReq, context: CouchersContext, session: Session
243 ) -> references_pb2.Reference:
244 if context.user_id == request.to_user_id:
245 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "cant_refer_self")
247 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
249 check_valid_reference(request, context)
251 if not session.execute( 251 ↛ 254line 251 didn't jump to line 254 because the condition on line 251 was never true
252 select(User).where(users_visible(context)).where(User.id == request.to_user_id)
253 ).scalar_one_or_none():
254 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "user_not_found")
256 if not are_friends(session, context, request.to_user_id):
257 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "can_only_refer_friends")
259 if session.execute(
260 select(Reference)
261 .where(Reference.from_user_id == context.user_id)
262 .where(Reference.to_user_id == request.to_user_id)
263 .where(Reference.reference_type == ReferenceType.friend)
264 ).scalar_one_or_none():
265 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "reference_already_given")
267 reference_text = request.text.strip()
269 reference = Reference(
270 from_user_id=context.user_id,
271 to_user_id=request.to_user_id,
272 reference_type=ReferenceType.friend,
273 text=reference_text,
274 private_text=request.private_text.strip(),
275 rating=request.rating,
276 was_appropriate=request.was_appropriate,
277 )
278 session.add(reference)
279 session.commit()
281 # send the recipient of the reference a reminder
282 notify(
283 session,
284 user_id=request.to_user_id,
285 topic_action=NotificationTopicAction.reference__receive_friend,
286 key=str(reference.id),
287 data=notification_data_pb2.ReferenceReceiveFriend(
288 from_user=user_model_to_pb(user, session, make_background_user_context(user_id=request.to_user_id)),
289 text=reference_text,
290 ),
291 )
293 # possibly send out an alert to the mod team if the reference was bad
294 maybe_send_reference_report_email(session, reference)
296 return reference_to_pb(reference, context)
298 def WriteHostRequestReference(
299 self, request: references_pb2.WriteHostRequestReferenceReq, context: CouchersContext, session: Session
300 ) -> references_pb2.Reference:
301 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
303 check_valid_reference(request, context)
305 host_request, surfed = get_host_req_and_check_can_write_ref(session, context, request.host_request_id)
307 reference_text = request.text.strip()
309 reference = Reference(
310 from_user_id=context.user_id,
311 host_request_id=host_request.conversation_id,
312 text=reference_text,
313 private_text=request.private_text.strip(),
314 rating=request.rating,
315 was_appropriate=request.was_appropriate,
316 )
318 if surfed:
319 # we requested to surf with someone
320 reference.reference_type = ReferenceType.surfed
321 reference.to_user_id = host_request.host_user_id
322 assert context.user_id == host_request.surfer_user_id
323 else:
324 # we hosted someone
325 reference.reference_type = ReferenceType.hosted
326 reference.to_user_id = host_request.surfer_user_id
327 assert context.user_id == host_request.host_user_id
329 session.add(reference)
330 session.commit()
332 other_reference = session.execute(
333 select(Reference)
334 .where(Reference.host_request_id == host_request.conversation_id)
335 .where(Reference.to_user_id == context.user_id)
336 ).scalar_one_or_none()
338 # send notification out
339 topic_action = (
340 NotificationTopicAction.reference__receive_surfed
341 if surfed
342 else NotificationTopicAction.reference__receive_hosted
343 )
344 notify(
345 session,
346 user_id=reference.to_user_id,
347 topic_action=topic_action,
348 key=str(host_request.conversation_id),
349 data=notification_data_pb2.ReferenceReceiveHostRequest(
350 host_request_id=host_request.conversation_id,
351 from_user=user_model_to_pb(user, session, make_background_user_context(user_id=reference.to_user_id)),
352 text=reference_text if other_reference is not None else None,
353 ),
354 )
356 # possibly send out an alert to the mod team if the reference was bad
357 maybe_send_reference_report_email(session, reference)
359 return reference_to_pb(reference, context)
361 def HostRequestIndicateDidntMeetup(
362 self, request: references_pb2.HostRequestIndicateDidntMeetupReq, context: CouchersContext, session: Session
363 ) -> empty_pb2.Empty:
364 host_request, surfed = get_host_req_and_check_can_write_ref(session, context, request.host_request_id)
366 reason = request.reason_didnt_meetup.strip()
368 if surfed: 368 ↛ 369line 368 didn't jump to line 369 because the condition on line 368 was never true
369 host_request.surfer_reason_didnt_meetup = reason
370 else:
371 host_request.host_reason_didnt_meetup = reason
373 return empty_pb2.Empty()
375 def AvailableWriteReferences(
376 self, request: references_pb2.AvailableWriteReferencesReq, context: CouchersContext, session: Session
377 ) -> references_pb2.AvailableWriteReferencesRes:
378 # can't write anything for ourselves, but let's return empty so this can be used generically on profile page
379 if request.to_user_id == context.user_id:
380 return references_pb2.AvailableWriteReferencesRes()
382 if not session.execute(
383 select(User).where(users_visible(context)).where(User.id == request.to_user_id)
384 ).scalar_one_or_none():
385 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "user_not_found")
387 can_write_friend_reference = (
388 session.execute(
389 select(Reference)
390 .where(Reference.from_user_id == context.user_id)
391 .where(Reference.to_user_id == request.to_user_id)
392 .where(Reference.reference_type == ReferenceType.friend)
393 ).scalar_one_or_none()
394 ) is None
396 q1 = (
397 select(literal(True), HostRequest)
398 .outerjoin(
399 Reference,
400 and_(
401 Reference.host_request_id == HostRequest.conversation_id,
402 Reference.from_user_id == context.user_id,
403 ),
404 )
405 .where(Reference.id == None)
406 .where(HostRequest.can_write_reference)
407 .where(HostRequest.surfer_user_id == context.user_id)
408 .where(HostRequest.host_user_id == request.to_user_id)
409 .where(HostRequest.surfer_reason_didnt_meetup == None)
410 )
412 q2 = (
413 select(literal(False), HostRequest)
414 .outerjoin(
415 Reference,
416 and_(
417 Reference.host_request_id == HostRequest.conversation_id,
418 Reference.from_user_id == context.user_id,
419 ),
420 )
421 .where(Reference.id == None)
422 .where(HostRequest.can_write_reference)
423 .where(HostRequest.surfer_user_id == request.to_user_id)
424 .where(HostRequest.host_user_id == context.user_id)
425 .where(HostRequest.host_reason_didnt_meetup == None)
426 )
428 union = union_all(q1, q2).order_by(HostRequest.end_time_to_write_reference.asc()).subquery()
429 query = select(union.c[0].label("surfed"), aliased(HostRequest, union))
430 host_request_references = session.execute(query).all()
432 return references_pb2.AvailableWriteReferencesRes(
433 can_write_friend_reference=can_write_friend_reference,
434 available_write_references=[
435 references_pb2.AvailableWriteReferenceType(
436 host_request_id=host_request.conversation_id,
437 reference_type=reftype2api[ReferenceType.surfed if surfed else ReferenceType.hosted],
438 time_expires=Timestamp_from_datetime(host_request.end_time_to_write_reference),
439 )
440 for surfed, host_request in host_request_references
441 ],
442 )
444 def ListPendingReferencesToWrite(
445 self, request: empty_pb2.Empty, context: CouchersContext, session: Session
446 ) -> references_pb2.ListPendingReferencesToWriteRes:
447 return references_pb2.ListPendingReferencesToWriteRes(
448 pending_references=[
449 references_pb2.AvailableWriteReferenceType(
450 host_request_id=host_request_id,
451 reference_type=reftype2api[reference_type],
452 time_expires=Timestamp_from_datetime(end_time_to_write_reference),
453 )
454 for host_request_id, reference_type, end_time_to_write_reference, other_user in get_pending_references_to_write(
455 session, context
456 )
457 ],
458 )
460 def GetHostRequestReferenceStatus(
461 self, request: references_pb2.GetHostRequestReferenceStatusReq, context: CouchersContext, session: Session
462 ) -> references_pb2.GetHostRequestReferenceStatusRes:
463 # Compute has_given (whether current user already wrote a reference for this host request)
464 has_given = (
465 session.execute(
466 select(Reference)
467 .where(Reference.host_request_id == request.host_request_id)
468 .where(Reference.from_user_id == context.user_id)
469 ).scalar_one_or_none()
470 is not None
471 )
473 query = select(HostRequest)
474 query = where_moderated_content_visible(query, context, HostRequest, is_list_operation=False)
475 query = query.where(HostRequest.conversation_id == request.host_request_id)
476 query = query.where(
477 or_(HostRequest.surfer_user_id == context.user_id, HostRequest.host_user_id == context.user_id)
478 )
479 host_request = session.execute(query).scalar_one_or_none()
481 can_write = False
482 is_expired = False
483 didnt_stay = False
485 if host_request is not None:
486 # Compute expired from end_time_to_write_reference
487 if host_request.end_time_to_write_reference is not None: 487 ↛ 491line 487 didn't jump to line 491 because the condition on line 487 was always true
488 is_expired = host_request.end_time_to_write_reference < now()
490 # Block only if current user indicated didn't meet up
491 didnt_stay = (
492 (host_request.surfer_reason_didnt_meetup is not None)
493 if host_request.surfer_user_id == context.user_id
494 else (host_request.host_reason_didnt_meetup is not None)
495 )
497 # You can write only if: host_request allows it, you didn't already give one, and you didn't indicate didn't meet up
498 can_write = bool(host_request.can_write_reference) and (not has_given) and (not didnt_stay)
500 return references_pb2.GetHostRequestReferenceStatusRes(
501 has_given=has_given,
502 can_write=can_write,
503 is_expired=is_expired,
504 didnt_stay=didnt_stay,
505 )