From b939e6d01c0eeb0e04cd3bcfcaaf4d924d9c001e Mon Sep 17 00:00:00 2001 From: Ryan Skonnord Date: Thu, 11 Apr 2024 16:08:59 -0700 Subject: [PATCH 1/6] fix(hc): Introduce Organization.get_bulk_owner_contacts Support a specialized optimization in getsentry. The purpose is to get a lightweight view of the owners of an arbitrary number of organizations and while making only one RPC. --- src/sentry/models/organization.py | 44 ++++++++++++++++++- src/sentry/services/hybrid_cloud/user/impl.py | 10 ++++- .../services/hybrid_cloud/user/model.py | 6 +++ .../services/hybrid_cloud/user/service.py | 12 ++++- 4 files changed, 69 insertions(+), 3 deletions(-) diff --git a/src/sentry/models/organization.py b/src/sentry/models/organization.py index c06e891fe75484..f6503f3e6cfb08 100644 --- a/src/sentry/models/organization.py +++ b/src/sentry/models/organization.py @@ -37,7 +37,7 @@ from sentry.roles.manager import Role from sentry.services.hybrid_cloud.notifications import notifications_service from sentry.services.hybrid_cloud.organization_mapping import organization_mapping_service -from sentry.services.hybrid_cloud.user import RpcUser +from sentry.services.hybrid_cloud.user import RpcUser, RpcUserContact from sentry.services.hybrid_cloud.user.service import user_service from sentry.types.organization import OrganizationAbsoluteUrlMixin from sentry.utils.http import is_using_customer_domain @@ -329,6 +329,48 @@ def default_owner_id(self): self._default_owner_id = owners[0].id return self._default_owner_id + @classmethod + def _get_bulk_owner_ids(cls, organizations: Collection[Organization]) -> dict[int, int]: + from sentry.models.organizationmember import OrganizationMember + + owner_id_table: dict[int, int] = {} + org_ids_to_query: list[int] = [] + for org in organizations: + default_owner = getattr(org, "_default_owner", None) + if default_owner: + owner_id_table[org.id] = default_owner.id + else: + org_ids_to_query.append(org.id) + + if org_ids_to_query: + queried_owner_ids = OrganizationMember.objects.filter( + organization_id__in=org_ids_to_query, role=roles.get_top_dog().id + ).values_list("organization_id", "user_id") + + for (org_id, user_id) in queried_owner_ids: + # An org may have multiple owners. Here we mimic the behavior of + # `get_default_owner`, which is to use the first one in the query + # result's iteration order. + if org_id not in owner_id_table: + owner_id_table[org_id] = user_id + + return owner_id_table + + @classmethod + def get_bulk_owner_contacts( + cls, organizations: Collection[Organization] + ) -> dict[int, RpcUserContact]: + owner_id_table = cls._get_bulk_owner_ids(organizations) + + contacts = user_service.get_bulk_contact_info(user_ids=list(owner_id_table.values())) + contact_table = {c.id: c for c in contacts} + + return { + org_id: contact_table[user_id] + for (org_id, user_id) in owner_id_table.items() + if user_id in contact_table + } + def has_single_owner(self): owners = list( self.get_members_with_org_roles([roles.get_top_dog().id])[:2].values_list( diff --git a/src/sentry/services/hybrid_cloud/user/impl.py b/src/sentry/services/hybrid_cloud/user/impl.py index 1944f33a409805..74909722d4b20a 100644 --- a/src/sentry/services/hybrid_cloud/user/impl.py +++ b/src/sentry/services/hybrid_cloud/user/impl.py @@ -37,7 +37,11 @@ UserSerializeType, UserUpdateArgs, ) -from sentry.services.hybrid_cloud.user.model import RpcVerifyUserEmail, UserIdEmailArgs +from sentry.services.hybrid_cloud.user.model import ( + RpcUserContact, + RpcVerifyUserEmail, + UserIdEmailArgs, +) from sentry.services.hybrid_cloud.user.serial import serialize_rpc_user, serialize_user_avatar from sentry.services.hybrid_cloud.user.service import UserService from sentry.signals import user_signup @@ -283,6 +287,10 @@ def get_user_avatar(self, *, user_id: int) -> RpcAvatar | None: return serialize_user_avatar(avatar=possible_avatar) + def get_bulk_contact_info(self, *, user_ids: list[int]) -> list[RpcUserContact]: + users = User.objects.filter(id__in=user_ids).values_list("id", "name", "email") + return [RpcUserContact(id=id, name=name, email=email) for (id, name, email) in users] + class _UserFilterQuery( FilterQueryDatabaseImpl[User, UserFilterArgs, RpcUser, UserSerializeType], ): diff --git a/src/sentry/services/hybrid_cloud/user/model.py b/src/sentry/services/hybrid_cloud/user/model.py index df9038b68cc66b..d27a0e8ea61f67 100644 --- a/src/sentry/services/hybrid_cloud/user/model.py +++ b/src/sentry/services/hybrid_cloud/user/model.py @@ -25,6 +25,12 @@ class RpcUserEmail(RpcModel): is_verified: bool = False +class RpcUserContact(RpcModel): + id: int = -1 + name: str = "" + email: str = "" + + class RpcAuthenticator(RpcModel): id: int = 0 user_id: int = -1 diff --git a/src/sentry/services/hybrid_cloud/user/service.py b/src/sentry/services/hybrid_cloud/user/service.py index ac9de3881f4c2a..45a594dc7e95d7 100644 --- a/src/sentry/services/hybrid_cloud/user/service.py +++ b/src/sentry/services/hybrid_cloud/user/service.py @@ -17,7 +17,12 @@ UserSerializeType, UserUpdateArgs, ) -from sentry.services.hybrid_cloud.user.model import RpcAvatar, RpcVerifyUserEmail, UserIdEmailArgs +from sentry.services.hybrid_cloud.user.model import ( + RpcAvatar, + RpcUserContact, + RpcVerifyUserEmail, + UserIdEmailArgs, +) from sentry.silo import SiloMode @@ -195,6 +200,11 @@ def verify_user_emails( def get_user_avatar(self, *, user_id: int) -> RpcAvatar | None: pass + @rpc_method + @abstractmethod + def get_bulk_contact_info(self, *, user_ids: list[int]) -> list[RpcUserContact]: + pass + @back_with_silo_cache("user_service.get_user", SiloMode.REGION, RpcUser) def get_user(user_id: int) -> RpcUser | None: From dff23cf704c4f7db0b2f7e1c643cd069bdcbcfee Mon Sep 17 00:00:00 2001 From: Ryan Skonnord Date: Fri, 12 Apr 2024 15:50:37 -0700 Subject: [PATCH 2/6] ref(hc): Introduce RpcUserProfile and UserService.get_many_profiles Offer a more performant alternative to UserService.get_many that provides all attributes that can be provided without fetching from any related tables. --- src/sentry/services/hybrid_cloud/user/impl.py | 16 +++++++-- .../services/hybrid_cloud/user/model.py | 8 +++-- .../services/hybrid_cloud/user/serial.py | 34 ++++++++++++++----- .../services/hybrid_cloud/user/service.py | 12 ++++++- tests/sentry/hybridcloud/test_user.py | 7 ++++ 5 files changed, 63 insertions(+), 14 deletions(-) diff --git a/src/sentry/services/hybrid_cloud/user/impl.py b/src/sentry/services/hybrid_cloud/user/impl.py index 1944f33a409805..1a42e53f03555d 100644 --- a/src/sentry/services/hybrid_cloud/user/impl.py +++ b/src/sentry/services/hybrid_cloud/user/impl.py @@ -37,8 +37,16 @@ UserSerializeType, UserUpdateArgs, ) -from sentry.services.hybrid_cloud.user.model import RpcVerifyUserEmail, UserIdEmailArgs -from sentry.services.hybrid_cloud.user.serial import serialize_rpc_user, serialize_user_avatar +from sentry.services.hybrid_cloud.user.model import ( + RpcUserProfile, + RpcVerifyUserEmail, + UserIdEmailArgs, +) +from sentry.services.hybrid_cloud.user.serial import ( + serialize_rpc_user, + serialize_rpc_user_profile, + serialize_user_avatar, +) from sentry.services.hybrid_cloud.user.service import UserService from sentry.signals import user_signup @@ -62,6 +70,10 @@ def get_many(self, *, filter: UserFilterArgs) -> list[RpcUser]: def get_many_ids(self, *, filter: UserFilterArgs) -> list[int]: return self._FQ.get_many_ids(filter) + def get_many_profiles(self, *, filter: UserFilterArgs) -> list[RpcUserProfile]: + users = self._FQ._query_many(filter, ids_only=True) + return [serialize_rpc_user_profile(user) for user in users] + def get_many_by_email( self, emails: list[str], diff --git a/src/sentry/services/hybrid_cloud/user/model.py b/src/sentry/services/hybrid_cloud/user/model.py index df9038b68cc66b..92d88342f1ce73 100644 --- a/src/sentry/services/hybrid_cloud/user/model.py +++ b/src/sentry/services/hybrid_cloud/user/model.py @@ -34,12 +34,13 @@ class RpcAuthenticator(RpcModel): config: Any = None -class RpcUser(RpcModel): +class RpcUserProfile(RpcModel): + """Minimal set of user attributes that can be fetched efficiently.""" + id: int = -1 pk: int = -1 name: str = "" email: str = "" - emails: frozenset[str] = frozenset() username: str = "" actor_id: int | None = None display_name: str = "" @@ -55,9 +56,12 @@ class RpcUser(RpcModel): is_password_expired: bool = False session_nonce: str | None = None + +class RpcUser(RpcUserProfile): roles: frozenset[str] = frozenset() permissions: frozenset[str] = frozenset() avatar: RpcAvatar | None = None + emails: frozenset[str] = frozenset() useremails: list[RpcUserEmail] = Field(default_factory=list) authenticators: list[RpcAuthenticator] = Field(default_factory=list) diff --git a/src/sentry/services/hybrid_cloud/user/serial.py b/src/sentry/services/hybrid_cloud/user/serial.py index 7e8adb3768c985..a53e3faadd9fb4 100644 --- a/src/sentry/services/hybrid_cloud/user/serial.py +++ b/src/sentry/services/hybrid_cloud/user/serial.py @@ -8,7 +8,13 @@ from sentry.db.models import BaseQuerySet from sentry.models.avatars.user_avatar import UserAvatar from sentry.models.user import User -from sentry.services.hybrid_cloud.user import RpcAuthenticator, RpcAvatar, RpcUser, RpcUserEmail +from sentry.services.hybrid_cloud.user import ( + RpcAuthenticator, + RpcAvatar, + RpcUser, + RpcUserEmail, + RpcUserProfile, +) def serialize_generic_user(user: Any) -> RpcUser | None: @@ -27,10 +33,10 @@ def serialize_generic_user(user: Any) -> RpcUser | None: raise TypeError(f"Can't serialize {type(user)} to RpcUser") -def serialize_rpc_user(user: User) -> RpcUser: +def _serialize_from_user_fields(user: User) -> dict[str, Any]: args = { field_name: getattr(user, field_name) - for field_name in RpcUser.__fields__ + for field_name in RpcUserProfile.__fields__ if hasattr(user, field_name) } args["pk"] = user.pk @@ -39,22 +45,32 @@ def serialize_rpc_user(user: User) -> RpcUser: args["is_superuser"] = user.is_superuser args["is_sentry_app"] = bool(user.is_sentry_app) args["password_usable"] = user.has_usable_password() + args["session_nonce"] = user.session_nonce + + if args["name"] is None: + # This field is non-nullable according to the Django schema, but may be null + # on some servers due to migration history + args["name"] = "" + + return args + + +def serialize_rpc_user_profile(user: User) -> RpcUserProfile: + return RpcUserProfile(**_serialize_from_user_fields(user)) + + +def serialize_rpc_user(user: User) -> RpcUser: + args = _serialize_from_user_fields(user) # Prefer eagerloaded attributes from _base_query if hasattr(user, "useremails") and user.useremails is not None: args["emails"] = frozenset([e["email"] for e in user.useremails if e["is_verified"]]) else: args["emails"] = frozenset([email.email for email in user.get_verified_emails()]) - args["session_nonce"] = user.session_nonce # And process the _base_query special data additions args["permissions"] = frozenset(getattr(user, "permissions", None) or ()) - if args["name"] is None: - # This field is non-nullable according to the Django schema, but may be null - # on some servers due to migration history - args["name"] = "" - roles: frozenset[str] = frozenset() if hasattr(user, "roles") and user.roles is not None: roles = frozenset(_flatten(user.roles)) diff --git a/src/sentry/services/hybrid_cloud/user/service.py b/src/sentry/services/hybrid_cloud/user/service.py index ac9de3881f4c2a..39f0706716590e 100644 --- a/src/sentry/services/hybrid_cloud/user/service.py +++ b/src/sentry/services/hybrid_cloud/user/service.py @@ -17,7 +17,12 @@ UserSerializeType, UserUpdateArgs, ) -from sentry.services.hybrid_cloud.user.model import RpcAvatar, RpcVerifyUserEmail, UserIdEmailArgs +from sentry.services.hybrid_cloud.user.model import ( + RpcAvatar, + RpcUserProfile, + RpcVerifyUserEmail, + UserIdEmailArgs, +) from sentry.silo import SiloMode @@ -53,6 +58,11 @@ def get_many(self, *, filter: UserFilterArgs) -> list[RpcUser]: def get_many_ids(self, *, filter: UserFilterArgs) -> list[int]: pass + @rpc_method + @abstractmethod + def get_many_profiles(self, *, filter: UserFilterArgs) -> list[RpcUserProfile]: + pass + @rpc_method @abstractmethod def get_many_by_email( diff --git a/tests/sentry/hybridcloud/test_user.py b/tests/sentry/hybridcloud/test_user.py index 727234de4748c3..5ef1ba737a4151 100644 --- a/tests/sentry/hybridcloud/test_user.py +++ b/tests/sentry/hybridcloud/test_user.py @@ -37,3 +37,10 @@ def test_user_serialize_multiple_emails(self): assert len(rpc_user.useremails) == 3 expected = {self.user.email, email.email, unverified_email.email} assert expected == {e.email for e in rpc_user.useremails} + + def test_get_many_profiles(self): + users = [self.create_user() for _ in range(2)] + target_ids = [users[0].id] + profiles = user_service.get_many_profiles(filter=dict(user_ids=target_ids)) + assert len(profiles) == 1 + assert profiles[0].id == users[0].id From b22d1d19aaeab54144296754554c48857610a4be Mon Sep 17 00:00:00 2001 From: Ryan Skonnord Date: Fri, 12 Apr 2024 15:57:19 -0700 Subject: [PATCH 3/6] ref(hc): Change FilterQueryDatabaseImpl signatures Rename `ids_only` to more accurately reflect what it does, since `get_many_profiles` is now using it to fetch more than just IDs but still wants to avoid selecting any related data. Make `query_many` no longer private by convention. We now want to call it directly, so that we can then do a different kind of serialization than _UserFilterQuery's default. (DatabaseBackedUserSocialAuthService also was previously calling it directly.) --- src/sentry/services/hybrid_cloud/app/impl.py | 4 ++-- src/sentry/services/hybrid_cloud/filter_query.py | 12 ++++++------ src/sentry/services/hybrid_cloud/identity/impl.py | 2 +- src/sentry/services/hybrid_cloud/user/impl.py | 6 +++--- src/sentry/services/hybrid_cloud/user_option/impl.py | 2 +- .../services/hybrid_cloud/usersocialauth/impl.py | 6 +++--- 6 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/sentry/services/hybrid_cloud/app/impl.py b/src/sentry/services/hybrid_cloud/app/impl.py index 7f382542eee11c..a73e4b5a7ded5f 100644 --- a/src/sentry/services/hybrid_cloud/app/impl.py +++ b/src/sentry/services/hybrid_cloud/app/impl.py @@ -168,8 +168,8 @@ class _AppServiceFilterQuery( SentryAppInstallation, SentryAppInstallationFilterArgs, RpcSentryAppInstallation, None ] ): - def base_query(self, ids_only: bool = False) -> QuerySet[SentryAppInstallation]: - if ids_only: + def base_query(self, select_related: bool = True) -> QuerySet[SentryAppInstallation]: + if not select_related: return SentryAppInstallation.objects return SentryAppInstallation.objects.select_related("sentry_app") diff --git a/src/sentry/services/hybrid_cloud/filter_query.py b/src/sentry/services/hybrid_cloud/filter_query.py index 0818cabaea5efb..72ab32d79048d2 100644 --- a/src/sentry/services/hybrid_cloud/filter_query.py +++ b/src/sentry/services/hybrid_cloud/filter_query.py @@ -50,7 +50,7 @@ class FilterQueryDatabaseImpl( # Required Overrides @abc.abstractmethod - def base_query(self, ids_only: bool = False) -> QuerySet[BASE_MODEL]: + def base_query(self, select_related: bool = True) -> QuerySet[BASE_MODEL]: # This should return a QuerySet for the model in question along with any other required data # that is not a filter pass @@ -91,14 +91,14 @@ def validator(d: FILTER_ARGS) -> str | None: # Helpers - def _query_many(self, filter: FILTER_ARGS, ids_only: bool = False) -> QuerySet: + def query_many(self, filter: FILTER_ARGS, select_related: bool = True) -> QuerySet: validation_error = self.filter_arg_validator()(filter) if validation_error is not None: raise TypeError( f"Failed to validate filter arguments passed to {self.__class__.__name__}: {validation_error}" ) - query = self.base_query(ids_only=ids_only) + query = self.base_query(select_related=select_related) return self.apply_filters(query, filter) # Public Interface @@ -121,7 +121,7 @@ def serialize_many( if as_user is None and auth_context: as_user = auth_context.user - result = self._query_many(filter=filter) + result = self.query_many(filter=filter) return serialize( list(result), user=as_user, @@ -129,7 +129,7 @@ def serialize_many( ) def get_many(self, filter: FILTER_ARGS) -> list[RPC_RESPONSE]: - return [self.serialize_rpc(o) for o in self._query_many(filter=filter)] + return [self.serialize_rpc(o) for o in self.query_many(filter=filter)] def get_many_ids(self, filter: FILTER_ARGS) -> list[int]: - return [o.id for o in self._query_many(filter=filter, ids_only=True)] + return [o.id for o in self.query_many(filter=filter, select_related=False)] diff --git a/src/sentry/services/hybrid_cloud/identity/impl.py b/src/sentry/services/hybrid_cloud/identity/impl.py index 8d528c20ae7508..f632fc0edfd889 100644 --- a/src/sentry/services/hybrid_cloud/identity/impl.py +++ b/src/sentry/services/hybrid_cloud/identity/impl.py @@ -108,7 +108,7 @@ def apply_filters( query = query.filter(idp__type=filters["provider_type"]) return query - def base_query(self, ids_only: bool = False) -> QuerySet[Identity]: + def base_query(self, select_related: bool = True) -> QuerySet[Identity]: return Identity.objects def filter_arg_validator(self) -> Callable[[IdentityFilterArgs], str | None]: diff --git a/src/sentry/services/hybrid_cloud/user/impl.py b/src/sentry/services/hybrid_cloud/user/impl.py index 1a42e53f03555d..3fe3705514cd7e 100644 --- a/src/sentry/services/hybrid_cloud/user/impl.py +++ b/src/sentry/services/hybrid_cloud/user/impl.py @@ -71,7 +71,7 @@ def get_many_ids(self, *, filter: UserFilterArgs) -> list[int]: return self._FQ.get_many_ids(filter) def get_many_profiles(self, *, filter: UserFilterArgs) -> list[RpcUserProfile]: - users = self._FQ._query_many(filter, ids_only=True) + users = self._FQ.query_many(filter, select_related=False) return [serialize_rpc_user_profile(user) for user in users] def get_many_by_email( @@ -325,8 +325,8 @@ def apply_filters(self, query: QuerySet[User], filters: UserFilterArgs) -> Query return query - def base_query(self, ids_only: bool = False) -> QuerySet[User]: - if ids_only: + def base_query(self, select_related: bool = True) -> QuerySet[User]: + if not select_related: return User.objects return User.objects.extra( diff --git a/src/sentry/services/hybrid_cloud/user_option/impl.py b/src/sentry/services/hybrid_cloud/user_option/impl.py index ccc6f67381ed79..9c25b6a9a82798 100644 --- a/src/sentry/services/hybrid_cloud/user_option/impl.py +++ b/src/sentry/services/hybrid_cloud/user_option/impl.py @@ -56,7 +56,7 @@ def set_option( class _UserOptionFilterQuery( FilterQueryDatabaseImpl[UserOption, UserOptionFilterArgs, RpcUserOption, None] ): - def base_query(self, ids_only: bool = False) -> QuerySet[UserOption]: + def base_query(self, select_related: bool = True) -> QuerySet[UserOption]: return UserOption.objects def filter_arg_validator(self) -> Callable[[UserOptionFilterArgs], str | None]: diff --git a/src/sentry/services/hybrid_cloud/usersocialauth/impl.py b/src/sentry/services/hybrid_cloud/usersocialauth/impl.py index d08facddfdf498..05c783ecf48314 100644 --- a/src/sentry/services/hybrid_cloud/usersocialauth/impl.py +++ b/src/sentry/services/hybrid_cloud/usersocialauth/impl.py @@ -37,7 +37,7 @@ def revoke_token( """ Calls UserSocialAuth.revoke_token() on all matching results, returning the modified RpcUserSocialAuths. """ - db_auths = self._FQ._query_many(filter=filter) + db_auths = self._FQ.query_many(filter=filter) for db_auth in db_auths: db_auth.revoke_token(drop_token=drop_token) return self.get_many(filter=filter) @@ -46,7 +46,7 @@ def refresh_token(self, *, filter: UserSocialAuthFilterArgs) -> list[RpcUserSoci """ Calls UserSocialAuth.refresh_token() on all matching results, returning the modified RpcUserSocialAuths. """ - db_auths = self._FQ._query_many(filter=filter) + db_auths = self._FQ.query_many(filter=filter) for db_auth in db_auths: db_auth.refresh_token() return self.get_many(filter=filter) @@ -78,7 +78,7 @@ def apply_filters( query = query.filter(uid=filters["uid"]) return query - def base_query(self, ids_only: bool = False) -> QuerySet[UserSocialAuth]: + def base_query(self, select_related: bool = True) -> QuerySet[UserSocialAuth]: return UserSocialAuth.objects.filter() def filter_arg_validator(self) -> Callable[[UserSocialAuthFilterArgs], str | None]: From 4f7fb133e6c8076c63d200fb0c7b542287c796c2 Mon Sep 17 00:00:00 2001 From: Ryan Skonnord Date: Fri, 12 Apr 2024 16:25:54 -0700 Subject: [PATCH 4/6] Update to use get_many_profiles --- src/sentry/models/organization.py | 15 ++++++++------- src/sentry/services/hybrid_cloud/user/model.py | 6 ------ 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/src/sentry/models/organization.py b/src/sentry/models/organization.py index f6503f3e6cfb08..fee8fe5f980dba 100644 --- a/src/sentry/models/organization.py +++ b/src/sentry/models/organization.py @@ -37,7 +37,7 @@ from sentry.roles.manager import Role from sentry.services.hybrid_cloud.notifications import notifications_service from sentry.services.hybrid_cloud.organization_mapping import organization_mapping_service -from sentry.services.hybrid_cloud.user import RpcUser, RpcUserContact +from sentry.services.hybrid_cloud.user import RpcUser, RpcUserProfile from sentry.services.hybrid_cloud.user.service import user_service from sentry.types.organization import OrganizationAbsoluteUrlMixin from sentry.utils.http import is_using_customer_domain @@ -357,18 +357,19 @@ def _get_bulk_owner_ids(cls, organizations: Collection[Organization]) -> dict[in return owner_id_table @classmethod - def get_bulk_owner_contacts( + def get_bulk_owner_profiles( cls, organizations: Collection[Organization] - ) -> dict[int, RpcUserContact]: + ) -> dict[int, RpcUserProfile]: owner_id_table = cls._get_bulk_owner_ids(organizations) + owner_ids = list(owner_id_table.values()) - contacts = user_service.get_bulk_contact_info(user_ids=list(owner_id_table.values())) - contact_table = {c.id: c for c in contacts} + profiles = user_service.get_many_profiles(filter=dict(user_ids=owner_ids)) + profile_table = {c.id: c for c in profiles} return { - org_id: contact_table[user_id] + org_id: profile_table[user_id] for (org_id, user_id) in owner_id_table.items() - if user_id in contact_table + if user_id in profile_table } def has_single_owner(self): diff --git a/src/sentry/services/hybrid_cloud/user/model.py b/src/sentry/services/hybrid_cloud/user/model.py index ba7db765887e62..92d88342f1ce73 100644 --- a/src/sentry/services/hybrid_cloud/user/model.py +++ b/src/sentry/services/hybrid_cloud/user/model.py @@ -25,12 +25,6 @@ class RpcUserEmail(RpcModel): is_verified: bool = False -class RpcUserContact(RpcModel): - id: int = -1 - name: str = "" - email: str = "" - - class RpcAuthenticator(RpcModel): id: int = 0 user_id: int = -1 From 7f02dc60adb44ccc912a8ad81fa84be30d87ffd6 Mon Sep 17 00:00:00 2001 From: Ryan Skonnord Date: Fri, 12 Apr 2024 16:28:23 -0700 Subject: [PATCH 5/6] Add docstrings --- src/sentry/models/organization.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/sentry/models/organization.py b/src/sentry/models/organization.py index fee8fe5f980dba..9e4b7305ecd4ca 100644 --- a/src/sentry/models/organization.py +++ b/src/sentry/models/organization.py @@ -331,6 +331,10 @@ def default_owner_id(self): @classmethod def _get_bulk_owner_ids(cls, organizations: Collection[Organization]) -> dict[int, int]: + """Find user IDs of the default owners of multiple organization. + + The returned table maps organization ID to user ID. + """ from sentry.models.organizationmember import OrganizationMember owner_id_table: dict[int, int] = {} @@ -360,6 +364,14 @@ def _get_bulk_owner_ids(cls, organizations: Collection[Organization]) -> dict[in def get_bulk_owner_profiles( cls, organizations: Collection[Organization] ) -> dict[int, RpcUserProfile]: + """Query for profile data of owners of multiple organizations. + + The returned table is keyed by organization ID and shows the default owner. + An organization may have multiple owners, in which case only the default + owner is shown. Organization IDs may be absent from the returned table if no + owner was found. + """ + owner_id_table = cls._get_bulk_owner_ids(organizations) owner_ids = list(owner_id_table.values()) From f394fac8577d96f17f26b40e51143ab71a917069 Mon Sep 17 00:00:00 2001 From: Ryan Skonnord Date: Fri, 12 Apr 2024 16:48:05 -0700 Subject: [PATCH 6/6] Add unit test case --- tests/sentry/models/test_organization.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/tests/sentry/models/test_organization.py b/tests/sentry/models/test_organization.py index c8805888e6aff7..83e05acfd86f19 100644 --- a/tests/sentry/models/test_organization.py +++ b/tests/sentry/models/test_organization.py @@ -31,7 +31,7 @@ from sentry.testutils.helpers.features import with_feature from sentry.testutils.hybrid_cloud import HybridCloudTestMixin from sentry.testutils.outbox import outbox_runner -from sentry.testutils.silo import assume_test_silo_mode +from sentry.testutils.silo import assume_test_silo_mode, assume_test_silo_mode_of class OrganizationTest(TestCase, HybridCloudTestMixin): @@ -418,6 +418,20 @@ def test_absolute_url_with_customer_domain(self): url = org.absolute_url("/organizations/acme/issues/", query="?project=123", fragment="#ref") assert url == "http://acme.testserver/issues/?project=123#ref" + def test_get_bulk_owner_profiles(self): + u1, u2, u3 = (self.create_user() for _ in range(3)) + o1, o2, o3 = (self.create_organization(owner=u) for u in (u1, u2, u3)) + o2.get_default_owner() # populate _default_owner + with assume_test_silo_mode_of(User): + u3.delete() + + bulk_owner_profiles = Organization.get_bulk_owner_profiles([o1, o2, o3]) + assert set(bulk_owner_profiles.keys()) == {o1.id, o2.id} + assert bulk_owner_profiles[o1.id].id == u1.id + assert bulk_owner_profiles[o2.id].id == u2.id + assert bulk_owner_profiles[o2.id].name == u2.name + assert bulk_owner_profiles[o2.id].email == u2.email + class OrganizationDeletionTest(TestCase): def add_org_notification_settings(self, org: Organization, user: User):