Coverage for src/couchers/servicers/api.py: 97%
397 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
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 errors, urls
9from couchers.config import config
10from couchers.crypto import b64encode, generate_hash_signature, random_hex
11from couchers.helpers.strong_verification import get_strong_verification_fields
12from couchers.materialized_views import LiteUser, UserResponseRate
13from couchers.models import (
14 FriendRelationship,
15 FriendStatus,
16 GroupChatSubscription,
17 HostingStatus,
18 HostRequest,
19 InitiatedUpload,
20 LanguageAbility,
21 LanguageFluency,
22 MeetupStatus,
23 Message,
24 Notification,
25 NotificationDeliveryType,
26 ParkingDetails,
27 RateLimitAction,
28 Reference,
29 RegionLived,
30 RegionVisited,
31 SleepingArrangement,
32 SmokingLocation,
33 User,
34 UserBadge,
35)
36from couchers.notifications.notify import notify
37from couchers.notifications.settings import get_topic_actions_by_delivery_type
38from couchers.rate_limits.check import process_rate_limits_and_check_abort
39from couchers.resources import get_badge_dict, language_is_allowed, region_is_allowed
40from couchers.sql import couchers_select as select
41from couchers.sql import is_valid_user_id, is_valid_username
42from couchers.utils import (
43 Duration_from_timedelta,
44 Timestamp_from_datetime,
45 create_coordinate,
46 get_coordinates,
47 is_valid_name,
48 now,
49)
50from proto import api_pb2, api_pb2_grpc, media_pb2, notification_data_pb2, requests_pb2
52MAX_USERS_PER_QUERY = 200
53MAX_PAGINATION_LENGTH = 50
55hostingstatus2sql = {
56 api_pb2.HOSTING_STATUS_UNKNOWN: None,
57 api_pb2.HOSTING_STATUS_CAN_HOST: HostingStatus.can_host,
58 api_pb2.HOSTING_STATUS_MAYBE: HostingStatus.maybe,
59 api_pb2.HOSTING_STATUS_CANT_HOST: HostingStatus.cant_host,
60}
62hostingstatus2api = {
63 None: api_pb2.HOSTING_STATUS_UNKNOWN,
64 HostingStatus.can_host: api_pb2.HOSTING_STATUS_CAN_HOST,
65 HostingStatus.maybe: api_pb2.HOSTING_STATUS_MAYBE,
66 HostingStatus.cant_host: api_pb2.HOSTING_STATUS_CANT_HOST,
67}
69meetupstatus2sql = {
70 api_pb2.MEETUP_STATUS_UNKNOWN: None,
71 api_pb2.MEETUP_STATUS_WANTS_TO_MEETUP: MeetupStatus.wants_to_meetup,
72 api_pb2.MEETUP_STATUS_OPEN_TO_MEETUP: MeetupStatus.open_to_meetup,
73 api_pb2.MEETUP_STATUS_DOES_NOT_WANT_TO_MEETUP: MeetupStatus.does_not_want_to_meetup,
74}
76meetupstatus2api = {
77 None: api_pb2.MEETUP_STATUS_UNKNOWN,
78 MeetupStatus.wants_to_meetup: api_pb2.MEETUP_STATUS_WANTS_TO_MEETUP,
79 MeetupStatus.open_to_meetup: api_pb2.MEETUP_STATUS_OPEN_TO_MEETUP,
80 MeetupStatus.does_not_want_to_meetup: api_pb2.MEETUP_STATUS_DOES_NOT_WANT_TO_MEETUP,
81}
83smokinglocation2sql = {
84 api_pb2.SMOKING_LOCATION_UNKNOWN: None,
85 api_pb2.SMOKING_LOCATION_YES: SmokingLocation.yes,
86 api_pb2.SMOKING_LOCATION_WINDOW: SmokingLocation.window,
87 api_pb2.SMOKING_LOCATION_OUTSIDE: SmokingLocation.outside,
88 api_pb2.SMOKING_LOCATION_NO: SmokingLocation.no,
89}
91smokinglocation2api = {
92 None: api_pb2.SMOKING_LOCATION_UNKNOWN,
93 SmokingLocation.yes: api_pb2.SMOKING_LOCATION_YES,
94 SmokingLocation.window: api_pb2.SMOKING_LOCATION_WINDOW,
95 SmokingLocation.outside: api_pb2.SMOKING_LOCATION_OUTSIDE,
96 SmokingLocation.no: api_pb2.SMOKING_LOCATION_NO,
97}
99sleepingarrangement2sql = {
100 api_pb2.SLEEPING_ARRANGEMENT_UNKNOWN: None,
101 api_pb2.SLEEPING_ARRANGEMENT_PRIVATE: SleepingArrangement.private,
102 api_pb2.SLEEPING_ARRANGEMENT_COMMON: SleepingArrangement.common,
103 api_pb2.SLEEPING_ARRANGEMENT_SHARED_ROOM: SleepingArrangement.shared_room,
104}
106sleepingarrangement2api = {
107 None: api_pb2.SLEEPING_ARRANGEMENT_UNKNOWN,
108 SleepingArrangement.private: api_pb2.SLEEPING_ARRANGEMENT_PRIVATE,
109 SleepingArrangement.common: api_pb2.SLEEPING_ARRANGEMENT_COMMON,
110 SleepingArrangement.shared_room: api_pb2.SLEEPING_ARRANGEMENT_SHARED_ROOM,
111}
113parkingdetails2sql = {
114 api_pb2.PARKING_DETAILS_UNKNOWN: None,
115 api_pb2.PARKING_DETAILS_FREE_ONSITE: ParkingDetails.free_onsite,
116 api_pb2.PARKING_DETAILS_FREE_OFFSITE: ParkingDetails.free_offsite,
117 api_pb2.PARKING_DETAILS_PAID_ONSITE: ParkingDetails.paid_onsite,
118 api_pb2.PARKING_DETAILS_PAID_OFFSITE: ParkingDetails.paid_offsite,
119}
121parkingdetails2api = {
122 None: api_pb2.PARKING_DETAILS_UNKNOWN,
123 ParkingDetails.free_onsite: api_pb2.PARKING_DETAILS_FREE_ONSITE,
124 ParkingDetails.free_offsite: api_pb2.PARKING_DETAILS_FREE_OFFSITE,
125 ParkingDetails.paid_onsite: api_pb2.PARKING_DETAILS_PAID_ONSITE,
126 ParkingDetails.paid_offsite: api_pb2.PARKING_DETAILS_PAID_OFFSITE,
127}
129fluency2sql = {
130 api_pb2.LanguageAbility.Fluency.FLUENCY_UNKNOWN: None,
131 api_pb2.LanguageAbility.Fluency.FLUENCY_BEGINNER: LanguageFluency.beginner,
132 api_pb2.LanguageAbility.Fluency.FLUENCY_CONVERSATIONAL: LanguageFluency.conversational,
133 api_pb2.LanguageAbility.Fluency.FLUENCY_FLUENT: LanguageFluency.fluent,
134}
136fluency2api = {
137 None: api_pb2.LanguageAbility.Fluency.FLUENCY_UNKNOWN,
138 LanguageFluency.beginner: api_pb2.LanguageAbility.Fluency.FLUENCY_BEGINNER,
139 LanguageFluency.conversational: api_pb2.LanguageAbility.Fluency.FLUENCY_CONVERSATIONAL,
140 LanguageFluency.fluent: api_pb2.LanguageAbility.Fluency.FLUENCY_FLUENT,
141}
144class API(api_pb2_grpc.APIServicer):
145 def Ping(self, request, context, session):
146 # auth ought to make sure the user exists
147 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
149 sent_reqs_last_seen_message_ids = (
150 select(HostRequest.conversation_id, HostRequest.surfer_last_seen_message_id)
151 .where(HostRequest.surfer_user_id == context.user_id)
152 .where_users_column_visible(context, HostRequest.host_user_id)
153 ).subquery()
155 unseen_sent_host_request_count = session.execute(
156 select(func.count(distinct(sent_reqs_last_seen_message_ids.c.conversation_id)))
157 .join(
158 Message,
159 Message.conversation_id == sent_reqs_last_seen_message_ids.c.conversation_id,
160 )
161 .where(sent_reqs_last_seen_message_ids.c.surfer_last_seen_message_id < Message.id)
162 .where(Message.id != None)
163 ).scalar_one()
165 received_reqs_last_seen_message_ids = (
166 select(HostRequest.conversation_id, HostRequest.host_last_seen_message_id)
167 .where(HostRequest.host_user_id == context.user_id)
168 .where_users_column_visible(context, HostRequest.surfer_user_id)
169 ).subquery()
171 unseen_received_host_request_count = session.execute(
172 select(func.count(distinct(received_reqs_last_seen_message_ids.c.conversation_id)))
173 .join(
174 Message,
175 Message.conversation_id == received_reqs_last_seen_message_ids.c.conversation_id,
176 )
177 .where(received_reqs_last_seen_message_ids.c.host_last_seen_message_id < Message.id)
178 .where(Message.id != None)
179 ).scalar_one()
181 unseen_message_count = session.execute(
182 select(func.count(Message.id))
183 .outerjoin(GroupChatSubscription, GroupChatSubscription.group_chat_id == Message.conversation_id)
184 .where(GroupChatSubscription.user_id == context.user_id)
185 .where(Message.time >= GroupChatSubscription.joined)
186 .where(or_(Message.time <= GroupChatSubscription.left, GroupChatSubscription.left == None))
187 .where(Message.id > GroupChatSubscription.last_seen_message_id)
188 ).scalar_one()
190 pending_friend_request_count = session.execute(
191 select(func.count(FriendRelationship.id))
192 .where(FriendRelationship.to_user_id == context.user_id)
193 .where_users_column_visible(context, FriendRelationship.from_user_id)
194 .where(FriendRelationship.status == FriendStatus.pending)
195 ).scalar_one()
197 unseen_notification_count = session.execute(
198 select(func.count())
199 .select_from(Notification)
200 .where(Notification.user_id == context.user_id)
201 .where(Notification.is_seen == False)
202 .where(
203 Notification.topic_action.in_(
204 get_topic_actions_by_delivery_type(session, user.id, NotificationDeliveryType.push)
205 )
206 )
207 ).scalar_one()
209 return api_pb2.PingRes(
210 user=user_model_to_pb(user, session, context),
211 unseen_message_count=unseen_message_count,
212 unseen_sent_host_request_count=unseen_sent_host_request_count,
213 unseen_received_host_request_count=unseen_received_host_request_count,
214 pending_friend_request_count=pending_friend_request_count,
215 unseen_notification_count=unseen_notification_count,
216 )
218 def GetUser(self, request, context, session):
219 user = session.execute(
220 select(User).where_users_visible(context).where_username_or_id(request.user)
221 ).scalar_one_or_none()
223 if not user:
224 context.abort(grpc.StatusCode.NOT_FOUND, errors.USER_NOT_FOUND)
226 return user_model_to_pb(user, session, context)
228 def GetLiteUser(self, request, context, session):
229 lite_user = session.execute(
230 select(LiteUser)
231 .where_users_visible(context, table=LiteUser)
232 .where_username_or_id(request.user, table=LiteUser)
233 ).scalar_one_or_none()
235 if not lite_user:
236 context.abort(grpc.StatusCode.NOT_FOUND, errors.USER_NOT_FOUND)
238 return lite_user_to_pb(lite_user)
240 def GetLiteUsers(self, request, context, session):
241 if len(request.users) > MAX_USERS_PER_QUERY:
242 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.REQUESTED_TOO_MANY_USERS)
244 usernames = {u for u in request.users if is_valid_username(u)}
245 ids = {u for u in request.users if is_valid_user_id(u)}
247 users = (
248 session.execute(
249 select(LiteUser)
250 .where_users_visible(context, table=LiteUser)
251 .where(or_(LiteUser.username.in_(usernames), LiteUser.id.in_(ids)))
252 )
253 .scalars()
254 .all()
255 )
257 users_by_id = {str(user.id): user for user in users}
258 users_by_username = {user.username: user for user in users}
260 res = api_pb2.GetLiteUsersRes()
262 for user in request.users:
263 lite_user = None
264 if user in users_by_id:
265 lite_user = users_by_id[user]
266 elif user in users_by_username:
267 lite_user = users_by_username[user]
269 res.responses.append(
270 api_pb2.LiteUserRes(
271 query=user,
272 not_found=lite_user is None,
273 user=lite_user_to_pb(lite_user) if lite_user else None,
274 )
275 )
277 return res
279 def UpdateProfile(self, request, context, session):
280 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
282 if request.HasField("name"):
283 if not is_valid_name(request.name.value):
284 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.INVALID_NAME)
285 user.name = request.name.value
287 if request.HasField("city"):
288 user.city = request.city.value
290 if request.HasField("hometown"):
291 if request.hometown.is_null:
292 user.hometown = None
293 else:
294 user.hometown = request.hometown.value
296 if request.HasField("lat") and request.HasField("lng"):
297 if request.lat.value == 0 and request.lng.value == 0:
298 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.INVALID_COORDINATE)
299 user.geom = create_coordinate(request.lat.value, request.lng.value)
300 user.randomized_geom = None
302 if request.HasField("radius"):
303 user.geom_radius = request.radius.value
305 if request.HasField("avatar_key"):
306 if request.avatar_key.is_null:
307 user.avatar_key = None
308 else:
309 user.avatar_key = request.avatar_key.value
311 # if request.HasField("gender"):
312 # user.gender = request.gender.value
314 if request.HasField("pronouns"):
315 if request.pronouns.is_null:
316 user.pronouns = None
317 else:
318 user.pronouns = request.pronouns.value
320 if request.HasField("occupation"):
321 if request.occupation.is_null:
322 user.occupation = None
323 else:
324 user.occupation = request.occupation.value
326 if request.HasField("education"):
327 if request.education.is_null:
328 user.education = None
329 else:
330 user.education = request.education.value
332 if request.HasField("about_me"):
333 if request.about_me.is_null:
334 user.about_me = None
335 else:
336 user.about_me = request.about_me.value
338 if request.HasField("things_i_like"):
339 if request.things_i_like.is_null:
340 user.things_i_like = None
341 else:
342 user.things_i_like = request.things_i_like.value
344 if request.HasField("about_place"):
345 if request.about_place.is_null:
346 user.about_place = None
347 else:
348 user.about_place = request.about_place.value
350 if request.hosting_status != api_pb2.HOSTING_STATUS_UNSPECIFIED:
351 if user.do_not_email and request.hosting_status != api_pb2.HOSTING_STATUS_CANT_HOST:
352 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.DO_NOT_EMAIL_CANNOT_HOST)
353 user.hosting_status = hostingstatus2sql[request.hosting_status]
355 if request.meetup_status != api_pb2.MEETUP_STATUS_UNSPECIFIED:
356 if user.do_not_email and request.meetup_status != api_pb2.MEETUP_STATUS_DOES_NOT_WANT_TO_MEETUP:
357 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.DO_NOT_EMAIL_CANNOT_MEET)
358 user.meetup_status = meetupstatus2sql[request.meetup_status]
360 if request.HasField("language_abilities"):
361 # delete all existing abilities
362 for ability in user.language_abilities:
363 session.delete(ability)
364 session.flush()
366 # add the new ones
367 for language_ability in request.language_abilities.value:
368 if not language_is_allowed(language_ability.code):
369 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.INVALID_LANGUAGE)
370 session.add(
371 LanguageAbility(
372 user=user,
373 language_code=language_ability.code,
374 fluency=fluency2sql[language_ability.fluency],
375 )
376 )
378 if request.HasField("regions_visited"):
379 session.execute(delete(RegionVisited).where(RegionVisited.user_id == context.user_id))
381 for region in request.regions_visited.value:
382 if not region_is_allowed(region):
383 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.INVALID_REGION)
384 session.add(
385 RegionVisited(
386 user_id=user.id,
387 region_code=region,
388 )
389 )
391 if request.HasField("regions_lived"):
392 session.execute(delete(RegionLived).where(RegionLived.user_id == context.user_id))
394 for region in request.regions_lived.value:
395 if not region_is_allowed(region):
396 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.INVALID_REGION)
397 session.add(
398 RegionLived(
399 user_id=user.id,
400 region_code=region,
401 )
402 )
404 if request.HasField("additional_information"):
405 if request.additional_information.is_null:
406 user.additional_information = None
407 else:
408 user.additional_information = request.additional_information.value
410 if request.HasField("max_guests"):
411 if request.max_guests.is_null:
412 user.max_guests = None
413 else:
414 user.max_guests = request.max_guests.value
416 if request.HasField("last_minute"):
417 if request.last_minute.is_null:
418 user.last_minute = None
419 else:
420 user.last_minute = request.last_minute.value
422 if request.HasField("has_pets"):
423 if request.has_pets.is_null:
424 user.has_pets = None
425 else:
426 user.has_pets = request.has_pets.value
428 if request.HasField("accepts_pets"):
429 if request.accepts_pets.is_null:
430 user.accepts_pets = None
431 else:
432 user.accepts_pets = request.accepts_pets.value
434 if request.HasField("pet_details"):
435 if request.pet_details.is_null:
436 user.pet_details = None
437 else:
438 user.pet_details = request.pet_details.value
440 if request.HasField("has_kids"):
441 if request.has_kids.is_null:
442 user.has_kids = None
443 else:
444 user.has_kids = request.has_kids.value
446 if request.HasField("accepts_kids"):
447 if request.accepts_kids.is_null:
448 user.accepts_kids = None
449 else:
450 user.accepts_kids = request.accepts_kids.value
452 if request.HasField("kid_details"):
453 if request.kid_details.is_null:
454 user.kid_details = None
455 else:
456 user.kid_details = request.kid_details.value
458 if request.HasField("has_housemates"):
459 if request.has_housemates.is_null:
460 user.has_housemates = None
461 else:
462 user.has_housemates = request.has_housemates.value
464 if request.HasField("housemate_details"):
465 if request.housemate_details.is_null:
466 user.housemate_details = None
467 else:
468 user.housemate_details = request.housemate_details.value
470 if request.HasField("wheelchair_accessible"):
471 if request.wheelchair_accessible.is_null:
472 user.wheelchair_accessible = None
473 else:
474 user.wheelchair_accessible = request.wheelchair_accessible.value
476 if request.smoking_allowed != api_pb2.SMOKING_LOCATION_UNSPECIFIED:
477 user.smoking_allowed = smokinglocation2sql[request.smoking_allowed]
479 if request.HasField("smokes_at_home"):
480 if request.smokes_at_home.is_null:
481 user.smokes_at_home = None
482 else:
483 user.smokes_at_home = request.smokes_at_home.value
485 if request.HasField("drinking_allowed"):
486 if request.drinking_allowed.is_null:
487 user.drinking_allowed = None
488 else:
489 user.drinking_allowed = request.drinking_allowed.value
491 if request.HasField("drinks_at_home"):
492 if request.drinks_at_home.is_null:
493 user.drinks_at_home = None
494 else:
495 user.drinks_at_home = request.drinks_at_home.value
497 if request.HasField("other_host_info"):
498 if request.other_host_info.is_null:
499 user.other_host_info = None
500 else:
501 user.other_host_info = request.other_host_info.value
503 if request.sleeping_arrangement != api_pb2.SLEEPING_ARRANGEMENT_UNSPECIFIED:
504 user.sleeping_arrangement = sleepingarrangement2sql[request.sleeping_arrangement]
506 if request.HasField("sleeping_details"):
507 if request.sleeping_details.is_null:
508 user.sleeping_details = None
509 else:
510 user.sleeping_details = request.sleeping_details.value
512 if request.HasField("area"):
513 if request.area.is_null:
514 user.area = None
515 else:
516 user.area = request.area.value
518 if request.HasField("house_rules"):
519 if request.house_rules.is_null:
520 user.house_rules = None
521 else:
522 user.house_rules = request.house_rules.value
524 if request.HasField("parking"):
525 if request.parking.is_null:
526 user.parking = None
527 else:
528 user.parking = request.parking.value
530 if request.parking_details != api_pb2.PARKING_DETAILS_UNSPECIFIED:
531 user.parking_details = parkingdetails2sql[request.parking_details]
533 if request.HasField("camping_ok"):
534 if request.camping_ok.is_null:
535 user.camping_ok = None
536 else:
537 user.camping_ok = request.camping_ok.value
539 return empty_pb2.Empty()
541 def ListFriends(self, request, context, session):
542 rels = (
543 session.execute(
544 select(FriendRelationship)
545 .where_users_column_visible(context, FriendRelationship.from_user_id)
546 .where_users_column_visible(context, FriendRelationship.to_user_id)
547 .where(
548 or_(
549 FriendRelationship.from_user_id == context.user_id,
550 FriendRelationship.to_user_id == context.user_id,
551 )
552 )
553 .where(FriendRelationship.status == FriendStatus.accepted)
554 )
555 .scalars()
556 .all()
557 )
558 return api_pb2.ListFriendsRes(
559 user_ids=[rel.from_user.id if rel.from_user.id != context.user_id else rel.to_user.id for rel in rels],
560 )
562 def RemoveFriend(self, request, context, session):
563 rel = session.execute(
564 select(FriendRelationship)
565 .where_users_column_visible(context, FriendRelationship.from_user_id)
566 .where_users_column_visible(context, FriendRelationship.to_user_id)
567 .where(
568 or_(
569 and_(
570 FriendRelationship.from_user_id == request.user_id,
571 FriendRelationship.to_user_id == context.user_id,
572 ),
573 and_(
574 FriendRelationship.from_user_id == context.user_id,
575 FriendRelationship.to_user_id == request.user_id,
576 ),
577 )
578 )
579 .where(FriendRelationship.status == FriendStatus.accepted)
580 ).scalar_one_or_none()
582 if not rel:
583 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.NOT_FRIENDS)
585 session.delete(rel)
587 return empty_pb2.Empty()
589 def ListMutualFriends(self, request, context, session):
590 if context.user_id == request.user_id:
591 return api_pb2.ListMutualFriendsRes(mutual_friends=[])
593 user = session.execute(
594 select(User).where_users_visible(context).where(User.id == request.user_id)
595 ).scalar_one_or_none()
597 if not user:
598 context.abort(grpc.StatusCode.NOT_FOUND, errors.USER_NOT_FOUND)
600 q1 = (
601 select(FriendRelationship.from_user_id.label("user_id"))
602 .where(FriendRelationship.to_user_id == context.user_id)
603 .where(FriendRelationship.from_user_id != request.user_id)
604 .where(FriendRelationship.status == FriendStatus.accepted)
605 )
607 q2 = (
608 select(FriendRelationship.to_user_id.label("user_id"))
609 .where(FriendRelationship.from_user_id == context.user_id)
610 .where(FriendRelationship.to_user_id != request.user_id)
611 .where(FriendRelationship.status == FriendStatus.accepted)
612 )
614 q3 = (
615 select(FriendRelationship.from_user_id.label("user_id"))
616 .where(FriendRelationship.to_user_id == request.user_id)
617 .where(FriendRelationship.from_user_id != context.user_id)
618 .where(FriendRelationship.status == FriendStatus.accepted)
619 )
621 q4 = (
622 select(FriendRelationship.to_user_id.label("user_id"))
623 .where(FriendRelationship.from_user_id == request.user_id)
624 .where(FriendRelationship.to_user_id != context.user_id)
625 .where(FriendRelationship.status == FriendStatus.accepted)
626 )
628 mutual = select(intersect(union(q1, q2), union(q3, q4)).subquery())
630 mutual_friends = (
631 session.execute(select(User).where_users_visible(context).where(User.id.in_(mutual))).scalars().all()
632 )
634 return api_pb2.ListMutualFriendsRes(
635 mutual_friends=[
636 api_pb2.MutualFriend(user_id=mutual_friend.id, username=mutual_friend.username, name=mutual_friend.name)
637 for mutual_friend in mutual_friends
638 ]
639 )
641 def SendFriendRequest(self, request, context, session):
642 if context.user_id == request.user_id:
643 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.CANT_FRIEND_SELF)
645 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
646 to_user = session.execute(
647 select(User).where_users_visible(context).where(User.id == request.user_id)
648 ).scalar_one_or_none()
650 if not to_user:
651 context.abort(grpc.StatusCode.NOT_FOUND, errors.USER_NOT_FOUND)
653 if (
654 session.execute(
655 select(FriendRelationship)
656 .where(
657 or_(
658 and_(
659 FriendRelationship.from_user_id == context.user_id,
660 FriendRelationship.to_user_id == request.user_id,
661 ),
662 and_(
663 FriendRelationship.from_user_id == request.user_id,
664 FriendRelationship.to_user_id == context.user_id,
665 ),
666 )
667 )
668 .where(
669 or_(
670 FriendRelationship.status == FriendStatus.accepted,
671 FriendRelationship.status == FriendStatus.pending,
672 )
673 )
674 ).scalar_one_or_none()
675 is not None
676 ):
677 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.FRIENDS_ALREADY_OR_PENDING)
679 # Check if user has been sending friend requests excessively
680 if process_rate_limits_and_check_abort(
681 session=session, user_id=context.user_id, action=RateLimitAction.friend_request
682 ):
683 context.abort(grpc.StatusCode.RESOURCE_EXHAUSTED, errors.FRIEND_REQUEST_RATE_LIMIT)
685 # TODO: Race condition where we can create two friend reqs, needs db constraint! See comment in table
687 friend_relationship = FriendRelationship(from_user=user, to_user=to_user, status=FriendStatus.pending)
688 session.add(friend_relationship)
689 session.flush()
691 notify(
692 session,
693 user_id=friend_relationship.to_user_id,
694 topic_action="friend_request:create",
695 key=friend_relationship.from_user_id,
696 data=notification_data_pb2.FriendRequestCreate(
697 other_user=user_model_to_pb(friend_relationship.from_user, session, context),
698 ),
699 )
701 return empty_pb2.Empty()
703 def ListFriendRequests(self, request, context, session):
704 # both sent and received
705 sent_requests = (
706 session.execute(
707 select(FriendRelationship)
708 .where_users_column_visible(context, FriendRelationship.to_user_id)
709 .where(FriendRelationship.from_user_id == context.user_id)
710 .where(FriendRelationship.status == FriendStatus.pending)
711 )
712 .scalars()
713 .all()
714 )
716 received_requests = (
717 session.execute(
718 select(FriendRelationship)
719 .where_users_column_visible(context, FriendRelationship.from_user_id)
720 .where(FriendRelationship.to_user_id == context.user_id)
721 .where(FriendRelationship.status == FriendStatus.pending)
722 )
723 .scalars()
724 .all()
725 )
727 return api_pb2.ListFriendRequestsRes(
728 sent=[
729 api_pb2.FriendRequest(
730 friend_request_id=friend_request.id,
731 state=api_pb2.FriendRequest.FriendRequestStatus.PENDING,
732 user_id=friend_request.to_user.id,
733 sent=True,
734 )
735 for friend_request in sent_requests
736 ],
737 received=[
738 api_pb2.FriendRequest(
739 friend_request_id=friend_request.id,
740 state=api_pb2.FriendRequest.FriendRequestStatus.PENDING,
741 user_id=friend_request.from_user.id,
742 sent=False,
743 )
744 for friend_request in received_requests
745 ],
746 )
748 def RespondFriendRequest(self, request, context, session):
749 friend_request = session.execute(
750 select(FriendRelationship)
751 .where_users_column_visible(context, FriendRelationship.from_user_id)
752 .where(FriendRelationship.to_user_id == context.user_id)
753 .where(FriendRelationship.status == FriendStatus.pending)
754 .where(FriendRelationship.id == request.friend_request_id)
755 ).scalar_one_or_none()
757 if not friend_request:
758 context.abort(grpc.StatusCode.NOT_FOUND, errors.FRIEND_REQUEST_NOT_FOUND)
760 friend_request.status = FriendStatus.accepted if request.accept else FriendStatus.rejected
761 friend_request.time_responded = func.now()
763 session.flush()
765 if friend_request.status == FriendStatus.accepted:
766 notify(
767 session,
768 user_id=friend_request.from_user_id,
769 topic_action="friend_request:accept",
770 key=friend_request.to_user_id,
771 data=notification_data_pb2.FriendRequestAccept(
772 other_user=user_model_to_pb(friend_request.to_user, session, context),
773 ),
774 )
776 return empty_pb2.Empty()
778 def CancelFriendRequest(self, request, context, session):
779 friend_request = session.execute(
780 select(FriendRelationship)
781 .where_users_column_visible(context, FriendRelationship.to_user_id)
782 .where(FriendRelationship.from_user_id == context.user_id)
783 .where(FriendRelationship.status == FriendStatus.pending)
784 .where(FriendRelationship.id == request.friend_request_id)
785 ).scalar_one_or_none()
787 if not friend_request:
788 context.abort(grpc.StatusCode.NOT_FOUND, errors.FRIEND_REQUEST_NOT_FOUND)
790 friend_request.status = FriendStatus.cancelled
791 friend_request.time_responded = func.now()
793 # note no notifications
795 session.commit()
797 return empty_pb2.Empty()
799 def InitiateMediaUpload(self, request, context, session):
800 key = random_hex()
802 created = now()
803 expiry = created + timedelta(minutes=20)
805 upload = InitiatedUpload(key=key, created=created, expiry=expiry, initiator_user_id=context.user_id)
806 session.add(upload)
807 session.commit()
809 req = media_pb2.UploadRequest(
810 key=upload.key,
811 type=media_pb2.UploadRequest.UploadType.IMAGE,
812 created=Timestamp_from_datetime(upload.created),
813 expiry=Timestamp_from_datetime(upload.expiry),
814 max_width=2000,
815 max_height=1600,
816 ).SerializeToString()
818 data = b64encode(req)
819 sig = b64encode(generate_hash_signature(req, config["MEDIA_SERVER_SECRET_KEY"]))
821 path = "upload?" + urlencode({"data": data, "sig": sig})
823 return api_pb2.InitiateMediaUploadRes(
824 upload_url=urls.media_upload_url(path=path),
825 expiry=Timestamp_from_datetime(expiry),
826 )
828 def ListBadgeUsers(self, request, context, session):
829 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
830 next_user_id = int(request.page_token) if request.page_token else 0
831 badge = get_badge_dict().get(request.badge_id)
832 if not badge:
833 context.abort(grpc.StatusCode.NOT_FOUND, errors.BADGE_NOT_FOUND)
835 badge_user_ids = (
836 session.execute(
837 select(UserBadge.user_id)
838 .where(UserBadge.badge_id == badge["id"])
839 .where(UserBadge.user_id >= next_user_id)
840 .order_by(UserBadge.user_id)
841 .limit(page_size + 1)
842 )
843 .scalars()
844 .all()
845 )
847 return api_pb2.ListBadgeUsersRes(
848 user_ids=badge_user_ids[:page_size],
849 next_page_token=str(badge_user_ids[-1]) if len(badge_user_ids) > page_size else None,
850 )
853def response_rate_to_pb(response_rate: UserResponseRate):
854 if not response_rate:
855 return {"insufficient_data": requests_pb2.ResponseRateInsufficientData()}
857 # if n is None, the user is new or they have no requests
858 if response_rate.requests < 3:
859 return {"insufficient_data": requests_pb2.ResponseRateInsufficientData()}
861 if response_rate.response_rate <= 0.33:
862 return {"low": requests_pb2.ResponseRateLow()}
864 response_time_p33_coarsened = Duration_from_timedelta(
865 timedelta(seconds=round(response_rate.response_time_33p.total_seconds() / 60) * 60)
866 )
868 if response_rate.response_rate <= 0.66:
869 return {"some": requests_pb2.ResponseRateSome(response_time_p33=response_time_p33_coarsened)}
871 response_time_p66_coarsened = Duration_from_timedelta(
872 timedelta(seconds=round(response_rate.response_time_66p.total_seconds() / 60) * 60)
873 )
875 if response_rate.response_rate <= 0.90:
876 return {
877 "most": requests_pb2.ResponseRateMost(
878 response_time_p33=response_time_p33_coarsened, response_time_p66=response_time_p66_coarsened
879 )
880 }
881 else:
882 return {
883 "almost_all": requests_pb2.ResponseRateAlmostAll(
884 response_time_p33=response_time_p33_coarsened, response_time_p66=response_time_p66_coarsened
885 )
886 }
889def get_num_references(session, user_ids):
890 return dict(
891 session.execute(
892 select(Reference.to_user_id, func.count(Reference.id))
893 .where(Reference.to_user_id.in_(user_ids))
894 .where(Reference.is_deleted == False)
895 .join(User, User.id == Reference.from_user_id)
896 .where(User.is_visible)
897 .group_by(Reference.to_user_id)
898 ).all()
899 )
902def user_model_to_pb(db_user, session, context):
903 # note that this function should work also for banned/deleted users as it's called from Admin.GetUser
904 # note that this function is sometimes called by a logged out user, in which case context comes from make_logged_out_context
905 num_references = get_num_references(session, [db_user.id]).get(db_user.id, 0)
907 # returns (lat, lng)
908 # we put people without coords on null island
909 # https://en.wikipedia.org/wiki/Null_Island
910 lat, lng = db_user.coordinates or (0, 0)
912 pending_friend_request = None
913 if db_user.id == context.user_id:
914 friends_status = api_pb2.User.FriendshipStatus.NA
915 else:
916 friend_relationship = session.execute(
917 select(FriendRelationship)
918 .where(
919 or_(
920 and_(
921 FriendRelationship.from_user_id == context.user_id,
922 FriendRelationship.to_user_id == db_user.id,
923 ),
924 and_(
925 FriendRelationship.from_user_id == db_user.id,
926 FriendRelationship.to_user_id == context.user_id,
927 ),
928 )
929 )
930 .where(
931 or_(
932 FriendRelationship.status == FriendStatus.accepted,
933 FriendRelationship.status == FriendStatus.pending,
934 )
935 )
936 ).scalar_one_or_none()
938 if friend_relationship:
939 if friend_relationship.status == FriendStatus.accepted:
940 friends_status = api_pb2.User.FriendshipStatus.FRIENDS
941 else:
942 friends_status = api_pb2.User.FriendshipStatus.PENDING
943 if friend_relationship.from_user_id == context.user_id:
944 # we sent it
945 pending_friend_request = api_pb2.FriendRequest(
946 friend_request_id=friend_relationship.id,
947 state=api_pb2.FriendRequest.FriendRequestStatus.PENDING,
948 user_id=friend_relationship.to_user.id,
949 sent=True,
950 )
951 else:
952 # we received it
953 pending_friend_request = api_pb2.FriendRequest(
954 friend_request_id=friend_relationship.id,
955 state=api_pb2.FriendRequest.FriendRequestStatus.PENDING,
956 user_id=friend_relationship.from_user.id,
957 sent=False,
958 )
959 else:
960 friends_status = api_pb2.User.FriendshipStatus.NOT_FRIENDS
962 response_rate = session.execute(
963 select(UserResponseRate).where(UserResponseRate.user_id == db_user.id)
964 ).scalar_one_or_none()
966 verification_score = 0.0
967 if db_user.phone_verification_verified:
968 verification_score += 1.0 * db_user.phone_is_verified
970 user = api_pb2.User(
971 user_id=db_user.id,
972 username=db_user.username,
973 name=db_user.name,
974 city=db_user.city,
975 hometown=db_user.hometown,
976 timezone=db_user.timezone,
977 lat=lat,
978 lng=lng,
979 radius=db_user.geom_radius,
980 verification=verification_score,
981 community_standing=db_user.community_standing,
982 num_references=num_references,
983 gender=db_user.gender,
984 pronouns=db_user.pronouns,
985 age=int(db_user.age),
986 joined=Timestamp_from_datetime(db_user.display_joined),
987 last_active=Timestamp_from_datetime(db_user.display_last_active),
988 hosting_status=hostingstatus2api[db_user.hosting_status],
989 meetup_status=meetupstatus2api[db_user.meetup_status],
990 occupation=db_user.occupation,
991 education=db_user.education,
992 about_me=db_user.about_me,
993 things_i_like=db_user.things_i_like,
994 about_place=db_user.about_place,
995 language_abilities=[
996 api_pb2.LanguageAbility(code=ability.language_code, fluency=fluency2api[ability.fluency])
997 for ability in db_user.language_abilities
998 ],
999 regions_visited=[region.code for region in db_user.regions_visited],
1000 regions_lived=[region.code for region in db_user.regions_lived],
1001 additional_information=db_user.additional_information,
1002 friends=friends_status,
1003 pending_friend_request=pending_friend_request,
1004 smoking_allowed=smokinglocation2api[db_user.smoking_allowed],
1005 sleeping_arrangement=sleepingarrangement2api[db_user.sleeping_arrangement],
1006 parking_details=parkingdetails2api[db_user.parking_details],
1007 avatar_url=db_user.avatar.full_url if db_user.avatar else None,
1008 avatar_thumbnail_url=db_user.avatar.thumbnail_url if db_user.avatar else None,
1009 badges=session.execute(select(UserBadge.badge_id).where(UserBadge.user_id == db_user.id).order_by(UserBadge.id))
1010 .scalars()
1011 .all(),
1012 **get_strong_verification_fields(session, db_user),
1013 **response_rate_to_pb(response_rate),
1014 )
1016 if db_user.max_guests is not None:
1017 user.max_guests.value = db_user.max_guests
1019 if db_user.last_minute is not None:
1020 user.last_minute.value = db_user.last_minute
1022 if db_user.has_pets is not None:
1023 user.has_pets.value = db_user.has_pets
1025 if db_user.accepts_pets is not None:
1026 user.accepts_pets.value = db_user.accepts_pets
1028 if db_user.pet_details is not None:
1029 user.pet_details.value = db_user.pet_details
1031 if db_user.has_kids is not None:
1032 user.has_kids.value = db_user.has_kids
1034 if db_user.accepts_kids is not None:
1035 user.accepts_kids.value = db_user.accepts_kids
1037 if db_user.kid_details is not None:
1038 user.kid_details.value = db_user.kid_details
1040 if db_user.has_housemates is not None:
1041 user.has_housemates.value = db_user.has_housemates
1043 if db_user.housemate_details is not None:
1044 user.housemate_details.value = db_user.housemate_details
1046 if db_user.wheelchair_accessible is not None:
1047 user.wheelchair_accessible.value = db_user.wheelchair_accessible
1049 if db_user.smokes_at_home is not None:
1050 user.smokes_at_home.value = db_user.smokes_at_home
1052 if db_user.drinking_allowed is not None:
1053 user.drinking_allowed.value = db_user.drinking_allowed
1055 if db_user.drinks_at_home is not None:
1056 user.drinks_at_home.value = db_user.drinks_at_home
1058 if db_user.other_host_info is not None:
1059 user.other_host_info.value = db_user.other_host_info
1061 if db_user.sleeping_details is not None:
1062 user.sleeping_details.value = db_user.sleeping_details
1064 if db_user.area is not None:
1065 user.area.value = db_user.area
1067 if db_user.house_rules is not None:
1068 user.house_rules.value = db_user.house_rules
1070 if db_user.parking is not None:
1071 user.parking.value = db_user.parking
1073 if db_user.camping_ok is not None:
1074 user.camping_ok.value = db_user.camping_ok
1076 return user
1079def lite_user_to_pb(lite_user: LiteUser):
1080 lat, lng = get_coordinates(lite_user.geom) or (0, 0)
1082 return api_pb2.LiteUser(
1083 user_id=lite_user.id,
1084 username=lite_user.username,
1085 name=lite_user.name,
1086 city=lite_user.city,
1087 age=int(lite_user.age),
1088 avatar_url=urls.media_url(filename=lite_user.avatar_filename, size="full")
1089 if lite_user.avatar_filename
1090 else None,
1091 avatar_thumbnail_url=urls.media_url(filename=lite_user.avatar_filename, size="thumbnail")
1092 if lite_user.avatar_filename
1093 else None,
1094 lat=lat,
1095 lng=lng,
1096 radius=lite_user.radius,
1097 has_strong_verification=lite_user.has_strong_verification,
1098 )