Coverage for app / backend / src / couchers / servicers / api.py: 97%
470 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-05 09:44 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-05 09:44 +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, selectinload
11from sqlalchemy.sql import and_, delete, exists, 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.event_log import log_event
19from couchers.helpers.strong_verification import get_strong_verification_fields
20from couchers.materialized_views import LiteUser, UserResponseRate
21from couchers.models import (
22 FriendRelationship,
23 FriendStatus,
24 GroupChat,
25 GroupChatSubscription,
26 HostingStatus,
27 HostRequest,
28 InitiatedUpload,
29 LanguageAbility,
30 LanguageFluency,
31 MeetupStatus,
32 Message,
33 ModerationObjectType,
34 Notification,
35 NotificationDeliveryType,
36 ParkingDetails,
37 RateLimitAction,
38 Reference,
39 RegionLived,
40 RegionVisited,
41 SleepingArrangement,
42 SmokingLocation,
43 User,
44 UserBadge,
45)
46from couchers.models.notifications import NotificationTopicAction
47from couchers.models.uploads import get_avatar_upload
48from couchers.moderation.utils import create_moderation
49from couchers.notifications.notify import notify
50from couchers.notifications.settings import get_topic_actions_by_delivery_type
51from couchers.proto import api_pb2, api_pb2_grpc, media_pb2, notification_data_pb2, requests_pb2
52from couchers.rate_limits.check import process_rate_limits_and_check_abort
53from couchers.rate_limits.definitions import RATE_LIMIT_HOURS
54from couchers.resources import get_badge_dict, language_is_allowed, region_is_allowed
55from couchers.servicers.blocking import is_not_visible
56from couchers.sql import (
57 moderation_state_column_visible,
58 username_or_id,
59 users_visible,
60 where_moderated_content_visible,
61 where_users_column_visible,
62)
63from couchers.utils import (
64 Duration_from_timedelta,
65 Timestamp_from_datetime,
66 create_coordinate,
67 get_coordinates,
68 is_valid_name,
69 is_valid_user_id,
70 is_valid_username,
71 not_none,
72 now,
73)
76class GhostUserSerializationError(Exception):
77 """
78 Raised when attempting to serialize a ghost user (deleted/banned/blocked)
79 """
81 pass
84MAX_USERS_PER_QUERY = 200
85MAX_PAGINATION_LENGTH = 50
87hostingstatus2sql = {
88 api_pb2.HOSTING_STATUS_UNKNOWN: None,
89 api_pb2.HOSTING_STATUS_CAN_HOST: HostingStatus.can_host,
90 api_pb2.HOSTING_STATUS_MAYBE: HostingStatus.maybe,
91 api_pb2.HOSTING_STATUS_CANT_HOST: HostingStatus.cant_host,
92}
94hostingstatus2api = {
95 None: api_pb2.HOSTING_STATUS_UNKNOWN,
96 HostingStatus.can_host: api_pb2.HOSTING_STATUS_CAN_HOST,
97 HostingStatus.maybe: api_pb2.HOSTING_STATUS_MAYBE,
98 HostingStatus.cant_host: api_pb2.HOSTING_STATUS_CANT_HOST,
99}
101meetupstatus2sql = {
102 api_pb2.MEETUP_STATUS_UNKNOWN: None,
103 api_pb2.MEETUP_STATUS_WANTS_TO_MEETUP: MeetupStatus.wants_to_meetup,
104 api_pb2.MEETUP_STATUS_OPEN_TO_MEETUP: MeetupStatus.open_to_meetup,
105 api_pb2.MEETUP_STATUS_DOES_NOT_WANT_TO_MEETUP: MeetupStatus.does_not_want_to_meetup,
106}
108meetupstatus2api = {
109 None: api_pb2.MEETUP_STATUS_UNKNOWN,
110 MeetupStatus.wants_to_meetup: api_pb2.MEETUP_STATUS_WANTS_TO_MEETUP,
111 MeetupStatus.open_to_meetup: api_pb2.MEETUP_STATUS_OPEN_TO_MEETUP,
112 MeetupStatus.does_not_want_to_meetup: api_pb2.MEETUP_STATUS_DOES_NOT_WANT_TO_MEETUP,
113}
115smokinglocation2sql = {
116 api_pb2.SMOKING_LOCATION_UNKNOWN: None,
117 api_pb2.SMOKING_LOCATION_YES: SmokingLocation.yes,
118 api_pb2.SMOKING_LOCATION_WINDOW: SmokingLocation.window,
119 api_pb2.SMOKING_LOCATION_OUTSIDE: SmokingLocation.outside,
120 api_pb2.SMOKING_LOCATION_NO: SmokingLocation.no,
121}
123smokinglocation2api = {
124 None: api_pb2.SMOKING_LOCATION_UNKNOWN,
125 SmokingLocation.yes: api_pb2.SMOKING_LOCATION_YES,
126 SmokingLocation.window: api_pb2.SMOKING_LOCATION_WINDOW,
127 SmokingLocation.outside: api_pb2.SMOKING_LOCATION_OUTSIDE,
128 SmokingLocation.no: api_pb2.SMOKING_LOCATION_NO,
129}
131sleepingarrangement2sql = {
132 api_pb2.SLEEPING_ARRANGEMENT_UNKNOWN: None,
133 api_pb2.SLEEPING_ARRANGEMENT_PRIVATE: SleepingArrangement.private,
134 api_pb2.SLEEPING_ARRANGEMENT_COMMON: SleepingArrangement.common,
135 api_pb2.SLEEPING_ARRANGEMENT_SHARED_ROOM: SleepingArrangement.shared_room,
136}
138sleepingarrangement2api = {
139 None: api_pb2.SLEEPING_ARRANGEMENT_UNKNOWN,
140 SleepingArrangement.private: api_pb2.SLEEPING_ARRANGEMENT_PRIVATE,
141 SleepingArrangement.common: api_pb2.SLEEPING_ARRANGEMENT_COMMON,
142 SleepingArrangement.shared_room: api_pb2.SLEEPING_ARRANGEMENT_SHARED_ROOM,
143}
145parkingdetails2sql = {
146 api_pb2.PARKING_DETAILS_UNKNOWN: None,
147 api_pb2.PARKING_DETAILS_FREE_ONSITE: ParkingDetails.free_onsite,
148 api_pb2.PARKING_DETAILS_FREE_OFFSITE: ParkingDetails.free_offsite,
149 api_pb2.PARKING_DETAILS_PAID_ONSITE: ParkingDetails.paid_onsite,
150 api_pb2.PARKING_DETAILS_PAID_OFFSITE: ParkingDetails.paid_offsite,
151}
153parkingdetails2api = {
154 None: api_pb2.PARKING_DETAILS_UNKNOWN,
155 ParkingDetails.free_onsite: api_pb2.PARKING_DETAILS_FREE_ONSITE,
156 ParkingDetails.free_offsite: api_pb2.PARKING_DETAILS_FREE_OFFSITE,
157 ParkingDetails.paid_onsite: api_pb2.PARKING_DETAILS_PAID_ONSITE,
158 ParkingDetails.paid_offsite: api_pb2.PARKING_DETAILS_PAID_OFFSITE,
159}
161fluency2sql = {
162 api_pb2.LanguageAbility.Fluency.FLUENCY_UNKNOWN: None,
163 api_pb2.LanguageAbility.Fluency.FLUENCY_BEGINNER: LanguageFluency.beginner,
164 api_pb2.LanguageAbility.Fluency.FLUENCY_CONVERSATIONAL: LanguageFluency.conversational,
165 api_pb2.LanguageAbility.Fluency.FLUENCY_FLUENT: LanguageFluency.fluent,
166}
168fluency2api = {
169 None: api_pb2.LanguageAbility.Fluency.FLUENCY_UNKNOWN,
170 LanguageFluency.beginner: api_pb2.LanguageAbility.Fluency.FLUENCY_BEGINNER,
171 LanguageFluency.conversational: api_pb2.LanguageAbility.Fluency.FLUENCY_CONVERSATIONAL,
172 LanguageFluency.fluent: api_pb2.LanguageAbility.Fluency.FLUENCY_FLUENT,
173}
176class API(api_pb2_grpc.APIServicer):
177 def Ping(self, request: api_pb2.PingReq, context: CouchersContext, session: Session) -> api_pb2.PingRes:
178 # auth ought to make sure the user exists
179 user = session.execute(
180 select(User)
181 .where(User.id == context.user_id)
182 .options(
183 selectinload(User.regions_visited),
184 selectinload(User.regions_lived),
185 selectinload(User.language_abilities),
186 )
187 ).scalar_one()
189 sent_reqs_query = select(HostRequest.conversation_id, HostRequest.initiator_last_seen_message_id).where(
190 HostRequest.initiator_user_id == context.user_id
191 )
192 sent_reqs_query = where_users_column_visible(sent_reqs_query, context, HostRequest.recipient_user_id)
193 sent_reqs_query = where_moderated_content_visible(sent_reqs_query, context, HostRequest, is_list_operation=True)
194 sent_reqs_last_seen_message_ids = sent_reqs_query.subquery()
196 unseen_sent_host_request_count = session.execute(
197 select(func.count())
198 .select_from(sent_reqs_last_seen_message_ids)
199 .where(
200 exists(
201 select(1)
202 .where(Message.conversation_id == sent_reqs_last_seen_message_ids.c.conversation_id)
203 .where(Message.id > sent_reqs_last_seen_message_ids.c.initiator_last_seen_message_id)
204 )
205 )
206 ).scalar_one()
208 received_reqs_query = select(HostRequest.conversation_id, HostRequest.recipient_last_seen_message_id).where(
209 HostRequest.recipient_user_id == context.user_id
210 )
211 received_reqs_query = where_users_column_visible(received_reqs_query, context, HostRequest.initiator_user_id)
212 received_reqs_query = where_moderated_content_visible(
213 received_reqs_query, context, HostRequest, is_list_operation=True
214 )
215 received_reqs_last_seen_message_ids = received_reqs_query.subquery()
217 unseen_received_host_request_count = session.execute(
218 select(func.count())
219 .select_from(received_reqs_last_seen_message_ids)
220 .where(
221 exists(
222 select(1)
223 .where(Message.conversation_id == received_reqs_last_seen_message_ids.c.conversation_id)
224 .where(Message.id > received_reqs_last_seen_message_ids.c.recipient_last_seen_message_id)
225 )
226 )
227 ).scalar_one()
229 unseen_message_query = (
230 select(func.count(Message.id))
231 .outerjoin(GroupChatSubscription, GroupChatSubscription.group_chat_id == Message.conversation_id)
232 .join(GroupChat, GroupChat.conversation_id == GroupChatSubscription.group_chat_id)
233 )
234 unseen_message_query = where_moderated_content_visible(
235 unseen_message_query, context, GroupChat, is_list_operation=True
236 )
237 unseen_message_query = (
238 unseen_message_query.where(GroupChatSubscription.user_id == context.user_id)
239 .where(Message.time >= GroupChatSubscription.joined)
240 .where(or_(Message.time <= GroupChatSubscription.left, GroupChatSubscription.left == None))
241 .where(Message.id > GroupChatSubscription.last_seen_message_id)
242 )
243 unseen_message_count = session.execute(unseen_message_query).scalar_one()
245 pending_friend_request_query = select(func.count(FriendRelationship.id)).where(
246 FriendRelationship.to_user_id == context.user_id
247 )
248 pending_friend_request_query = where_users_column_visible(
249 pending_friend_request_query, context, FriendRelationship.from_user_id
250 )
251 pending_friend_request_query = pending_friend_request_query.where(
252 FriendRelationship.status == FriendStatus.pending
253 )
254 pending_friend_request_query = where_moderated_content_visible(
255 pending_friend_request_query, context, FriendRelationship, is_list_operation=True
256 )
257 pending_friend_request_count = session.execute(pending_friend_request_query).scalar_one()
259 unseen_notification_count = session.execute(
260 select(func.count())
261 .select_from(Notification)
262 .where(Notification.user_id == context.user_id)
263 .where(Notification.is_seen == False)
264 .where(
265 Notification.topic_action.in_(
266 get_topic_actions_by_delivery_type(session, user.id, NotificationDeliveryType.push)
267 )
268 )
269 .where(moderation_state_column_visible(context, Notification.moderation_state_id))
270 ).scalar_one()
272 return api_pb2.PingRes(
273 user=user_model_to_pb(user, session, context),
274 unseen_message_count=unseen_message_count,
275 unseen_sent_host_request_count=unseen_sent_host_request_count,
276 unseen_received_host_request_count=unseen_received_host_request_count,
277 pending_friend_request_count=pending_friend_request_count,
278 unseen_notification_count=unseen_notification_count,
279 )
281 def GetUser(self, request: api_pb2.GetUserReq, context: CouchersContext, session: Session) -> api_pb2.User:
282 user = session.execute(
283 select(User)
284 .where(username_or_id(request.user))
285 .options(
286 selectinload(User.regions_visited),
287 selectinload(User.regions_lived),
288 selectinload(User.language_abilities),
289 )
290 ).scalar_one_or_none()
292 if not user: 292 ↛ 293line 292 didn't jump to line 293 because the condition on line 292 was never true
293 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "user_not_found")
295 return user_model_to_pb(user, session, context, is_get_user_return_ghosts=True)
297 def GetLiteUser(
298 self, request: api_pb2.GetLiteUserReq, context: CouchersContext, session: Session
299 ) -> api_pb2.LiteUser:
300 lite_user = session.execute(
301 select(LiteUser).where(username_or_id(request.user, table=LiteUser))
302 ).scalar_one_or_none()
304 if not lite_user: 304 ↛ 305line 304 didn't jump to line 305 because the condition on line 304 was never true
305 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "user_not_found")
307 return lite_user_to_pb(session, lite_user, context, is_get_user_return_ghosts=True)
309 def GetLiteUsers(
310 self, request: api_pb2.GetLiteUsersReq, context: CouchersContext, session: Session
311 ) -> api_pb2.GetLiteUsersRes:
312 if len(request.users) > MAX_USERS_PER_QUERY:
313 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "requested_too_many_users")
315 usernames = {u for u in request.users if is_valid_username(u)}
316 ids = {int(u) for u in request.users if is_valid_user_id(u)}
318 # decomposed where_username_or_id...
319 users = (
320 session.execute(select(LiteUser).where(or_(LiteUser.username.in_(usernames), LiteUser.id.in_(ids))))
321 .scalars()
322 .all()
323 )
325 users_by_id = {str(user.id): user for user in users}
326 users_by_username = {user.username: user for user in users}
328 res = api_pb2.GetLiteUsersRes()
330 for user in request.users:
331 lite_user = None
332 if user in users_by_id:
333 lite_user = users_by_id[user]
334 elif user in users_by_username:
335 lite_user = users_by_username[user]
337 res.responses.append(
338 api_pb2.LiteUserRes(
339 query=user,
340 not_found=lite_user is None,
341 user=lite_user_to_pb(session, lite_user, context, is_get_user_return_ghosts=True)
342 if lite_user
343 else None,
344 )
345 )
347 return res
349 def UpdateProfile(
350 self, request: api_pb2.UpdateProfileReq, context: CouchersContext, session: Session
351 ) -> empty_pb2.Empty:
352 user: User = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
354 if request.HasField("name"):
355 if not is_valid_name(request.name.value):
356 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_name")
357 user.name = request.name.value
359 if request.HasField("city"):
360 user.city = request.city.value
362 if request.HasField("hometown"):
363 if request.hometown.is_null:
364 user.hometown = None
365 else:
366 user.hometown = request.hometown.value
368 if request.HasField("lat") and request.HasField("lng"):
369 if request.lat.value == 0 and request.lng.value == 0:
370 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_coordinate")
371 user.geom = create_coordinate(request.lat.value, request.lng.value)
372 user.randomized_geom = None
374 if request.HasField("radius"):
375 user.geom_radius = request.radius.value
377 # if request.HasField("gender"):
378 # user.gender = request.gender.value
380 if request.HasField("pronouns"):
381 if request.pronouns.is_null:
382 user.pronouns = None
383 else:
384 user.pronouns = request.pronouns.value
386 if request.HasField("occupation"):
387 if request.occupation.is_null:
388 user.occupation = None
389 else:
390 user.occupation = request.occupation.value
392 if request.HasField("education"):
393 if request.education.is_null:
394 user.education = None
395 else:
396 user.education = request.education.value
398 if request.HasField("about_me"):
399 if request.about_me.is_null:
400 user.about_me = None
401 else:
402 user.about_me = request.about_me.value
404 if request.HasField("things_i_like"):
405 if request.things_i_like.is_null:
406 user.things_i_like = None
407 else:
408 user.things_i_like = request.things_i_like.value
410 if request.HasField("about_place"):
411 if request.about_place.is_null:
412 user.about_place = None
413 else:
414 user.about_place = request.about_place.value
416 if request.hosting_status != api_pb2.HOSTING_STATUS_UNSPECIFIED:
417 if user.do_not_email and request.hosting_status != api_pb2.HOSTING_STATUS_CANT_HOST:
418 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "do_not_email_cannot_host")
419 user.hosting_status = hostingstatus2sql[request.hosting_status] # type: ignore[assignment]
421 if request.meetup_status != api_pb2.MEETUP_STATUS_UNSPECIFIED:
422 if user.do_not_email and request.meetup_status != api_pb2.MEETUP_STATUS_DOES_NOT_WANT_TO_MEETUP:
423 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "do_not_email_cannot_meet")
424 user.meetup_status = meetupstatus2sql[request.meetup_status] # type: ignore[assignment]
426 if request.HasField("language_abilities"):
427 # delete all existing abilities
428 for ability in user.language_abilities:
429 session.delete(ability)
430 session.flush()
432 # add the new ones
433 for language_ability in request.language_abilities.value:
434 if not language_is_allowed(language_ability.code):
435 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_language")
436 session.add(
437 LanguageAbility(
438 user_id=user.id,
439 language_code=language_ability.code,
440 fluency=not_none(fluency2sql[language_ability.fluency]),
441 )
442 )
444 if request.HasField("regions_visited"):
445 session.execute(delete(RegionVisited).where(RegionVisited.user_id == context.user_id))
447 for region in request.regions_visited.value:
448 if not region_is_allowed(region):
449 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_region")
450 session.add(
451 RegionVisited(
452 user_id=user.id,
453 region_code=region,
454 )
455 )
457 if request.HasField("regions_lived"):
458 session.execute(delete(RegionLived).where(RegionLived.user_id == context.user_id))
460 for region in request.regions_lived.value:
461 if not region_is_allowed(region):
462 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_region")
463 session.add(
464 RegionLived(
465 user_id=user.id,
466 region_code=region,
467 )
468 )
470 if request.HasField("additional_information"):
471 if request.additional_information.is_null:
472 user.additional_information = None
473 else:
474 user.additional_information = request.additional_information.value
476 if request.HasField("max_guests"):
477 if request.max_guests.is_null:
478 user.max_guests = None
479 else:
480 user.max_guests = request.max_guests.value
482 if request.HasField("last_minute"):
483 if request.last_minute.is_null:
484 user.last_minute = None
485 else:
486 user.last_minute = request.last_minute.value
488 if request.HasField("has_pets"):
489 if request.has_pets.is_null:
490 user.has_pets = None
491 else:
492 user.has_pets = request.has_pets.value
494 if request.HasField("accepts_pets"):
495 if request.accepts_pets.is_null:
496 user.accepts_pets = None
497 else:
498 user.accepts_pets = request.accepts_pets.value
500 if request.HasField("pet_details"):
501 if request.pet_details.is_null:
502 user.pet_details = None
503 else:
504 user.pet_details = request.pet_details.value
506 if request.HasField("has_kids"):
507 if request.has_kids.is_null:
508 user.has_kids = None
509 else:
510 user.has_kids = request.has_kids.value
512 if request.HasField("accepts_kids"):
513 if request.accepts_kids.is_null:
514 user.accepts_kids = None
515 else:
516 user.accepts_kids = request.accepts_kids.value
518 if request.HasField("kid_details"):
519 if request.kid_details.is_null:
520 user.kid_details = None
521 else:
522 user.kid_details = request.kid_details.value
524 if request.HasField("has_housemates"):
525 if request.has_housemates.is_null:
526 user.has_housemates = None
527 else:
528 user.has_housemates = request.has_housemates.value
530 if request.HasField("housemate_details"):
531 if request.housemate_details.is_null:
532 user.housemate_details = None
533 else:
534 user.housemate_details = request.housemate_details.value
536 if request.HasField("wheelchair_accessible"):
537 if request.wheelchair_accessible.is_null:
538 user.wheelchair_accessible = None
539 else:
540 user.wheelchair_accessible = request.wheelchair_accessible.value
542 if request.smoking_allowed != api_pb2.SMOKING_LOCATION_UNSPECIFIED:
543 user.smoking_allowed = smokinglocation2sql[request.smoking_allowed]
545 if request.HasField("smokes_at_home"):
546 if request.smokes_at_home.is_null:
547 user.smokes_at_home = None
548 else:
549 user.smokes_at_home = request.smokes_at_home.value
551 if request.HasField("drinking_allowed"):
552 if request.drinking_allowed.is_null:
553 user.drinking_allowed = None
554 else:
555 user.drinking_allowed = request.drinking_allowed.value
557 if request.HasField("drinks_at_home"):
558 if request.drinks_at_home.is_null:
559 user.drinks_at_home = None
560 else:
561 user.drinks_at_home = request.drinks_at_home.value
563 if request.HasField("other_host_info"):
564 if request.other_host_info.is_null:
565 user.other_host_info = None
566 else:
567 user.other_host_info = request.other_host_info.value
569 if request.sleeping_arrangement != api_pb2.SLEEPING_ARRANGEMENT_UNSPECIFIED:
570 user.sleeping_arrangement = sleepingarrangement2sql[request.sleeping_arrangement]
572 if request.HasField("sleeping_details"):
573 if request.sleeping_details.is_null:
574 user.sleeping_details = None
575 else:
576 user.sleeping_details = request.sleeping_details.value
578 if request.HasField("area"):
579 if request.area.is_null:
580 user.area = None
581 else:
582 user.area = request.area.value
584 if request.HasField("house_rules"):
585 if request.house_rules.is_null:
586 user.house_rules = None
587 else:
588 user.house_rules = request.house_rules.value
590 if request.HasField("parking"):
591 if request.parking.is_null:
592 user.parking = None
593 else:
594 user.parking = request.parking.value
596 if request.parking_details != api_pb2.PARKING_DETAILS_UNSPECIFIED:
597 user.parking_details = parkingdetails2sql[request.parking_details]
599 if request.HasField("camping_ok"):
600 if request.camping_ok.is_null:
601 user.camping_ok = None
602 else:
603 user.camping_ok = request.camping_ok.value
605 user.profile_last_updated = now()
607 return empty_pb2.Empty()
609 def ListFriends(
610 self, request: empty_pb2.Empty, context: CouchersContext, session: Session
611 ) -> api_pb2.ListFriendsRes:
612 rels_query = select(FriendRelationship)
613 rels_query = where_users_column_visible(rels_query, context, FriendRelationship.from_user_id)
614 rels_query = where_users_column_visible(rels_query, context, FriendRelationship.to_user_id)
615 rels_query = rels_query.where(
616 or_(
617 FriendRelationship.from_user_id == context.user_id,
618 FriendRelationship.to_user_id == context.user_id,
619 )
620 ).where(FriendRelationship.status == FriendStatus.accepted)
621 rels_query = where_moderated_content_visible(rels_query, context, FriendRelationship, is_list_operation=True)
622 rels = session.execute(rels_query).scalars().all()
623 return api_pb2.ListFriendsRes(
624 user_ids=[rel.from_user.id if rel.from_user.id != context.user_id else rel.to_user.id for rel in rels],
625 )
627 def RemoveFriend(
628 self, request: api_pb2.RemoveFriendReq, context: CouchersContext, session: Session
629 ) -> empty_pb2.Empty:
630 rel_query = select(FriendRelationship)
631 rel_query = where_users_column_visible(rel_query, context, FriendRelationship.from_user_id)
632 rel_query = where_users_column_visible(rel_query, context, FriendRelationship.to_user_id)
633 rel_query = rel_query.where(
634 or_(
635 and_(
636 FriendRelationship.from_user_id == request.user_id,
637 FriendRelationship.to_user_id == context.user_id,
638 ),
639 and_(
640 FriendRelationship.from_user_id == context.user_id,
641 FriendRelationship.to_user_id == request.user_id,
642 ),
643 )
644 ).where(FriendRelationship.status == FriendStatus.accepted)
645 rel_query = where_moderated_content_visible(rel_query, context, FriendRelationship)
646 rel = session.execute(rel_query).scalar_one_or_none()
648 if not rel:
649 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "not_friends")
651 session.delete(rel)
652 log_event(context, session, "friendship.removed", {"other_user_id": request.user_id})
654 return empty_pb2.Empty()
656 def ListMutualFriends(
657 self, request: api_pb2.ListMutualFriendsReq, context: CouchersContext, session: Session
658 ) -> api_pb2.ListMutualFriendsRes:
659 if context.user_id == request.user_id:
660 return api_pb2.ListMutualFriendsRes(mutual_friends=[])
662 user = session.execute(
663 select(User).where(users_visible(context)).where(User.id == request.user_id)
664 ).scalar_one_or_none()
666 if not user: 666 ↛ 667line 666 didn't jump to line 667 because the condition on line 666 was never true
667 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "user_not_found")
669 q1 = where_moderated_content_visible(
670 select(FriendRelationship.from_user_id.label("user_id"))
671 .where(FriendRelationship.to_user_id == context.user_id)
672 .where(FriendRelationship.from_user_id != request.user_id)
673 .where(FriendRelationship.status == FriendStatus.accepted),
674 context,
675 FriendRelationship,
676 )
678 q2 = where_moderated_content_visible(
679 select(FriendRelationship.to_user_id.label("user_id"))
680 .where(FriendRelationship.from_user_id == context.user_id)
681 .where(FriendRelationship.to_user_id != request.user_id)
682 .where(FriendRelationship.status == FriendStatus.accepted),
683 context,
684 FriendRelationship,
685 )
687 q3 = where_moderated_content_visible(
688 select(FriendRelationship.from_user_id.label("user_id"))
689 .where(FriendRelationship.to_user_id == request.user_id)
690 .where(FriendRelationship.from_user_id != context.user_id)
691 .where(FriendRelationship.status == FriendStatus.accepted),
692 context,
693 FriendRelationship,
694 )
696 q4 = where_moderated_content_visible(
697 select(FriendRelationship.to_user_id.label("user_id"))
698 .where(FriendRelationship.from_user_id == request.user_id)
699 .where(FriendRelationship.to_user_id != context.user_id)
700 .where(FriendRelationship.status == FriendStatus.accepted),
701 context,
702 FriendRelationship,
703 )
705 mutual = select(intersect(union(q1, q2), union(q3, q4)).subquery())
707 mutual_friends = (
708 session.execute(select(User).where(users_visible(context)).where(User.id.in_(mutual))).scalars().all()
709 )
711 return api_pb2.ListMutualFriendsRes(
712 mutual_friends=[
713 api_pb2.MutualFriend(user_id=mutual_friend.id, username=mutual_friend.username, name=mutual_friend.name)
714 for mutual_friend in mutual_friends
715 ]
716 )
718 def SendFriendRequest(
719 self, request: api_pb2.SendFriendRequestReq, context: CouchersContext, session: Session
720 ) -> empty_pb2.Empty:
721 if context.user_id == request.user_id: 721 ↛ 722line 721 didn't jump to line 722 because the condition on line 721 was never true
722 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "cant_friend_self")
724 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
725 to_user = session.execute(
726 select(User).where(users_visible(context)).where(User.id == request.user_id)
727 ).scalar_one_or_none()
729 if not to_user: 729 ↛ 730line 729 didn't jump to line 730 because the condition on line 729 was never true
730 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "user_not_found")
732 if (
733 session.execute(
734 select(FriendRelationship)
735 .where(
736 or_(
737 and_(
738 FriendRelationship.from_user_id == context.user_id,
739 FriendRelationship.to_user_id == request.user_id,
740 ),
741 and_(
742 FriendRelationship.from_user_id == request.user_id,
743 FriendRelationship.to_user_id == context.user_id,
744 ),
745 )
746 )
747 .where(
748 or_(
749 FriendRelationship.status == FriendStatus.accepted,
750 FriendRelationship.status == FriendStatus.pending,
751 )
752 )
753 ).scalar_one_or_none()
754 is not None
755 ):
756 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "friends_already_or_pending")
758 # Check if user has been sending friend requests excessively
759 if process_rate_limits_and_check_abort(
760 session=session, user_id=context.user_id, action=RateLimitAction.friend_request
761 ):
762 context.abort_with_error_code(
763 grpc.StatusCode.RESOURCE_EXHAUSTED,
764 "friend_request_rate_limit2",
765 substitutions={"count": RATE_LIMIT_HOURS},
766 )
768 # TODO: Race condition where we can create two friend reqs, needs db constraint! See comment in table
770 # Use callback pattern to handle circular dependency between FriendRelationship and ModerationState
771 friend_relationship = None
773 def create_friend_relationship(moderation_state_id: int) -> int:
774 nonlocal friend_relationship
775 friend_relationship = FriendRelationship(
776 from_user_id=user.id,
777 to_user_id=to_user.id,
778 status=FriendStatus.pending,
779 moderation_state_id=moderation_state_id,
780 )
781 session.add(friend_relationship)
782 session.flush()
783 return friend_relationship.id
785 moderation_state = create_moderation(
786 session, ModerationObjectType.friend_request, create_friend_relationship, context.user_id
787 )
789 assert friend_relationship is not None # set by create_friend_relationship callback
791 notify(
792 session,
793 user_id=friend_relationship.to_user_id,
794 topic_action=NotificationTopicAction.friend_request__create,
795 key=str(friend_relationship.from_user_id),
796 data=notification_data_pb2.FriendRequestCreate(
797 other_user=user_model_to_pb(friend_relationship.from_user, session, context),
798 ),
799 moderation_state_id=moderation_state.id,
800 )
801 log_event(context, session, "friendship.request_sent", {"to_user_id": to_user.id})
803 return empty_pb2.Empty()
805 def ListFriendRequests(
806 self, request: empty_pb2.Empty, context: CouchersContext, session: Session
807 ) -> api_pb2.ListFriendRequestsRes:
808 # both sent and received
809 sent_requests_query = select(FriendRelationship)
810 sent_requests_query = where_users_column_visible(sent_requests_query, context, FriendRelationship.to_user_id)
811 sent_requests_query = sent_requests_query.where(FriendRelationship.from_user_id == context.user_id).where(
812 FriendRelationship.status == FriendStatus.pending
813 )
814 sent_requests_query = where_moderated_content_visible(
815 sent_requests_query, context, FriendRelationship, is_list_operation=True
816 )
817 sent_requests = session.execute(sent_requests_query).scalars().all()
819 received_requests_query = select(FriendRelationship)
820 received_requests_query = where_users_column_visible(
821 received_requests_query, context, FriendRelationship.from_user_id
822 )
823 received_requests_query = received_requests_query.where(FriendRelationship.to_user_id == context.user_id).where(
824 FriendRelationship.status == FriendStatus.pending
825 )
826 received_requests_query = where_moderated_content_visible(
827 received_requests_query, context, FriendRelationship, is_list_operation=True
828 )
829 received_requests = session.execute(received_requests_query).scalars().all()
831 return api_pb2.ListFriendRequestsRes(
832 sent=[
833 api_pb2.FriendRequest(
834 friend_request_id=friend_request.id,
835 state=api_pb2.FriendRequest.FriendRequestStatus.PENDING,
836 user_id=friend_request.to_user.id,
837 sent=True,
838 )
839 for friend_request in sent_requests
840 ],
841 received=[
842 api_pb2.FriendRequest(
843 friend_request_id=friend_request.id,
844 state=api_pb2.FriendRequest.FriendRequestStatus.PENDING,
845 user_id=friend_request.from_user.id,
846 sent=False,
847 )
848 for friend_request in received_requests
849 ],
850 )
852 def RespondFriendRequest(
853 self, request: api_pb2.RespondFriendRequestReq, context: CouchersContext, session: Session
854 ) -> empty_pb2.Empty:
855 friend_request_query = select(FriendRelationship)
856 friend_request_query = where_users_column_visible(
857 friend_request_query, context, FriendRelationship.from_user_id
858 )
859 friend_request_query = (
860 friend_request_query.where(FriendRelationship.to_user_id == context.user_id)
861 .where(FriendRelationship.status == FriendStatus.pending)
862 .where(FriendRelationship.id == request.friend_request_id)
863 )
864 friend_request_query = where_moderated_content_visible(friend_request_query, context, FriendRelationship)
865 friend_request = session.execute(friend_request_query).scalar_one_or_none()
867 if not friend_request: 867 ↛ 868line 867 didn't jump to line 868 because the condition on line 867 was never true
868 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "friend_request_not_found")
870 friend_request.status = FriendStatus.accepted if request.accept else FriendStatus.rejected
871 friend_request.time_responded = func.now()
873 session.flush()
875 if friend_request.status == FriendStatus.accepted:
876 notify(
877 session,
878 user_id=friend_request.from_user_id,
879 topic_action=NotificationTopicAction.friend_request__accept,
880 key=str(friend_request.to_user_id),
881 data=notification_data_pb2.FriendRequestAccept(
882 other_user=user_model_to_pb(friend_request.to_user, session, context),
883 ),
884 )
886 log_event(
887 context,
888 session,
889 "friendship.request_responded",
890 {"from_user_id": friend_request.from_user_id, "accepted": request.accept},
891 )
893 return empty_pb2.Empty()
895 def CancelFriendRequest(
896 self, request: api_pb2.CancelFriendRequestReq, context: CouchersContext, session: Session
897 ) -> empty_pb2.Empty:
898 friend_request_query = select(FriendRelationship)
899 friend_request_query = where_users_column_visible(friend_request_query, context, FriendRelationship.to_user_id)
900 friend_request_query = (
901 friend_request_query.where(FriendRelationship.from_user_id == context.user_id)
902 .where(FriendRelationship.status == FriendStatus.pending)
903 .where(FriendRelationship.id == request.friend_request_id)
904 )
905 friend_request_query = where_moderated_content_visible(friend_request_query, context, FriendRelationship)
906 friend_request = session.execute(friend_request_query).scalar_one_or_none()
908 if not friend_request: 908 ↛ 909line 908 didn't jump to line 909 because the condition on line 908 was never true
909 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "friend_request_not_found")
911 friend_request.status = FriendStatus.cancelled
912 friend_request.time_responded = func.now()
914 # note no notifications
915 log_event(context, session, "friendship.request_cancelled", {"to_user_id": friend_request.to_user_id})
917 session.commit()
919 return empty_pb2.Empty()
921 def InitiateMediaUpload(
922 self, request: empty_pb2.Empty, context: CouchersContext, session: Session
923 ) -> api_pb2.InitiateMediaUploadRes:
924 key = random_hex()
926 created = now()
927 expiry = created + timedelta(minutes=20)
929 upload = InitiatedUpload(key=key, created=created, expiry=expiry, initiator_user_id=context.user_id)
930 session.add(upload)
931 session.commit()
933 req = media_pb2.UploadRequest(
934 key=upload.key,
935 type=media_pb2.UploadRequest.UploadType.IMAGE,
936 created=Timestamp_from_datetime(upload.created),
937 expiry=Timestamp_from_datetime(upload.expiry),
938 max_width=2000,
939 max_height=1600,
940 ).SerializeToString()
942 data = b64encode(req)
943 sig = b64encode(generate_hash_signature(req, config["MEDIA_SERVER_SECRET_KEY"]))
945 path = "upload?" + urlencode({"data": data, "sig": sig})
947 return api_pb2.InitiateMediaUploadRes(
948 upload_url=urls.media_upload_url(path=path),
949 expiry=Timestamp_from_datetime(expiry),
950 )
952 def ListBadgeUsers(
953 self, request: api_pb2.ListBadgeUsersReq, context: CouchersContext, session: Session
954 ) -> api_pb2.ListBadgeUsersRes:
955 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
956 next_user_id = int(request.page_token) if request.page_token else 0
957 badge = get_badge_dict().get(request.badge_id)
958 if not badge: 958 ↛ 959line 958 didn't jump to line 959 because the condition on line 958 was never true
959 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "badge_not_found")
961 badge_user_ids_query = (
962 select(UserBadge.user_id).where(UserBadge.badge_id == badge.id).where(UserBadge.user_id >= next_user_id)
963 )
964 badge_user_ids_query = where_users_column_visible(badge_user_ids_query, context, UserBadge.user_id)
965 badge_user_ids_query = badge_user_ids_query.order_by(UserBadge.user_id).limit(page_size + 1)
966 badge_user_ids = session.execute(badge_user_ids_query).scalars().all()
968 return api_pb2.ListBadgeUsersRes(
969 user_ids=badge_user_ids[:page_size],
970 next_page_token=str(badge_user_ids[-1]) if len(badge_user_ids) > page_size else None,
971 )
974def response_rate_to_pb(response_rate: UserResponseRate | None) -> dict[str, google.protobuf.message.Message]:
975 if not response_rate:
976 return {"insufficient_data": requests_pb2.ResponseRateInsufficientData()}
978 # if n is None, the user is new, or they have no requests
979 if response_rate.requests < 3:
980 return {"insufficient_data": requests_pb2.ResponseRateInsufficientData()}
982 if response_rate.response_rate <= 0.33:
983 return {"low": requests_pb2.ResponseRateLow()}
985 response_time_p33_coarsened = Duration_from_timedelta(
986 timedelta(seconds=round(response_rate.response_time_33p.total_seconds() / 60) * 60)
987 )
989 if response_rate.response_rate <= 0.66:
990 return {"some": requests_pb2.ResponseRateSome(response_time_p33=response_time_p33_coarsened)}
992 response_time_p66_coarsened = Duration_from_timedelta(
993 timedelta(seconds=round(response_rate.response_time_66p.total_seconds() / 60) * 60)
994 )
996 if response_rate.response_rate <= 0.90:
997 return {
998 "most": requests_pb2.ResponseRateMost(
999 response_time_p33=response_time_p33_coarsened, response_time_p66=response_time_p66_coarsened
1000 )
1001 }
1002 else:
1003 return {
1004 "almost_all": requests_pb2.ResponseRateAlmostAll(
1005 response_time_p33=response_time_p33_coarsened, response_time_p66=response_time_p66_coarsened
1006 )
1007 }
1010def get_num_references(session: Session, user_ids: Iterable[int]) -> dict[int, int]:
1011 query = (
1012 select(Reference.to_user_id, func.count(Reference.id))
1013 .where(Reference.to_user_id.in_(user_ids))
1014 .where(Reference.is_deleted == False)
1015 .join(User, User.id == Reference.from_user_id)
1016 .where(User.is_visible)
1017 .group_by(Reference.to_user_id)
1018 )
1019 return cast(dict[int, int], dict(session.execute(query).all())) # type: ignore[arg-type]
1022def user_model_to_pb(
1023 db_user: User,
1024 session: Session,
1025 context: CouchersContext,
1026 *,
1027 is_admin_see_ghosts: bool = False,
1028 is_get_user_return_ghosts: bool = False,
1029) -> api_pb2.User:
1030 # note that this function should work also for banned/deleted users as it's called from Admin.GetUser
1031 # note that this function is sometimes called by a logged out user, in which case context comes from make_logged_out_context
1033 viewer_user_id = context.user_id if context.is_logged_in() else None
1034 if not is_admin_see_ghosts and is_not_visible(session, viewer_user_id, db_user.id):
1035 # User is not visible (deleted, banned, or blocked)
1036 if is_get_user_return_ghosts: 1036 ↛ 1045line 1036 didn't jump to line 1045 because the condition on line 1036 was always true
1037 # Return an anonymized "ghost" user profile
1038 return api_pb2.User(
1039 user_id=db_user.id,
1040 is_ghost=True,
1041 username=GHOST_USERNAME,
1042 name=context.localization.localize_string("ghost_users.display_name"),
1043 about_me=context.localization.localize_string("ghost_users.about_me"),
1044 )
1045 raise GhostUserSerializationError(
1046 f"Tried to serialize ghost profile in user_model_to_pb without appropriate flags. "
1047 f"Context user_id: {context.user_id}, db_user id: {db_user.id} (username: {db_user.username})"
1048 )
1050 num_references = get_num_references(session, [db_user.id]).get(db_user.id, 0)
1051 lat, lng = db_user.coordinates
1053 pending_friend_request = None
1054 if context.is_logged_out() or db_user.id == context.user_id:
1055 friends_status = api_pb2.User.FriendshipStatus.NA
1056 else:
1057 friend_relationship = session.execute(
1058 where_moderated_content_visible(
1059 select(FriendRelationship)
1060 .where(
1061 or_(
1062 and_(
1063 FriendRelationship.from_user_id == context.user_id,
1064 FriendRelationship.to_user_id == db_user.id,
1065 ),
1066 and_(
1067 FriendRelationship.from_user_id == db_user.id,
1068 FriendRelationship.to_user_id == context.user_id,
1069 ),
1070 )
1071 )
1072 .where(
1073 or_(
1074 FriendRelationship.status == FriendStatus.accepted,
1075 FriendRelationship.status == FriendStatus.pending,
1076 )
1077 ),
1078 context,
1079 FriendRelationship,
1080 )
1081 ).scalar_one_or_none()
1083 if friend_relationship:
1084 if friend_relationship.status == FriendStatus.accepted:
1085 friends_status = api_pb2.User.FriendshipStatus.FRIENDS
1086 else:
1087 friends_status = api_pb2.User.FriendshipStatus.PENDING
1088 if friend_relationship.from_user_id == context.user_id: 1088 ↛ 1090line 1088 didn't jump to line 1090 because the condition on line 1088 was never true
1089 # we sent it
1090 pending_friend_request = api_pb2.FriendRequest(
1091 friend_request_id=friend_relationship.id,
1092 state=api_pb2.FriendRequest.FriendRequestStatus.PENDING,
1093 user_id=friend_relationship.to_user.id,
1094 sent=True,
1095 )
1096 else:
1097 # we received it
1098 pending_friend_request = api_pb2.FriendRequest(
1099 friend_request_id=friend_relationship.id,
1100 state=api_pb2.FriendRequest.FriendRequestStatus.PENDING,
1101 user_id=friend_relationship.from_user.id,
1102 sent=False,
1103 )
1104 else:
1105 friends_status = api_pb2.User.FriendshipStatus.NOT_FRIENDS
1107 response_rate = session.execute(
1108 select(UserResponseRate).where(UserResponseRate.user_id == db_user.id)
1109 ).scalar_one_or_none()
1111 avatar_upload = get_avatar_upload(session, db_user)
1113 verification_score = 0.0
1114 if db_user.phone_verification_verified:
1115 verification_score += 1.0 * db_user.phone_is_verified
1117 user = api_pb2.User(
1118 user_id=db_user.id,
1119 username=db_user.username,
1120 name=db_user.name,
1121 city=db_user.city,
1122 hometown=db_user.hometown,
1123 timezone=db_user.timezone,
1124 lat=lat,
1125 lng=lng,
1126 radius=db_user.geom_radius,
1127 verification=verification_score,
1128 community_standing=db_user.community_standing,
1129 num_references=num_references,
1130 gender=db_user.gender,
1131 pronouns=db_user.pronouns,
1132 age=int(db_user.age),
1133 joined=Timestamp_from_datetime(db_user.display_joined),
1134 last_active=Timestamp_from_datetime(db_user.display_last_active),
1135 hosting_status=hostingstatus2api[db_user.hosting_status],
1136 meetup_status=meetupstatus2api[db_user.meetup_status],
1137 occupation=db_user.occupation,
1138 education=db_user.education,
1139 about_me=db_user.about_me,
1140 things_i_like=db_user.things_i_like,
1141 about_place=db_user.about_place,
1142 language_abilities=[
1143 api_pb2.LanguageAbility(code=ability.language_code, fluency=fluency2api[ability.fluency])
1144 for ability in db_user.language_abilities
1145 ],
1146 regions_visited=[region.code for region in db_user.regions_visited],
1147 regions_lived=[region.code for region in db_user.regions_lived],
1148 additional_information=db_user.additional_information,
1149 friends=friends_status,
1150 pending_friend_request=pending_friend_request,
1151 smoking_allowed=smokinglocation2api[db_user.smoking_allowed],
1152 sleeping_arrangement=sleepingarrangement2api[db_user.sleeping_arrangement],
1153 parking_details=parkingdetails2api[db_user.parking_details],
1154 avatar_url=avatar_upload.full_url if avatar_upload else None,
1155 avatar_thumbnail_url=avatar_upload.thumbnail_url if avatar_upload else None,
1156 profile_gallery_id=db_user.profile_gallery_id,
1157 badges=session.execute(select(UserBadge.badge_id).where(UserBadge.user_id == db_user.id).order_by(UserBadge.id))
1158 .scalars()
1159 .all(),
1160 **get_strong_verification_fields(session, db_user),
1161 **response_rate_to_pb(response_rate), # type: ignore[arg-type]
1162 )
1164 if db_user.max_guests is not None:
1165 user.max_guests.value = db_user.max_guests
1167 if db_user.last_minute is not None:
1168 user.last_minute.value = db_user.last_minute
1170 if db_user.has_pets is not None:
1171 user.has_pets.value = db_user.has_pets
1173 if db_user.accepts_pets is not None:
1174 user.accepts_pets.value = db_user.accepts_pets
1176 if db_user.pet_details is not None:
1177 user.pet_details.value = db_user.pet_details
1179 if db_user.has_kids is not None:
1180 user.has_kids.value = db_user.has_kids
1182 if db_user.accepts_kids is not None:
1183 user.accepts_kids.value = db_user.accepts_kids
1185 if db_user.kid_details is not None:
1186 user.kid_details.value = db_user.kid_details
1188 if db_user.has_housemates is not None:
1189 user.has_housemates.value = db_user.has_housemates
1191 if db_user.housemate_details is not None:
1192 user.housemate_details.value = db_user.housemate_details
1194 if db_user.wheelchair_accessible is not None:
1195 user.wheelchair_accessible.value = db_user.wheelchair_accessible
1197 if db_user.smokes_at_home is not None:
1198 user.smokes_at_home.value = db_user.smokes_at_home
1200 if db_user.drinking_allowed is not None:
1201 user.drinking_allowed.value = db_user.drinking_allowed
1203 if db_user.drinks_at_home is not None:
1204 user.drinks_at_home.value = db_user.drinks_at_home
1206 if db_user.other_host_info is not None:
1207 user.other_host_info.value = db_user.other_host_info
1209 if db_user.sleeping_details is not None:
1210 user.sleeping_details.value = db_user.sleeping_details
1212 if db_user.area is not None:
1213 user.area.value = db_user.area
1215 if db_user.house_rules is not None:
1216 user.house_rules.value = db_user.house_rules
1218 if db_user.parking is not None:
1219 user.parking.value = db_user.parking
1221 if db_user.camping_ok is not None:
1222 user.camping_ok.value = db_user.camping_ok
1224 return user
1227def lite_user_to_pb(
1228 session: Session,
1229 lite_user: LiteUser,
1230 context: CouchersContext,
1231 *,
1232 is_admin_see_ghosts: bool = False,
1233 is_get_user_return_ghosts: bool = False,
1234) -> api_pb2.LiteUser:
1235 if not is_admin_see_ghosts and is_not_visible(session, context.user_id, lite_user.id):
1236 # User is not visible (deleted, banned, or blocked)
1237 if is_get_user_return_ghosts: 1237 ↛ 1245line 1237 didn't jump to line 1245 because the condition on line 1237 was always true
1238 # Return an anonymized "ghost" user profile
1239 return api_pb2.LiteUser(
1240 user_id=lite_user.id,
1241 is_ghost=True,
1242 username=GHOST_USERNAME,
1243 name=context.localization.localize_string("ghost_users.display_name"),
1244 )
1245 raise GhostUserSerializationError(
1246 f"Tried to serialize ghost profile in lite_user_to_pb without appropriate flags. "
1247 f"Context user_id: {context.user_id}, lite_user id: {lite_user.id} (username: {lite_user.username})"
1248 )
1250 lat, lng = get_coordinates(lite_user.geom)
1252 return api_pb2.LiteUser(
1253 user_id=lite_user.id,
1254 username=lite_user.username,
1255 name=lite_user.name,
1256 city=lite_user.city,
1257 age=int(lite_user.age),
1258 avatar_url=urls.media_url(filename=lite_user.avatar_filename, size="full")
1259 if lite_user.avatar_filename
1260 else None,
1261 avatar_thumbnail_url=urls.media_url(filename=lite_user.avatar_filename, size="thumbnail")
1262 if lite_user.avatar_filename
1263 else None,
1264 lat=lat,
1265 lng=lng,
1266 radius=lite_user.radius,
1267 has_strong_verification=lite_user.has_strong_verification,
1268 )