From f98a882869604d7cc9ebf9f4b5636100d1790a85 Mon Sep 17 00:00:00 2001 From: Michael Sun <55160142+MichaelSun48@users.noreply.github.com> Date: Fri, 14 Jun 2024 13:35:52 -0700 Subject: [PATCH] feat(custom-views): add custom views get endpoint (#71942) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This endpoint creates the `GET` `organizations//group-search-views/` Endpoint along with some tests. This endpoint will be responsible for fetching a user's custom views within an organization. ⚠️ This PR is dependent on #71731 being ~~merged~~ run in production, and this branch will need to be rebased upon that happening --- .../api/serializers/models/groupsearchview.py | 29 +++++ src/sentry/api/urls.py | 6 + src/sentry/issues/endpoints/__init__.py | 2 + .../organization_group_search_views.py | 74 ++++++++++++ src/sentry/models/savedsearch.py | 8 +- .../test_organization_group_search_views.py | 105 ++++++++++++++++++ 6 files changed, 222 insertions(+), 2 deletions(-) create mode 100644 src/sentry/api/serializers/models/groupsearchview.py create mode 100644 src/sentry/issues/endpoints/organization_group_search_views.py create mode 100644 tests/sentry/issues/endpoints/test_organization_group_search_views.py diff --git a/src/sentry/api/serializers/models/groupsearchview.py b/src/sentry/api/serializers/models/groupsearchview.py new file mode 100644 index 00000000000000..ff97c11e9ac784 --- /dev/null +++ b/src/sentry/api/serializers/models/groupsearchview.py @@ -0,0 +1,29 @@ +from typing import TypedDict + +from sentry.api.serializers import Serializer, register +from sentry.models.groupsearchview import GroupSearchView +from sentry.models.savedsearch import SORT_LITERALS + + +class GroupSearchViewSerializerResponse(TypedDict): + id: str + name: str + query: str + querySort: SORT_LITERALS + position: int + dateCreated: str | None + dateUpdated: str | None + + +@register(GroupSearchView) +class GroupSearchViewSerializer(Serializer): + def serialize(self, obj, attrs, user, **kwargs) -> GroupSearchViewSerializerResponse: + return { + "id": str(obj.id), + "name": obj.name, + "query": obj.query, + "querySort": obj.query_sort, + "position": obj.position, + "dateCreated": obj.date_added, + "dateUpdated": obj.date_updated, + } diff --git a/src/sentry/api/urls.py b/src/sentry/api/urls.py index 92a2d9f2f40645..91747954df2593 100644 --- a/src/sentry/api/urls.py +++ b/src/sentry/api/urls.py @@ -115,6 +115,7 @@ GroupEventsEndpoint, OrganizationActivityEndpoint, OrganizationGroupIndexEndpoint, + OrganizationGroupSearchViewsEndpoint, OrganizationReleasePreviousCommitsEndpoint, OrganizationSearchesEndpoint, ProjectStacktraceLinkEndpoint, @@ -1700,6 +1701,11 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]: ), name="sentry-api-0-organization-monitor-check-in-attachment", ), + re_path( + r"^(?P[^\/]+)/group-search-views/$", + OrganizationGroupSearchViewsEndpoint.as_view(), + name="sentry-api-0-organization-group-search-views", + ), # Pinned and saved search re_path( r"^(?P[^\/]+)/pinned-searches/$", diff --git a/src/sentry/issues/endpoints/__init__.py b/src/sentry/issues/endpoints/__init__.py index d8f10edf83a16b..811a8ded212ef7 100644 --- a/src/sentry/issues/endpoints/__init__.py +++ b/src/sentry/issues/endpoints/__init__.py @@ -2,6 +2,7 @@ from .group_events import GroupEventsEndpoint from .organization_activity import OrganizationActivityEndpoint from .organization_group_index import OrganizationGroupIndexEndpoint +from .organization_group_search_views import OrganizationGroupSearchViewsEndpoint from .organization_release_previous_commits import OrganizationReleasePreviousCommitsEndpoint from .organization_searches import OrganizationSearchesEndpoint from .project_stacktrace_link import ProjectStacktraceLinkEndpoint @@ -12,6 +13,7 @@ "GroupEventsEndpoint", "OrganizationActivityEndpoint", "OrganizationGroupIndexEndpoint", + "OrganizationGroupSearchViewsEndpoint", "OrganizationReleasePreviousCommitsEndpoint", "OrganizationSearchesEndpoint", "ProjectStacktraceLinkEndpoint", diff --git a/src/sentry/issues/endpoints/organization_group_search_views.py b/src/sentry/issues/endpoints/organization_group_search_views.py new file mode 100644 index 00000000000000..ceefe5a914124f --- /dev/null +++ b/src/sentry/issues/endpoints/organization_group_search_views.py @@ -0,0 +1,74 @@ +from rest_framework import status +from rest_framework.request import Request +from rest_framework.response import Response + +from sentry import features +from sentry.api.api_owners import ApiOwner +from sentry.api.api_publish_status import ApiPublishStatus +from sentry.api.base import region_silo_endpoint +from sentry.api.bases.organization import OrganizationEndpoint, OrganizationPermission +from sentry.api.paginator import SequencePaginator +from sentry.api.serializers import serialize +from sentry.api.serializers.models.groupsearchview import ( + GroupSearchViewSerializer, + GroupSearchViewSerializerResponse, +) +from sentry.models.groupsearchview import GroupSearchView +from sentry.models.organization import Organization +from sentry.models.savedsearch import SortOptions + +DEFAULT_VIEWS: list[GroupSearchViewSerializerResponse] = [ + { + "id": "", + "name": "Prioritized", + "query": "is:unresolved issue.priority:[high, medium]", + "querySort": SortOptions.DATE.value, + "position": 0, + "dateCreated": None, + "dateUpdated": None, + } +] + + +class MemberPermission(OrganizationPermission): + scope_map = { + "GET": ["member:read", "member:write"], + } + + +@region_silo_endpoint +class OrganizationGroupSearchViewsEndpoint(OrganizationEndpoint): + publish_status = { + "GET": ApiPublishStatus.EXPERIMENTAL, + } + owner = ApiOwner.ISSUES + permission_classes = (MemberPermission,) + + def get(self, request: Request, organization: Organization) -> Response: + """ + List the current organization member's custom views + ````````````````````````````````````````` + + Retrieve a list of custom views for the current organization member. + """ + if not features.has("organizations:issue-stream-custom-views", organization): + return Response(status=status.HTTP_404_NOT_FOUND) + + query = GroupSearchView.objects.filter(organization=organization, user_id=request.user.id) + + # Return only the prioritized view if user has no custom views yet + if not query.exists(): + return self.paginate( + request=request, + paginator=SequencePaginator( + [(idx, view) for idx, view in enumerate(DEFAULT_VIEWS)] + ), + on_results=lambda results: serialize(results, request.user), + ) + + return self.paginate( + request=request, + queryset=query, + order_by="position", + on_results=lambda x: serialize(x, request.user, serializer=GroupSearchViewSerializer()), + ) diff --git a/src/sentry/models/savedsearch.py b/src/sentry/models/savedsearch.py index 46df52d80d933d..40d6cc387e5caa 100644 --- a/src/sentry/models/savedsearch.py +++ b/src/sentry/models/savedsearch.py @@ -1,4 +1,5 @@ -from typing import Any +from enum import StrEnum +from typing import Any, Literal from django.db import models from django.db.models import Q, UniqueConstraint @@ -14,7 +15,7 @@ from sentry.models.search_common import SearchType -class SortOptions: +class SortOptions(StrEnum): DATE = "date" NEW = "new" TRENDS = "trends" @@ -34,6 +35,9 @@ def as_choices(cls): ) +SORT_LITERALS = Literal["date", "new", "trends", "freq", "user", "inbox"] + + class Visibility: ORGANIZATION = "organization" OWNER = "owner" diff --git a/tests/sentry/issues/endpoints/test_organization_group_search_views.py b/tests/sentry/issues/endpoints/test_organization_group_search_views.py new file mode 100644 index 00000000000000..609597f8ad5a10 --- /dev/null +++ b/tests/sentry/issues/endpoints/test_organization_group_search_views.py @@ -0,0 +1,105 @@ +from sentry.api.serializers.base import serialize +from sentry.models.groupsearchview import GroupSearchView +from sentry.testutils.cases import APITestCase +from sentry.testutils.helpers.features import with_feature + + +class OrganizationGroupSearchViewsTest(APITestCase): + endpoint = "sentry-api-0-organization-group-search-views" + method = "get" + + def create_base_data(self): + user_1 = self.user + self.user_2 = self.create_user() + self.user_3 = self.create_user() + + self.create_member(organization=self.organization, user=self.user_2) + self.create_member(organization=self.organization, user=self.user_3) + + first_custom_view_user_one = GroupSearchView.objects.create( + name="Custom View One", + organization=self.organization, + user_id=user_1.id, + query="is:unresolved", + query_sort="date", + position=0, + ) + + # This is out of order to test that the endpoint returns the views in the correct order + third_custom_view_user_one = GroupSearchView.objects.create( + name="Custom View Three", + organization=self.organization, + user_id=user_1.id, + query="is:ignored", + query_sort="freq", + position=2, + ) + + second_custom_view_user_one = GroupSearchView.objects.create( + name="Custom View Two", + organization=self.organization, + user_id=user_1.id, + query="is:resolved", + query_sort="new", + position=1, + ) + + first_custom_view_user_two = GroupSearchView.objects.create( + name="Custom View One", + organization=self.organization, + user_id=self.user_2.id, + query="is:unresolved", + query_sort="date", + position=0, + ) + + second_custom_view_user_two = GroupSearchView.objects.create( + name="Custom View Two", + organization=self.organization, + user_id=self.user_2.id, + query="is:resolved", + query_sort="new", + position=1, + ) + + return { + "user_one_views": [ + first_custom_view_user_one, + second_custom_view_user_one, + third_custom_view_user_one, + ], + "user_two_views": [first_custom_view_user_two, second_custom_view_user_two], + } + + @with_feature({"organizations:issue-stream-custom-views": True}) + def test_get_user_one_custom_views(self): + objs = self.create_base_data() + + self.login_as(user=self.user) + response = self.get_success_response(self.organization.slug) + + assert response.data == serialize(objs["user_one_views"]) + + @with_feature({"organizations:issue-stream-custom-views": True}) + def test_get_user_two_custom_views(self): + objs = self.create_base_data() + + self.login_as(user=self.user_2) + response = self.get_success_response(self.organization.slug) + + assert response.data == serialize(objs["user_two_views"]) + + @with_feature({"organizations:issue-stream-custom-views": True}) + def test_get_default_views(self): + self.create_base_data() + + self.login_as(user=self.user_3) + response = self.get_success_response(self.organization.slug) + assert len(response.data) == 1 + + view = response.data[0] + + assert view["name"] == "Prioritized" + assert view["query"] == "is:unresolved issue.priority:[high, medium]" + assert view["querySort"] == "date" + assert view["position"] == 0