Browse Source

feat(api): Add api to pin/unpin a query for a user/organization (SEN-400)

Pin just accepts a query and pins it as the default for a user for a given org. If a pin already
exists then we just overwrite.

Unpin will remove the pin for that user for an org if one exists. If none exist, just returns a
success.
Dan Fuller 6 years ago
parent
commit
f5004534b0

+ 7 - 0
src/sentry/api/bases/organization.py

@@ -128,6 +128,13 @@ class OrganizationUserReportsPermission(OrganizationPermission):
     }
 
 
+class OrganizationPinnedSearchPermission(OrganizationPermission):
+    scope_map = {
+        'PUT': ['org:read', 'org:write', 'org:admin'],
+        'DELETE': ['org:read', 'org:write', 'org:admin'],
+    }
+
+
 class OrganizationEndpoint(Endpoint):
     permission_classes = (OrganizationPermission, )
 

+ 63 - 0
src/sentry/api/endpoints/organization_pinned_searches.py

@@ -0,0 +1,63 @@
+from __future__ import absolute_import
+
+from rest_framework import serializers
+from rest_framework.response import Response
+from django.utils import six
+
+from sentry.api.bases.organization import (
+    OrganizationEndpoint,
+    OrganizationPinnedSearchPermission,
+)
+from sentry.api.serializers import serialize
+from sentry.models import SavedSearch
+from sentry.models.search_common import SearchType
+
+
+class OrganizationSearchSerializer(serializers.Serializer):
+    type = serializers.IntegerField(required=True)
+    query = serializers.CharField(required=True)
+
+    def validate_type(self, attrs, source):
+        try:
+            SearchType(attrs[source])
+        except ValueError as e:
+            raise serializers.ValidationError(six.text_type(e))
+        return attrs
+
+
+class OrganizationPinnedSearchEndpoint(OrganizationEndpoint):
+    permission_classes = (OrganizationPinnedSearchPermission, )
+
+    def put(self, request, organization):
+        serializer = OrganizationSearchSerializer(data=request.DATA)
+
+        if serializer.is_valid():
+            result = serializer.object
+            SavedSearch.objects.create_or_update(
+                organization=organization,
+                owner=request.user,
+                type=result['type'],
+                values={'query': result['query']},
+            )
+            pinned_search = SavedSearch.objects.get(
+                organization=organization,
+                owner=request.user,
+                type=result['type'],
+            )
+            return Response(serialize(pinned_search, request.user), status=201)
+        return Response(serializer.errors, status=400)
+
+    def delete(self, request, organization):
+        try:
+            search_type = SearchType(int(request.DATA.get('type', 0)))
+        except ValueError as e:
+            return Response(
+                {'detail': 'Invalid input for `type`. Error: %s' % six.text_type(e)},
+                status=400,
+            )
+        SavedSearch.objects.filter(
+            organization=organization,
+            owner=request.user,
+            type=search_type.value,
+        ).delete()
+        return Response(status=204)

+ 6 - 0
src/sentry/api/urls.py

@@ -90,6 +90,7 @@ from .endpoints.organization_member_team_details import OrganizationMemberTeamDe
 from .endpoints.organization_monitors import OrganizationMonitorsEndpoint
 from .endpoints.organization_onboarding_tasks import OrganizationOnboardingTaskEndpoint
 from .endpoints.organization_index import OrganizationIndexEndpoint
+from .endpoints.organization_pinned_searches import OrganizationPinnedSearchEndpoint
 from .endpoints.organization_plugins import OrganizationPluginsEndpoint
 from .endpoints.organization_processingissues import OrganizationProcessingIssuesEndpoint
 from .endpoints.organization_projects import OrganizationProjectsEndpoint
@@ -587,6 +588,11 @@ urlpatterns = patterns(
         r'^organizations/(?P<organization_slug>[^\/]+)/monitors/$',
         OrganizationMonitorsEndpoint.as_view(),
     ),
+    url(
+        r'^organizations/(?P<organization_slug>[^\/]+)/pinned-searches/$',
+        OrganizationPinnedSearchEndpoint.as_view(),
+        name='sentry-api-0-organization-pinned-searches'
+    ),
     url(
         r'^organizations/(?P<organization_slug>[^\/]+)/recent-searches/$',
         OrganizationRecentSearchesEndpoint.as_view(),

+ 2 - 1
src/sentry/testutils/cases.py

@@ -442,8 +442,9 @@ class APITestCase(BaseTestCase, BaseAPITestCase):
         )
 
     def get_valid_response(self, *args, **params):
