Coverage for src/couchers/servicers/references.py: 97%
110 statements
« prev ^ index » next coverage.py v7.5.0, created at 2024-12-02 20:27 +0000
« prev ^ index » next coverage.py v7.5.0, created at 2024-12-02 20:27 +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 types import SimpleNamespace
10import grpc
11from sqlalchemy.orm import aliased
12from sqlalchemy.sql import and_, func, literal, or_, union_all
14from couchers import errors
15from couchers.models import HostRequest, Reference, ReferenceType, User
16from couchers.notifications.notify import notify
17from couchers.servicers.api import user_model_to_pb
18from couchers.sql import couchers_select as select
19from couchers.tasks import maybe_send_reference_report_email
20from couchers.utils import Timestamp_from_datetime
21from proto import notification_data_pb2, references_pb2, references_pb2_grpc
23reftype2sql = {
24 references_pb2.ReferenceType.REFERENCE_TYPE_FRIEND: ReferenceType.friend,
25 references_pb2.ReferenceType.REFERENCE_TYPE_SURFED: ReferenceType.surfed,
26 references_pb2.ReferenceType.REFERENCE_TYPE_HOSTED: ReferenceType.hosted,
27}
29reftype2api = {
30 ReferenceType.friend: references_pb2.ReferenceType.REFERENCE_TYPE_FRIEND,
31 ReferenceType.surfed: references_pb2.ReferenceType.REFERENCE_TYPE_SURFED,
32 ReferenceType.hosted: references_pb2.ReferenceType.REFERENCE_TYPE_HOSTED,
33}
36def reference_to_pb(reference: Reference, context):
37 return references_pb2.Reference(
38 reference_id=reference.id,
39 from_user_id=reference.from_user_id,
40 to_user_id=reference.to_user_id,
41 reference_type=reftype2api[reference.reference_type],
42 text=reference.text,
43 written_time=Timestamp_from_datetime(reference.time.replace(hour=0, minute=0, second=0, microsecond=0)),
44 host_request_id=(
45 reference.host_request_id if context.user_id in [reference.from_user_id, reference.to_user_id] else None
46 ),
47 )
50def check_valid_reference(request, context):
51 if request.rating < 0 or request.rating > 1:
52 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.REFERENCE_INVALID_RATING)
54 if request.text.strip() == "":
55 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.REFERENCE_NO_TEXT)
58MAX_PAGINATION_LENGTH = 100
61class References(references_pb2_grpc.ReferencesServicer):
62 def ListReferences(self, request, context, session):
63 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
64 next_reference_id = int(request.page_token) if request.page_token else 0
66 if not request.from_user_id and not request.to_user_id:
67 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.NEED_TO_SPECIFY_AT_LEAST_ONE_USER)
69 to_users = aliased(User)
70 from_users = aliased(User)
71 statement = select(Reference)
72 if request.from_user_id:
73 # join the to_users, because only interested if the recipient is visible
74 statement = (
75 statement.join(to_users, Reference.to_user_id == to_users.id)
76 .where(
77 ~to_users.is_banned
78 ) # instead of where_users_visible; if user is deleted or blocked, reference still visible
79 .where(Reference.from_user_id == request.from_user_id)
80 )
81 if request.to_user_id:
82 # join the from_users, because only interested if the writer is visible
83 statement = (
84 statement.join(from_users, Reference.from_user_id == from_users.id)
85 .where(
86 ~from_users.is_banned
87 ) # instead of where_users_visible; if user is deleted or blocked, reference still visible
88 .where(Reference.to_user_id == request.to_user_id)
89 )
90 if len(request.reference_type_filter) > 0:
91 statement = statement.where(
92 Reference.reference_type.in_([reftype2sql[t] for t in request.reference_type_filter])
93 )
95 if next_reference_id:
96 statement = statement.where(Reference.id <= next_reference_id)
98 # Reference visibility logic:
99 # A reference is visible if any of the following apply:
100 # 1. It is a friend reference
101 # 2. Both references have been written
102 # 3. It has been over 2 weeks since the host request ended
104 # we get the matching other references through this subquery
105 sub = select(Reference.id.label("sub_id"), Reference.host_request_id).where(
106 Reference.reference_type != ReferenceType.friend
107 )
108 if request.from_user_id:
109 sub = sub.where(Reference.to_user_id == request.from_user_id)
110 if request.to_user_id:
111 sub = sub.where(Reference.from_user_id == request.to_user_id)
113 sub = sub.subquery()
114 statement = (
115 statement.outerjoin(sub, sub.c.host_request_id == Reference.host_request_id)
116 .outerjoin(HostRequest, HostRequest.conversation_id == Reference.host_request_id)
117 .where(
118 or_(
119 Reference.reference_type == ReferenceType.friend,
120 sub.c.sub_id != None,
121 HostRequest.end_time_to_write_reference < func.now(),
122 )
123 )
124 )
126 statement = statement.order_by(Reference.id.desc()).limit(page_size + 1)
127 references = session.execute(statement).scalars().all()
129 return references_pb2.ListReferencesRes(
130 references=[reference_to_pb(reference, context) for reference in references[:page_size]],
131 next_page_token=str(references[-1].id) if len(references) > page_size else None,
132 )
134 def WriteFriendReference(self, request, context, session):
135 if context.user_id == request.to_user_id:
136 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.CANT_REFER_SELF)
138 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
140 check_valid_reference(request, context)
142 if not session.execute(
143 select(User).where_users_visible(context).where(User.id == request.to_user_id)
144 ).scalar_one_or_none():
145 context.abort(grpc.StatusCode.NOT_FOUND, errors.USER_NOT_FOUND)
147 if session.execute(
148 select(Reference)
149 .where(Reference.from_user_id == context.user_id)
150 .where(Reference.to_user_id == request.to_user_id)
151 .where(Reference.reference_type == ReferenceType.friend)
152 ).scalar_one_or_none():
153 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.REFERENCE_ALREADY_GIVEN)
155 reference_text = request.text.strip()
157 reference = Reference(
158 from_user_id=context.user_id,
159 to_user_id=request.to_user_id,
160 reference_type=ReferenceType.friend,
161 text=reference_text,
162 private_text=request.private_text.strip(),
163 rating=request.rating,
164 was_appropriate=request.was_appropriate,
165 )
166 session.add(reference)
167 session.commit()
169 # send the recipient of the reference a reminder
170 notify(
171 session,
172 user_id=request.to_user_id,
173 topic_action="reference:receive_friend",
174 data=notification_data_pb2.ReferenceReceiveFriend(
175 from_user=user_model_to_pb(user, session, SimpleNamespace(user_id=request.to_user_id)),
176 text=reference_text,
177 ),
178 )
180 # possibly send out an alert to the mod team if the reference was bad
181 maybe_send_reference_report_email(session, reference)
183 return reference_to_pb(reference, context)
185 def WriteHostRequestReference(self, request, context, session):
186 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
188 check_valid_reference(request, context)
190 host_request = session.execute(
191 select(HostRequest)
192 .where_users_column_visible(context, HostRequest.surfer_user_id)
193 .where_users_column_visible(context, HostRequest.host_user_id)
194 .where(HostRequest.conversation_id == request.host_request_id)
195 .where(or_(HostRequest.surfer_user_id == context.user_id, HostRequest.host_user_id == context.user_id))
196 ).scalar_one_or_none()
198 if not host_request:
199 context.abort(grpc.StatusCode.NOT_FOUND, errors.HOST_REQUEST_NOT_FOUND)
201 if not host_request.can_write_reference:
202 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.CANT_WRITE_REFERENCE_FOR_REQUEST)
204 if session.execute(
205 select(Reference)
206 .where(Reference.host_request_id == host_request.conversation_id)
207 .where(Reference.from_user_id == context.user_id)
208 ).scalar_one_or_none():
209 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.REFERENCE_ALREADY_GIVEN)
211 other_reference = session.execute(
212 select(Reference)
213 .where(Reference.host_request_id == host_request.conversation_id)
214 .where(Reference.to_user_id == context.user_id)
215 ).scalar_one_or_none()
217 reference_text = request.text.strip()
219 reference = Reference(
220 from_user_id=context.user_id,
221 host_request_id=host_request.conversation_id,
222 text=reference_text,
223 private_text=request.private_text.strip(),
224 rating=request.rating,
225 was_appropriate=request.was_appropriate,
226 )
228 surfed = host_request.surfer_user_id == context.user_id
230 if surfed:
231 # we requested to surf with someone
232 reference.reference_type = ReferenceType.surfed
233 reference.to_user_id = host_request.host_user_id
234 assert context.user_id == host_request.surfer_user_id
235 else:
236 # we hosted someone
237 reference.reference_type = ReferenceType.hosted
238 reference.to_user_id = host_request.surfer_user_id
239 assert context.user_id == host_request.host_user_id
241 session.add(reference)
242 session.commit()
244 # send notification out
245 notify(
246 session,
247 user_id=reference.to_user_id,
248 topic_action="reference:receive_surfed" if surfed else "reference:receive_hosted",
249 data=notification_data_pb2.ReferenceReceiveHostRequest(
250 host_request_id=host_request.conversation_id,
251 from_user=user_model_to_pb(user, session, SimpleNamespace(user_id=reference.to_user_id)),
252 text=reference_text if other_reference is not None else None,
253 ),
254 )
256 # possibly send out an alert to the mod team if the reference was bad
257 maybe_send_reference_report_email(session, reference)
259 return reference_to_pb(reference, context)
261 def AvailableWriteReferences(self, request, context, session):
262 # can't write anything for ourselves, but let's return empty so this can be used generically on profile page
263 if request.to_user_id == context.user_id:
264 return references_pb2.AvailableWriteReferencesRes()
266 if not session.execute(
267 select(User).where_users_visible(context).where(User.id == request.to_user_id)
268 ).scalar_one_or_none():
269 context.abort(grpc.StatusCode.NOT_FOUND, errors.USER_NOT_FOUND)
271 can_write_friend_reference = (
272 session.execute(
273 select(Reference)
274 .where(Reference.from_user_id == context.user_id)
275 .where(Reference.to_user_id == request.to_user_id)
276 .where(Reference.reference_type == ReferenceType.friend)
277 ).scalar_one_or_none()
278 ) is None
280 q1 = (
281 select(literal(True), HostRequest)
282 .outerjoin(
283 Reference,
284 and_(
285 Reference.host_request_id == HostRequest.conversation_id,
286 Reference.from_user_id == context.user_id,
287 ),
288 )
289 .where(Reference.id == None)
290 .where(HostRequest.can_write_reference)
291 .where(HostRequest.surfer_user_id == context.user_id)
292 .where(HostRequest.host_user_id == request.to_user_id)
293 )
295 q2 = (
296 select(literal(False), HostRequest)
297 .outerjoin(
298 Reference,
299 and_(
300 Reference.host_request_id == HostRequest.conversation_id,
301 Reference.from_user_id == context.user_id,
302 ),
303 )
304 .where(Reference.id == None)
305 .where(HostRequest.can_write_reference)
306 .where(HostRequest.surfer_user_id == request.to_user_id)
307 .where(HostRequest.host_user_id == context.user_id)
308 )
310 union = union_all(q1, q2).order_by(HostRequest.end_time_to_write_reference.asc()).subquery()
311 union = select(union.c[0].label("surfed"), aliased(HostRequest, union))
312 host_request_references = session.execute(union).all()
314 return references_pb2.AvailableWriteReferencesRes(
315 can_write_friend_reference=can_write_friend_reference,
316 available_write_references=[
317 references_pb2.AvailableWriteReferenceType(
318 host_request_id=host_request.conversation_id,
319 reference_type=reftype2api[ReferenceType.surfed if surfed else ReferenceType.hosted],
320 time_expires=Timestamp_from_datetime(host_request.end_time_to_write_reference),
321 )
322 for surfed, host_request in host_request_references
323 ],
324 )
326 def ListPendingReferencesToWrite(self, request, context, session):
327 q1 = (
328 select(literal(True), HostRequest)
329 .outerjoin(
330 Reference,
331 and_(
332 Reference.host_request_id == HostRequest.conversation_id,
333 Reference.from_user_id == context.user_id,
334 ),
335 )
336 .where_users_column_visible(context, HostRequest.host_user_id)
337 .where(Reference.id == None)
338 .where(HostRequest.can_write_reference)
339 .where(HostRequest.surfer_user_id == context.user_id)
340 )
342 q2 = (
343 select(literal(False), HostRequest)
344 .outerjoin(
345 Reference,
346 and_(
347 Reference.host_request_id == HostRequest.conversation_id,
348 Reference.from_user_id == context.user_id,
349 ),
350 )
351 .where_users_column_visible(context, HostRequest.surfer_user_id)
352 .where(Reference.id == None)
353 .where(HostRequest.can_write_reference)
354 .where(HostRequest.host_user_id == context.user_id)
355 )
357 union = union_all(q1, q2).order_by(HostRequest.end_time_to_write_reference.asc()).subquery()
358 union = select(union.c[0].label("surfed"), aliased(HostRequest, union))
359 host_request_references = session.execute(union).all()
361 return references_pb2.ListPendingReferencesToWriteRes(
362 pending_references=[
363 references_pb2.AvailableWriteReferenceType(
364 host_request_id=host_request.conversation_id,
365 reference_type=reftype2api[ReferenceType.surfed if surfed else ReferenceType.hosted],
366 time_expires=Timestamp_from_datetime(host_request.end_time_to_write_reference),
367 )
368 for surfed, host_request in host_request_references
369 ],
370 )