Coverage for src/couchers/servicers/references.py: 97%
129 statements
« prev ^ index » next coverage.py v7.6.10, created at 2025-08-28 14:55 +0000
« prev ^ index » next coverage.py v7.6.10, created at 2025-08-28 14:55 +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"""
8import grpc
9from google.protobuf import empty_pb2
10from sqlalchemy.orm import aliased
11from sqlalchemy.sql import and_, func, literal, or_, union_all
13from couchers import errors
14from couchers.context import make_background_user_context
15from couchers.materialized_views import LiteUser
16from couchers.models import HostRequest, Reference, ReferenceType, User
17from couchers.notifications.notify import notify
18from couchers.servicers.api import user_model_to_pb
19from couchers.sql import couchers_select as select
20from couchers.tasks import maybe_send_reference_report_email
21from couchers.utils import Timestamp_from_datetime
22from proto import notification_data_pb2, references_pb2, references_pb2_grpc
24MAX_PAGINATION_LENGTH = 100
26reftype2sql = {
27 references_pb2.ReferenceType.REFERENCE_TYPE_FRIEND: ReferenceType.friend,
28 references_pb2.ReferenceType.REFERENCE_TYPE_SURFED: ReferenceType.surfed,
29 references_pb2.ReferenceType.REFERENCE_TYPE_HOSTED: ReferenceType.hosted,
30}
32reftype2api = {
33 ReferenceType.friend: references_pb2.ReferenceType.REFERENCE_TYPE_FRIEND,
34 ReferenceType.surfed: references_pb2.ReferenceType.REFERENCE_TYPE_SURFED,
35 ReferenceType.hosted: references_pb2.ReferenceType.REFERENCE_TYPE_HOSTED,
36}
39def reference_to_pb(reference: Reference, context):
40 return references_pb2.Reference(
41 reference_id=reference.id,
42 from_user_id=reference.from_user_id,
43 to_user_id=reference.to_user_id,
44 reference_type=reftype2api[reference.reference_type],
45 text=reference.text,
46 written_time=Timestamp_from_datetime(reference.time.replace(hour=0, minute=0, second=0, microsecond=0)),
47 host_request_id=(
48 reference.host_request_id if context.user_id in [reference.from_user_id, reference.to_user_id] else None
49 ),
50 )
53def get_host_req_and_check_can_write_ref(session, context, host_request_id):
54 """
55 Checks that this can see the given host req and write a ref for it
57 Returns the host req and `surfed`, a boolean of if the user was the surfer or not
58 """
59 host_request = session.execute(
60 select(HostRequest)
61 .where_users_column_visible(context, HostRequest.surfer_user_id)
62 .where_users_column_visible(context, HostRequest.host_user_id)
63 .where(HostRequest.conversation_id == host_request_id)
64 .where(or_(HostRequest.surfer_user_id == context.user_id, HostRequest.host_user_id == context.user_id))
65 ).scalar_one_or_none()
67 if not host_request:
68 context.abort(grpc.StatusCode.NOT_FOUND, errors.HOST_REQUEST_NOT_FOUND)
70 if not host_request.can_write_reference:
71 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.CANT_WRITE_REFERENCE_FOR_REQUEST)
73 if session.execute(
74 select(Reference)
75 .where(Reference.host_request_id == host_request.conversation_id)
76 .where(Reference.from_user_id == context.user_id)
77 ).scalar_one_or_none():
78 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.REFERENCE_ALREADY_GIVEN)
80 surfed = host_request.surfer_user_id == context.user_id
82 if surfed:
83 my_reason = host_request.surfer_reason_didnt_meetup
84 else:
85 my_reason = host_request.host_reason_didnt_meetup
87 if my_reason != None:
88 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.CANT_WRITE_REFERENCE_INDICATED_DIDNT_MEETUP)
90 return host_request, surfed
93def check_valid_reference(request, context):
94 if request.rating < 0 or request.rating > 1:
95 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.REFERENCE_INVALID_RATING)
97 if request.text.strip() == "":
98 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.REFERENCE_NO_TEXT)
101def get_pending_references_to_write(session, context):
102 q1 = (
103 select(literal(True), HostRequest, LiteUser)
104 .outerjoin(
105 Reference,
106 and_(
107 Reference.host_request_id == HostRequest.conversation_id,
108 Reference.from_user_id == context.user_id,
109 ),
110 )
111 .join(LiteUser, LiteUser.id == HostRequest.host_user_id)
112 .where_users_column_visible(context, HostRequest.host_user_id)
113 .where(Reference.id == None)
114 .where(HostRequest.can_write_reference)
115 .where(HostRequest.surfer_user_id == context.user_id)
116 .where(HostRequest.surfer_reason_didnt_meetup == None)
117 )
119 q2 = (
120 select(literal(False), HostRequest, LiteUser)
121 .outerjoin(
122 Reference,
123 and_(
124 Reference.host_request_id == HostRequest.conversation_id,
125 Reference.from_user_id == context.user_id,
126 ),
127 )
128 .join(LiteUser, LiteUser.id == HostRequest.surfer_user_id)
129 .where_users_column_visible(context, HostRequest.surfer_user_id)
130 .where(Reference.id == None)
131 .where(HostRequest.can_write_reference)
132 .where(HostRequest.host_user_id == context.user_id)
133 .where(HostRequest.host_reason_didnt_meetup == None)
134 )
136 union = union_all(q1, q2).order_by(HostRequest.end_time_to_write_reference.asc()).subquery()
137 union = select(union.c[0].label("surfed"), aliased(HostRequest, union), aliased(LiteUser, union))
138 host_request_references = session.execute(union).all()
140 return [
141 (
142 host_request.conversation_id,
143 ReferenceType.surfed if surfed else ReferenceType.hosted,
144 host_request.end_time_to_write_reference,
145 other_user,
146 )
147 for surfed, host_request, other_user in host_request_references
148 ]
151class References(references_pb2_grpc.ReferencesServicer):
152 def ListReferences(self, request, context, session):
153 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
154 next_reference_id = int(request.page_token) if request.page_token else 0
156 if not request.from_user_id and not request.to_user_id:
157 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.NEED_TO_SPECIFY_AT_LEAST_ONE_USER)
159 to_users = aliased(User)
160 from_users = aliased(User)
161 statement = select(Reference).where(Reference.is_deleted == False)
162 if request.from_user_id:
163 # join the to_users, because only interested if the recipient is visible
164 statement = (
165 statement.join(to_users, Reference.to_user_id == to_users.id)
166 .where(
167 ~to_users.is_banned
168 ) # instead of where_users_visible; if user is deleted or blocked, reference still visible
169 .where(Reference.from_user_id == request.from_user_id)
170 )
171 if request.to_user_id:
172 # join the from_users, because only interested if the writer is visible
173 statement = (
174 statement.join(from_users, Reference.from_user_id == from_users.id)
175 .where(
176 ~from_users.is_banned
177 ) # instead of where_users_visible; if user is deleted or blocked, reference still visible
178 .where(Reference.to_user_id == request.to_user_id)
179 )
180 if len(request.reference_type_filter) > 0:
181 statement = statement.where(
182 Reference.reference_type.in_([reftype2sql[t] for t in request.reference_type_filter])
183 )
185 if next_reference_id:
186 statement = statement.where(Reference.id <= next_reference_id)
188 # Reference visibility logic:
189 # A reference is visible if any of the following apply:
190 # 1. It is a friend reference
191 # 2. Both references have been written
192 # 3. It has been over 2 weeks since the host request ended
194 # we get the matching other references through this subquery
195 sub = select(Reference.id.label("sub_id"), Reference.host_request_id).where(
196 Reference.reference_type != ReferenceType.friend
197 )
198 if request.from_user_id:
199 sub = sub.where(Reference.to_user_id == request.from_user_id)
200 if request.to_user_id:
201 sub = sub.where(Reference.from_user_id == request.to_user_id)
203 sub = sub.subquery()
204 statement = (
205 statement.outerjoin(sub, sub.c.host_request_id == Reference.host_request_id)
206 .outerjoin(HostRequest, HostRequest.conversation_id == Reference.host_request_id)
207 .where(
208 or_(
209 Reference.reference_type == ReferenceType.friend,
210 sub.c.sub_id != None,
211 HostRequest.end_time_to_write_reference < func.now(),
212 )
213 )
214 )
216 statement = statement.order_by(Reference.id.desc()).limit(page_size + 1)
217 references = session.execute(statement).scalars().all()
219 return references_pb2.ListReferencesRes(
220 references=[reference_to_pb(reference, context) for reference in references[:page_size]],
221 next_page_token=str(references[-1].id) if len(references) > page_size else None,
222 )
224 def WriteFriendReference(self, request, context, session):
225 if context.user_id == request.to_user_id:
226 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.CANT_REFER_SELF)
228 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
230 check_valid_reference(request, context)
232 if not session.execute(
233 select(User).where_users_visible(context).where(User.id == request.to_user_id)
234 ).scalar_one_or_none():
235 context.abort(grpc.StatusCode.NOT_FOUND, errors.USER_NOT_FOUND)
237 if session.execute(
238 select(Reference)
239 .where(Reference.from_user_id == context.user_id)
240 .where(Reference.to_user_id == request.to_user_id)
241 .where(Reference.reference_type == ReferenceType.friend)
242 ).scalar_one_or_none():
243 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.REFERENCE_ALREADY_GIVEN)
245 reference_text = request.text.strip()
247 reference = Reference(
248 from_user_id=context.user_id,
249 to_user_id=request.to_user_id,
250 reference_type=ReferenceType.friend,
251 text=reference_text,
252 private_text=request.private_text.strip(),
253 rating=request.rating,
254 was_appropriate=request.was_appropriate,
255 )
256 session.add(reference)
257 session.commit()
259 # send the recipient of the reference a reminder
260 notify(
261 session,
262 user_id=request.to_user_id,
263 topic_action="reference:receive_friend",
264 data=notification_data_pb2.ReferenceReceiveFriend(
265 from_user=user_model_to_pb(user, session, make_background_user_context(user_id=request.to_user_id)),
266 text=reference_text,
267 ),
268 )
270 # possibly send out an alert to the mod team if the reference was bad
271 maybe_send_reference_report_email(session, reference)
273 return reference_to_pb(reference, context)
275 def WriteHostRequestReference(self, request, context, session):
276 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
278 check_valid_reference(request, context)
280 host_request, surfed = get_host_req_and_check_can_write_ref(session, context, request.host_request_id)
282 reference_text = request.text.strip()
284 reference = Reference(
285 from_user_id=context.user_id,
286 host_request_id=host_request.conversation_id,
287 text=reference_text,
288 private_text=request.private_text.strip(),
289 rating=request.rating,
290 was_appropriate=request.was_appropriate,
291 )
293 if surfed:
294 # we requested to surf with someone
295 reference.reference_type = ReferenceType.surfed
296 reference.to_user_id = host_request.host_user_id
297 assert context.user_id == host_request.surfer_user_id
298 else:
299 # we hosted someone
300 reference.reference_type = ReferenceType.hosted
301 reference.to_user_id = host_request.surfer_user_id
302 assert context.user_id == host_request.host_user_id
304 session.add(reference)
305 session.commit()
307 other_reference = session.execute(
308 select(Reference)
309 .where(Reference.host_request_id == host_request.conversation_id)
310 .where(Reference.to_user_id == context.user_id)
311 ).scalar_one_or_none()
313 # send notification out
314 notify(
315 session,
316 user_id=reference.to_user_id,
317 topic_action="reference:receive_surfed" if surfed else "reference:receive_hosted",
318 data=notification_data_pb2.ReferenceReceiveHostRequest(
319 host_request_id=host_request.conversation_id,
320 from_user=user_model_to_pb(user, session, make_background_user_context(user_id=reference.to_user_id)),
321 text=reference_text if other_reference is not None else None,
322 ),
323 )
325 # possibly send out an alert to the mod team if the reference was bad
326 maybe_send_reference_report_email(session, reference)
328 return reference_to_pb(reference, context)
330 def HostRequestIndicateDidntMeetup(self, request, context, session):
331 host_request, surfed = get_host_req_and_check_can_write_ref(session, context, request.host_request_id)
333 reason = request.reason_didnt_meetup.strip()
335 if surfed:
336 host_request.surfer_reason_didnt_meetup = reason
337 else:
338 host_request.host_reason_didnt_meetup = reason
340 return empty_pb2.Empty()
342 def AvailableWriteReferences(self, request, context, session):
343 # can't write anything for ourselves, but let's return empty so this can be used generically on profile page
344 if request.to_user_id == context.user_id:
345 return references_pb2.AvailableWriteReferencesRes()
347 if not session.execute(
348 select(User).where_users_visible(context).where(User.id == request.to_user_id)
349 ).scalar_one_or_none():
350 context.abort(grpc.StatusCode.NOT_FOUND, errors.USER_NOT_FOUND)
352 can_write_friend_reference = (
353 session.execute(
354 select(Reference)
355 .where(Reference.from_user_id == context.user_id)
356 .where(Reference.to_user_id == request.to_user_id)
357 .where(Reference.reference_type == ReferenceType.friend)
358 ).scalar_one_or_none()
359 ) is None
361 q1 = (
362 select(literal(True), HostRequest)
363 .outerjoin(
364 Reference,
365 and_(
366 Reference.host_request_id == HostRequest.conversation_id,
367 Reference.from_user_id == context.user_id,
368 ),
369 )
370 .where(Reference.id == None)
371 .where(HostRequest.can_write_reference)
372 .where(HostRequest.surfer_user_id == context.user_id)
373 .where(HostRequest.host_user_id == request.to_user_id)
374 .where(HostRequest.surfer_reason_didnt_meetup == None)
375 )
377 q2 = (
378 select(literal(False), HostRequest)
379 .outerjoin(
380 Reference,
381 and_(
382 Reference.host_request_id == HostRequest.conversation_id,
383 Reference.from_user_id == context.user_id,
384 ),
385 )
386 .where(Reference.id == None)
387 .where(HostRequest.can_write_reference)
388 .where(HostRequest.surfer_user_id == request.to_user_id)
389 .where(HostRequest.host_user_id == context.user_id)
390 .where(HostRequest.host_reason_didnt_meetup == None)
391 )
393 union = union_all(q1, q2).order_by(HostRequest.end_time_to_write_reference.asc()).subquery()
394 union = select(union.c[0].label("surfed"), aliased(HostRequest, union))
395 host_request_references = session.execute(union).all()
397 return references_pb2.AvailableWriteReferencesRes(
398 can_write_friend_reference=can_write_friend_reference,
399 available_write_references=[
400 references_pb2.AvailableWriteReferenceType(
401 host_request_id=host_request.conversation_id,
402 reference_type=reftype2api[ReferenceType.surfed if surfed else ReferenceType.hosted],
403 time_expires=Timestamp_from_datetime(host_request.end_time_to_write_reference),
404 )
405 for surfed, host_request in host_request_references
406 ],
407 )
409 def ListPendingReferencesToWrite(self, request, context, session):
410 return references_pb2.ListPendingReferencesToWriteRes(
411 pending_references=[
412 references_pb2.AvailableWriteReferenceType(
413 host_request_id=host_request_id,
414 reference_type=reftype2api[reference_type],
415 time_expires=Timestamp_from_datetime(end_time_to_write_reference),
416 )
417 for host_request_id, reference_type, end_time_to_write_reference, other_user in get_pending_references_to_write(
418 session, context
419 )
420 ],
421 )