Coverage for app / backend / src / couchers / servicers / api.py: 97%
470 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-19 14:14 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-19 14:14 +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.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 username_or_id, users_visible, where_moderated_content_visible, where_users_column_visible
57from couchers.utils import (
58 Duration_from_timedelta,
59 Timestamp_from_datetime,
60 create_coordinate,
61 get_coordinates,
62 is_valid_name,
63 is_valid_user_id,
64 is_valid_username,
65 not_none,
66 now,
67)
70class GhostUserSerializationError(Exception):
71 """
72 Raised when attempting to serialize a ghost user (deleted/banned/blocked)
73 """
75 pass
78MAX_USERS_PER_QUERY = 200
79MAX_PAGINATION_LENGTH = 50
81hostingstatus2sql = {
82 api_pb2.HOSTING_STATUS_UNKNOWN: None,
83 api_pb2.HOSTING_STATUS_CAN_HOST: HostingStatus.can_host,
84 api_pb2.HOSTING_STATUS_MAYBE: HostingStatus.maybe,
85 api_pb2.HOSTING_STATUS_CANT_HOST: HostingStatus.cant_host,
86}
88hostingstatus2api = {
89 None: api_pb2.HOSTING_STATUS_UNKNOWN,
90 HostingStatus.can_host: api_pb2.HOSTING_STATUS_CAN_HOST,
91 HostingStatus.maybe: api_pb2.HOSTING_STATUS_MAYBE,
92 HostingStatus.cant_host: api_pb2.HOSTING_STATUS_CANT_HOST,
93}
95meetupstatus2sql = {
96 api_pb2.MEETUP_STATUS_UNKNOWN: None,
97 api_pb2.MEETUP_STATUS_WANTS_TO_MEETUP: MeetupStatus.wants_to_meetup,
98 api_pb2.MEETUP_STATUS_OPEN_TO_MEETUP: MeetupStatus.open_to_meetup,
99 api_pb2.MEETUP_STATUS_DOES_NOT_WANT_TO_MEETUP: MeetupStatus.does_not_want_to_meetup,
100}
102meetupstatus2api = {
103 None: api_pb2.MEETUP_STATUS_UNKNOWN,
104 MeetupStatus.wants_to_meetup: api_pb2.MEETUP_STATUS_WANTS_TO_MEETUP,
105 MeetupStatus.open_to_meetup: api_pb2.MEETUP_STATUS_OPEN_TO_MEETUP,
106 MeetupStatus.does_not_want_to_meetup: api_pb2.MEETUP_STATUS_DOES_NOT_WANT_TO_MEETUP,
107}
109smokinglocation2sql = {
110 api_pb2.SMOKING_LOCATION_UNKNOWN: None,
111 api_pb2.SMOKING_LOCATION_YES: SmokingLocation.yes,
112 api_pb2.SMOKING_LOCATION_WINDOW: SmokingLocation.window,
113 api_pb2.SMOKING_LOCATION_OUTSIDE: SmokingLocation.outside,
114 api_pb2.SMOKING_LOCATION_NO: SmokingLocation.no,
115}
117smokinglocation2api = {
118 None: api_pb2.SMOKING_LOCATION_UNKNOWN,
119 SmokingLocation.yes: api_pb2.SMOKING_LOCATION_YES,
120 SmokingLocation.window: api_pb2.SMOKING_LOCATION_WINDOW,
121 SmokingLocation.outside: api_pb2.SMOKING_LOCATION_OUTSIDE,
122 SmokingLocation.no: api_pb2.SMOKING_LOCATION_NO,
123}
125sleepingarrangement2sql = {
126 api_pb2.SLEEPING_ARRANGEMENT_UNKNOWN: None,
127 api_pb2.SLEEPING_ARRANGEMENT_PRIVATE: SleepingArrangement.private,
128 api_pb2.SLEEPING_ARRANGEMENT_COMMON: SleepingArrangement.common,
129 api_pb2.SLEEPING_ARRANGEMENT_SHARED_ROOM: SleepingArrangement.shared_room,
130}
132sleepingarrangement2api = {
133 None: api_pb2.SLEEPING_ARRANGEMENT_UNKNOWN,
134 SleepingArrangement.private: api_pb2.SLEEPING_ARRANGEMENT_PRIVATE,
135 SleepingArrangement.common: api_pb2.SLEEPING_ARRANGEMENT_COMMON,
136 SleepingArrangement.shared_room: api_pb2.SLEEPING_ARRANGEMENT_SHARED_ROOM,
137}
139parkingdetails2sql = {
140 api_pb2.PARKING_DETAILS_UNKNOWN: None,
141 api_pb2.PARKING_DETAILS_FREE_ONSITE: ParkingDetails.free_onsite,
142 api_pb2.PARKING_DETAILS_FREE_OFFSITE: ParkingDetails.free_offsite,
143 api_pb2.PARKING_DETAILS_PAID_ONSITE: ParkingDetails.paid_onsite,
144 api_pb2.PARKING_DETAILS_PAID_OFFSITE: ParkingDetails.paid_offsite,
145}
147parkingdetails2api = {
148 None: api_pb2.PARKING_DETAILS_UNKNOWN,
149 ParkingDetails.free_onsite: api_pb2.PARKING_DETAILS_FREE_ONSITE,
150 ParkingDetails.free_offsite: api_pb2.PARKING_DETAILS_FREE_OFFSITE,
151 ParkingDetails.paid_onsite: api_pb2.PARKING_DETAILS_PAID_ONSITE,
152 ParkingDetails.paid_offsite: api_pb2.PARKING_DETAILS_PAID_OFFSITE,
153}
155fluency2sql = {
156 api_pb2.LanguageAbility.Fluency.FLUENCY_UNKNOWN: None,
157 api_pb2.LanguageAbility.Fluency.FLUENCY_BEGINNER: LanguageFluency.beginner,
158 api_pb2.LanguageAbility.Fluency.FLUENCY_CONVERSATIONAL: LanguageFluency.conversational,
159 api_pb2.LanguageAbility.Fluency.FLUENCY_FLUENT: LanguageFluency.fluent,
160}
162fluency2api = {
163 None: api_pb2.LanguageAbility.Fluency.FLUENCY_UNKNOWN,
164 LanguageFluency.beginner: api_pb2.LanguageAbility.Fluency.FLUENCY_BEGINNER,
165 LanguageFluency.conversational: api_pb2.LanguageAbility.Fluency.FLUENCY_CONVERSATIONAL,
166 LanguageFluency.fluent: api_pb2.LanguageAbility.Fluency.FLUENCY_FLUENT,
167}
170class API(api_pb2_grpc.APIServicer):
171 def Ping(self, request: api_pb2.PingReq, context: CouchersContext, session: Session) -> api_pb2.PingRes:
172 # auth ought to make sure the user exists
173 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
175 sent_reqs_query = select(HostRequest.conversation_id, HostRequest.surfer_last_seen_message_id).where(
176 HostRequest.surfer_user_id == context.user_id
177 )
178 sent_reqs_query = where_users_column_visible(sent_reqs_query, context, HostRequest.host_user_id)
179 sent_reqs_query = where_moderated_content_visible(sent_reqs_query, context, HostRequest, is_list_operation=True)
180 sent_reqs_last_seen_message_ids = sent_reqs_query.subquery()
182 unseen_sent_host_request_count = session.execute(
183 select(func.count(distinct(sent_reqs_last_seen_message_ids.c.conversation_id)))
184 .join(
185 Message,
186 Message.conversation_id == sent_reqs_last_seen_message_ids.c.conversation_id,
187 )
188 .where(sent_reqs_last_seen_message_ids.c.surfer_last_seen_message_id < Message.id)
189 .where(Message.id != None)
190 ).scalar_one()
192 received_reqs_query = select(HostRequest.conversation_id, HostRequest.host_last_seen_message_id).where(
193 HostRequest.host_user_id == context.user_id
194 )
195 received_reqs_query = where_users_column_visible(received_reqs_query, context, HostRequest.surfer_user_id)
196 received_reqs_query = where_moderated_content_visible(
197 received_reqs_query, context, HostRequest, is_list_operation=True
198 )
199 received_reqs_last_seen_message_ids = received_reqs_query.subquery()
201 unseen_received_host_request_count = session.execute(
202 select(func.count(distinct(received_reqs_last_seen_message_ids.c.conversation_id)))
203 .join(
204 Message,
205 Message.conversation_id == received_reqs_last_seen_message_ids.c.conversation_id,
206 )
207 .where(received_reqs_last_seen_message_ids.c.host_last_seen_message_id < Message.id)
208 .where(Message.id != None)
209 ).scalar_one()
211 unseen_message_query = (
212 select(func.count(Message.id))
213 .outerjoin(GroupChatSubscription, GroupChatSubscription.group_chat_id == Message.conversation_id)
214 .join(GroupChat, GroupChat.conversation_id == GroupChatSubscription.group_chat_id)
215 )
216 unseen_message_query = where_moderated_content_visible(
217 unseen_message_query, context, GroupChat, is_list_operation=True
218 )
219 unseen_message_query = (
220 unseen_message_query.where(GroupChatSubscription.user_id == context.user_id)
221 .where(Message.time >= GroupChatSubscription.joined)
222 .where(or_(Message.time <= GroupChatSubscription.left, GroupChatSubscription.left == None))
223 .where(Message.id > GroupChatSubscription.last_seen_message_id)
224 )
225 unseen_message_count = session.execute(unseen_message_query).scalar_one()
227 pending_friend_request_query = select(func.count(FriendRelationship.id)).where(
228 FriendRelationship.to_user_id == context.user_id
229 )
230 pending_friend_request_query = where_users_column_visible(
231 pending_friend_request_query, context, FriendRelationship.from_user_id
232 )
233 pending_friend_request_query = pending_friend_request_query.where(
234 FriendRelationship.status == FriendStatus.pending
235 )
236 pending_friend_request_query = where_moderated_content_visible(
237 pending_friend_request_query, context, FriendRelationship, is_list_operation=True
238 )
239 pending_friend_request_count = session.execute(pending_friend_request_query).scalar_one()
241 unseen_notification_count = session.execute(
242 select(func.count())
243 .select_from(Notification)
244 .where(Notification.user_id == context.user_id)
245 .where(Notification.is_seen == False)
246 .where(
247 Notification.topic_action.in_(
248 get_topic_actions_by_delivery_type(session, user.id, NotificationDeliveryType.push)
249 )
250 )
251 ).scalar_one()
253 return api_pb2.PingRes(
254 user=user_model_to_pb(user, session, context),
255 unseen_message_count=unseen_message_count,
256 unseen_sent_host_request_count=unseen_sent_host_request_count,
257 unseen_received_host_request_count=unseen_received_host_request_count,
258 pending_friend_request_count=pending_friend_request_count,
259 unseen_notification_count=unseen_notification_count,
260 )
262 def GetUser(self, request: api_pb2.GetUserReq, context: CouchersContext, session: Session) -> api_pb2.User:
263 user = session.execute(select(User).where(username_or_id(request.user))).scalar_one_or_none()
265 if not user: 265 ↛ 266line 265 didn't jump to line 266 because the condition on line 265 was never true
266 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "user_not_found")
268 return user_model_to_pb(user, session, context, is_get_user_return_ghosts=True)
270 def GetLiteUser(
271 self, request: api_pb2.GetLiteUserReq, context: CouchersContext, session: Session
272 ) -> api_pb2.LiteUser:
273 lite_user = session.execute(
274 select(LiteUser).where(username_or_id(request.user, table=LiteUser))
275 ).scalar_one_or_none()
277 if not lite_user: 277 ↛ 278line 277 didn't jump to line 278 because the condition on line 277 was never true
278 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "user_not_found")
280 return lite_user_to_pb(session, lite_user, context, is_get_user_return_ghosts=True)
282 def GetLiteUsers(
283 self, request: api_pb2.GetLiteUsersReq, context: CouchersContext, session: Session
284 ) -> api_pb2.GetLiteUsersRes:
285 if len(request.users) > MAX_USERS_PER_QUERY:
286 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "requested_too_many_users")
288 usernames = {u for u in request.users if is_valid_username(u)}
289 ids = {u for u in request.users if is_valid_user_id(u)}
291 # decomposed where_username_or_id...
292 users = (
293 session.execute(select(LiteUser).where(or_(LiteUser.username.in_(usernames), LiteUser.id.in_(ids))))
294 .scalars()
295 .all()
296 )
298 users_by_id = {str(user.id): user for user in users}
299 users_by_username = {user.username: user for user in users}
301 res = api_pb2.GetLiteUsersRes()
303 for user in request.users:
304 lite_user = None
305 if user in users_by_id:
306 lite_user = users_by_id[user]
307 elif user in users_by_username:
308 lite_user = users_by_username[user]
310 res.responses.append(
311 api_pb2.LiteUserRes(
312 query=user,
313 not_found=lite_user is None,
314 user=lite_user_to_pb(session, lite_user, context, is_get_user_return_ghosts=True)
315 if lite_user
316 else None,
317 )
318 )
320 return res
322 def UpdateProfile(
323 self, request: api_pb2.UpdateProfileReq, context: CouchersContext, session: Session
324 ) -> empty_pb2.Empty:
325 user: User = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
327 if request.HasField("name"):
328 if not is_valid_name(request.name.value):
329 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_name")
330 user.name = request.name.value
332 if request.HasField("city"):
333 user.city = request.city.value
335 if request.HasField("hometown"):
336 if request.hometown.is_null:
337 user.hometown = None
338 else:
339 user.hometown = request.hometown.value
341 if request.HasField("lat") and request.HasField("lng"):
342 if request.lat.value == 0 and request.lng.value == 0:
343 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_coordinate")
344 user.geom = create_coordinate(request.lat.value, request.lng.value)
345 user.randomized_geom = None
347 if request.HasField("radius"):
348 user.geom_radius = request.radius.value
350 # if request.HasField("gender"):
351 # user.gender = request.gender.value
353 if request.HasField("pronouns"):
354 if request.pronouns.is_null:
355 user.pronouns = None
356 else:
357 user.pronouns = request.pronouns.value
359 if request.HasField("occupation"):
360 if request.occupation.is_null:
361 user.occupation = None
362 else:
363 user.occupation = request.occupation.value
365 if request.HasField("education"):
366 if request.education.is_null:
367 user.education = None
368 else:
369 user.education = request.education.value
371 if request.HasField("about_me"):
372 if request.about_me.is_null:
373 user.about_me = None
374 else:
375 user.about_me = request.about_me.value
377 if request.HasField("things_i_like"):
378 if request.things_i_like.is_null:
379 user.things_i_like = None
380 else:
381 user.things_i_like = request.things_i_like.value
383 if request.HasField("about_place"):
384 if request.about_place.is_null:
385 user.about_place = None
386 else:
387 user.about_place = request.about_place.value
389 if request.hosting_status != api_pb2.HOSTING_STATUS_UNSPECIFIED:
390 if user.do_not_email and request.hosting_status != api_pb2.HOSTING_STATUS_CANT_HOST:
391 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "do_not_email_cannot_host")
392 user.hosting_status = hostingstatus2sql[request.hosting_status] # type: ignore[assignment]
394 if request.meetup_status != api_pb2.MEETUP_STATUS_UNSPECIFIED:
395 if user.do_not_email and request.meetup_status != api_pb2.MEETUP_STATUS_DOES_NOT_WANT_TO_MEETUP:
396 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "do_not_email_cannot_meet")
397 user.meetup_status = meetupstatus2sql[request.meetup_status] # type: ignore[assignment]
399 if request.HasField("language_abilities"):
400 # delete all existing abilities
401 for ability in user.language_abilities:
402 session.delete(ability)
403 session.flush()
405 # add the new ones
406 for language_ability in request.language_abilities.value:
407 if not language_is_allowed(language_ability.code):
408 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_language")
409 session.add(
410 LanguageAbility(
411 user_id=user.id,
412 language_code=language_ability.code,
413 fluency=not_none(fluency2sql[language_ability.fluency]),
414 )
415 )
417 if request.HasField("regions_visited"):
418 session.execute(delete(RegionVisited).where(RegionVisited.user_id == context.user_id))
420 for region in request.regions_visited.value:
421 if not region_is_allowed(region):
422 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_region")
423 session.add(
424 RegionVisited(
425 user_id=user.id,
426 region_code=region,
427 )
428 )
430 if request.HasField("regions_lived"):
431 session.execute(delete(RegionLived).where(RegionLived.user_id == context.user_id))
433 for region in request.regions_lived.value:
434 if not region_is_allowed(region):
435 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_region")
436 session.add(
437 RegionLived(
438 user_id=user.id,
439 region_code=region,
440 )
441 )
443 if request.HasField("additional_information"):
444 if request.additional_information.is_null:
445 user.additional_information = None
446 else:
447 user.additional_information = request.additional_information.value
449 if request.HasField("max_guests"):
450 if request.max_guests.is_null:
451 user.max_guests = None
452 else:
453 user.max_guests = request.max_guests.value
455 if request.HasField("last_minute"):
456 if request.last_minute.is_null:
457 user.last_minute = None
458 else:
459 user.last_minute = request.last_minute.value
461 if request.HasField("has_pets"):
462 if request.has_pets.is_null:
463 user.has_pets = None
464 else:
465 user.has_pets = request.has_pets.value
467 if request.HasField("accepts_pets"):
468 if request.accepts_pets.is_null:
469 user.accepts_pets = None
470 else:
471 user.accepts_pets = request.accepts_pets.value
473 if request.HasField("pet_details"):
474 if request.pet_details.is_null:
475 user.pet_details = None
476 else:
477 user.pet_details = request.pet_details.value
479 if request.HasField("has_kids"):
480 if request.has_kids.is_null:
481 user.has_kids = None
482 else:
483 user.has_kids = request.has_kids.value
485 if request.HasField("accepts_kids"):
486 if request.accepts_kids.is_null:
487 user.accepts_kids = None
488 else:
489 user.accepts_kids = request.accepts_kids.value
491 if request.HasField("kid_details"):
492 if request.kid_details.is_null:
493 user.kid_details = None
494 else:
495 user.kid_details = request.kid_details.value
497 if request.HasField("has_housemates"):
498 if request.has_housemates.is_null:
499 user.has_housemates = None
500 else:
501 user.has_housemates = request.has_housemates.value
503 if request.HasField("housemate_details"):
504 if request.housemate_details.is_null:
505 user.housemate_details = None
506 else:
507 user.housemate_details = request.housemate_details.value
509 if request.HasField("wheelchair_accessible"):
510 if request.wheelchair_accessible.is_null:
511 user.wheelchair_accessible = None
512 else:
513 user.wheelchair_accessible = request.wheelchair_accessible.value
515 if request.smoking_allowed != api_pb2.SMOKING_LOCATION_UNSPECIFIED:
516 user.smoking_allowed = smokinglocation2sql[request.smoking_allowed]
518 if request.HasField("smokes_at_home"):
519 if request.smokes_at_home.is_null:
520 user.smokes_at_home = None
521 else:
522 user.smokes_at_home = request.smokes_at_home.value
524 if request.HasField("drinking_allowed"):
525 if request.drinking_allowed.is_null:
526 user.drinking_allowed = None
527 else:
528 user.drinking_allowed = request.drinking_allowed.value
530 if request.HasField("drinks_at_home"):
531 if request.drinks_at_home.is_null:
532 user.drinks_at_home = None
533 else:
534 user.drinks_at_home = request.drinks_at_home.value
536 if request.HasField("other_host_info"):
537 if request.other_host_info.is_null:
538 user.other_host_info = None
539 else:
540 user.other_host_info = request.other_host_info.value
542 if request.sleeping_arrangement != api_pb2.SLEEPING_ARRANGEMENT_UNSPECIFIED:
543 user.sleeping_arrangement = sleepingarrangement2sql[request.sleeping_arrangement]
545 if request.HasField("sleeping_details"):
546 if request.sleeping_details.is_null:
547 user.sleeping_details = None
548 else:
549 user.sleeping_details = request.sleeping_details.value
551 if request.HasField("area"):
552 if request.area.is_null:
553 user.area = None
554 else:
555 user.area = request.area.value
557 if request.HasField("house_rules"):
558 if request.house_rules.is_null:
559 user.house_rules = None
560 else:
561 user.house_rules = request.house_rules.value
563 if request.HasField("parking"):
564 if request.parking.is_null:
565 user.parking = None
566 else:
567 user.parking = request.parking.value
569 if request.parking_details != api_pb2.PARKING_DETAILS_UNSPECIFIED:
570 user.parking_details = parkingdetails2sql[request.parking_details]
572 if request.HasField("camping_ok"):
573 if request.camping_ok.is_null:
574 user.camping_ok = None
575 else:
576 user.camping_ok = request.camping_ok.value
578 user.profile_last_updated = now()
580 return empty_pb2.Empty()
582 def ListFriends(
583 self, request: empty_pb2.Empty, context: CouchersContext, session: Session
584 ) -> api_pb2.ListFriendsRes:
585 rels_query = select(FriendRelationship)
586 rels_query = where_users_column_visible(rels_query, context, FriendRelationship.from_user_id)
587 rels_query = where_users_column_visible(rels_query, context, FriendRelationship.to_user_id)
588 rels_query = rels_query.where(
589 or_(
590 FriendRelationship.from_user_id == context.user_id,
591 FriendRelationship.to_user_id == context.user_id,
592 )
593 ).where(FriendRelationship.status == FriendStatus.accepted)
594 rels_query = where_moderated_content_visible(rels_query, context, FriendRelationship, is_list_operation=True)
595 rels = session.execute(rels_query).scalars().all()
596 return api_pb2.ListFriendsRes(
597 user_ids=[rel.from_user.id if rel.from_user.id != context.user_id else rel.to_user.id for rel in rels],
598 )
600 def RemoveFriend(
601 self, request: api_pb2.RemoveFriendReq, context: CouchersContext, session: Session
602 ) -> empty_pb2.Empty:
603 rel_query = select(FriendRelationship)
604 rel_query = where_users_column_visible(rel_query, context, FriendRelationship.from_user_id)
605 rel_query = where_users_column_visible(rel_query, context, FriendRelationship.to_user_id)
606 rel_query = rel_query.where(
607 or_(
608 and_(
609 FriendRelationship.from_user_id == request.user_id,
610 FriendRelationship.to_user_id == context.user_id,
611 ),
612 and_(
613 FriendRelationship.from_user_id == context.user_id,
614 FriendRelationship.to_user_id == request.user_id,
615 ),
616 )
617 ).where(FriendRelationship.status == FriendStatus.accepted)
618 rel_query = where_moderated_content_visible(rel_query, context, FriendRelationship)
619 rel = session.execute(rel_query).scalar_one_or_none()
621 if not rel:
622 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "not_friends")
624 session.delete(rel)
625 log_event(context, session, "friendship.removed", {"other_user_id": request.user_id})
627 return empty_pb2.Empty()
629 def ListMutualFriends(
630 self, request: api_pb2.ListMutualFriendsReq, context: CouchersContext, session: Session
631 ) -> api_pb2.ListMutualFriendsRes:
632 if context.user_id == request.user_id:
633 return api_pb2.ListMutualFriendsRes(mutual_friends=[])
635 user = session.execute(
636 select(User).where(users_visible(context)).where(User.id == request.user_id)
637 ).scalar_one_or_none()
639 if not user: 639 ↛ 640line 639 didn't jump to line 640 because the condition on line 639 was never true
640 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "user_not_found")
642 q1 = where_moderated_content_visible(
643 select(FriendRelationship.from_user_id.label("user_id"))
644 .where(FriendRelationship.to_user_id == context.user_id)
645 .where(FriendRelationship.from_user_id != request.user_id)
646 .where(FriendRelationship.status == FriendStatus.accepted),
647 context,
648 FriendRelationship,
649 )
651 q2 = where_moderated_content_visible(
652 select(FriendRelationship.to_user_id.label("user_id"))
653 .where(FriendRelationship.from_user_id == context.user_id)
654 .where(FriendRelationship.to_user_id != request.user_id)
655 .where(FriendRelationship.status == FriendStatus.accepted),
656 context,
657 FriendRelationship,
658 )
660 q3 = where_moderated_content_visible(
661 select(FriendRelationship.from_user_id.label("user_id"))
662 .where(FriendRelationship.to_user_id == request.user_id)
663 .where(FriendRelationship.from_user_id != context.user_id)
664 .where(FriendRelationship.status == FriendStatus.accepted),
665 context,
666 FriendRelationship,
667 )
669 q4 = where_moderated_content_visible(
670 select(FriendRelationship.to_user_id.label("user_id"))
671 .where(FriendRelationship.from_user_id == request.user_id)
672 .where(FriendRelationship.to_user_id != context.user_id)
673 .where(FriendRelationship.status == FriendStatus.accepted),
674 context,
675 FriendRelationship,
676 )
678 mutual = select(intersect(union(q1, q2), union(q3, q4)).subquery())
680 mutual_friends = (
681 session.execute(select(User).where(users_visible(context)).where(User.id.in_(mutual))).scalars().all()
682 )
684 return api_pb2.ListMutualFriendsRes(
685 mutual_friends=[
686 api_pb2.MutualFriend(user_id=mutual_friend.id, username=mutual_friend.username, name=mutual_friend.name)
687 for mutual_friend in mutual_friends
688 ]
689 )
691 def SendFriendRequest(
692 self, request: api_pb2.SendFriendRequestReq, context: CouchersContext, session: Session
693 ) -> empty_pb2.Empty:
694 if context.user_id == request.user_id: 694 ↛ 695line 694 didn't jump to line 695 because the condition on line 694 was never true
695 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "cant_friend_self")
697 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
698 to_user = session.execute(
699 select(User).where(users_visible(context)).where(User.id == request.user_id)
700 ).scalar_one_or_none()
702 if not to_user: 702 ↛ 703line 702 didn't jump to line 703 because the condition on line 702 was never true
703 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "user_not_found")
705 if (
706 session.execute(
707 select(FriendRelationship)
708 .where(
709 or_(
710 and_(
711 FriendRelationship.from_user_id == context.user_id,
712 FriendRelationship.to_user_id == request.user_id,
713 ),
714 and_(
715 FriendRelationship.from_user_id == request.user_id,
716 FriendRelationship.to_user_id == context.user_id,
717 ),
718 )
719 )
720 .where(
721 or_(
722 FriendRelationship.status == FriendStatus.accepted,
723 FriendRelationship.status == FriendStatus.pending,
724 )
725 )
726 ).scalar_one_or_none()
727 is not None
728 ):
729 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "friends_already_or_pending")
731 # Check if user has been sending friend requests excessively
732 if process_rate_limits_and_check_abort(
733 session=session, user_id=context.user_id, action=RateLimitAction.friend_request
734 ):
735 context.abort_with_error_code(
736 grpc.StatusCode.RESOURCE_EXHAUSTED,
737 "friend_request_rate_limit",
738 substitutions={"hours": str(RATE_LIMIT_HOURS)},
739 )
741 # TODO: Race condition where we can create two friend reqs, needs db constraint! See comment in table
743 # Use callback pattern to handle circular dependency between FriendRelationship and ModerationState
744 friend_relationship = None
746 def create_friend_relationship(moderation_state_id: int) -> int:
747 nonlocal friend_relationship
748 friend_relationship = FriendRelationship(
749 from_user_id=user.id,
750 to_user_id=to_user.id,
751 status=FriendStatus.pending,
752 moderation_state_id=moderation_state_id,
753 )
754 session.add(friend_relationship)
755 session.flush()
756 return friend_relationship.id
758 moderation_state = create_moderation(
759 session, ModerationObjectType.friend_request, create_friend_relationship, context.user_id
760 )
762 assert friend_relationship is not None # set by create_friend_relationship callback
764 notify(
765 session,
766 user_id=friend_relationship.to_user_id,
767 topic_action=NotificationTopicAction.friend_request__create,
768 key=str(friend_relationship.from_user_id),
769 data=notification_data_pb2.FriendRequestCreate(
770 other_user=user_model_to_pb(friend_relationship.from_user, session, context),
771 ),
772 moderation_state_id=moderation_state.id,
773 )
774 log_event(context, session, "friendship.request_sent", {"to_user_id": to_user.id})
776 return empty_pb2.Empty()
778 def ListFriendRequests(
779 self, request: empty_pb2.Empty, context: CouchersContext, session: Session
780 ) -> api_pb2.ListFriendRequestsRes:
781 # both sent and received
782 sent_requests_query = select(FriendRelationship)
783 sent_requests_query = where_users_column_visible(sent_requests_query, context, FriendRelationship.to_user_id)
784 sent_requests_query = sent_requests_query.where(FriendRelationship.from_user_id == context.user_id).where(
785 FriendRelationship.status == FriendStatus.pending
786 )
787 sent_requests_query = where_moderated_content_visible(
788 sent_requests_query, context, FriendRelationship, is_list_operation=True
789 )
790 sent_requests = session.execute(sent_requests_query).scalars().all()
792 received_requests_query = select(FriendRelationship)
793 received_requests_query = where_users_column_visible(
794 received_requests_query, context, FriendRelationship.from_user_id
795 )
796 received_requests_query = received_requests_query.where(FriendRelationship.to_user_id == context.user_id).where(
797 FriendRelationship.status == FriendStatus.pending
798 )
799 received_requests_query = where_moderated_content_visible(
800 received_requests_query, context, FriendRelationship, is_list_operation=True
801 )
802 received_requests = session.execute(received_requests_query).scalars().all()
804 return api_pb2.ListFriendRequestsRes(
805 sent=[
806 api_pb2.FriendRequest(
807 friend_request_id=friend_request.id,
808 state=api_pb2.FriendRequest.FriendRequestStatus.PENDING,
809 user_id=friend_request.to_user.id,
810 sent=True,
811 )
812 for friend_request in sent_requests
813 ],
814 received=[
815 api_pb2.FriendRequest(
816 friend_request_id=friend_request.id,
817 state=api_pb2.FriendRequest.FriendRequestStatus.PENDING,
818 user_id=friend_request.from_user.id,
819 sent=False,
820 )
821 for friend_request in received_requests
822 ],
823 )
825 def RespondFriendRequest(
826 self, request: api_pb2.RespondFriendRequestReq, context: CouchersContext, session: Session
827 ) -> empty_pb2.Empty:
828 friend_request_query = select(FriendRelationship)
829 friend_request_query = where_users_column_visible(
830 friend_request_query, context, FriendRelationship.from_user_id
831 )
832 friend_request_query = (
833 friend_request_query.where(FriendRelationship.to_user_id == context.user_id)
834 .where(FriendRelationship.status == FriendStatus.pending)
835 .where(FriendRelationship.id == request.friend_request_id)
836 )
837 friend_request_query = where_moderated_content_visible(friend_request_query, context, FriendRelationship)
838 friend_request = session.execute(friend_request_query).scalar_one_or_none()
840 if not friend_request: 840 ↛ 841line 840 didn't jump to line 841 because the condition on line 840 was never true
841 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "friend_request_not_found")
843 friend_request.status = FriendStatus.accepted if request.accept else FriendStatus.rejected
844 friend_request.time_responded = func.now()
846 session.flush()
848 if friend_request.status == FriendStatus.accepted:
849 notify(
850 session,
851 user_id=friend_request.from_user_id,
852 topic_action=NotificationTopicAction.friend_request__accept,
853 key=str(friend_request.to_user_id),
854 data=notification_data_pb2.FriendRequestAccept(
855 other_user=user_model_to_pb(friend_request.to_user, session, context),
856 ),
857 )
859 log_event(
860 context,
861 session,
862 "friendship.request_responded",
863 {"from_user_id": friend_request.from_user_id, "accepted": request.accept},
864 )
866 return empty_pb2.Empty()
868 def CancelFriendRequest(
869 self, request: api_pb2.CancelFriendRequestReq, context: CouchersContext, session: Session
870 ) -> empty_pb2.Empty:
871 friend_request_query = select(FriendRelationship)
872 friend_request_query = where_users_column_visible(friend_request_query, context, FriendRelationship.to_user_id)
873 friend_request_query = (
874 friend_request_query.where(FriendRelationship.from_user_id == context.user_id)
875 .where(FriendRelationship.status == FriendStatus.pending)
876 .where(FriendRelationship.id == request.friend_request_id)
877 )
878 friend_request_query = where_moderated_content_visible(friend_request_query, context, FriendRelationship)
879 friend_request = session.execute(friend_request_query).scalar_one_or_none()
881 if not friend_request: 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, "friend_request_not_found")
884 friend_request.status = FriendStatus.cancelled
885 friend_request.time_responded = func.now()
887 # note no notifications
888 log_event(context, session, "friendship.request_cancelled", {"to_user_id": friend_request.to_user_id})
890 session.commit()
892 return empty_pb2.Empty()
894 def InitiateMediaUpload(
895 self, request: empty_pb2.Empty, context: CouchersContext, session: Session
896 ) -> api_pb2.InitiateMediaUploadRes:
897 key = random_hex()
899 created = now()
900 expiry = created + timedelta(minutes=20)
902 upload = InitiatedUpload(key=key, created=created, expiry=expiry, initiator_user_id=context.user_id)
903 session.add(upload)
904 session.commit()
906 req = media_pb2.UploadRequest(
907 key=upload.key,
908 type=media_pb2.UploadRequest.UploadType.IMAGE,
909 created=Timestamp_from_datetime(upload.created),
910 expiry=Timestamp_from_datetime(upload.expiry),
911 max_width=2000,
912 max_height=1600,
913 ).SerializeToString()
915 data = b64encode(req)
916 sig = b64encode(generate_hash_signature(req, config["MEDIA_SERVER_SECRET_KEY"]))
918 path = "upload?" + urlencode({"data": data, "sig": sig})
920 return api_pb2.InitiateMediaUploadRes(
921 upload_url=urls.media_upload_url(path=path),
922 expiry=Timestamp_from_datetime(expiry),
923 )
925 def ListBadgeUsers(
926 self, request: api_pb2.ListBadgeUsersReq, context: CouchersContext, session: Session
927 ) -> api_pb2.ListBadgeUsersRes:
928 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
929 next_user_id = int(request.page_token) if request.page_token else 0
930 badge = get_badge_dict().get(request.badge_id)
931 if not badge: 931 ↛ 932line 931 didn't jump to line 932 because the condition on line 931 was never true
932 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "badge_not_found")
934 badge_user_ids_query = (
935 select(UserBadge.user_id).where(UserBadge.badge_id == badge.id).where(UserBadge.user_id >= next_user_id)
936 )
937 badge_user_ids_query = where_users_column_visible(badge_user_ids_query, context, UserBadge.user_id)
938 badge_user_ids_query = badge_user_ids_query.order_by(UserBadge.user_id).limit(page_size + 1)
939 badge_user_ids = session.execute(badge_user_ids_query).scalars().all()
941 return api_pb2.ListBadgeUsersRes(
942 user_ids=badge_user_ids[:page_size],
943 next_page_token=str(badge_user_ids[-1]) if len(badge_user_ids) > page_size else None,
944 )
947def response_rate_to_pb(response_rate: UserResponseRate | None) -> dict[str, google.protobuf.message.Message]:
948 if not response_rate:
949 return {"insufficient_data": requests_pb2.ResponseRateInsufficientData()}
951 # if n is None, the user is new, or they have no requests
952 if response_rate.requests < 3:
953 return {"insufficient_data": requests_pb2.ResponseRateInsufficientData()}
955 if response_rate.response_rate <= 0.33:
956 return {"low": requests_pb2.ResponseRateLow()}
958 response_time_p33_coarsened = Duration_from_timedelta(
959 timedelta(seconds=round(response_rate.response_time_33p.total_seconds() / 60) * 60)
960 )
962 if response_rate.response_rate <= 0.66:
963 return {"some": requests_pb2.ResponseRateSome(response_time_p33=response_time_p33_coarsened)}
965 response_time_p66_coarsened = Duration_from_timedelta(
966 timedelta(seconds=round(response_rate.response_time_66p.total_seconds() / 60) * 60)
967 )
969 if response_rate.response_rate <= 0.90:
970 return {
971 "most": requests_pb2.ResponseRateMost(
972 response_time_p33=response_time_p33_coarsened, response_time_p66=response_time_p66_coarsened
973 )
974 }
975 else:
976 return {
977 "almost_all": requests_pb2.ResponseRateAlmostAll(
978 response_time_p33=response_time_p33_coarsened, response_time_p66=response_time_p66_coarsened
979 )
980 }
983def get_num_references(session: Session, user_ids: Iterable[int]) -> dict[int, int]:
984 query = (
985 select(Reference.to_user_id, func.count(Reference.id))
986 .where(Reference.to_user_id.in_(user_ids))
987 .where(Reference.is_deleted == False)
988 .join(User, User.id == Reference.from_user_id)
989 .where(User.is_visible)
990 .group_by(Reference.to_user_id)
991 )
992 return cast(dict[int, int], dict(session.execute(query).all())) # type: ignore[arg-type]
995def user_model_to_pb(
996 db_user: User,
997 session: Session,
998 context: CouchersContext,
999 *,
1000 is_admin_see_ghosts: bool = False,
1001 is_get_user_return_ghosts: bool = False,
1002) -> api_pb2.User:
1003 # note that this function should work also for banned/deleted users as it's called from Admin.GetUser
1004 # note that this function is sometimes called by a logged out user, in which case context comes from make_logged_out_context
1006 viewer_user_id = context.user_id if context.is_logged_in() else None
1007 if not is_admin_see_ghosts and is_not_visible(session, viewer_user_id, db_user.id):
1008 # User is not visible (deleted, banned, or blocked)
1009 if is_get_user_return_ghosts: 1009 ↛ 1018line 1009 didn't jump to line 1018 because the condition on line 1009 was always true
1010 # Return an anonymized "ghost" user profile
1011 return api_pb2.User(
1012 user_id=db_user.id,
1013 is_ghost=True,
1014 username=GHOST_USERNAME,
1015 name=context.localization.localize_string("ghost_users.display_name"),
1016 about_me=context.localization.localize_string("ghost_users.about_me"),
1017 )
1018 raise GhostUserSerializationError(
1019 f"Tried to serialize ghost profile in user_model_to_pb without appropriate flags. "
1020 f"Context user_id: {context.user_id}, db_user id: {db_user.id} (username: {db_user.username})"
1021 )
1023 num_references = get_num_references(session, [db_user.id]).get(db_user.id, 0)
1024 lat, lng = db_user.coordinates
1026 pending_friend_request = None
1027 if context.is_logged_out() or db_user.id == context.user_id:
1028 friends_status = api_pb2.User.FriendshipStatus.NA
1029 else:
1030 friend_relationship = session.execute(
1031 where_moderated_content_visible(
1032 select(FriendRelationship)
1033 .where(
1034 or_(
1035 and_(
1036 FriendRelationship.from_user_id == context.user_id,
1037 FriendRelationship.to_user_id == db_user.id,
1038 ),
1039 and_(
1040 FriendRelationship.from_user_id == db_user.id,
1041 FriendRelationship.to_user_id == context.user_id,
1042 ),
1043 )
1044 )
1045 .where(
1046 or_(
1047 FriendRelationship.status == FriendStatus.accepted,
1048 FriendRelationship.status == FriendStatus.pending,
1049 )
1050 ),
1051 context,
1052 FriendRelationship,
1053 )
1054 ).scalar_one_or_none()
1056 if friend_relationship:
1057 if friend_relationship.status == FriendStatus.accepted:
1058 friends_status = api_pb2.User.FriendshipStatus.FRIENDS
1059 else:
1060 friends_status = api_pb2.User.FriendshipStatus.PENDING
1061 if friend_relationship.from_user_id == context.user_id: 1061 ↛ 1063line 1061 didn't jump to line 1063 because the condition on line 1061 was never true
1062 # we sent it
1063 pending_friend_request = api_pb2.FriendRequest(
1064 friend_request_id=friend_relationship.id,
1065 state=api_pb2.FriendRequest.FriendRequestStatus.PENDING,
1066 user_id=friend_relationship.to_user.id,
1067 sent=True,
1068 )
1069 else:
1070 # we received it
1071 pending_friend_request = api_pb2.FriendRequest(
1072 friend_request_id=friend_relationship.id,
1073 state=api_pb2.FriendRequest.FriendRequestStatus.PENDING,
1074 user_id=friend_relationship.from_user.id,
1075 sent=False,
1076 )
1077 else:
1078 friends_status = api_pb2.User.FriendshipStatus.NOT_FRIENDS
1080 response_rate = session.execute(
1081 select(UserResponseRate).where(UserResponseRate.user_id == db_user.id)
1082 ).scalar_one_or_none()
1084 avatar_upload = get_avatar_upload(session, db_user)
1086 verification_score = 0.0
1087 if db_user.phone_verification_verified:
1088 verification_score += 1.0 * db_user.phone_is_verified
1090 user = api_pb2.User(
1091 user_id=db_user.id,
1092 username=db_user.username,
1093 name=db_user.name,
1094 city=db_user.city,
1095 hometown=db_user.hometown,
1096 timezone=db_user.timezone,
1097 lat=lat,
1098 lng=lng,
1099 radius=db_user.geom_radius,
1100 verification=verification_score,
1101 community_standing=db_user.community_standing,
1102 num_references=num_references,
1103 gender=db_user.gender,
1104 pronouns=db_user.pronouns,
1105 age=int(db_user.age),
1106 joined=Timestamp_from_datetime(db_user.display_joined),
1107 last_active=Timestamp_from_datetime(db_user.display_last_active),
1108 hosting_status=hostingstatus2api[db_user.hosting_status],
1109 meetup_status=meetupstatus2api[db_user.meetup_status],
1110 occupation=db_user.occupation,
1111 education=db_user.education,
1112 about_me=db_user.about_me,
1113 things_i_like=db_user.things_i_like,
1114 about_place=db_user.about_place,
1115 language_abilities=[
1116 api_pb2.LanguageAbility(code=ability.language_code, fluency=fluency2api[ability.fluency])
1117 for ability in db_user.language_abilities
1118 ],
1119 regions_visited=[region.code for region in db_user.regions_visited],
1120 regions_lived=[region.code for region in db_user.regions_lived],
1121 additional_information=db_user.additional_information,
1122 friends=friends_status,
1123 pending_friend_request=pending_friend_request,
1124 smoking_allowed=smokinglocation2api[db_user.smoking_allowed],
1125 sleeping_arrangement=sleepingarrangement2api[db_user.sleeping_arrangement],
1126 parking_details=parkingdetails2api[db_user.parking_details],
1127 avatar_url=avatar_upload.full_url if avatar_upload else None,
1128 avatar_thumbnail_url=avatar_upload.thumbnail_url if avatar_upload else None,
1129 profile_gallery_id=db_user.profile_gallery_id,
1130 badges=session.execute(select(UserBadge.badge_id).where(UserBadge.user_id == db_user.id).order_by(UserBadge.id))
1131 .scalars()
1132 .all(),
1133 **get_strong_verification_fields(session, db_user),
1134 **response_rate_to_pb(response_rate), # type: ignore[arg-type]
1135 )
1137 if db_user.max_guests is not None:
1138 user.max_guests.value = db_user.max_guests
1140 if db_user.last_minute is not None:
1141 user.last_minute.value = db_user.last_minute
1143 if db_user.has_pets is not None:
1144 user.has_pets.value = db_user.has_pets
1146 if db_user.accepts_pets is not None:
1147 user.accepts_pets.value = db_user.accepts_pets
1149 if db_user.pet_details is not None:
1150 user.pet_details.value = db_user.pet_details
1152 if db_user.has_kids is not None:
1153 user.has_kids.value = db_user.has_kids
1155 if db_user.accepts_kids is not None:
1156 user.accepts_kids.value = db_user.accepts_kids
1158 if db_user.kid_details is not None:
1159 user.kid_details.value = db_user.kid_details
1161 if db_user.has_housemates is not None:
1162 user.has_housemates.value = db_user.has_housemates
1164 if db_user.housemate_details is not None:
1165 user.housemate_details.value = db_user.housemate_details
1167 if db_user.wheelchair_accessible is not None:
1168 user.wheelchair_accessible.value = db_user.wheelchair_accessible
1170 if db_user.smokes_at_home is not None:
1171 user.smokes_at_home.value = db_user.smokes_at_home
1173 if db_user.drinking_allowed is not None:
1174 user.drinking_allowed.value = db_user.drinking_allowed
1176 if db_user.drinks_at_home is not None:
1177 user.drinks_at_home.value = db_user.drinks_at_home
1179 if db_user.other_host_info is not None:
1180 user.other_host_info.value = db_user.other_host_info
1182 if db_user.sleeping_details is not None:
1183 user.sleeping_details.value = db_user.sleeping_details
1185 if db_user.area is not None:
1186 user.area.value = db_user.area
1188 if db_user.house_rules is not None:
1189 user.house_rules.value = db_user.house_rules
1191 if db_user.parking is not None:
1192 user.parking.value = db_user.parking
1194 if db_user.camping_ok is not None:
1195 user.camping_ok.value = db_user.camping_ok
1197 return user
1200def lite_user_to_pb(
1201 session: Session,
1202 lite_user: LiteUser,
1203 context: CouchersContext,
1204 *,
1205 is_admin_see_ghosts: bool = False,
1206 is_get_user_return_ghosts: bool = False,
1207) -> api_pb2.LiteUser:
1208 if not is_admin_see_ghosts and is_not_visible(session, context.user_id, lite_user.id):
1209 # User is not visible (deleted, banned, or blocked)
1210 if is_get_user_return_ghosts: 1210 ↛ 1218line 1210 didn't jump to line 1218 because the condition on line 1210 was always true
1211 # Return an anonymized "ghost" user profile
1212 return api_pb2.LiteUser(
1213 user_id=lite_user.id,
1214 is_ghost=True,
1215 username=GHOST_USERNAME,
1216 name=context.localization.localize_string("ghost_users.display_name"),
1217 )
1218 raise GhostUserSerializationError(
1219 f"Tried to serialize ghost profile in lite_user_to_pb without appropriate flags. "
1220 f"Context user_id: {context.user_id}, lite_user id: {lite_user.id} (username: {lite_user.username})"
1221 )
1223 lat, lng = get_coordinates(lite_user.geom)
1225 return api_pb2.LiteUser(
1226 user_id=lite_user.id,
1227 username=lite_user.username,
1228 name=lite_user.name,
1229 city=lite_user.city,
1230 age=int(lite_user.age),
1231 avatar_url=urls.media_url(filename=lite_user.avatar_filename, size="full")
1232 if lite_user.avatar_filename
1233 else None,
1234 avatar_thumbnail_url=urls.media_url(filename=lite_user.avatar_filename, size="thumbnail")
1235 if lite_user.avatar_filename
1236 else None,
1237 lat=lat,
1238 lng=lng,
1239 radius=lite_user.radius,
1240 has_strong_verification=lite_user.has_strong_verification,
1241 )