Skip to content

feat: Migrate organizations list to control #92442

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 16 additions & 114 deletions src/sentry/api/endpoints/organization_index.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -24,21 +22,16 @@
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,
PostProvisionOptions,
)
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__)

Expand Down Expand Up @@ -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(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another wrinkle we'll run into with having org list be available on control is that we don't have a way for an endpoint to be partially in control and partially in region. The other methods on this endpoint need to stay in the region because they mutate the organization. Django might not like having two endpoints bound to the same URL as well.

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:
"""
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

broken out into its own module just because the giant impl file is insanity

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the API would call this RPC method, and if its fails remotely (e.g. on control), could try/catch onto calling local?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the API would call this RPC method, and if its fails remotely (e.g. on control), could try/catch onto calling local?

That isn't typically how RPC services work. Instead they choose a mode (rpc proxy or local) based on the silo mode that the application is deployed in.

*,
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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this will work as RPC services need to serialize their return values into JSON via pydantic models.


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"))
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

confirm: are projects on control? i think they should be

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Projects are not available in Control Silo:

class Project(Model):

Similarly, organizations are not fully available in Control Silo either:

class Organization(ReplicatedRegionModel):

We do have a thin replicated version of organizations in Control though (OrganizationMapping)

I don't have the full context on that particular decision, though @markstory could probably weigh in on this.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Projects are not available in Control Silo:

Projects are organization scoped, and needed to stay in the region for existing transactions and application logic to work. Because all of this logic will need to be region scoped (because of organization) you'll also be able to use projects.

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,
},
}
Loading