Coverage for src/couchers/servicers/references.py: 97%
125 statements
« prev ^ index » next coverage.py v7.6.10, created at 2025-06-01 15:07 +0000
« prev ^ index » next coverage.py v7.6.10, created at 2025-06-01 15:07 +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.models import HostRequest, Reference, ReferenceType, User
15from couchers.notifications.notify import notify
16from couchers.servicers.api import user_model_to_pb
17from couchers.sql import couchers_select as select
18from couchers.tasks import maybe_send_reference_report_email
19from couchers.utils import Timestamp_from_datetime, make_user_context
20from proto import notification_data_pb2, references_pb2, references_pb2_grpc
22reftype2sql = {
23 references_pb2.ReferenceType.REFERENCE_TYPE_FRIEND: ReferenceType.friend,
24 references_pb2.ReferenceType.REFERENCE_TYPE_SURFED: ReferenceType.surfed,
25 references_pb2.ReferenceType.REFERENCE_TYPE_HOSTED: ReferenceType.hosted,
26}
28reftype2api = {
29 ReferenceType.friend: references_pb2.ReferenceType.REFERENCE_TYPE_FRIEND,
30 ReferenceType.surfed: references_pb2.ReferenceType.REFERENCE_TYPE_SURFED,
31 ReferenceType.hosted: references_pb2.ReferenceType.REFERENCE_TYPE_HOSTED,
32}
35def reference_to_pb(reference: Reference, context):
36 return references_pb2.Reference(
37 reference_id=reference.id,
38 from_user_id=reference.from_user_id,
39 to_user_id=reference.to_user_id,
40 reference_type=reftype2api[reference.reference_type],
41 text=reference.text,
42 written_time=Timestamp_from_datetime(reference.time.replace(hour=0, minute=0, second=0, microsecond=0)),
43 host_request_id=(
44 reference.host_request_id if context.user_id in [reference.from_user_id, reference.to_user_id] else None
45 ),
46 )
49def get_host_req_and_check_can_write_ref(session, context, host_request_id):
50 """
51 Checks that this can see the given host req and write a ref for it
53 Returns the host req and `surfed`, a boolean of if the user was the surfer or not
54 """
55 host_request = session.execute(
56 select(HostRequest)
57 .where_users_column_visible(context, HostRequest.surfer_user_id)
58 .where_users_column_visible(context, HostRequest.host_user_id)
59 .where(HostRequest.conversation_id == host_request_id)
60 .where(or_(HostRequest.surfer_user_id == context.user_id, HostRequest.host_user_id == context.user_id))
61 ).scalar_one_or_none()
63 if not host_request:
64 context.abort(grpc.StatusCode.NOT_FOUND, errors.HOST_REQUEST_NOT_FOUND)
66 if not host_request.can_write_reference:
67 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.CANT_WRITE_REFERENCE_FOR_REQUEST)
69 if session.execute(
70 select(Reference)
71 .where(Reference.host_request_id == host_request.conversation_id)
72 .where(Reference.from_user_id == context.user_id)
73 ).scalar_one_or_none():
74 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.REFERENCE_ALREADY_GIVEN)
76 surfed = host_request.surfer_user_id == context.user_id
78 if surfed:
79 my_reason = host_request.surfer_reason_didnt_meetup
80 else:
81 my_reason = host_request.host_reason_didnt_meetup
83 if my_reason != None:
84 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.CANT_WRITE_REFERENCE_INDICATED_DIDNT_MEETUP)
86 return host_request, surfed
89def check_valid_reference(request, context):
90 if request.rating < 0 or request.rating > 1:
91 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.REFERENCE_INVALID_RATING)
93 if request.text.strip() == "":
94 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.REFERENCE_NO_TEXT)
97MAX_PAGINATION_LENGTH = 100
100class References(references_pb2_grpc.ReferencesServicer):
101 def ListReferences(self, request, context, session):
102 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
103 next_reference_id = int(request.page_token) if request.page_token else 0
105 if not request.from_user_id and not request.to_user_id:
106 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.NEED_TO_SPECIFY_AT_LEAST_ONE_USER)
108 to_users = aliased(User)
109 from_users = aliased(User)
110 statement = select(Reference).where(Reference.is_deleted == False)
111 if request.from_user_id:
112 # join the to_users, because only interested if the recipient is visible
113 statement = (
114 statement.join(to_users, Reference.to_user_id == to_users.id)
115 .where(
116 ~to_users.is_banned
117 ) # instead of where_users_visible; if user is deleted or blocked, reference still visible
118 .where(Reference.from_user_id == request.from_user_id)
119 )
120 if request.to_user_id:
121 # join the from_users, because only interested if the writer is visible
122 statement = (
123 statement.join(from_users, Reference.from_user_id == from_users.id)
124 .where(
125 ~from_users.is_banned
126 ) # instead of where_users_visible; if user is deleted or blocked, reference still visible
127 .where(Reference.to_user_id == request.to_user_id)
128 )
129 if len(request.reference_type_filter) > 0:
130 statement = statement.where(
131 Reference.reference_type.in_([reftype2sql[t] for t in request.reference_type_filter])
132 )
134 if next_reference_id:
135 statement = statement.where(Reference.id <= next_reference_id)
137 # Reference visibility logic:
138 # A reference is visible if any of the following apply:
139 # 1. It is a friend reference
140 # 2. Both references have been written
141 # 3. It has been over 2 weeks since the host request ended
143 # we get the matching other references through this subquery
144 sub = select(Reference.id.label("sub_id"), Reference.host_request_id).where(
145 Reference.reference_type != ReferenceType.friend
146 )
147 if request.from_user_id:
148 sub = sub.where(Reference.to_user_id == request.from_user_id)
149 if request.to_user_id:
150 sub = sub.where(Reference.from_user_id == request.to_user_id)
152 sub = sub.subquery()
153 statement = (
154 statement.outerjoin(sub, sub.c.host_request_id == Reference.host_request_id)
155 .outerjoin(HostRequest, HostRequest.conversation_id == Reference.host_request_id)
156 .where(
157 or_(
158 Reference.reference_type == ReferenceType.friend,
159 sub.c.sub_id != None,
160 HostRequest.end_time_to_write_reference < func.now(),
161 )
162 )
163 )
165 statement = statement.order_by(Reference.id.desc()).limit(page_size + 1)
166 references = session.execute(statement).scalars().all()
168 return references_pb2.ListReferencesRes(
169 references=[reference_to_pb(reference, context) for reference in references[:page_size]],
170 next_page_token=str(references[-1].id) if len(references) > page_size else None,
171 )
173 def WriteFriendReference(self, request, context, session):
174 if context.user_id == request.to_user_id:
175 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.CANT_REFER_SELF)
177 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
179 check_valid_reference(request, context)
181 if not session.execute(
182 select(User).where_users_visible(context).where(User.id == request.to_user_id)
183 ).scalar_one_or_none():
184 context.abort(grpc.StatusCode.NOT_FOUND, errors.USER_NOT_FOUND)
186 if session.execute(
187 select(Reference)
188 .where(Reference.from_user_id == context.user_id)
189 .where(Reference.to_user_id == request.to_user_id)
190 .where(Reference.reference_type == ReferenceType.friend)
191 ).scalar_one_or_none():
192 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.REFERENCE_ALREADY_GIVEN)
194 reference_text = request.text.strip()
196 reference = Reference(
197 from_user_id=context.user_id,
198 to_user_id=request.to_user_id,
199 reference_type=ReferenceType.friend,
200 text=reference_text,
201 private_text=request.private_text.strip(),
202 rating=request.rating,
203 was_appropriate=request.was_appropriate,
204 )
205 session.add(reference)
206 session.commit()
208 # send the recipient of the reference a reminder
209 notify(
210 session,
211 user_id=request.to_user_id,
212 topic_action="reference:receive_friend",
213 data=notification_data_pb2.ReferenceReceiveFriend(
214 from_user=user_model_to_pb(user, session, make_user_context(user_id=request.to_user_id)),
215 text=reference_text,
216 ),
217 )
219 # possibly send out an alert to the mod team if the reference was bad
220 maybe_send_reference_report_email(session, reference)
222 return reference_to_pb(reference, context)
224 def WriteHostRequestReference(self, request, context, session):
225 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
227 check_valid_reference(request, context)
229 host_request, surfed = get_host_req_and_check_can_write_ref(session, context, request.host_request_id)
231 reference_text = request.text.strip()
233 reference = Reference(
234 from_user_id=context.user_id,
235 host_request_id=host_request.conversation_id,
236 text=reference_text,
237 private_text=request.private_text.strip(),
238 rating=request.rating,
239 was_appropriate=request.was_appropriate,
240 )
242 if surfed:
243 # we requested to surf with someone
244 reference.reference_type = ReferenceType.surfed
245 reference.to_user_id = host_request.host_user_id
246 assert context.user_id == host_request.surfer_user_id
247 else:
248 # we hosted someone
249 reference.reference_type = ReferenceType.hosted
250 reference.to_user_id = host_request.surfer_user_id
251 assert context.user_id == host_request.host_user_id
253 session.add(reference)
254 session.commit()
256 other_reference = session.execute(
257 select(Reference)
258 .where(Reference.host_request_id == host_request.conversation_id)
259 .where(Reference.to_user_id == context.user_id)
260 ).scalar_one_or_none()
262 # send notification out
263 notify(
264 session,
265 user_id=reference.to_user_id,
266 topic_action="reference:receive_surfed" if surfed else "reference:receive_hosted",
267 data=notification_data_pb2.ReferenceReceiveHostRequest(
268 host_request_id=host_request.conversation_id,
269 from_user=user_model_to_pb(user, session, make_user_context(user_id=reference.to_user_id)),
270 text=reference_text if other_reference is not None else None,
271 ),
272 )
274 # possibly send out an alert to the mod team if the reference was bad
275 maybe_send_reference_report_email(session, reference)
277 return reference_to_pb(reference, context)
279 def HostRequestIndicateDidntMeetup(self, request, context, session):
280 host_request, surfed = get_host_req_and_check_can_write_ref(session, context, request.host_request_id)
282 reason = request.reason_didnt_meetup.strip()
284 if surfed:
285 host_request.surfer_reason_didnt_meetup = reason
286 else:
287 host_request.host_reason_didnt_meetup = reason
289 return empty_pb2.Empty()
291 def AvailableWriteReferences(self, request, context, session):
292 # can't write anything for ourselves, but let's return empty so this can be used generically on profile page
293 if request.to_user_id == context.user_id:
294 return references_pb2.AvailableWriteReferencesRes()
296 if not session.execute(
297 select(User).where_users_visible(context).where(User.id == request.to_user_id)
298 ).scalar_one_or_none():
299 context.abort(grpc.StatusCode.NOT_FOUND, errors.USER_NOT_FOUND)
301 can_write_friend_reference = (
302 session.execute(
303 select(Reference)
304 .where(Reference.from_user_id == context.user_id)
305 .where(Reference.to_user_id == request.to_user_id)
306 .where(Reference.reference_type == ReferenceType.friend)
307 ).scalar_one_or_none()
308 ) is None
310 q1 = (
311 select(literal(True), HostRequest)
312 .outerjoin(
313 Reference,
314 and_(
315 Reference.host_request_id == HostRequest.conversation_id,
316 Reference.from_user_id == context.user_id,
317 ),
318 )
319 .where(Reference.id == None)
320 .where(HostRequest.can_write_reference)
321 .where(HostRequest.surfer_user_id == context.user_id)
322 .where(HostRequest.host_user_id == request.to_user_id)
323 .where(HostRequest.surfer_reason_didnt_meetup == None)
324 )
326 q2 = (
327 select(literal(False), HostRequest)
328 .outerjoin(
329 Reference,
330 and_(
331 Reference.host_request_id == HostRequest.conversation_id,
332 Reference.from_user_id == context.user_id,
333 ),
334 )
335 .where(Reference.id == None)
336 .where(HostRequest.can_write_reference)
337 .where(HostRequest.surfer_user_id == request.to_user_id)
338 .where(HostRequest.host_user_id == context.user_id)
339 .where(HostRequest.host_reason_didnt_meetup == None)
340 )
342 union = union_all(q1, q2).order_by(HostRequest.end_time_to_write_reference.asc()).subquery()
343 union = select(union.c[0].label("surfed"), aliased(HostRequest, union))
344 host_request_references = session.execute(union).all()
346 return references_pb2.AvailableWriteReferencesRes(
347 can_write_friend_reference=can_write_friend_reference,
348 available_write_references=[
349 references_pb2.AvailableWriteReferenceType(
350 host_request_id=host_request.conversation_id,
351 reference_type=reftype2api[ReferenceType.surfed if surfed else ReferenceType.hosted],
352 time_expires=Timestamp_from_datetime(host_request.end_time_to_write_reference),
353 )
354 for surfed, host_request in host_request_references
355 ],
356 )
358 def ListPendingReferencesToWrite(self, request, context, session):
359 q1 = (
360 select(literal(True), HostRequest)
361 .outerjoin(
362 Reference,
363 and_(
364 Reference.host_request_id == HostRequest.conversation_id,
365 Reference.from_user_id == context.user_id,
366 ),
367 )
368 .where_users_column_visible(context, HostRequest.host_user_id)
369 .where(Reference.id == None)
370 .where(HostRequest.can_write_reference)
371 .where(HostRequest.surfer_user_id == context.user_id)
372 .where(HostRequest.surfer_reason_didnt_meetup == None)
373 )
375 q2 = (
376 select(literal(False), HostRequest)
377 .outerjoin(
378 Reference,
379 and_(
380 Reference.host_request_id == HostRequest.conversation_id,
381 Reference.from_user_id == context.user_id,
382 ),
383 )
384 .where_users_column_visible(context, HostRequest.surfer_user_id)
385 .where(Reference.id == None)
386 .where(HostRequest.can_write_reference)
387 .where(HostRequest.host_user_id == context.user_id)
388 .where(HostRequest.host_reason_didnt_meetup == None)
389 )
391 union = union_all(q1, q2).order_by(HostRequest.end_time_to_write_reference.asc()).subquery()
392 union = select(union.c[0].label("surfed"), aliased(HostRequest, union))
393 host_request_references = session.execute(union).all()
395 return references_pb2.ListPendingReferencesToWriteRes(
396 pending_references=[
397 references_pb2.AvailableWriteReferenceType(
398 host_request_id=host_request.conversation_id,
399 reference_type=reftype2api[ReferenceType.surfed if surfed else ReferenceType.hosted],
400 time_expires=Timestamp_from_datetime(host_request.end_time_to_write_reference),
401 )
402 for surfed, host_request in host_request_references
403 ],
404 )