Coverage for src/couchers/servicers/api.py: 97%
410 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-12-04 01:57 +0000
« prev ^ index » next coverage.py v7.11.0, created at 2025-12-04 01:57 +0000
1from datetime import timedelta
2from urllib.parse import urlencode
4import grpc
5from google.protobuf import empty_pb2
6from sqlalchemy.sql import and_, delete, distinct, func, intersect, or_, union
8from couchers import urls
9from couchers.config import config
10from couchers.constants import GHOST_USERNAME
11from couchers.crypto import b64encode, generate_hash_signature, random_hex
12from couchers.helpers.strong_verification import get_strong_verification_fields
13from couchers.materialized_views import LiteUser, UserResponseRate
14from couchers.models import (
15 FriendRelationship,
16 FriendStatus,
17 GroupChatSubscription,
18 HostingStatus,
19 HostRequest,
20 InitiatedUpload,
21 LanguageAbility,
22 LanguageFluency,
23 MeetupStatus,
24 Message,
25 Notification,
26 NotificationDeliveryType,
27 ParkingDetails,
28 RateLimitAction,
29 Reference,
30 RegionLived,
31 RegionVisited,
32 SleepingArrangement,
33 SmokingLocation,
34 User,
35 UserBadge,
36)
37from couchers.notifications.notify import notify
38from couchers.notifications.settings import get_topic_actions_by_delivery_type
39from couchers.proto import api_pb2, api_pb2_grpc, media_pb2, notification_data_pb2, requests_pb2
40from couchers.rate_limits.check import process_rate_limits_and_check_abort
41from couchers.rate_limits.definitions import RATE_LIMIT_HOURS
42from couchers.resources import get_badge_dict, language_is_allowed, region_is_allowed
43from couchers.servicers.blocking import is_not_visible
44from couchers.sql import couchers_select as select
45from couchers.sql import is_valid_user_id, is_valid_username
46from couchers.utils import (
47 Duration_from_timedelta,
48 Timestamp_from_datetime,
49 create_coordinate,
50 get_coordinates,
51 is_valid_name,
52 now,
53)
56class GhostUserSerializationError(Exception):
57 """
58 Raised when attempting to serialize a ghost user (deleted/banned/blocked)
59 """
61 pass
64MAX_USERS_PER_QUERY = 200
65MAX_PAGINATION_LENGTH = 50
67hostingstatus2sql = {
68 api_pb2.HOSTING_STATUS_UNKNOWN: None,
69 api_pb2.HOSTING_STATUS_CAN_HOST: HostingStatus.can_host,
70 api_pb2.HOSTING_STATUS_MAYBE: HostingStatus.maybe,
71 api_pb2.HOSTING_STATUS_CANT_HOST: HostingStatus.cant_host,
72}
74hostingstatus2api = {
75 None: api_pb2.HOSTING_STATUS_UNKNOWN,
76 HostingStatus.can_host: api_pb2.HOSTING_STATUS_CAN_HOST,
77 HostingStatus.maybe: api_pb2.HOSTING_STATUS_MAYBE,
78 HostingStatus.cant_host: api_pb2.HOSTING_STATUS_CANT_HOST,
79}
81meetupstatus2sql = {
82 api_pb2.MEETUP_STATUS_UNKNOWN: None,
83 api_pb2.MEETUP_STATUS_WANTS_TO_MEETUP: MeetupStatus.wants_to_meetup,
84 api_pb2.MEETUP_STATUS_OPEN_TO_MEETUP: MeetupStatus.open_to_meetup,
85 api_pb2.MEETUP_STATUS_DOES_NOT_WANT_TO_MEETUP: MeetupStatus.does_not_want_to_meetup,
86}
88meetupstatus2api = {
89 None: api_pb2.MEETUP_STATUS_UNKNOWN,
90 MeetupStatus.wants_to_meetup: api_pb2.MEETUP_STATUS_WANTS_TO_MEETUP,
91 MeetupStatus.open_to_meetup: api_pb2.MEETUP_STATUS_OPEN_TO_MEETUP,
92 MeetupStatus.does_not_want_to_meetup: api_pb2.MEETUP_STATUS_DOES_NOT_WANT_TO_MEETUP,
93}
95smokinglocation2sql = {
96 api_pb2.SMOKING_LOCATION_UNKNOWN: None,
97 api_pb2.SMOKING_LOCATION_YES: SmokingLocation.yes,
98 api_pb2.SMOKING_LOCATION_WINDOW: SmokingLocation.window,
99 api_pb2.SMOKING_LOCATION_OUTSIDE: SmokingLocation.outside,
100 api_pb2.SMOKING_LOCATION_NO: SmokingLocation.no,
101}
103smokinglocation2api = {
104 None: api_pb2.SMOKING_LOCATION_UNKNOWN,
105 SmokingLocation.yes: api_pb2.SMOKING_LOCATION_YES,
106 SmokingLocation.window: api_pb2.SMOKING_LOCATION_WINDOW,
107 SmokingLocation.outside: api_pb2.SMOKING_LOCATION_OUTSIDE,
108 SmokingLocation.no: api_pb2.SMOKING_LOCATION_NO,
109}
111sleepingarrangement2sql = {
112 api_pb2.SLEEPING_ARRANGEMENT_UNKNOWN: None,
113 api_pb2.SLEEPING_ARRANGEMENT_PRIVATE: SleepingArrangement.private,
114 api_pb2.SLEEPING_ARRANGEMENT_COMMON: SleepingArrangement.common,
115 api_pb2.SLEEPING_ARRANGEMENT_SHARED_ROOM: SleepingArrangement.shared_room,
116}
118sleepingarrangement2api = {
119 None: api_pb2.SLEEPING_ARRANGEMENT_UNKNOWN,
120 SleepingArrangement.private: api_pb2.SLEEPING_ARRANGEMENT_PRIVATE,
121 SleepingArrangement.common: api_pb2.SLEEPING_ARRANGEMENT_COMMON,
122 SleepingArrangement.shared_room: api_pb2.SLEEPING_ARRANGEMENT_SHARED_ROOM,
123}
125parkingdetails2sql = {
126 api_pb2.PARKING_DETAILS_UNKNOWN: None,
127 api_pb2.PARKING_DETAILS_FREE_ONSITE: ParkingDetails.free_onsite,
128 api_pb2.PARKING_DETAILS_FREE_OFFSITE: ParkingDetails.free_offsite,
129 api_pb2.PARKING_DETAILS_PAID_ONSITE: ParkingDetails.paid_onsite,
130 api_pb2.PARKING_DETAILS_PAID_OFFSITE: ParkingDetails.paid_offsite,
131}
133parkingdetails2api = {
134 None: api_pb2.PARKING_DETAILS_UNKNOWN,
135 ParkingDetails.free_onsite: api_pb2.PARKING_DETAILS_FREE_ONSITE,
136 ParkingDetails.free_offsite: api_pb2.PARKING_DETAILS_FREE_OFFSITE,
137 ParkingDetails.paid_onsite: api_pb2.PARKING_DETAILS_PAID_ONSITE,
138 ParkingDetails.paid_offsite: api_pb2.PARKING_DETAILS_PAID_OFFSITE,
139}
141fluency2sql = {
142 api_pb2.LanguageAbility.Fluency.FLUENCY_UNKNOWN: None,
143 api_pb2.LanguageAbility.Fluency.FLUENCY_BEGINNER: LanguageFluency.beginner,
144 api_pb2.LanguageAbility.Fluency.FLUENCY_CONVERSATIONAL: LanguageFluency.conversational,
145 api_pb2.LanguageAbility.Fluency.FLUENCY_FLUENT: LanguageFluency.fluent,
146}
148fluency2api = {
149 None: api_pb2.LanguageAbility.Fluency.FLUENCY_UNKNOWN,
150 LanguageFluency.beginner: api_pb2.LanguageAbility.Fluency.FLUENCY_BEGINNER,
151 LanguageFluency.conversational: api_pb2.LanguageAbility.Fluency.FLUENCY_CONVERSATIONAL,
152 LanguageFluency.fluent: api_pb2.LanguageAbility.Fluency.FLUENCY_FLUENT,
153}
156class API(api_pb2_grpc.APIServicer):
157 def Ping(self, request, context, session):
158 # auth ought to make sure the user exists
159 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
161 sent_reqs_last_seen_message_ids = (
162 select(HostRequest.conversation_id, HostRequest.surfer_last_seen_message_id)
163 .where(HostRequest.surfer_user_id == context.user_id)
164 .where_users_column_visible(context, HostRequest.host_user_id)
165 ).subquery()
167 unseen_sent_host_request_count = session.execute(
168 select(func.count(distinct(sent_reqs_last_seen_message_ids.c.conversation_id)))
169 .join(
170 Message,
171 Message.conversation_id == sent_reqs_last_seen_message_ids.c.conversation_id,
172 )
173 .where(sent_reqs_last_seen_message_ids.c.surfer_last_seen_message_id < Message.id)
174 .where(Message.id != None)
175 ).scalar_one()
177 received_reqs_last_seen_message_ids = (
178 select(HostRequest.conversation_id, HostRequest.host_last_seen_message_id)
179 .where(HostRequest.host_user_id == context.user_id)
180 .where_users_column_visible(context, HostRequest.surfer_user_id)
181 ).subquery()
183 unseen_received_host_request_count = session.execute(
184 select(func.count(distinct(received_reqs_last_seen_message_ids.c.conversation_id)))
185 .join(
186 Message,
187 Message.conversation_id == received_reqs_last_seen_message_ids.c.conversation_id,
188 )
189 .where(received_reqs_last_seen_message_ids.c.host_last_seen_message_id < Message.id)
190 .where(Message.id != None)
191 ).scalar_one()
193 unseen_message_count = session.execute(
194 select(func.count(Message.id))
195 .outerjoin(GroupChatSubscription, GroupChatSubscription.group_chat_id == Message.conversation_id)
196 .where(GroupChatSubscription.user_id == context.user_id)
197 .where(Message.time >= GroupChatSubscription.joined)
198 .where(or_(Message.time <= GroupChatSubscription.left, GroupChatSubscription.left == None))
199 .where(Message.id > GroupChatSubscription.last_seen_message_id)
200 ).scalar_one()
202 pending_friend_request_count = session.execute(
203 select(func.count(FriendRelationship.id))
204 .where(FriendRelationship.to_user_id == context.user_id)
205 .where_users_column_visible(context, FriendRelationship.from_user_id)
206 .where(FriendRelationship.status == FriendStatus.pending)
207 ).scalar_one()
209 unseen_notification_count = session.execute(
210 select(func.count())
211 .select_from(Notification)
212 .where(Notification.user_id == context.user_id)
213 .where(Notification.is_seen == False)
214 .where(
215 Notification.topic_action.in_(
216 get_topic_actions_by_delivery_type(session, user.id, NotificationDeliveryType.push)
217 )
218 )
219 ).scalar_one()
221 return api_pb2.PingRes(
222 user=user_model_to_pb(user, session, context),
223 unseen_message_count=unseen_message_count,
224 unseen_sent_host_request_count=unseen_sent_host_request_count,
225 unseen_received_host_request_count=unseen_received_host_request_count,
226 pending_friend_request_count=pending_friend_request_count,
227 unseen_notification_count=unseen_notification_count,
228 )
230 def GetUser(self, request, context, session):
231 user = session.execute(select(User).where_username_or_id(request.user)).scalar_one_or_none()
233 if not user:
234 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "user_not_found")
236 return user_model_to_pb(user, session, context, is_get_user_return_ghosts=True)
238 def GetLiteUser(self, request, context, session):
239 lite_user = session.execute(
240 select(LiteUser).where_username_or_id(request.user, table=LiteUser)
241 ).scalar_one_or_none()
243 if not lite_user:
244 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "user_not_found")
246 return lite_user_to_pb(session, lite_user, context, is_get_user_return_ghosts=True)
248 def GetLiteUsers(self, request, context, session):
249 if len(request.users) > MAX_USERS_PER_QUERY:
250 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "requested_too_many_users")
252 usernames = {u for u in request.users if is_valid_username(u)}
253 ids = {u for u in request.users if is_valid_user_id(u)}
255 # decomposed where_username_or_id...
256 users = (
257 session.execute(select(LiteUser).where(or_(LiteUser.username.in_(usernames), LiteUser.id.in_(ids))))
258 .scalars()
259 .all()
260 )
262 users_by_id = {str(user.id): user for user in users}
263 users_by_username = {user.username: user for user in users}
265 res = api_pb2.GetLiteUsersRes()
267 for user in request.users:
268 lite_user = None
269 if user in users_by_id:
270 lite_user = users_by_id[user]
271 elif user in users_by_username:
272 lite_user = users_by_username[user]
274 res.responses.append(
275 api_pb2.LiteUserRes(
276 query=user,
277 not_found=lite_user is None,
278 user=lite_user_to_pb(session, lite_user, context, is_get_user_return_ghosts=True)
279 if lite_user
280 else None,
281 )
282 )
284 return res
286 def UpdateProfile(self, request, context, session):
287 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
289 if request.HasField("name"):
290 if not is_valid_name(request.name.value):
291 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_name")
292 user.name = request.name.value
294 if request.HasField("city"):
295 user.city = request.city.value
297 if request.HasField("hometown"):
298 if request.hometown.is_null:
299 user.hometown = None
300 else:
301 user.hometown = request.hometown.value
303 if request.HasField("lat") and request.HasField("lng"):
304 if request.lat.value == 0 and request.lng.value == 0:
305 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_coordinate")
306 user.geom = create_coordinate(request.lat.value, request.lng.value)
307 user.randomized_geom = None
309 if request.HasField("radius"):
310 user.geom_radius = request.radius.value
312 if request.HasField("avatar_key"):
313 if request.avatar_key.is_null:
314 user.avatar_key = None
315 else:
316 user.avatar_key = request.avatar_key.value
318 # if request.HasField("gender"):
319 # user.gender = request.gender.value
321 if request.HasField("pronouns"):
322 if request.pronouns.is_null:
323 user.pronouns = None
324 else:
325 user.pronouns = request.pronouns.value
327 if request.HasField("occupation"):
328 if request.occupation.is_null:
329 user.occupation = None
330 else:
331 user.occupation = request.occupation.value
333 if request.HasField("education"):
334 if request.education.is_null:
335 user.education = None
336 else:
337 user.education = request.education.value
339 if request.HasField("about_me"):
340 if request.about_me.is_null:
341 user.about_me = None
342 else:
343 user.about_me = request.about_me.value
345 if request.HasField("things_i_like"):
346 if request.things_i_like.is_null:
347 user.things_i_like = None
348 else:
349 user.things_i_like = request.things_i_like.value
351 if request.HasField("about_place"):
352 if request.about_place.is_null:
353 user.about_place = None
354 else:
355 user.about_place = request.about_place.value
357 if request.hosting_status != api_pb2.HOSTING_STATUS_UNSPECIFIED:
358 if user.do_not_email and request.hosting_status != api_pb2.HOSTING_STATUS_CANT_HOST:
359 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "do_not_email_cannot_host")
360 user.hosting_status = hostingstatus2sql[request.hosting_status]
362 if request.meetup_status != api_pb2.MEETUP_STATUS_UNSPECIFIED:
363 if user.do_not_email and request.meetup_status != api_pb2.MEETUP_STATUS_DOES_NOT_WANT_TO_MEETUP:
364 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "do_not_email_cannot_meet")
365 user.meetup_status = meetupstatus2sql[request.meetup_status]
367 if request.HasField("language_abilities"):
368 # delete all existing abilities
369 for ability in user.language_abilities:
370 session.delete(ability)
371 session.flush()
373 # add the new ones
374 for language_ability in request.language_abilities.value:
375 if not language_is_allowed(language_ability.code):
376 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_language")
377 session.add(
378 LanguageAbility(
379 user=user,
380 language_code=language_ability.code,
381 fluency=fluency2sql[language_ability.fluency],
382 )
383 )
385 if request.HasField("regions_visited"):
386 session.execute(delete(RegionVisited).where(RegionVisited.user_id == context.user_id))
388 for region in request.regions_visited.value:
389 if not region_is_allowed(region):
390 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_region")
391 session.add(
392 RegionVisited(
393 user_id=user.id,
394 region_code=region,
395 )
396 )
398 if request.HasField("regions_lived"):
399 session.execute(delete(RegionLived).where(RegionLived.user_id == context.user_id))
401 for region in request.regions_lived.value:
402 if not region_is_allowed(region):
403 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_region")
404 session.add(
405 RegionLived(
406 user_id=user.id,
407 region_code=region,
408 )
409 )
411 if request.HasField("additional_information"):
412 if request.additional_information.is_null:
413 user.additional_information = None
414 else:
415 user.additional_information = request.additional_information.value
417 if request.HasField("max_guests"):
418 if request.max_guests.is_null:
419 user.max_guests = None
420 else:
421 user.max_guests = request.max_guests.value
423 if request.HasField("last_minute"):
424 if request.last_minute.is_null:
425 user.last_minute = None
426 else:
427 user.last_minute = request.last_minute.value
429 if request.HasField("has_pets"):
430 if request.has_pets.is_null:
431 user.has_pets = None
432 else:
433 user.has_pets = request.has_pets.value
435 if request.HasField("accepts_pets"):
436 if request.accepts_pets.is_null:
437 user.accepts_pets = None
438 else:
439 user.accepts_pets = request.accepts_pets.value
441 if request.HasField("pet_details"):
442 if request.pet_details.is_null:
443 user.pet_details = None
444 else:
445 user.pet_details = request.pet_details.value
447 if request.HasField("has_kids"):
448 if request.has_kids.is_null:
449 user.has_kids = None
450 else:
451 user.has_kids = request.has_kids.value
453 if request.HasField("accepts_kids"):
454 if request.accepts_kids.is_null:
455 user.accepts_kids = None
456 else:
457 user.accepts_kids = request.accepts_kids.value
459 if request.HasField("kid_details"):
460 if request.kid_details.is_null:
461 user.kid_details = None
462 else:
463 user.kid_details = request.kid_details.value
465 if request.HasField("has_housemates"):
466 if request.has_housemates.is_null:
467 user.has_housemates = None
468 else:
469 user.has_housemates = request.has_housemates.value
471 if request.HasField("housemate_details"):
472 if request.housemate_details.is_null:
473 user.housemate_details = None
474 else:
475 user.housemate_details = request.housemate_details.value
477 if request.HasField("wheelchair_accessible"):
478 if request.wheelchair_accessible.is_null:
479 user.wheelchair_accessible = None
480 else:
481 user.wheelchair_accessible = request.wheelchair_accessible.value
483 if request.smoking_allowed != api_pb2.SMOKING_LOCATION_UNSPECIFIED:
484 user.smoking_allowed = smokinglocation2sql[request.smoking_allowed]
486 if request.HasField("smokes_at_home"):
487 if request.smokes_at_home.is_null:
488 user.smokes_at_home = None
489 else:
490 user.smokes_at_home = request.smokes_at_home.value
492 if request.HasField("drinking_allowed"):
493 if request.drinking_allowed.is_null:
494 user.drinking_allowed = None
495 else:
496 user.drinking_allowed = request.drinking_allowed.value
498 if request.HasField("drinks_at_home"):
499 if request.drinks_at_home.is_null:
500 user.drinks_at_home = None
501 else:
502 user.drinks_at_home = request.drinks_at_home.value
504 if request.HasField("other_host_info"):
505 if request.other_host_info.is_null:
506 user.other_host_info = None
507 else:
508 user.other_host_info = request.other_host_info.value
510 if request.sleeping_arrangement != api_pb2.SLEEPING_ARRANGEMENT_UNSPECIFIED:
511 user.sleeping_arrangement = sleepingarrangement2sql[request.sleeping_arrangement]
513 if request.HasField("sleeping_details"):
514 if request.sleeping_details.is_null:
515 user.sleeping_details = None
516 else:
517 user.sleeping_details = request.sleeping_details.value
519 if request.HasField("area"):
520 if request.area.is_null:
521 user.area = None
522 else:
523 user.area = request.area.value
525 if request.HasField("house_rules"):
526 if request.house_rules.is_null:
527 user.house_rules = None
528 else:
529 user.house_rules = request.house_rules.value
531 if request.HasField("parking"):
532 if request.parking.is_null:
533 user.parking = None
534 else:
535 user.parking = request.parking.value
537 if request.parking_details != api_pb2.PARKING_DETAILS_UNSPECIFIED:
538 user.parking_details = parkingdetails2sql[request.parking_details]
540 if request.HasField("camping_ok"):
541 if request.camping_ok.is_null:
542 user.camping_ok = None
543 else:
544 user.camping_ok = request.camping_ok.value
546 return empty_pb2.Empty()
548 def ListFriends(self, request, context, session):
549 rels = (
550 session.execute(
551 select(FriendRelationship)
552 .where_users_column_visible(context, FriendRelationship.from_user_id)
553 .where_users_column_visible(context, FriendRelationship.to_user_id)
554 .where(
555 or_(
556 FriendRelationship.from_user_id == context.user_id,
557 FriendRelationship.to_user_id == context.user_id,
558 )
559 )
560 .where(FriendRelationship.status == FriendStatus.accepted)
561 )
562 .scalars()
563 .all()
564 )
565 return api_pb2.ListFriendsRes(
566 user_ids=[rel.from_user.id if rel.from_user.id != context.user_id else rel.to_user.id for rel in rels],
567 )
569 def RemoveFriend(self, request, context, session):
570 rel = session.execute(
571 select(FriendRelationship)
572 .where_users_column_visible(context, FriendRelationship.from_user_id)
573 .where_users_column_visible(context, FriendRelationship.to_user_id)
574 .where(
575 or_(
576 and_(
577 FriendRelationship.from_user_id == request.user_id,
578 FriendRelationship.to_user_id == context.user_id,
579 ),
580 and_(
581 FriendRelationship.from_user_id == context.user_id,
582 FriendRelationship.to_user_id == request.user_id,
583 ),
584 )
585 )
586 .where(FriendRelationship.status == FriendStatus.accepted)
587 ).scalar_one_or_none()
589 if not rel:
590 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "not_friends")
592 session.delete(rel)
594 return empty_pb2.Empty()
596 def ListMutualFriends(self, request, context, session):
597 if context.user_id == request.user_id:
598 return api_pb2.ListMutualFriendsRes(mutual_friends=[])
600 user = session.execute(
601 select(User).where_users_visible(context).where(User.id == request.user_id)
602 ).scalar_one_or_none()
604 if not user:
605 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "user_not_found")
607 q1 = (
608 select(FriendRelationship.from_user_id.label("user_id"))
609 .where(FriendRelationship.to_user_id == context.user_id)
610 .where(FriendRelationship.from_user_id != request.user_id)
611 .where(FriendRelationship.status == FriendStatus.accepted)
612 )
614 q2 = (
615 select(FriendRelationship.to_user_id.label("user_id"))
616 .where(FriendRelationship.from_user_id == context.user_id)
617 .where(FriendRelationship.to_user_id != request.user_id)
618 .where(FriendRelationship.status == FriendStatus.accepted)
619 )
621 q3 = (
622 select(FriendRelationship.from_user_id.label("user_id"))
623 .where(FriendRelationship.to_user_id == request.user_id)
624 .where(FriendRelationship.from_user_id != context.user_id)
625 .where(FriendRelationship.status == FriendStatus.accepted)
626 )
628 q4 = (
629 select(FriendRelationship.to_user_id.label("user_id"))
630 .where(FriendRelationship.from_user_id == request.user_id)
631 .where(FriendRelationship.to_user_id != context.user_id)
632 .where(FriendRelationship.status == FriendStatus.accepted)
633 )
635 mutual = select(intersect(union(q1, q2), union(q3, q4)).subquery())
637 mutual_friends = (
638 session.execute(select(User).where_users_visible(context).where(User.id.in_(mutual))).scalars().all()
639 )
641 return api_pb2.ListMutualFriendsRes(
642 mutual_friends=[
643 api_pb2.MutualFriend(user_id=mutual_friend.id, username=mutual_friend.username, name=mutual_friend.name)
644 for mutual_friend in mutual_friends
645 ]
646 )
648 def SendFriendRequest(self, request, context, session):
649 if context.user_id == request.user_id:
650 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "cant_friend_self")
652 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
653 to_user = session.execute(
654 select(User).where_users_visible(context).where(User.id == request.user_id)
655 ).scalar_one_or_none()
657 if not to_user:
658 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "user_not_found")
660 if (
661 session.execute(
662 select(FriendRelationship)
663 .where(
664 or_(
665 and_(
666 FriendRelationship.from_user_id == context.user_id,
667 FriendRelationship.to_user_id == request.user_id,
668 ),
669 and_(
670 FriendRelationship.from_user_id == request.user_id,
671 FriendRelationship.to_user_id == context.user_id,
672 ),
673 )
674 )
675 .where(
676 or_(
677 FriendRelationship.status == FriendStatus.accepted,
678 FriendRelationship.status == FriendStatus.pending,
679 )
680 )
681 ).scalar_one_or_none()
682 is not None
683 ):
684 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "friends_already_or_pending")
686 # Check if user has been sending friend requests excessively
687 if process_rate_limits_and_check_abort(
688 session=session, user_id=context.user_id, action=RateLimitAction.friend_request
689 ):
690 context.abort_with_error_code(
691 grpc.StatusCode.RESOURCE_EXHAUSTED,
692 "friend_request_rate_limit",
693 substitutions={"hours": RATE_LIMIT_HOURS},
694 )
696 # TODO: Race condition where we can create two friend reqs, needs db constraint! See comment in table
698 friend_relationship = FriendRelationship(from_user=user, to_user=to_user, status=FriendStatus.pending)
699 session.add(friend_relationship)
700 session.flush()
702 notify(
703 session,
704 user_id=friend_relationship.to_user_id,
705 topic_action="friend_request:create",
706 key=friend_relationship.from_user_id,
707 data=notification_data_pb2.FriendRequestCreate(
708 other_user=user_model_to_pb(friend_relationship.from_user, session, context),
709 ),
710 )
712 return empty_pb2.Empty()
714 def ListFriendRequests(self, request, context, session):
715 # both sent and received
716 sent_requests = (
717 session.execute(
718 select(FriendRelationship)
719 .where_users_column_visible(context, FriendRelationship.to_user_id)
720 .where(FriendRelationship.from_user_id == context.user_id)
721 .where(FriendRelationship.status == FriendStatus.pending)
722 )
723 .scalars()
724 .all()
725 )
727 received_requests = (
728 session.execute(
729 select(FriendRelationship)
730 .where_users_column_visible(context, FriendRelationship.from_user_id)
731 .where(FriendRelationship.to_user_id == context.user_id)
732 .where(FriendRelationship.status == FriendStatus.pending)
733 )
734 .scalars()
735 .all()
736 )
738 return api_pb2.ListFriendRequestsRes(
739 sent=[
740 api_pb2.FriendRequest(
741 friend_request_id=friend_request.id,
742 state=api_pb2.FriendRequest.FriendRequestStatus.PENDING,
743 user_id=friend_request.to_user.id,
744 sent=True,
745 )
746 for friend_request in sent_requests
747 ],
748 received=[
749 api_pb2.FriendRequest(
750 friend_request_id=friend_request.id,
751 state=api_pb2.FriendRequest.FriendRequestStatus.PENDING,
752 user_id=friend_request.from_user.id,
753 sent=False,
754 )
755 for friend_request in received_requests
756 ],
757 )
759 def RespondFriendRequest(self, request, context, session):
760 friend_request = session.execute(
761 select(FriendRelationship)
762 .where_users_column_visible(context, FriendRelationship.from_user_id)
763 .where(FriendRelationship.to_user_id == context.user_id)
764 .where(FriendRelationship.status == FriendStatus.pending)
765 .where(FriendRelationship.id == request.friend_request_id)
766 ).scalar_one_or_none()
768 if not friend_request:
769 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "friend_request_not_found")
771 friend_request.status = FriendStatus.accepted if request.accept else FriendStatus.rejected
772 friend_request.time_responded = func.now()
774 session.flush()
776 if friend_request.status == FriendStatus.accepted:
777 notify(
778 session,
779 user_id=friend_request.from_user_id,
780 topic_action="friend_request:accept",
781 key=friend_request.to_user_id,
782 data=notification_data_pb2.FriendRequestAccept(
783 other_user=user_model_to_pb(friend_request.to_user, session, context),
784 ),
785 )
787 return empty_pb2.Empty()
789 def CancelFriendRequest(self, request, context, session):
790 friend_request = session.execute(
791 select(FriendRelationship)
792 .where_users_column_visible(context, FriendRelationship.to_user_id)
793 .where(FriendRelationship.from_user_id == context.user_id)
794 .where(FriendRelationship.status == FriendStatus.pending)
795 .where(FriendRelationship.id == request.friend_request_id)
796 ).scalar_one_or_none()
798 if not friend_request:
799 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "friend_request_not_found")
801 friend_request.status = FriendStatus.cancelled
802 friend_request.time_responded = func.now()
804 # note no notifications
806 session.commit()
808 return empty_pb2.Empty()
810 def InitiateMediaUpload(self, request, context, session):
811 key = random_hex()
813 created = now()
814 expiry = created + timedelta(minutes=20)
816 upload = InitiatedUpload(key=key, created=created, expiry=expiry, initiator_user_id=context.user_id)
817 session.add(upload)
818 session.commit()
820 req = media_pb2.UploadRequest(
821 key=upload.key,
822 type=media_pb2.UploadRequest.UploadType.IMAGE,
823 created=Timestamp_from_datetime(upload.created),
824 expiry=Timestamp_from_datetime(upload.expiry),
825 max_width=2000,
826 max_height=1600,
827 ).SerializeToString()
829 data = b64encode(req)
830 sig = b64encode(generate_hash_signature(req, config["MEDIA_SERVER_SECRET_KEY"]))
832 path = "upload?" + urlencode({"data": data, "sig": sig})
834 return api_pb2.InitiateMediaUploadRes(
835 upload_url=urls.media_upload_url(path=path),
836 expiry=Timestamp_from_datetime(expiry),
837 )
839 def ListBadgeUsers(self, request, context, session):
840 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
841 next_user_id = int(request.page_token) if request.page_token else 0
842 badge = get_badge_dict().get(request.badge_id)
843 if not badge:
844 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "badge_not_found")
846 badge_user_ids = (
847 session.execute(
848 select(UserBadge.user_id)
849 .where(UserBadge.badge_id == badge["id"])
850 .where(UserBadge.user_id >= next_user_id)
851 .order_by(UserBadge.user_id)
852 .limit(page_size + 1)
853 )
854 .scalars()
855 .all()
856 )
858 return api_pb2.ListBadgeUsersRes(
859 user_ids=badge_user_ids[:page_size],
860 next_page_token=str(badge_user_ids[-1]) if len(badge_user_ids) > page_size else None,
861 )
864def response_rate_to_pb(response_rate: UserResponseRate):
865 if not response_rate:
866 return {"insufficient_data": requests_pb2.ResponseRateInsufficientData()}
868 # if n is None, the user is new or they have no requests
869 if response_rate.requests < 3:
870 return {"insufficient_data": requests_pb2.ResponseRateInsufficientData()}
872 if response_rate.response_rate <= 0.33:
873 return {"low": requests_pb2.ResponseRateLow()}
875 response_time_p33_coarsened = Duration_from_timedelta(
876 timedelta(seconds=round(response_rate.response_time_33p.total_seconds() / 60) * 60)
877 )
879 if response_rate.response_rate <= 0.66:
880 return {"some": requests_pb2.ResponseRateSome(response_time_p33=response_time_p33_coarsened)}
882 response_time_p66_coarsened = Duration_from_timedelta(
883 timedelta(seconds=round(response_rate.response_time_66p.total_seconds() / 60) * 60)
884 )
886 if response_rate.response_rate <= 0.90:
887 return {
888 "most": requests_pb2.ResponseRateMost(
889 response_time_p33=response_time_p33_coarsened, response_time_p66=response_time_p66_coarsened
890 )
891 }
892 else:
893 return {
894 "almost_all": requests_pb2.ResponseRateAlmostAll(
895 response_time_p33=response_time_p33_coarsened, response_time_p66=response_time_p66_coarsened
896 )
897 }
900def get_num_references(session, user_ids):
901 return dict(
902 session.execute(
903 select(Reference.to_user_id, func.count(Reference.id))
904 .where(Reference.to_user_id.in_(user_ids))
905 .where(Reference.is_deleted == False)
906 .join(User, User.id == Reference.from_user_id)
907 .where(User.is_visible)
908 .group_by(Reference.to_user_id)
909 ).all()
910 )
913def user_model_to_pb(db_user, session, context, *, is_admin_see_ghosts=False, is_get_user_return_ghosts=False):
914 # note that this function should work also for banned/deleted users as it's called from Admin.GetUser
915 # note that this function is sometimes called by a logged out user, in which case context comes from make_logged_out_context
917 if not is_admin_see_ghosts and is_not_visible(session, context.user_id, db_user.id):
918 # User is not visible (deleted, banned, or blocked)
919 if is_get_user_return_ghosts:
920 # Return an anonymized "ghost" user profile
921 return api_pb2.User(
922 user_id=db_user.id,
923 is_ghost=True,
924 username=GHOST_USERNAME,
925 name=context.get_localized_string("ghost_users", "display_name"),
926 about_me=context.get_localized_string("ghost_users", "about_me"),
927 )
928 raise GhostUserSerializationError(
929 f"Tried to serialize ghost profile in user_model_to_pb without appropriate flags. "
930 f"Context user_id: {context.user_id}, db_user id: {db_user.id} (username: {db_user.username})"
931 )
933 num_references = get_num_references(session, [db_user.id]).get(db_user.id, 0)
935 # returns (lat, lng)
936 # we put people without coords on null island
937 # https://en.wikipedia.org/wiki/Null_Island
938 lat, lng = db_user.coordinates or (0, 0)
940 pending_friend_request = None
941 if db_user.id == context.user_id:
942 friends_status = api_pb2.User.FriendshipStatus.NA
943 else:
944 friend_relationship = session.execute(
945 select(FriendRelationship)
946 .where(
947 or_(
948 and_(
949 FriendRelationship.from_user_id == context.user_id,
950 FriendRelationship.to_user_id == db_user.id,
951 ),
952 and_(
953 FriendRelationship.from_user_id == db_user.id,
954 FriendRelationship.to_user_id == context.user_id,
955 ),
956 )
957 )
958 .where(
959 or_(
960 FriendRelationship.status == FriendStatus.accepted,
961 FriendRelationship.status == FriendStatus.pending,
962 )
963 )
964 ).scalar_one_or_none()
966 if friend_relationship:
967 if friend_relationship.status == FriendStatus.accepted:
968 friends_status = api_pb2.User.FriendshipStatus.FRIENDS
969 else:
970 friends_status = api_pb2.User.FriendshipStatus.PENDING
971 if friend_relationship.from_user_id == context.user_id:
972 # we sent it
973 pending_friend_request = api_pb2.FriendRequest(
974 friend_request_id=friend_relationship.id,
975 state=api_pb2.FriendRequest.FriendRequestStatus.PENDING,
976 user_id=friend_relationship.to_user.id,
977 sent=True,
978 )
979 else:
980 # we received it
981 pending_friend_request = api_pb2.FriendRequest(
982 friend_request_id=friend_relationship.id,
983 state=api_pb2.FriendRequest.FriendRequestStatus.PENDING,
984 user_id=friend_relationship.from_user.id,
985 sent=False,
986 )
987 else:
988 friends_status = api_pb2.User.FriendshipStatus.NOT_FRIENDS
990 response_rate = session.execute(
991 select(UserResponseRate).where(UserResponseRate.user_id == db_user.id)
992 ).scalar_one_or_none()
994 verification_score = 0.0
995 if db_user.phone_verification_verified:
996 verification_score += 1.0 * db_user.phone_is_verified
998 user = api_pb2.User(
999 user_id=db_user.id,
1000 username=db_user.username,
1001 name=db_user.name,
1002 city=db_user.city,
1003 hometown=db_user.hometown,
1004 timezone=db_user.timezone,
1005 lat=lat,
1006 lng=lng,
1007 radius=db_user.geom_radius,
1008 verification=verification_score,
1009 community_standing=db_user.community_standing,
1010 num_references=num_references,
1011 gender=db_user.gender,
1012 pronouns=db_user.pronouns,
1013 age=int(db_user.age),
1014 joined=Timestamp_from_datetime(db_user.display_joined),
1015 last_active=Timestamp_from_datetime(db_user.display_last_active),
1016 hosting_status=hostingstatus2api[db_user.hosting_status],
1017 meetup_status=meetupstatus2api[db_user.meetup_status],
1018 occupation=db_user.occupation,
1019 education=db_user.education,
1020 about_me=db_user.about_me,
1021 things_i_like=db_user.things_i_like,
1022 about_place=db_user.about_place,
1023 language_abilities=[
1024 api_pb2.LanguageAbility(code=ability.language_code, fluency=fluency2api[ability.fluency])
1025 for ability in db_user.language_abilities
1026 ],
1027 regions_visited=[region.code for region in db_user.regions_visited],
1028 regions_lived=[region.code for region in db_user.regions_lived],
1029 additional_information=db_user.additional_information,
1030 friends=friends_status,
1031 pending_friend_request=pending_friend_request,
1032 smoking_allowed=smokinglocation2api[db_user.smoking_allowed],
1033 sleeping_arrangement=sleepingarrangement2api[db_user.sleeping_arrangement],
1034 parking_details=parkingdetails2api[db_user.parking_details],
1035 avatar_url=db_user.avatar.full_url if db_user.avatar else None,
1036 avatar_thumbnail_url=db_user.avatar.thumbnail_url if db_user.avatar else None,
1037 badges=session.execute(select(UserBadge.badge_id).where(UserBadge.user_id == db_user.id).order_by(UserBadge.id))
1038 .scalars()
1039 .all(),
1040 **get_strong_verification_fields(session, db_user),
1041 **response_rate_to_pb(response_rate),
1042 )
1044 if db_user.max_guests is not None:
1045 user.max_guests.value = db_user.max_guests
1047 if db_user.last_minute is not None:
1048 user.last_minute.value = db_user.last_minute
1050 if db_user.has_pets is not None:
1051 user.has_pets.value = db_user.has_pets
1053 if db_user.accepts_pets is not None:
1054 user.accepts_pets.value = db_user.accepts_pets
1056 if db_user.pet_details is not None:
1057 user.pet_details.value = db_user.pet_details
1059 if db_user.has_kids is not None:
1060 user.has_kids.value = db_user.has_kids
1062 if db_user.accepts_kids is not None:
1063 user.accepts_kids.value = db_user.accepts_kids
1065 if db_user.kid_details is not None:
1066 user.kid_details.value = db_user.kid_details
1068 if db_user.has_housemates is not None:
1069 user.has_housemates.value = db_user.has_housemates
1071 if db_user.housemate_details is not None:
1072 user.housemate_details.value = db_user.housemate_details
1074 if db_user.wheelchair_accessible is not None:
1075 user.wheelchair_accessible.value = db_user.wheelchair_accessible
1077 if db_user.smokes_at_home is not None:
1078 user.smokes_at_home.value = db_user.smokes_at_home
1080 if db_user.drinking_allowed is not None:
1081 user.drinking_allowed.value = db_user.drinking_allowed
1083 if db_user.drinks_at_home is not None:
1084 user.drinks_at_home.value = db_user.drinks_at_home
1086 if db_user.other_host_info is not None:
1087 user.other_host_info.value = db_user.other_host_info
1089 if db_user.sleeping_details is not None:
1090 user.sleeping_details.value = db_user.sleeping_details
1092 if db_user.area is not None:
1093 user.area.value = db_user.area
1095 if db_user.house_rules is not None:
1096 user.house_rules.value = db_user.house_rules
1098 if db_user.parking is not None:
1099 user.parking.value = db_user.parking
1101 if db_user.camping_ok is not None:
1102 user.camping_ok.value = db_user.camping_ok
1104 return user
1107def lite_user_to_pb(
1108 session, lite_user: LiteUser, context, *, is_admin_see_ghosts=False, is_get_user_return_ghosts=False
1109):
1110 if not is_admin_see_ghosts and is_not_visible(session, context.user_id, lite_user.id):
1111 # User is not visible (deleted, banned, or blocked)
1112 if is_get_user_return_ghosts:
1113 # Return an anonymized "ghost" user profile
1114 return api_pb2.LiteUser(
1115 user_id=lite_user.id,
1116 is_ghost=True,
1117 username=GHOST_USERNAME,
1118 name=context.get_localized_string("ghost_users", "display_name"),
1119 )
1120 raise GhostUserSerializationError(
1121 f"Tried to serialize ghost profile in lite_user_to_pb without appropriate flags. "
1122 f"Context user_id: {context.user_id}, lite_user id: {lite_user.id} (username: {lite_user.username})"
1123 )
1125 lat, lng = get_coordinates(lite_user.geom) or (0, 0)
1127 return api_pb2.LiteUser(
1128 user_id=lite_user.id,
1129 username=lite_user.username,
1130 name=lite_user.name,
1131 city=lite_user.city,
1132 age=int(lite_user.age),
1133 avatar_url=urls.media_url(filename=lite_user.avatar_filename, size="full")
1134 if lite_user.avatar_filename
1135 else None,
1136 avatar_thumbnail_url=urls.media_url(filename=lite_user.avatar_filename, size="thumbnail")
1137 if lite_user.avatar_filename
1138 else None,
1139 lat=lat,
1140 lng=lng,
1141 radius=lite_user.radius,
1142 has_strong_verification=lite_user.has_strong_verification,
1143 )