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