Coverage for src / couchers / servicers / references.py: 96%
169 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 00:39 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 00:39 +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 if surfed:
310 # we requested to surf with someone
311 reference_type = ReferenceType.surfed
312 to_user_id = host_request.host_user_id
313 assert context.user_id == host_request.surfer_user_id
314 else:
315 # we hosted someone
316 reference_type = ReferenceType.hosted
317 to_user_id = host_request.surfer_user_id
318 assert context.user_id == host_request.host_user_id
320 reference = Reference(
321 from_user_id=context.user_id,
322 to_user_id=to_user_id,
323 host_request_id=host_request.conversation_id,
324 text=reference_text,
325 private_text=request.private_text.strip(),
326 rating=request.rating,
327 was_appropriate=request.was_appropriate,
328 reference_type=reference_type,
329 )
331 session.add(reference)
332 session.commit()
334 other_reference = session.execute(
335 select(Reference)
336 .where(Reference.host_request_id == host_request.conversation_id)
337 .where(Reference.to_user_id == context.user_id)
338 ).scalar_one_or_none()
340 # send notification out
341 topic_action = (
342 NotificationTopicAction.reference__receive_surfed
343 if surfed
344 else NotificationTopicAction.reference__receive_hosted
345 )
346 notify(
347 session,
348 user_id=reference.to_user_id,
349 topic_action=topic_action,
350 key=str(host_request.conversation_id),
351 data=notification_data_pb2.ReferenceReceiveHostRequest(
352 host_request_id=host_request.conversation_id,
353 from_user=user_model_to_pb(user, session, make_background_user_context(user_id=reference.to_user_id)),
354 text=reference_text if other_reference is not None else None,
355 ),
356 )
358 # possibly send out an alert to the mod team if the reference was bad
359 maybe_send_reference_report_email(session, reference)
361 return reference_to_pb(reference, context)
363 def HostRequestIndicateDidntMeetup(
364 self, request: references_pb2.HostRequestIndicateDidntMeetupReq, context: CouchersContext, session: Session
365 ) -> empty_pb2.Empty:
366 host_request, surfed = get_host_req_and_check_can_write_ref(session, context, request.host_request_id)
368 reason = request.reason_didnt_meetup.strip()
370 if surfed: 370 ↛ 371line 370 didn't jump to line 371 because the condition on line 370 was never true
371 host_request.surfer_reason_didnt_meetup = reason
372 else:
373 host_request.host_reason_didnt_meetup = reason
375 return empty_pb2.Empty()
377 def AvailableWriteReferences(
378 self, request: references_pb2.AvailableWriteReferencesReq, context: CouchersContext, session: Session
379 ) -> references_pb2.AvailableWriteReferencesRes:
380 # can't write anything for ourselves, but let's return empty so this can be used generically on profile page
381 if request.to_user_id == context.user_id:
382 return references_pb2.AvailableWriteReferencesRes()
384 if not session.execute(
385 select(User).where(users_visible(context)).where(User.id == request.to_user_id)
386 ).scalar_one_or_none():
387 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "user_not_found")
389 can_write_friend_reference = (
390 session.execute(
391 select(Reference)
392 .where(Reference.from_user_id == context.user_id)
393 .where(Reference.to_user_id == request.to_user_id)
394 .where(Reference.reference_type == ReferenceType.friend)
395 ).scalar_one_or_none()
396 ) is None
398 q1 = (
399 select(literal(True), HostRequest)
400 .outerjoin(
401 Reference,
402 and_(
403 Reference.host_request_id == HostRequest.conversation_id,
404 Reference.from_user_id == context.user_id,
405 ),
406 )
407 .where(Reference.id == None)
408 .where(HostRequest.can_write_reference)
409 .where(HostRequest.surfer_user_id == context.user_id)
410 .where(HostRequest.host_user_id == request.to_user_id)
411 .where(HostRequest.surfer_reason_didnt_meetup == None)
412 )
414 q2 = (
415 select(literal(False), HostRequest)
416 .outerjoin(
417 Reference,
418 and_(
419 Reference.host_request_id == HostRequest.conversation_id,
420 Reference.from_user_id == context.user_id,
421 ),
422 )
423 .where(Reference.id == None)
424 .where(HostRequest.can_write_reference)
425 .where(HostRequest.surfer_user_id == request.to_user_id)
426 .where(HostRequest.host_user_id == context.user_id)
427 .where(HostRequest.host_reason_didnt_meetup == None)
428 )
430 union = union_all(q1, q2).order_by(HostRequest.end_time_to_write_reference.asc()).subquery()
431 query = select(union.c[0].label("surfed"), aliased(HostRequest, union))
432 host_request_references = session.execute(query).all()
434 return references_pb2.AvailableWriteReferencesRes(
435 can_write_friend_reference=can_write_friend_reference,
436 available_write_references=[
437 references_pb2.AvailableWriteReferenceType(
438 host_request_id=host_request.conversation_id,
439 reference_type=reftype2api[ReferenceType.surfed if surfed else ReferenceType.hosted],
440 time_expires=Timestamp_from_datetime(host_request.end_time_to_write_reference),
441 )
442 for surfed, host_request in host_request_references
443 ],
444 )
446 def ListPendingReferencesToWrite(
447 self, request: empty_pb2.Empty, context: CouchersContext, session: Session
448 ) -> references_pb2.ListPendingReferencesToWriteRes:
449 return references_pb2.ListPendingReferencesToWriteRes(
450 pending_references=[
451 references_pb2.AvailableWriteReferenceType(
452 host_request_id=host_request_id,
453 reference_type=reftype2api[reference_type],
454 time_expires=Timestamp_from_datetime(end_time_to_write_reference),
455 )
456 for host_request_id, reference_type, end_time_to_write_reference, other_user in get_pending_references_to_write(
457 session, context
458 )
459 ],
460 )
462 def GetHostRequestReferenceStatus(
463 self, request: references_pb2.GetHostRequestReferenceStatusReq, context: CouchersContext, session: Session
464 ) -> references_pb2.GetHostRequestReferenceStatusRes:
465 # Compute has_given (whether current user already wrote a reference for this host request)
466 has_given = (
467 session.execute(
468 select(Reference)
469 .where(Reference.host_request_id == request.host_request_id)
470 .where(Reference.from_user_id == context.user_id)
471 ).scalar_one_or_none()
472 is not None
473 )
475 query = select(HostRequest)
476 query = where_moderated_content_visible(query, context, HostRequest, is_list_operation=False)
477 query = query.where(HostRequest.conversation_id == request.host_request_id)
478 query = query.where(
479 or_(HostRequest.surfer_user_id == context.user_id, HostRequest.host_user_id == context.user_id)
480 )
481 host_request = session.execute(query).scalar_one_or_none()
483 can_write = False
484 is_expired = False
485 didnt_stay = False
487 if host_request is not None:
488 # Compute expired from end_time_to_write_reference
489 if host_request.end_time_to_write_reference is not None: 489 ↛ 493line 489 didn't jump to line 493 because the condition on line 489 was always true
490 is_expired = host_request.end_time_to_write_reference < now()
492 # Block only if current user indicated didn't meet up
493 didnt_stay = (
494 (host_request.surfer_reason_didnt_meetup is not None)
495 if host_request.surfer_user_id == context.user_id
496 else (host_request.host_reason_didnt_meetup is not None)
497 )
499 # You can write only if: host_request allows it, you didn't already give one, and you didn't indicate didn't meet up
500 can_write = bool(host_request.can_write_reference) and (not has_given) and (not didnt_stay)
502 return references_pb2.GetHostRequestReferenceStatusRes(
503 has_given=has_given,
504 can_write=can_write,
505 is_expired=is_expired,
506 didnt_stay=didnt_stay,
507 )