+        status_code = params.pop('status_code', 200)
         resp = self.get_response(*args, **params)
-        assert resp.status_code == 200, resp.content
+        assert resp.status_code == status_code, resp.content
         return resp
 
 

+ 127 - 0
tests/sentry/api/endpoints/test_organization_pinned_searches.py

@@ -0,0 +1,127 @@
+from __future__ import absolute_import
+
+from exam import fixture
+
+from sentry.models import SavedSearch
+from sentry.models.search_common import SearchType
+from sentry.testutils import APITestCase
+
+
+class CreateOrganizationPinnedSearchTest(APITestCase):
+    endpoint = 'sentry-api-0-organization-pinned-searches'
+    method = 'put'
+
+    @fixture
+    def member(self):
+        user = self.create_user('test@test.com')
+        self.create_member(organization=self.organization, user=user)
+        return user
+
+    def get_response(self, *args, **params):
+        return super(CreateOrganizationPinnedSearchTest, self).get_response(
+            *((self.organization.slug,) + args),
+            **params
+        )
+
+    def test(self):
+        self.login_as(self.member)
+        query = 'test'
+        search_type = SearchType.ISSUE.value
+        self.get_valid_response(type=search_type, query=query, status_code=201)
+        assert SavedSearch.objects.filter(
+            organization=self.organization,
+            owner=self.member,
+            type=search_type,
+            query=query,
+        ).exists()
+
+        query = 'test_2'
+        self.get_valid_response(type=search_type, query=query, status_code=201)
+        assert SavedSearch.objects.filter(
+            organization=self.organization,
+            owner=self.member,
+            type=search_type,
+            query=query,
+        ).exists()
+
+        self.get_valid_response(type=SearchType.EVENT.value, query=query, status_code=201)
+        assert SavedSearch.objects.filter(
+            organization=self.organization,
+            owner=self.member,
+            type=search_type,
+            query=query,
+        ).exists()
+        assert SavedSearch.objects.filter(
+            organization=self.organization,
+            owner=self.member,
+            type=SearchType.EVENT.value,
+            query=query,
+        ).exists()
+
+        self.login_as(self.user)
+        self.get_valid_response(type=search_type, query=query, status_code=201)
+        assert SavedSearch.objects.filter(
+            organization=self.organization,
+            owner=self.member,
+            type=search_type,
+            query=query,
+        ).exists()
+        assert SavedSearch.objects.filter(
+            organization=self.organization,
+            owner=self.user,
+            type=search_type,
+            query=query,
+        ).exists()
+
+    def test_invalid_type(self):
+        self.login_as(self.member)
+        resp = self.get_response(type=55, query='test', status_code=201)
+        assert resp.status_code == 400
+        assert 'not a valid SearchType' in resp.data['type'][0]
+
+
+class DeleteOrganizationPinnedSearchTest(APITestCase):
+    endpoint = 'sentry-api-0-organization-pinned-searches'
+    method = 'delete'
+
+    @fixture
+    def member(self):
+        user = self.create_user('test@test.com')
+        self.create_member(organization=self.organization, user=user)
+        return user
+
+    def get_response(self, *args, **params):
+        return super(DeleteOrganizationPinnedSearchTest, self).get_response(
+            *((self.organization.slug,) + args),
+            **params
+        )
+
+    def test(self):
+        saved_search = SavedSearch.objects.create(
+            organization=self.organization,
+            owner=self.member,
+            type=SearchType.ISSUE.value,
+            query='wat',
+        )
+        other_saved_search = SavedSearch.objects.create(
+            organization=self.organization,
+            owner=self.user,
+            type=SearchType.ISSUE.value,
+            query='wat',
+        )
+
+        self.login_as(self.member)
+        self.get_valid_response(type=saved_search.type, status_code=204)
+        assert not SavedSearch.objects.filter(id=saved_search.id).exists()
+        assert SavedSearch.objects.filter(id=other_saved_search.id).exists()
+
+        # Test calling mulitple times works ok, doesn't cause other rows to
+        # delete
+        self.get_valid_response(type=saved_search.type, status_code=204)
+        assert SavedSearch.objects.filter(id=other_saved_search.id).exists()
+
+    def test_invalid_type(self):
+        self.login_as(self.member)
+        resp = self.get_response(type=55)
+        assert resp.status_code == 400
+        assert 'Invalid input for `type`' in resp.data['detail']