From b79c998695da72d8b6126ac335a73b2d1339ac50 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Wed, 28 May 2025 14:17:24 -0700 Subject: [PATCH] feat: Migrate organizations list to control --- .../api/endpoints/organization_index.py | 130 ++------------- .../methods/list_organizations.py | 151 ++++++++++++++++++ 2 files changed, 167 insertions(+), 114 deletions(-) create mode 100644 src/sentry/organizations/services/organization/methods/list_organizations.py diff --git a/src/sentry/api/endpoints/organization_index.py b/src/sentry/api/endpoints/organization_index.py index 8626a0b2cd97b3..0cdbad09cef4dc 100644 --- a/src/sentry/api/endpoints/organization_index.py +++ b/src/sentry/api/endpoints/organization_index.py @@ -2,7 +2,6 @@ from django.conf import settings from django.db import IntegrityError -from django.db.models import Count, Q from drf_spectacular.utils import extend_schema from rest_framework import serializers, status from rest_framework.request import Request @@ -13,7 +12,6 @@ from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import Endpoint, region_silo_endpoint from sentry.api.bases.organization import OrganizationPermission -from sentry.api.paginator import DateTimePaginator, OffsetPaginator from sentry.api.serializers import serialize from sentry.api.serializers.models.organization import ( BaseOrganizationSerializer, @@ -24,12 +22,9 @@ from sentry.apidocs.parameters import CursorQueryParam, OrganizationParams from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.auth.superuser import is_active_superuser -from sentry.db.models.query import in_iexact from sentry.hybridcloud.rpc import IDEMPOTENCY_KEY_LENGTH -from sentry.models.organization import Organization, OrganizationStatus -from sentry.models.organizationmember import OrganizationMember -from sentry.models.projectplatform import ProjectPlatform -from sentry.search.utils import tokenize_query +from sentry.models.organization import Organization +from sentry.organizations.services.organization import organization_service from sentry.services.organization import ( OrganizationOptions, OrganizationProvisioningOptions, @@ -37,8 +32,6 @@ ) from sentry.services.organization.provisioning import organization_provisioning_service from sentry.signals import org_setup_complete, terms_accepted -from sentry.users.services.user.service import user_service -from sentry.utils.pagination_factory import PaginatorLike logger = logging.getLogger(__name__) @@ -95,113 +88,22 @@ def get(self, request: Request) -> Response: Return a list of organizations available to the authenticated session in a region. This is particularly useful for requests with a user bound context. For API key-based requests this will only return the organization that belongs to the key. """ - owner_only = request.GET.get("owner") in ("1", "true") - - queryset = Organization.objects.distinct() - - if request.auth and not request.user.is_authenticated: - if hasattr(request.auth, "project"): - queryset = queryset.filter(id=request.auth.project.organization_id) - elif request.auth.organization_id is not None: - queryset = queryset.filter(id=request.auth.organization_id) - - elif owner_only and request.user.is_authenticated: - # This is used when closing an account - - # also fetches organizations in which you are a member of an owner team - queryset = Organization.objects.get_organizations_where_user_is_owner( - user_id=request.user.id - ) - org_results = [] - for org in sorted(queryset, key=lambda x: x.name): - # O(N) query - org_results.append( - {"organization": serialize(org), "singleOwner": org.has_single_owner()} - ) - - return Response(org_results) - - elif not (is_active_superuser(request) and request.GET.get("show") == "all"): - queryset = queryset.filter( - id__in=OrganizationMember.objects.filter(user_id=request.user.id).values( - "organization" - ) - ) - if request.auth and request.auth.organization_id is not None and queryset.count() > 1: - # If a token is limited to one organization, this endpoint should only return that one organization - queryset = queryset.filter(id=request.auth.organization_id) - - query = request.GET.get("query") - if query: - tokens = tokenize_query(query) - for key, value in tokens.items(): - if key == "query": - query_value = " ".join(value) - user_ids = { - u.id - for u in user_service.get_many_by_email( - emails=[query_value], is_verified=False - ) - } - queryset = queryset.filter( - Q(name__icontains=query_value) - | Q(slug__icontains=query_value) - | Q(member_set__user_id__in=user_ids) - ) - elif key == "slug": - queryset = queryset.filter(in_iexact("slug", value)) - elif key == "email": - user_ids = { - u.id - for u in user_service.get_many_by_email(emails=value, is_verified=False) - } - queryset = queryset.filter(Q(member_set__user_id__in=user_ids)) - elif key == "platform": - queryset = queryset.filter( - project__in=ProjectPlatform.objects.filter(platform__in=value).values( - "project_id" - ) - ) - elif key == "id": - queryset = queryset.filter(id__in=value) - elif key == "status": - try: - queryset = queryset.filter( - status__in=[OrganizationStatus[v.upper()] for v in value] - ) - except KeyError: - queryset = queryset.none() - elif key == "member_id": - queryset = queryset.filter( - id__in=OrganizationMember.objects.filter(id__in=value).values( - "organization" - ) - ) - else: - queryset = queryset.none() - - sort_by = request.GET.get("sortBy") - paginator_cls: type[PaginatorLike] - if sort_by == "members": - queryset = queryset.annotate(member_count=Count("member_set")) - order_by = "-member_count" - paginator_cls = OffsetPaginator - elif sort_by == "projects": - queryset = queryset.annotate(project_count=Count("project")) - order_by = "-project_count" - paginator_cls = OffsetPaginator - else: - order_by = "-date_added" - paginator_cls = DateTimePaginator - - return self.paginate( - request=request, - queryset=queryset, - order_by=order_by, - on_results=lambda x: serialize(x, request.user), - paginator_cls=paginator_cls, + result = organization_service.list_organizations( + actor_user_id=request.user.id, + owner_only=request.GET.get("owner") in ("1", "true"), + query=request.GET.get("query"), + sort_by=request.GET.get("sortBy"), + show=request.GET.get("show"), + actor_organization_id=request.auth.organization_id if request.auth else None, + actor_project_id=request.auth.project_id if request.auth else None, + actor_is_active_superuser=is_active_superuser(request), + as_user=request.user, ) + response = Response(result["results"]) + self.add_cursor_headers(request, response, result["cursor"]) + return response + # XXX: endpoint useless for end-users as it needs user context. def post(self, request: Request) -> Response: """ diff --git a/src/sentry/organizations/services/organization/methods/list_organizations.py b/src/sentry/organizations/services/organization/methods/list_organizations.py new file mode 100644 index 00000000000000..8b07ce3571e7c1 --- /dev/null +++ b/src/sentry/organizations/services/organization/methods/list_organizations.py @@ -0,0 +1,151 @@ +import logging +from enum import Enum + +from django.db.models import Count, Q +from rest_framework.response import Response + +from sentry.api.paginator import DateTimePaginator, OffsetPaginator +from sentry.api.serializers import serialize +from sentry.db.models.query import in_iexact +from sentry.models.organization import Organization, OrganizationStatus +from sentry.models.organizationmember import OrganizationMember +from sentry.models.projectplatform import ProjectPlatform +from sentry.search.utils import tokenize_query +from sentry.users.services.user.service import user_service +from sentry.utils.cursors import Cursor +from sentry.utils.pagination_factory import PaginatorLike + +logger = logging.getLogger(__name__) + + +class SortBy(Enum): + MEMBERS = "members" + PROJECTS = "projects" + DATE_ADDED = "date" + + +class Show(Enum): + ALL = "all" + + +def list_organizations( + *, + actor_user_id: int, + owner_only: bool = False, + query: str | None = None, + show: Show | None = None, + sort_by: SortBy | None = "date", + cursor: Cursor | None = None, + per_page: int = 100, + # actor specific stuff + actor_is_active_superuser: bool = False, + actor_organization_id: int | None = None, + actor_project_id: int | None = None, +): + """ + Return a list of organizations available to the authenticated session in a region. + This is particularly useful for requests with a user bound context. For API key-based requests this will only return the organization that belongs to the key. + """ + queryset = Organization.objects.distinct() + + if actor_project_id is not None: + queryset = queryset.filter(id=actor_project_id.organization_id) + elif actor_organization_id is not None: + queryset = queryset.filter(id=actor_organization_id) + + if owner_only: + # This is used when closing an account + # also fetches organizations in which you are a member of an owner team + queryset = Organization.objects.get_organizations_where_user_is_owner(user_id=actor_user_id) + org_results = [] + for org in sorted(queryset, key=lambda x: x.name): + # O(N) query + org_results.append( + {"organization": serialize(org), "singleOwner": org.has_single_owner()} + ) + + return Response(org_results) + + elif not (actor_is_active_superuser and show == "all"): + queryset = queryset.filter( + id__in=OrganizationMember.objects.filter(user_id=actor_user_id).values("organization") + ) + if actor_organization_id is not None and queryset.count() > 1: + # If a token is limited to one organization, this endpoint should only return that one organization + queryset = queryset.filter(id=actor_organization_id) + + if query: + tokens = tokenize_query(query) + for key, value in tokens.items(): + if key == "query": + query_value = " ".join(value) + user_ids = { + u.id + for u in user_service.get_many_by_email(emails=[query_value], is_verified=False) + } + queryset = queryset.filter( + Q(name__icontains=query_value) + | Q(slug__icontains=query_value) + | Q(member_set__user_id__in=user_ids) + ) + elif key == "slug": + queryset = queryset.filter(in_iexact("slug", value)) + elif key == "email": + user_ids = { + u.id for u in user_service.get_many_by_email(emails=value, is_verified=False) + } + queryset = queryset.filter(Q(member_set__user_id__in=user_ids)) + elif key == "platform": + queryset = queryset.filter( + project__in=ProjectPlatform.objects.filter(platform__in=value).values( + "project_id" + ) + ) + elif key == "id": + queryset = queryset.filter(id__in=value) + elif key == "status": + try: + queryset = queryset.filter( + status__in=[OrganizationStatus[v.upper()] for v in value] + ) + except KeyError: + queryset = queryset.none() + elif key == "member_id": + queryset = queryset.filter( + id__in=OrganizationMember.objects.filter(id__in=value).values("organization") + ) + else: + queryset = queryset.none() + + paginator_cls: type[PaginatorLike] + if sort_by == "members": + queryset = queryset.annotate(member_count=Count("member_set")) + order_by = "-member_count" + paginator_cls = OffsetPaginator + elif sort_by == "projects": + queryset = queryset.annotate(project_count=Count("project")) + order_by = "-project_count" + paginator_cls = OffsetPaginator + else: + order_by = "-date_added" + paginator_cls = DateTimePaginator + + paginator = paginator_cls() + cursor_result = paginator.get_result( + limit=per_page, + cursor=cursor, + order_by=order_by, + ) + + # TODO: missing user to serialize + results = [serialize(org) for org in cursor_result.results] + + return { + "results": results, + "cursor": { + "next": cursor_result.next, + "prev": cursor_result.prev, + "hits": cursor_result.hits, + "max_hits": cursor_result.max_hits, + }, + }