Coverage for src/couchers/servicers/references.py: 97%
144 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-12-25 10:58 +0000
« prev ^ index » next coverage.py v7.11.0, created at 2025-12-25 10:58 +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.orm import Session, aliased
13from sqlalchemy.sql import and_, func, literal, or_, union_all
15from couchers.context import CouchersContext, make_background_user_context
16from couchers.db import are_friends
17from couchers.materialized_views import LiteUser
18from couchers.models import HostRequest, Reference, ReferenceType, User
19from couchers.notifications.notify import notify
20from couchers.proto import notification_data_pb2, references_pb2, references_pb2_grpc
21from couchers.servicers.api import user_model_to_pb
22from couchers.sql import couchers_select as select
23from couchers.tasks import maybe_send_reference_report_email
24from couchers.utils import Timestamp_from_datetime, now
26MAX_PAGINATION_LENGTH = 100
28reftype2sql = {
29 references_pb2.ReferenceType.REFERENCE_TYPE_FRIEND: ReferenceType.friend,
30 references_pb2.ReferenceType.REFERENCE_TYPE_SURFED: ReferenceType.surfed,
31 references_pb2.ReferenceType.REFERENCE_TYPE_HOSTED: ReferenceType.hosted,
32}
34reftype2api = {
35 ReferenceType.friend: references_pb2.ReferenceType.REFERENCE_TYPE_FRIEND,
36 ReferenceType.surfed: references_pb2.ReferenceType.REFERENCE_TYPE_SURFED,
37 ReferenceType.hosted: references_pb2.ReferenceType.REFERENCE_TYPE_HOSTED,
38}
41def reference_to_pb(reference: Reference, context: CouchersContext) -> references_pb2.Reference:
42 return references_pb2.Reference(
43 reference_id=reference.id,
44 from_user_id=reference.from_user_id,
45 to_user_id=reference.to_user_id,
46 reference_type=reftype2api[reference.reference_type],
47 text=reference.text,
48 written_time=Timestamp_from_datetime(reference.time.replace(hour=0, minute=0, second=0, microsecond=0)),
49 host_request_id=(
50 reference.host_request_id if context.user_id in [reference.from_user_id, reference.to_user_id] else None
51 ),
52 )
55def get_host_req_and_check_can_write_ref(
56 session: Session, context: CouchersContext, host_request_id: int
57) -> tuple[HostRequest, bool]:
58 """
59 Checks that this can see the given host req and write a ref for it
61 Returns the host req and `surfed`, a boolean of if the user was the surfer or not
62 """
63 host_request = session.execute(
64 select(HostRequest)
65 .where_users_column_visible(context, HostRequest.surfer_user_id)
66 .where_users_column_visible(context, HostRequest.host_user_id)
67 .where_moderated_content_visible(context, HostRequest, is_list_operation=False)
68 .where(HostRequest.conversation_id == host_request_id)
69 .where(or_(HostRequest.surfer_user_id == context.user_id, HostRequest.host_user_id == context.user_id))
70 ).scalar_one_or_none()
72 if not host_request:
73 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "host_request_not_found")
75 if not host_request.can_write_reference:
76 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "cant_write_reference_for_request")
78 if session.execute(
79 select(Reference)
80 .where(Reference.host_request_id == host_request.conversation_id)
81 .where(Reference.from_user_id == context.user_id)
82 ).scalar_one_or_none():
83 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "reference_already_given")
85 surfed = host_request.surfer_user_id == context.user_id
87 if surfed:
88 my_reason = host_request.surfer_reason_didnt_meetup
89 else:
90 my_reason = host_request.host_reason_didnt_meetup
92 if my_reason != None:
93 context.abort_with_error_code(
94 grpc.StatusCode.FAILED_PRECONDITION, "cant_write_reference_indicated_didnt_meetup"
95 )
97 return host_request, surfed
100def check_valid_reference(
101 request: references_pb2.WriteFriendReferenceReq | references_pb2.WriteHostRequestReferenceReq,
102 context: CouchersContext,
103) -> None:
104 if request.rating < 0 or request.rating > 1:
105 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "reference_invalid_rating")
107 if request.text.strip() == "":
108 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "reference_no_text")
111def get_pending_references_to_write(
112 session: Session, context: CouchersContext
113) -> list[tuple[int, ReferenceType, datetime, LiteUser]]:
114 q1 = (
115 select(literal(True), HostRequest, LiteUser)
116 .outerjoin(
117 Reference,
118 and_(
119 Reference.host_request_id == HostRequest.conversation_id,
120 Reference.from_user_id == context.user_id,
121 ),
122 )
123 .join(LiteUser, LiteUser.id == HostRequest.host_user_id)
124 .where_users_column_visible(context, HostRequest.host_user_id)
125 .where_moderated_content_visible(context, HostRequest, is_list_operation=True)
126 .where(Reference.id == None)
127 .where(HostRequest.can_write_reference)
128 .where(HostRequest.surfer_user_id == context.user_id)
129 .where(HostRequest.surfer_reason_didnt_meetup == None)
130 )
132 q2 = (
133 select(literal(False), HostRequest, LiteUser)
134 .outerjoin(
135 Reference,
136 and_(
137 Reference.host_request_id == HostRequest.conversation_id,
138 Reference.from_user_id == context.user_id,
139 ),
140 )
141 .join(LiteUser, LiteUser.id == HostRequest.surfer_user_id)
142 .where_users_column_visible(context, HostRequest.surfer_user_id)
143 .where_moderated_content_visible(context, HostRequest, is_list_operation=True)
144 .where(Reference.id == None)
145 .where(HostRequest.can_write_reference)
146 .where(HostRequest.host_user_id == context.user_id)
147 .where(HostRequest.host_reason_didnt_meetup == None)
148 )
150 union = union_all(q1, q2).order_by(HostRequest.end_time_to_write_reference.asc()).subquery()
151 query = select(union.c[0].label("surfed"), aliased(HostRequest, union), aliased(LiteUser, union))
152 host_request_references = session.execute(query).all()
154 return [
155 (
156 host_request.conversation_id,
157 ReferenceType.surfed if surfed else ReferenceType.hosted,
158 host_request.end_time_to_write_reference,
159 other_user,
160 )
161 for surfed, host_request, other_user in host_request_references
162 ]
165class References(references_pb2_grpc.ReferencesServicer):
166 def ListReferences(
167 self, request: references_pb2.ListReferencesReq, context: CouchersContext, session: Session
168 ) -> references_pb2.ListReferencesRes:
169 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
170 next_reference_id = int(request.page_token) if request.page_token else 0
172 if not request.from_user_id and not request.to_user_id:
173 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "need_to_specify_at_least_one_user")
175 to_users = aliased(User)
176 from_users = aliased(User)
177 statement = select(Reference).where(Reference.is_deleted == False)
178 if request.from_user_id:
179 # join the to_users, because only interested if the recipient is visible
180 statement = (
181 statement.join(to_users, Reference.to_user_id == to_users.id)
182 .where(
183 ~to_users.is_banned
184 ) # instead of where_users_visible; if user is deleted or blocked, reference still visible
185 .where(Reference.from_user_id == request.from_user_id)
186 )
187 if request.to_user_id:
188 # join the from_users, because only interested if the writer is visible
189 statement = (
190 statement.join(from_users, Reference.from_user_id == from_users.id)
191 .where(
192 ~from_users.is_banned
193 ) # instead of where_users_visible; if user is deleted or blocked, reference still visible
194 .where(Reference.to_user_id == request.to_user_id)
195 )
196 if len(request.reference_type_filter) > 0:
197 statement = statement.where(
198 Reference.reference_type.in_([reftype2sql[t] for t in request.reference_type_filter])
199 )
201 if next_reference_id:
202 statement = statement.where(Reference.id <= next_reference_id)
204 # Reference visibility logic:
205 # A reference is visible if any of the following apply:
206 # 1. It is a friend reference
207 # 2. Both references have been written
208 # 3. It has been over 2 weeks since the host request ended
210 # we get the matching other references through this subquery
211 sub = select(Reference.id.label("sub_id"), Reference.host_request_id).where(
212 Reference.reference_type != ReferenceType.friend
213 )
214 if request.from_user_id:
215 sub = sub.where(Reference.to_user_id == request.from_user_id)
216 if request.to_user_id:
217 sub = sub.where(Reference.from_user_id == request.to_user_id)
219 query = sub.subquery()
220 statement = (
221 statement.outerjoin(query, query.c.host_request_id == Reference.host_request_id)
222 .outerjoin(HostRequest, HostRequest.conversation_id == Reference.host_request_id)
223 .where(
224 or_(
225 Reference.reference_type == ReferenceType.friend,
226 query.c.sub_id != None,
227 HostRequest.end_time_to_write_reference < func.now(),
228 )
229 )
230 )
232 statement = statement.order_by(Reference.id.desc()).limit(page_size + 1)
233 references = session.execute(statement).scalars().all()
235 return references_pb2.ListReferencesRes(
236 references=[reference_to_pb(reference, context) for reference in references[:page_size]],
237 next_page_token=str(references[-1].id) if len(references) > page_size else None,
238 )
240 def WriteFriendReference(
241 self, request: references_pb2.WriteFriendReferenceReq, context: CouchersContext, session: Session
242 ) -> references_pb2.Reference:
243 if context.user_id == request.to_user_id:
244 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "cant_refer_self")
246 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
248 check_valid_reference(request, context)
250 if not session.execute(
251 select(User).where_users_visible(context).where(User.id == request.to_user_id)
252 ).scalar_one_or_none():
253 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "user_not_found")
255 if not are_friends(session, context, request.to_user_id):
256 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "can_only_refer_friends")
258 if session.execute(
259 select(Reference)
260 .where(Reference.from_user_id == context.user_id)
261 .where(Reference.to_user_id == request.to_user_id)
262 .where(Reference.reference_type == ReferenceType.friend)
263 ).scalar_one_or_none():
264 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "reference_already_given")
266 reference_text = request.text.strip()
268 reference = Reference(
269 from_user_id=context.user_id,
270 to_user_id=request.to_user_id,
271 reference_type=ReferenceType.friend,
272 text=reference_text,
273 private_text=request.private_text.strip(),
274 rating=request.rating,
275 was_appropriate=request.was_appropriate,
276 )
277 session.add(reference)
278 session.commit()
280 # send the recipient of the reference a reminder
281 notify(
282 session,
283 user_id=request.to_user_id,
284 topic_action="reference:receive_friend",
285 key=str(reference.id),
286 data=notification_data_pb2.ReferenceReceiveFriend(
287 from_user=user_model_to_pb(user, session, make_background_user_context(user_id=request.to_user_id)),
288 text=reference_text,
289 ),
290 )
292 # possibly send out an alert to the mod team if the reference was bad
293 maybe_send_reference_report_email(session, reference)
295 return reference_to_pb(reference, context)
297 def WriteHostRequestReference(
298 self, request: references_pb2.WriteHostRequestReferenceReq, context: CouchersContext, session: Session
299 ) -> references_pb2.Reference:
300 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
302 check_valid_reference(request, context)
304 host_request, surfed = get_host_req_and_check_can_write_ref(session, context, request.host_request_id)
306 reference_text = request.text.strip()
308 reference = Reference(
309 from_user_id=context.user_id,
310 host_request_id=host_request.conversation_id,
311 text=reference_text,
312 private_text=request.private_text.strip(),
313 rating=request.rating,
314 was_appropriate=request.was_appropriate,
315 )
317 if surfed:
318 # we requested to surf with someone
319 reference.reference_type = ReferenceType.surfed
320 reference.to_user_id = host_request.host_user_id
321 assert context.user_id == host_request.surfer_user_id
322 else:
323 # we hosted someone
324 reference.reference_type = ReferenceType.hosted
325 reference.to_user_id = host_request.surfer_user_id
326 assert context.user_id == host_request.host_user_id
328 session.add(reference)
329 session.commit()
331 other_reference = session.execute(
332 select(Reference)
333 .where(Reference.host_request_id == host_request.conversation_id)
334 .where(Reference.to_user_id == context.user_id)
335 ).scalar_one_or_none()
337 # send notification out
338 notify(
339 session,
340 user_id=reference.to_user_id,
341 topic_action="reference:receive_surfed" if surfed else "reference:receive_hosted",
342 key=str(host_request.conversation_id),
343 data=notification_data_pb2.ReferenceReceiveHostRequest(
344 host_request_id=host_request.conversation_id,
345 from_user=user_model_to_pb(user, session, make_background_user_context(user_id=reference.to_user_id)),
346 text=reference_text if other_reference is not None else None,
347 ),
348 )
350 # possibly send out an alert to the mod team if the reference was bad
351 maybe_send_reference_report_email(session, reference)
353 return reference_to_pb(reference, context)
355 def HostRequestIndicateDidntMeetup(
356 self, request: references_pb2.HostRequestIndicateDidntMeetupReq, context: CouchersContext, session: Session
357 ) -> empty_pb2.Empty:
358 host_request, surfed = get_host_req_and_check_can_write_ref(session, context, request.host_request_id)
360 reason = request.reason_didnt_meetup.strip()
362 if surfed:
363 host_request.surfer_reason_didnt_meetup = reason
364 else:
365 host_request.host_reason_didnt_meetup = reason
367 return empty_pb2.Empty()
369 def AvailableWriteReferences(
370 self, request: references_pb2.AvailableWriteReferencesReq, context: CouchersContext, session: Session
371 ) -> references_pb2.AvailableWriteReferencesRes:
372 # can't write anything for ourselves, but let's return empty so this can be used generically on profile page
373 if request.to_user_id == context.user_id:
374 return references_pb2.AvailableWriteReferencesRes()
376 if not session.execute(
377 select(User).where_users_visible(context).where(User.id == request.to_user_id)
378 ).scalar_one_or_none():
379 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "user_not_found")
381 can_write_friend_reference = (
382 session.execute(
383 select(Reference)
384 .where(Reference.from_user_id == context.user_id)
385 .where(Reference.to_user_id == request.to_user_id)
386 .where(Reference.reference_type == ReferenceType.friend)
387 ).scalar_one_or_none()
388 ) is None
390 q1 = (
391 select(literal(True), HostRequest)
392 .outerjoin(
393 Reference,
394 and_(
395 Reference.host_request_id == HostRequest.conversation_id,
396 Reference.from_user_id == context.user_id,
397 ),
398 )
399 .where(Reference.id == None)
400 .where(HostRequest.can_write_reference)
401 .where(HostRequest.surfer_user_id == context.user_id)
402 .where(HostRequest.host_user_id == request.to_user_id)
403 .where(HostRequest.surfer_reason_didnt_meetup == None)
404 )
406 q2 = (
407 select(literal(False), HostRequest)
408 .outerjoin(
409 Reference,
410 and_(
411 Reference.host_request_id == HostRequest.conversation_id,
412 Reference.from_user_id == context.user_id,
413 ),
414 )
415 .where(Reference.id == None)
416 .where(HostRequest.can_write_reference)
417 .where(HostRequest.surfer_user_id == request.to_user_id)
418 .where(HostRequest.host_user_id == context.user_id)
419 .where(HostRequest.host_reason_didnt_meetup == None)
420 )
422 union = union_all(q1, q2).order_by(HostRequest.end_time_to_write_reference.asc()).subquery()
423 query = select(union.c[0].label("surfed"), aliased(HostRequest, union))
424 host_request_references = session.execute(query).all()
426 return references_pb2.AvailableWriteReferencesRes(
427 can_write_friend_reference=can_write_friend_reference,
428 available_write_references=[
429 references_pb2.AvailableWriteReferenceType(
430 host_request_id=host_request.conversation_id,
431 reference_type=reftype2api[ReferenceType.surfed if surfed else ReferenceType.hosted],
432 time_expires=Timestamp_from_datetime(host_request.end_time_to_write_reference),
433 )
434 for surfed, host_request in host_request_references
435 ],
436 )
438 def ListPendingReferencesToWrite(
439 self, request: empty_pb2.Empty, context: CouchersContext, session: Session
440 ) -> references_pb2.ListPendingReferencesToWriteRes:
441 return references_pb2.ListPendingReferencesToWriteRes(
442 pending_references=[
443 references_pb2.AvailableWriteReferenceType(
444 host_request_id=host_request_id,
445 reference_type=reftype2api[reference_type],
446 time_expires=Timestamp_from_datetime(end_time_to_write_reference),
447 )
448 for host_request_id, reference_type, end_time_to_write_reference, other_user in get_pending_references_to_write(
449 session, context
450 )
451 ],
452 )
454 def GetHostRequestReferenceStatus(
455 self, request: references_pb2.GetHostRequestReferenceStatusReq, context: CouchersContext, session: Session
456 ) -> references_pb2.GetHostRequestReferenceStatusRes:
457 # Compute has_given (whether current user already wrote a reference for this host request)
458 has_given = (
459 session.execute(
460 select(Reference)
461 .where(Reference.host_request_id == request.host_request_id)
462 .where(Reference.from_user_id == context.user_id)
463 ).scalar_one_or_none()
464 is not None
465 )
467 host_request = session.execute(
468 select(HostRequest)
469 .where_moderated_content_visible(context, HostRequest, is_list_operation=False)
470 .where(HostRequest.conversation_id == request.host_request_id)
471 .where(or_(HostRequest.surfer_user_id == context.user_id, HostRequest.host_user_id == context.user_id))
472 ).scalar_one_or_none()
474 can_write = False
475 is_expired = False
476 didnt_stay = False
478 if host_request is not None:
479 # Compute expired from end_time_to_write_reference
480 if host_request.end_time_to_write_reference is not None:
481 is_expired = host_request.end_time_to_write_reference < now()
483 # Block only if current user indicated didn't meet up
484 didnt_stay = (
485 (host_request.surfer_reason_didnt_meetup is not None)
486 if host_request.surfer_user_id == context.user_id
487 else (host_request.host_reason_didnt_meetup is not None)
488 )
490 # You can write only if: host_request allows it, you didn't already give one, and you didn't indicate didn't meet up
491 can_write = bool(host_request.can_write_reference) and (not has_given) and (not didnt_stay)
493 return references_pb2.GetHostRequestReferenceStatusRes(
494 has_given=has_given,
495 can_write=can_write,
496 is_expired=is_expired,
497 didnt_stay=didnt_stay,
498 )