Coverage for src / couchers / servicers / api.py: 97%
452 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-29 02:10 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-29 02:10 +0000
1from collections.abc import Iterable
2from datetime import timedelta
3from typing import cast
4from urllib.parse import urlencode
6import google.protobuf.message
7import grpc
8from google.protobuf import empty_pb2
9from sqlalchemy import select
10from sqlalchemy.orm import Session
11from sqlalchemy.sql import and_, delete, distinct, func, intersect, or_, union
13from couchers import urls
14from couchers.config import config
15from couchers.constants import GHOST_USERNAME
16from couchers.context import CouchersContext
17from couchers.crypto import b64encode, generate_hash_signature, random_hex
18from couchers.helpers.strong_verification import get_strong_verification_fields
19from couchers.materialized_views import LiteUser, UserResponseRate
20from couchers.models import (
21 FriendRelationship,
22 FriendStatus,
23 GroupChat,
24 GroupChatSubscription,
25 HostingStatus,
26 HostRequest,
27 InitiatedUpload,
28 LanguageAbility,
29 LanguageFluency,
30 MeetupStatus,
31 Message,
32 Notification,
33 NotificationDeliveryType,
34 ParkingDetails,
35 RateLimitAction,
36 Reference,
37 RegionLived,
38 RegionVisited,
39 SleepingArrangement,
40 SmokingLocation,
41 User,
42 UserBadge,
43)
44from couchers.models.notifications import NotificationTopicAction
45from couchers.models.uploads import get_avatar_upload
46from couchers.notifications.notify import notify
47from couchers.notifications.settings import get_topic_actions_by_delivery_type
48from couchers.proto import api_pb2, api_pb2_grpc, media_pb2, notification_data_pb2, requests_pb2
49from couchers.rate_limits.check import process_rate_limits_and_check_abort
50from couchers.rate_limits.definitions import RATE_LIMIT_HOURS
51from couchers.resources import get_badge_dict, language_is_allowed, region_is_allowed
52from couchers.servicers.blocking import is_not_visible
53from couchers.sql import username_or_id, users_visible, where_moderated_content_visible, where_users_column_visible
54from couchers.utils import (
55 Duration_from_timedelta,
56 Timestamp_from_datetime,
57 create_coordinate,
58 get_coordinates,
59 is_valid_name,
60 is_valid_user_id,
61 is_valid_username,
62 not_none,
63 now,
64)
67class GhostUserSerializationError(Exception):
68 """
69 Raised when attempting to serialize a ghost user (deleted/banned/blocked)
70 """
72 pass
75MAX_USERS_PER_QUERY = 200
76MAX_PAGINATION_LENGTH = 50
78hostingstatus2sql = {
79 api_pb2.HOSTING_STATUS_UNKNOWN: None,
80 api_pb2.HOSTING_STATUS_CAN_HOST: HostingStatus.can_host,
81 api_pb2.HOSTING_STATUS_MAYBE: HostingStatus.maybe,
82 api_pb2.HOSTING_STATUS_CANT_HOST: HostingStatus.cant_host,
83}
85hostingstatus2api = {
86 None: api_pb2.HOSTING_STATUS_UNKNOWN,
87 HostingStatus.can_host: api_pb2.HOSTING_STATUS_CAN_HOST,
88 HostingStatus.maybe: api_pb2.HOSTING_STATUS_MAYBE,
89 HostingStatus.cant_host: api_pb2.HOSTING_STATUS_CANT_HOST,
90}
92meetupstatus2sql = {
93 api_pb2.MEETUP_STATUS_UNKNOWN: None,
94 api_pb2.MEETUP_STATUS_WANTS_TO_MEETUP: MeetupStatus.wants_to_meetup,
95 api_pb2.MEETUP_STATUS_OPEN_TO_MEETUP: MeetupStatus.open_to_meetup,
96 api_pb2.MEETUP_STATUS_DOES_NOT_WANT_TO_MEETUP: MeetupStatus.does_not_want_to_meetup,
97}
99meetupstatus2api = {
100 None: api_pb2.MEETUP_STATUS_UNKNOWN,
101 MeetupStatus.wants_to_meetup: api_pb2.MEETUP_STATUS_WANTS_TO_MEETUP,
102 MeetupStatus.open_to_meetup: api_pb2.MEETUP_STATUS_OPEN_TO_MEETUP,
103 MeetupStatus.does_not_want_to_meetup: api_pb2.MEETUP_STATUS_DOES_NOT_WANT_TO_MEETUP,
104}
106smokinglocation2sql = {
107 api_pb2.SMOKING_LOCATION_UNKNOWN: None,
108 api_pb2.SMOKING_LOCATION_YES: SmokingLocation.yes,
109 api_pb2.SMOKING_LOCATION_WINDOW: SmokingLocation.window,
110 api_pb2.SMOKING_LOCATION_OUTSIDE: SmokingLocation.outside,
111 api_pb2.SMOKING_LOCATION_NO: SmokingLocation.no,
112}
114smokinglocation2api = {
115 None: api_pb2.SMOKING_LOCATION_UNKNOWN,
116 SmokingLocation.yes: api_pb2.SMOKING_LOCATION_YES,
117 SmokingLocation.window: api_pb2.SMOKING_LOCATION_WINDOW,
118 SmokingLocation.outside: api_pb2.SMOKING_LOCATION_OUTSIDE,
119 SmokingLocation.no: api_pb2.SMOKING_LOCATION_NO,
120}
122sleepingarrangement2sql = {
123 api_pb2.SLEEPING_ARRANGEMENT_UNKNOWN: None,
124 api_pb2.SLEEPING_ARRANGEMENT_PRIVATE: SleepingArrangement.private,
125 api_pb2.SLEEPING_ARRANGEMENT_COMMON: SleepingArrangement.common,
126 api_pb2.SLEEPING_ARRANGEMENT_SHARED_ROOM: SleepingArrangement.shared_room,
127}
129sleepingarrangement2api = {
130 None: api_pb2.SLEEPING_ARRANGEMENT_UNKNOWN,
131 SleepingArrangement.private: api_pb2.SLEEPING_ARRANGEMENT_PRIVATE,
132 SleepingArrangement.common: api_pb2.SLEEPING_ARRANGEMENT_COMMON,
133 SleepingArrangement.shared_room: api_pb2.SLEEPING_ARRANGEMENT_SHARED_ROOM,
134}
136parkingdetails2sql = {
137 api_pb2.PARKING_DETAILS_UNKNOWN: None,
138 api_pb2.PARKING_DETAILS_FREE_ONSITE: ParkingDetails.free_onsite,
139 api_pb2.PARKING_DETAILS_FREE_OFFSITE: ParkingDetails.free_offsite,
140 api_pb2.PARKING_DETAILS_PAID_ONSITE: ParkingDetails.paid_onsite,
141 api_pb2.PARKING_DETAILS_PAID_OFFSITE: ParkingDetails.paid_offsite,
142}
144parkingdetails2api = {
145 None: api_pb2.PARKING_DETAILS_UNKNOWN,
146 ParkingDetails.free_onsite: api_pb2.PARKING_DETAILS_FREE_ONSITE,
147 ParkingDetails.free_offsite: api_pb2.PARKING_DETAILS_FREE_OFFSITE,
148 ParkingDetails.paid_onsite: api_pb2.PARKING_DETAILS_PAID_ONSITE,
149 ParkingDetails.paid_offsite: api_pb2.PARKING_DETAILS_PAID_OFFSITE,
150}
152fluency2sql = {
153 api_pb2.LanguageAbility.Fluency.FLUENCY_UNKNOWN: None,
154 api_pb2.LanguageAbility.Fluency.FLUENCY_BEGINNER: LanguageFluency.beginner,
155 api_pb2.LanguageAbility.Fluency.FLUENCY_CONVERSATIONAL: LanguageFluency.conversational,
156 api_pb2.LanguageAbility.Fluency.FLUENCY_FLUENT: LanguageFluency.fluent,
157}
159fluency2api = {
160 None: api_pb2.LanguageAbility.Fluency.FLUENCY_UNKNOWN,
161 LanguageFluency.beginner: api_pb2.LanguageAbility.Fluency.FLUENCY_BEGINNER,
162 LanguageFluency.conversational: api_pb2.LanguageAbility.Fluency.FLUENCY_CONVERSATIONAL,
163 LanguageFluency.fluent: api_pb2.LanguageAbility.Fluency.FLUENCY_FLUENT,
164}
167class API(api_pb2_grpc.APIServicer):
168 def Ping(self, request: api_pb2.PingReq, context: CouchersContext, session: Session) -> api_pb2.PingRes:
169 # auth ought to make sure the user exists
170 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
172 sent_reqs_query = select(HostRequest.conversation_id, HostRequest.surfer_last_seen_message_id).where(
173 HostRequest.surfer_user_id == context.user_id
174 )
175 sent_reqs_query = where_users_column_visible(sent_reqs_query, context, HostRequest.host_user_id)
176 sent_reqs_query = where_moderated_content_visible(sent_reqs_query, context, HostRequest, is_list_operation=True)
177 sent_reqs_last_seen_message_ids = sent_reqs_query.subquery()
179 unseen_sent_host_request_count = session.execute(
180 select(func.count(distinct(sent_reqs_last_seen_message_ids.c.conversation_id)))
181 .join(
182 Message,
183 Message.conversation_id == sent_reqs_last_seen_message_ids.c.conversation_id,
184 )
185 .where(sent_reqs_last_seen_message_ids.c.surfer_last_seen_message_id < Message.id)
186 .where(Message.id != None)
187 ).scalar_one()
189 received_reqs_query = select(HostRequest.conversation_id, HostRequest.host_last_seen_message_id).where(
190 HostRequest.host_user_id == context.user_id
191 )
192 received_reqs_query = where_users_column_visible(received_reqs_query, context, HostRequest.surfer_user_id)
193 received_reqs_query = where_moderated_content_visible(
194 received_reqs_query, context, HostRequest, is_list_operation=True
195 )
196 received_reqs_last_seen_message_ids = received_reqs_query.subquery()
198 unseen_received_host_request_count = session.execute(
199 select(func.count(distinct(received_reqs_last_seen_message_ids.c.conversation_id)))
200 .join(
201 Message,
202 Message.conversation_id == received_reqs_last_seen_message_ids.c.conversation_id,
203 )
204 .where(received_reqs_last_seen_message_ids.c.host_last_seen_message_id < Message.id)
205 .where(Message.id != None)
206 ).scalar_one()
208 unseen_message_query = (
209 select(func.count(Message.id))
210 .outerjoin(GroupChatSubscription, GroupChatSubscription.group_chat_id == Message.conversation_id)
211 .join(GroupChat, GroupChat.conversation_id == GroupChatSubscription.group_chat_id)
212 )
213 unseen_message_query = where_moderated_content_visible(
214 unseen_message_query, context, GroupChat, is_list_operation=True
215 )
216 unseen_message_query = (
217 unseen_message_query.where(GroupChatSubscription.user_id == context.user_id)
218 .where(Message.time >= GroupChatSubscription.joined)
219 .where(or_(Message.time <= GroupChatSubscription.left, GroupChatSubscription.left == None))
220 .where(Message.id > GroupChatSubscription.last_seen_message_id)
221 )
222 unseen_message_count = session.execute(unseen_message_query).scalar_one()
224 pending_friend_request_query = select(func.count(FriendRelationship.id)).where(
225 FriendRelationship.to_user_id == context.user_id
226 )
227 pending_friend_request_query = where_users_column_visible(
228 pending_friend_request_query, context, FriendRelationship.from_user_id
229 )
230 pending_friend_request_query = pending_friend_request_query.where(
231 FriendRelationship.status == FriendStatus.pending
232 )
233 pending_friend_request_count = session.execute(pending_friend_request_query).scalar_one()
235 unseen_notification_count = session.execute(
236 select(func.count())
237 .select_from(Notification)
238 .where(Notification.user_id == context.user_id)
239 .where(Notification.is_seen == False)
240 .where(
241 Notification.topic_action.in_(
242 get_topic_actions_by_delivery_type(session, user.id, NotificationDeliveryType.push)
243 )
244 )
245 ).scalar_one()
247 return api_pb2.PingRes(
248 user=user_model_to_pb(user, session, context),
249 unseen_message_count=unseen_message_count,
250 unseen_sent_host_request_count=unseen_sent_host_request_count,
251 unseen_received_host_request_count=unseen_received_host_request_count,
252 pending_friend_request_count=pending_friend_request_count,
253 unseen_notification_count=unseen_notification_count,
254 )
256 def GetUser(self, request: api_pb2.GetUserReq, context: CouchersContext, session: Session) -> api_pb2.User:
257 user = session.execute(select(User).where(username_or_id(request.user))).scalar_one_or_none()
259 if not user: 259 ↛ 260line 259 didn't jump to line 260 because the condition on line 259 was never true
260 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "user_not_found")
262 return user_model_to_pb(user, session, context, is_get_user_return_ghosts=True)
264 def GetLiteUser(
265 self, request: api_pb2.GetLiteUserReq, context: CouchersContext, session: Session
266 ) -> api_pb2.LiteUser:
267 lite_user = session.execute(
268 select(LiteUser).where(username_or_id(request.user, table=LiteUser))
269 ).scalar_one_or_none()
271 if not lite_user: 271 ↛ 272line 271 didn't jump to line 272 because the condition on line 271 was never true
272 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "user_not_found")
274 return lite_user_to_pb(session, lite_user, context, is_get_user_return_ghosts=True)
276 def GetLiteUsers(
277 self, request: api_pb2.GetLiteUsersReq, context: CouchersContext, session: Session
278 ) -> api_pb2.GetLiteUsersRes:
279 if len(request.users) > MAX_USERS_PER_QUERY:
280 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "requested_too_many_users")
282 usernames = {u for u in request.users if is_valid_username(u)}
283 ids = {u for u in request.users if is_valid_user_id(u)}
285 # decomposed where_username_or_id...
286 users = (
287 session.execute(select(LiteUser).where(or_(LiteUser.username.in_(usernames), LiteUser.id.in_(ids))))
288 .scalars()
289 .all()
290 )
292 users_by_id = {str(user.id): user for user in users}
293 users_by_username = {user.username: user for user in users}
295 res = api_pb2.GetLiteUsersRes()
297 for user in request.users:
298 lite_user = None
299 if user in users_by_id:
300 lite_user = users_by_id[user]
301 elif user in users_by_username:
302 lite_user = users_by_username[user]
304 res.responses.append(
305 api_pb2.LiteUserRes(
306 query=user,
307 not_found=lite_user is None,
308 user=lite_user_to_pb(session, lite_user, context, is_get_user_return_ghosts=True)
309 if lite_user
310 else None,
311 )
312 )
314 return res
316 def UpdateProfile(
317 self, request: api_pb2.UpdateProfileReq, context: CouchersContext, session: Session
318 ) -> empty_pb2.Empty:
319 user: User = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
321 if request.HasField("name"):
322 if not is_valid_name(request.name.value):
323 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_name")
324 user.name = request.name.value
326 if request.HasField("city"):
327 user.city = request.city.value
329 if request.HasField("hometown"):
330 if request.hometown.is_null:
331 user.hometown = None
332 else:
333 user.hometown = request.hometown.value
335 if request.HasField("lat") and request.HasField("lng"):
336 if request.lat.value == 0 and request.lng.value == 0:
337 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_coordinate")
338 user.geom = create_coordinate(request.lat.value, request.lng.value)
339 user.randomized_geom = None
341 if request.HasField("radius"):
342 user.geom_radius = request.radius.value
344 # if request.HasField("gender"):
345 # user.gender = request.gender.value
347 if request.HasField("pronouns"):
348 if request.pronouns.is_null:
349 user.pronouns = None
350 else:
351 user.pronouns = request.pronouns.value
353 if request.HasField("occupation"):
354 if request.occupation.is_null:
355 user.occupation = None
356 else:
357 user.occupation = request.occupation.value
359 if request.HasField("education"):
360 if request.education.is_null:
361 user.education = None
362 else:
363 user.education = request.education.value
365 if request.HasField("about_me"):
366 if request.about_me.is_null:
367 user.about_me = None
368 else:
369 user.about_me = request.about_me.value
371 if request.HasField("things_i_like"):
372 if request.things_i_like.is_null:
373 user.things_i_like = None
374 else:
375 user.things_i_like = request.things_i_like.value
377 if request.HasField("about_place"):
378 if request.about_place.is_null:
379 user.about_place = None
380 else:
381 user.about_place = request.about_place.value
383 if request.hosting_status != api_pb2.HOSTING_STATUS_UNSPECIFIED:
384 if user.do_not_email and request.hosting_status != api_pb2.HOSTING_STATUS_CANT_HOST:
385 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "do_not_email_cannot_host")
386 user.hosting_status = hostingstatus2sql[request.hosting_status] # type: ignore[assignment]
388 if request.meetup_status != api_pb2.MEETUP_STATUS_UNSPECIFIED:
389 if user.do_not_email and request.meetup_status != api_pb2.MEETUP_STATUS_DOES_NOT_WANT_TO_MEETUP:
390 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "do_not_email_cannot_meet")
391 user.meetup_status = meetupstatus2sql[request.meetup_status] # type: ignore[assignment]
393 if request.HasField("language_abilities"):
394 # delete all existing abilities
395 for ability in user.language_abilities:
396 session.delete(ability)
397 session.flush()
399 # add the new ones
400 for language_ability in request.language_abilities.value:
401 if not language_is_allowed(language_ability.code):
402 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_language")
403 session.add(
404 LanguageAbility(
405 user_id=user.id,
406 language_code=language_ability.code,
407 fluency=not_none(fluency2sql[language_ability.fluency]),
408 )
409 )
411 if request.HasField("regions_visited"):
412 session.execute(delete(RegionVisited).where(RegionVisited.user_id == context.user_id))
414 for region in request.regions_visited.value:
415 if not region_is_allowed(region):
416 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_region")
417 session.add(
418 RegionVisited(
419 user_id=user.id,
420 region_code=region,
421 )
422 )
424 if request.HasField("regions_lived"):
425 session.execute(delete(RegionLived).where(RegionLived.user_id == context.user_id))
427 for region in request.regions_lived.value:
428 if not region_is_allowed(region):
429 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_region")
430 session.add(
431 RegionLived(
432 user_id=user.id,
433 region_code=region,
434 )
435 )
437 if request.HasField("additional_information"):
438 if request.additional_information.is_null:
439 user.additional_information = None
440 else:
441 user.additional_information = request.additional_information.value
443 if request.HasField("max_guests"):
444 if request.max_guests.is_null:
445 user.max_guests = None
446 else:
447 user.max_guests = request.max_guests.value
449 if request.HasField("last_minute"):
450 if request.last_minute.is_null:
451 user.last_minute = None
452 else:
453 user.last_minute = request.last_minute.value
455 if request.HasField("has_pets"):
456 if request.has_pets.is_null:
457 user.has_pets = None
458 else:
459 user.has_pets = request.has_pets.value
461 if request.HasField("accepts_pets"):
462 if request.accepts_pets.is_null:
463 user.accepts_pets = None
464 else:
465 user.accepts_pets = request.accepts_pets.value
467 if request.HasField("pet_details"):
468 if request.pet_details.is_null:
469 user.pet_details = None
470 else:
471 user.pet_details = request.pet_details.value
473 if request.HasField("has_kids"):
474 if request.has_kids.is_null:
475 user.has_kids = None
476 else:
477 user.has_kids = request.has_kids.value
479 if request.HasField("accepts_kids"):
480 if request.accepts_kids.is_null:
481 user.accepts_kids = None
482 else:
483 user.accepts_kids = request.accepts_kids.value
485 if request.HasField("kid_details"):
486 if request.kid_details.is_null:
487 user.kid_details = None
488 else:
489 user.kid_details = request.kid_details.value
491 if request.HasField("has_housemates"):
492 if request.has_housemates.is_null:
493 user.has_housemates = None
494 else:
495 user.has_housemates = request.has_housemates.value
497 if request.HasField("housemate_details"):
498 if request.housemate_details.is_null:
499 user.housemate_details = None
500 else:
501 user.housemate_details = request.housemate_details.value
503 if request.HasField("wheelchair_accessible"):
504 if request.wheelchair_accessible.is_null:
505 user.wheelchair_accessible = None
506 else:
507 user.wheelchair_accessible = request.wheelchair_accessible.value
509 if request.smoking_allowed != api_pb2.SMOKING_LOCATION_UNSPECIFIED:
510 user.smoking_allowed = smokinglocation2sql[request.smoking_allowed]
512 if request.HasField("smokes_at_home"):
513 if request.smokes_at_home.is_null:
514 user.smokes_at_home = None
515 else:
516 user.smokes_at_home = request.smokes_at_home.value
518 if request.HasField("drinking_allowed"):
519 if request.drinking_allowed.is_null:
520 user.drinking_allowed = None
521 else:
522 user.drinking_allowed = request.drinking_allowed.value
524 if request.HasField("drinks_at_home"):
525 if request.drinks_at_home.is_null:
526 user.drinks_at_home = None
527 else:
528 user.drinks_at_home = request.drinks_at_home.value
530 if request.HasField("other_host_info"):
531 if request.other_host_info.is_null:
532 user.other_host_info = None
533 else:
534 user.other_host_info = request.other_host_info.value
536 if request.sleeping_arrangement != api_pb2.SLEEPING_ARRANGEMENT_UNSPECIFIED:
537 user.sleeping_arrangement = sleepingarrangement2sql[request.sleeping_arrangement]
539 if request.HasField("sleeping_details"):
540 if request.sleeping_details.is_null:
541 user.sleeping_details = None
542 else:
543 user.sleeping_details = request.sleeping_details.value
545 if request.HasField("area"):
546 if request.area.is_null:
547 user.area = None
548 else:
549 user.area = request.area.value
551 if request.HasField("house_rules"):
552 if request.house_rules.is_null:
553 user.house_rules = None
554 else:
555 user.house_rules = request.house_rules.value
557 if request.HasField("parking"):
558 if request.parking.is_null:
559 user.parking = None
560 else:
561 user.parking = request.parking.value
563 if request.parking_details != api_pb2.PARKING_DETAILS_UNSPECIFIED:
564 user.parking_details = parkingdetails2sql[request.parking_details]
566 if request.HasField("camping_ok"):
567 if request.camping_ok.is_null:
568 user.camping_ok = None
569 else:
570 user.camping_ok = request.camping_ok.value
572 user.profile_last_updated = now()
574 return empty_pb2.Empty()
576 def ListFriends(
577 self, request: empty_pb2.Empty, context: CouchersContext, session: Session
578 ) -> api_pb2.ListFriendsRes:
579 rels_query = select(FriendRelationship)
580 rels_query = where_users_column_visible(rels_query, context, FriendRelationship.from_user_id)
581 rels_query = where_users_column_visible(rels_query, context, FriendRelationship.to_user_id)
582 rels_query = rels_query.where(
583 or_(
584 FriendRelationship.from_user_id == context.user_id,
585 FriendRelationship.to_user_id == context.user_id,
586 )
587 ).where(FriendRelationship.status == FriendStatus.accepted)
588 rels = session.execute(rels_query).scalars().all()
589 return api_pb2.ListFriendsRes(
590 user_ids=[rel.from_user.id if rel.from_user.id != context.user_id else rel.to_user.id for rel in rels],
591 )
593 def RemoveFriend(
594 self, request: api_pb2.RemoveFriendReq, context: CouchersContext, session: Session
595 ) -> empty_pb2.Empty:
596 rel_query = select(FriendRelationship)
597 rel_query = where_users_column_visible(rel_query, context, FriendRelationship.from_user_id)
598 rel_query = where_users_column_visible(rel_query, context, FriendRelationship.to_user_id)
599 rel_query = rel_query.where(
600 or_(
601 and_(
602 FriendRelationship.from_user_id == request.user_id,
603 FriendRelationship.to_user_id == context.user_id,
604 ),
605 and_(
606 FriendRelationship.from_user_id == context.user_id,
607 FriendRelationship.to_user_id == request.user_id,
608 ),
609 )
610 ).where(FriendRelationship.status == FriendStatus.accepted)
611 rel = session.execute(rel_query).scalar_one_or_none()
613 if not rel:
614 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "not_friends")
616 session.delete(rel)
618 return empty_pb2.Empty()
620 def ListMutualFriends(
621 self, request: api_pb2.ListMutualFriendsReq, context: CouchersContext, session: Session
622 ) -> api_pb2.ListMutualFriendsRes:
623 if context.user_id == request.user_id:
624 return api_pb2.ListMutualFriendsRes(mutual_friends=[])
626 user = session.execute(
627 select(User).where(users_visible(context)).where(User.id == request.user_id)
628 ).scalar_one_or_none()
630 if not user: 630 ↛ 631line 630 didn't jump to line 631 because the condition on line 630 was never true
631 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "user_not_found")
633 q1 = (
634 select(FriendRelationship.from_user_id.label("user_id"))
635 .where(FriendRelationship.to_user_id == context.user_id)
636 .where(FriendRelationship.from_user_id != request.user_id)
637 .where(FriendRelationship.status == FriendStatus.accepted)
638 )
640 q2 = (
641 select(FriendRelationship.to_user_id.label("user_id"))
642 .where(FriendRelationship.from_user_id == context.user_id)
643 .where(FriendRelationship.to_user_id != request.user_id)
644 .where(FriendRelationship.status == FriendStatus.accepted)
645 )
647 q3 = (
648 select(FriendRelationship.from_user_id.label("user_id"))
649 .where(FriendRelationship.to_user_id == request.user_id)
650 .where(FriendRelationship.from_user_id != context.user_id)
651 .where(FriendRelationship.status == FriendStatus.accepted)
652 )
654 q4 = (
655 select(FriendRelationship.to_user_id.label("user_id"))
656 .where(FriendRelationship.from_user_id == request.user_id)
657 .where(FriendRelationship.to_user_id != context.user_id)
658 .where(FriendRelationship.status == FriendStatus.accepted)
659 )
661 mutual = select(intersect(union(q1, q2), union(q3, q4)).subquery())
663 mutual_friends = (
664 session.execute(select(User).where(users_visible(context)).where(User.id.in_(mutual))).scalars().all()
665 )
667 return api_pb2.ListMutualFriendsRes(
668 mutual_friends=[
669 api_pb2.MutualFriend(user_id=mutual_friend.id, username=mutual_friend.username, name=mutual_friend.name)
670 for mutual_friend in mutual_friends
671 ]
672 )
674 def SendFriendRequest(
675 self, request: api_pb2.SendFriendRequestReq, context: CouchersContext, session: Session
676 ) -> empty_pb2.Empty:
677 if context.user_id == request.user_id: 677 ↛ 678line 677 didn't jump to line 678 because the condition on line 677 was never true
678 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "cant_friend_self")
680 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
681 to_user = session.execute(
682 select(User).where(users_visible(context)).where(User.id == request.user_id)
683 ).scalar_one_or_none()
685 if not to_user: 685 ↛ 686line 685 didn't jump to line 686 because the condition on line 685 was never true
686 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "user_not_found")
688 if (
689 session.execute(
690 select(FriendRelationship)
691 .where(
692 or_(
693 and_(
694 FriendRelationship.from_user_id == context.user_id,
695 FriendRelationship.to_user_id == request.user_id,
696 ),
697 and_(
698 FriendRelationship.from_user_id == request.user_id,
699 FriendRelationship.to_user_id == context.user_id,
700 ),
701 )
702 )
703 .where(
704 or_(
705 FriendRelationship.status == FriendStatus.accepted,
706 FriendRelationship.status == FriendStatus.pending,
707 )
708 )
709 ).scalar_one_or_none()
710 is not None
711 ):
712 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "friends_already_or_pending")
714 # Check if user has been sending friend requests excessively
715 if process_rate_limits_and_check_abort(
716 session=session, user_id=context.user_id, action=RateLimitAction.friend_request
717 ):
718 context.abort_with_error_code(
719 grpc.StatusCode.RESOURCE_EXHAUSTED,
720 "friend_request_rate_limit",
721 substitutions={"hours": str(RATE_LIMIT_HOURS)},
722 )
724 # TODO: Race condition where we can create two friend reqs, needs db constraint! See comment in table
726 friend_relationship = FriendRelationship(
727 from_user_id=user.id, to_user_id=to_user.id, status=FriendStatus.pending
728 )
729 session.add(friend_relationship)
730 session.flush()
732 notify(
733 session,
734 user_id=friend_relationship.to_user_id,
735 topic_action=NotificationTopicAction.friend_request__create,
736 key=str(friend_relationship.from_user_id),
737 data=notification_data_pb2.FriendRequestCreate(
738 other_user=user_model_to_pb(friend_relationship.from_user, session, context),
739 ),
740 )
742 return empty_pb2.Empty()
744 def ListFriendRequests(
745 self, request: empty_pb2.Empty, context: CouchersContext, session: Session
746 ) -> api_pb2.ListFriendRequestsRes:
747 # both sent and received
748 sent_requests_query = select(FriendRelationship)
749 sent_requests_query = where_users_column_visible(sent_requests_query, context, FriendRelationship.to_user_id)
750 sent_requests_query = sent_requests_query.where(FriendRelationship.from_user_id == context.user_id).where(
751 FriendRelationship.status == FriendStatus.pending
752 )
753 sent_requests = session.execute(sent_requests_query).scalars().all()
755 received_requests_query = select(FriendRelationship)
756 received_requests_query = where_users_column_visible(
757 received_requests_query, context, FriendRelationship.from_user_id
758 )
759 received_requests_query = received_requests_query.where(FriendRelationship.to_user_id == context.user_id).where(
760 FriendRelationship.status == FriendStatus.pending
761 )
762 received_requests = session.execute(received_requests_query).scalars().all()
764 return api_pb2.ListFriendRequestsRes(
765 sent=[
766 api_pb2.FriendRequest(
767 friend_request_id=friend_request.id,
768 state=api_pb2.FriendRequest.FriendRequestStatus.PENDING,
769 user_id=friend_request.to_user.id,
770 sent=True,
771 )
772 for friend_request in sent_requests
773 ],
774 received=[
775 api_pb2.FriendRequest(
776 friend_request_id=friend_request.id,
777 state=api_pb2.FriendRequest.FriendRequestStatus.PENDING,
778 user_id=friend_request.from_user.id,
779 sent=False,
780 )
781 for friend_request in received_requests
782 ],
783 )
785 def RespondFriendRequest(
786 self, request: api_pb2.RespondFriendRequestReq, context: CouchersContext, session: Session
787 ) -> empty_pb2.Empty:
788 friend_request_query = select(FriendRelationship)
789 friend_request_query = where_users_column_visible(
790 friend_request_query, context, FriendRelationship.from_user_id
791 )
792 friend_request_query = (
793 friend_request_query.where(FriendRelationship.to_user_id == context.user_id)
794 .where(FriendRelationship.status == FriendStatus.pending)
795 .where(FriendRelationship.id == request.friend_request_id)
796 )
797 friend_request = session.execute(friend_request_query).scalar_one_or_none()
799 if not friend_request: 799 ↛ 800line 799 didn't jump to line 800 because the condition on line 799 was never true
800 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "friend_request_not_found")
802 friend_request.status = FriendStatus.accepted if request.accept else FriendStatus.rejected
803 friend_request.time_responded = func.now()
805 session.flush()
807 if friend_request.status == FriendStatus.accepted:
808 notify(
809 session,
810 user_id=friend_request.from_user_id,
811 topic_action=NotificationTopicAction.friend_request__accept,
812 key=str(friend_request.to_user_id),
813 data=notification_data_pb2.FriendRequestAccept(
814 other_user=user_model_to_pb(friend_request.to_user, session, context),
815 ),
816 )
818 return empty_pb2.Empty()
820 def CancelFriendRequest(
821 self, request: api_pb2.CancelFriendRequestReq, context: CouchersContext, session: Session
822 ) -> empty_pb2.Empty:
823 friend_request_query = select(FriendRelationship)
824 friend_request_query = where_users_column_visible(friend_request_query, context, FriendRelationship.to_user_id)
825 friend_request_query = (
826 friend_request_query.where(FriendRelationship.from_user_id == context.user_id)
827 .where(FriendRelationship.status == FriendStatus.pending)
828 .where(FriendRelationship.id == request.friend_request_id)
829 )
830 friend_request = session.execute(friend_request_query).scalar_one_or_none()
832 if not friend_request: 832 ↛ 833line 832 didn't jump to line 833 because the condition on line 832 was never true
833 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "friend_request_not_found")
835 friend_request.status = FriendStatus.cancelled
836 friend_request.time_responded = func.now()
838 # note no notifications
840 session.commit()
842 return empty_pb2.Empty()
844 def InitiateMediaUpload(
845 self, request: empty_pb2.Empty, context: CouchersContext, session: Session
846 ) -> api_pb2.InitiateMediaUploadRes:
847 key = random_hex()
849 created = now()
850 expiry = created + timedelta(minutes=20)
852 upload = InitiatedUpload(key=key, created=created, expiry=expiry, initiator_user_id=context.user_id)
853 session.add(upload)
854 session.commit()
856 req = media_pb2.UploadRequest(
857 key=upload.key,
858 type=media_pb2.UploadRequest.UploadType.IMAGE,
859 created=Timestamp_from_datetime(upload.created),
860 expiry=Timestamp_from_datetime(upload.expiry),
861 max_width=2000,
862 max_height=1600,
863 ).SerializeToString()
865 data = b64encode(req)
866 sig = b64encode(generate_hash_signature(req, config["MEDIA_SERVER_SECRET_KEY"]))
868 path = "upload?" + urlencode({"data": data, "sig": sig})
870 return api_pb2.InitiateMediaUploadRes(
871 upload_url=urls.media_upload_url(path=path),
872 expiry=Timestamp_from_datetime(expiry),
873 )
875 def ListBadgeUsers(
876 self, request: api_pb2.ListBadgeUsersReq, context: CouchersContext, session: Session
877 ) -> api_pb2.ListBadgeUsersRes:
878 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
879 next_user_id = int(request.page_token) if request.page_token else 0
880 badge = get_badge_dict().get(request.badge_id)
881 if not badge: 881 ↛ 882line 881 didn't jump to line 882 because the condition on line 881 was never true
882 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "badge_not_found")
884 badge_user_ids_query = (
885 select(UserBadge.user_id).where(UserBadge.badge_id == badge.id).where(UserBadge.user_id >= next_user_id)
886 )
887 badge_user_ids_query = where_users_column_visible(badge_user_ids_query, context, UserBadge.user_id)
888 badge_user_ids_query = badge_user_ids_query.order_by(UserBadge.user_id).limit(page_size + 1)
889 badge_user_ids = session.execute(badge_user_ids_query).scalars().all()
891 return api_pb2.ListBadgeUsersRes(
892 user_ids=badge_user_ids[:page_size],
893 next_page_token=str(badge_user_ids[-1]) if len(badge_user_ids) > page_size else None,
894 )
897def response_rate_to_pb(response_rate: UserResponseRate | None) -> dict[str, google.protobuf.message.Message]:
898 if not response_rate:
899 return {"insufficient_data": requests_pb2.ResponseRateInsufficientData()}
901 # if n is None, the user is new, or they have no requests
902 if response_rate.requests < 3:
903 return {"insufficient_data": requests_pb2.ResponseRateInsufficientData()}
905 if response_rate.response_rate <= 0.33:
906 return {"low": requests_pb2.ResponseRateLow()}
908 response_time_p33_coarsened = Duration_from_timedelta(
909 timedelta(seconds=round(response_rate.response_time_33p.total_seconds() / 60) * 60)
910 )
912 if response_rate.response_rate <= 0.66:
913 return {"some": requests_pb2.ResponseRateSome(response_time_p33=response_time_p33_coarsened)}
915 response_time_p66_coarsened = Duration_from_timedelta(
916 timedelta(seconds=round(response_rate.response_time_66p.total_seconds() / 60) * 60)
917 )
919 if response_rate.response_rate <= 0.90:
920 return {
921 "most": requests_pb2.ResponseRateMost(
922 response_time_p33=response_time_p33_coarsened, response_time_p66=response_time_p66_coarsened
923 )
924 }
925 else:
926 return {
927 "almost_all": requests_pb2.ResponseRateAlmostAll(
928 response_time_p33=response_time_p33_coarsened, response_time_p66=response_time_p66_coarsened
929 )
930 }
933def get_num_references(session: Session, user_ids: Iterable[int]) -> dict[int, int]:
934 query = (
935 select(Reference.to_user_id, func.count(Reference.id))
936 .where(Reference.to_user_id.in_(user_ids))
937 .where(Reference.is_deleted == False)
938 .join(User, User.id == Reference.from_user_id)
939 .where(User.is_visible)
940 .group_by(Reference.to_user_id)
941 )
942 return cast(dict[int, int], dict(session.execute(query).all())) # type: ignore[arg-type]
945def user_model_to_pb(
946 db_user: User,
947 session: Session,
948 context: CouchersContext,
949 *,
950 is_admin_see_ghosts: bool = False,
951 is_get_user_return_ghosts: bool = False,
952) -> api_pb2.User:
953 # note that this function should work also for banned/deleted users as it's called from Admin.GetUser
954 # note that this function is sometimes called by a logged out user, in which case context comes from make_logged_out_context
956 viewer_user_id = context.user_id if context.is_logged_in() else None
957 if not is_admin_see_ghosts and is_not_visible(session, viewer_user_id, db_user.id):
958 # User is not visible (deleted, banned, or blocked)
959 if is_get_user_return_ghosts: 959 ↛ 968line 959 didn't jump to line 968 because the condition on line 959 was always true
960 # Return an anonymized "ghost" user profile
961 return api_pb2.User(
962 user_id=db_user.id,
963 is_ghost=True,
964 username=GHOST_USERNAME,
965 name=context.get_localized_string("ghost_users.display_name"),
966 about_me=context.get_localized_string("ghost_users.about_me"),
967 )
968 raise GhostUserSerializationError(
969 f"Tried to serialize ghost profile in user_model_to_pb without appropriate flags. "
970 f"Context user_id: {context.user_id}, db_user id: {db_user.id} (username: {db_user.username})"
971 )
973 num_references = get_num_references(session, [db_user.id]).get(db_user.id, 0)
974 lat, lng = db_user.coordinates
976 pending_friend_request = None
977 if context.is_logged_out() or db_user.id == context.user_id:
978 friends_status = api_pb2.User.FriendshipStatus.NA
979 else:
980 friend_relationship = session.execute(
981 select(FriendRelationship)
982 .where(
983 or_(
984 and_(
985 FriendRelationship.from_user_id == context.user_id,
986 FriendRelationship.to_user_id == db_user.id,
987 ),
988 and_(
989 FriendRelationship.from_user_id == db_user.id,
990 FriendRelationship.to_user_id == context.user_id,
991 ),
992 )
993 )
994 .where(
995 or_(
996 FriendRelationship.status == FriendStatus.accepted,
997 FriendRelationship.status == FriendStatus.pending,
998 )
999 )
1000 ).scalar_one_or_none()
1002 if friend_relationship:
1003 if friend_relationship.status == FriendStatus.accepted:
1004 friends_status = api_pb2.User.FriendshipStatus.FRIENDS
1005 else:
1006 friends_status = api_pb2.User.FriendshipStatus.PENDING
1007 if friend_relationship.from_user_id == context.user_id: 1007 ↛ 1009line 1007 didn't jump to line 1009 because the condition on line 1007 was never true
1008 # we sent it
1009 pending_friend_request = api_pb2.FriendRequest(
1010 friend_request_id=friend_relationship.id,
1011 state=api_pb2.FriendRequest.FriendRequestStatus.PENDING,
1012 user_id=friend_relationship.to_user.id,
1013 sent=True,
1014 )
1015 else:
1016 # we received it
1017 pending_friend_request = api_pb2.FriendRequest(
1018 friend_request_id=friend_relationship.id,
1019 state=api_pb2.FriendRequest.FriendRequestStatus.PENDING,
1020 user_id=friend_relationship.from_user.id,
1021 sent=False,
1022 )
1023 else:
1024 friends_status = api_pb2.User.FriendshipStatus.NOT_FRIENDS
1026 response_rate = session.execute(
1027 select(UserResponseRate).where(UserResponseRate.user_id == db_user.id)
1028 ).scalar_one_or_none()
1030 avatar_upload = get_avatar_upload(session, db_user)
1032 verification_score = 0.0
1033 if db_user.phone_verification_verified:
1034 verification_score += 1.0 * db_user.phone_is_verified
1036 user = api_pb2.User(
1037 user_id=db_user.id,
1038 username=db_user.username,
1039 name=db_user.name,
1040 city=db_user.city,
1041 hometown=db_user.hometown,
1042 timezone=db_user.timezone,
1043 lat=lat,
1044 lng=lng,
1045 radius=db_user.geom_radius,
1046 verification=verification_score,
1047 community_standing=db_user.community_standing,
1048 num_references=num_references,
1049 gender=db_user.gender,
1050 pronouns=db_user.pronouns,
1051 age=int(db_user.age),
1052 joined=Timestamp_from_datetime(db_user.display_joined),
1053 last_active=Timestamp_from_datetime(db_user.display_last_active),
1054 hosting_status=hostingstatus2api[db_user.hosting_status],
1055 meetup_status=meetupstatus2api[db_user.meetup_status],
1056 occupation=db_user.occupation,
1057 education=db_user.education,
1058 about_me=db_user.about_me,
1059 things_i_like=db_user.things_i_like,
1060 about_place=db_user.about_place,
1061 language_abilities=[
1062 api_pb2.LanguageAbility(code=ability.language_code, fluency=fluency2api[ability.fluency])
1063 for ability in db_user.language_abilities
1064 ],
1065 regions_visited=[region.code for region in db_user.regions_visited],
1066 regions_lived=[region.code for region in db_user.regions_lived],
1067 additional_information=db_user.additional_information,
1068 friends=friends_status,
1069 pending_friend_request=pending_friend_request,
1070 smoking_allowed=smokinglocation2api[db_user.smoking_allowed],
1071 sleeping_arrangement=sleepingarrangement2api[db_user.sleeping_arrangement],
1072 parking_details=parkingdetails2api[db_user.parking_details],
1073 avatar_url=avatar_upload.full_url if avatar_upload else None,
1074 avatar_thumbnail_url=avatar_upload.thumbnail_url if avatar_upload else None,
1075 profile_gallery_id=db_user.profile_gallery_id,
1076 badges=session.execute(select(UserBadge.badge_id).where(UserBadge.user_id == db_user.id).order_by(UserBadge.id))
1077 .scalars()
1078 .all(),
1079 **get_strong_verification_fields(session, db_user),
1080 **response_rate_to_pb(response_rate), # type: ignore[arg-type]
1081 )
1083 if db_user.max_guests is not None:
1084 user.max_guests.value = db_user.max_guests
1086 if db_user.last_minute is not None:
1087 user.last_minute.value = db_user.last_minute
1089 if db_user.has_pets is not None:
1090 user.has_pets.value = db_user.has_pets
1092 if db_user.accepts_pets is not None:
1093 user.accepts_pets.value = db_user.accepts_pets
1095 if db_user.pet_details is not None:
1096 user.pet_details.value = db_user.pet_details
1098 if db_user.has_kids is not None:
1099 user.has_kids.value = db_user.has_kids
1101 if db_user.accepts_kids is not None:
1102 user.accepts_kids.value = db_user.accepts_kids
1104 if db_user.kid_details is not None:
1105 user.kid_details.value = db_user.kid_details
1107 if db_user.has_housemates is not None:
1108 user.has_housemates.value = db_user.has_housemates
1110 if db_user.housemate_details is not None:
1111 user.housemate_details.value = db_user.housemate_details
1113 if db_user.wheelchair_accessible is not None:
1114 user.wheelchair_accessible.value = db_user.wheelchair_accessible
1116 if db_user.smokes_at_home is not None:
1117 user.smokes_at_home.value = db_user.smokes_at_home
1119 if db_user.drinking_allowed is not None:
1120 user.drinking_allowed.value = db_user.drinking_allowed
1122 if db_user.drinks_at_home is not None:
1123 user.drinks_at_home.value = db_user.drinks_at_home
1125 if db_user.other_host_info is not None:
1126 user.other_host_info.value = db_user.other_host_info
1128 if db_user.sleeping_details is not None:
1129 user.sleeping_details.value = db_user.sleeping_details
1131 if db_user.area is not None:
1132 user.area.value = db_user.area
1134 if db_user.house_rules is not None:
1135 user.house_rules.value = db_user.house_rules
1137 if db_user.parking is not None:
1138 user.parking.value = db_user.parking
1140 if db_user.camping_ok is not None:
1141 user.camping_ok.value = db_user.camping_ok
1143 return user
1146def lite_user_to_pb(
1147 session: Session,
1148 lite_user: LiteUser,
1149 context: CouchersContext,
1150 *,
1151 is_admin_see_ghosts: bool = False,
1152 is_get_user_return_ghosts: bool = False,
1153) -> api_pb2.LiteUser:
1154 if not is_admin_see_ghosts and is_not_visible(session, context.user_id, lite_user.id):
1155 # User is not visible (deleted, banned, or blocked)
1156 if is_get_user_return_ghosts: 1156 ↛ 1164line 1156 didn't jump to line 1164 because the condition on line 1156 was always true
1157 # Return an anonymized "ghost" user profile
1158 return api_pb2.LiteUser(
1159 user_id=lite_user.id,
1160 is_ghost=True,
1161 username=GHOST_USERNAME,
1162 name=context.get_localized_string("ghost_users.display_name"),
1163 )
1164 raise GhostUserSerializationError(
1165 f"Tried to serialize ghost profile in lite_user_to_pb without appropriate flags. "
1166 f"Context user_id: {context.user_id}, lite_user id: {lite_user.id} (username: {lite_user.username})"
1167 )
1169 lat, lng = get_coordinates(lite_user.geom)
1171 return api_pb2.LiteUser(
1172 user_id=lite_user.id,
1173 username=lite_user.username,
1174 name=lite_user.name,
1175 city=lite_user.city,
1176 age=int(lite_user.age),
1177 avatar_url=urls.media_url(filename=lite_user.avatar_filename, size="full")
1178 if lite_user.avatar_filename
1179 else None,
1180 avatar_thumbnail_url=urls.media_url(filename=lite_user.avatar_filename, size="thumbnail")
1181 if lite_user.avatar_filename
1182 else None,
1183 lat=lat,
1184 lng=lng,
1185 radius=lite_user.radius,
1186 has_strong_verification=lite_user.has_strong_verification,
1187 )