Browse Source

feat(api): Implement endpoints to create/delete org level saved searches (SEN-399)

Allows org owners and admins to create/delete org wide saved searches
Dan Fuller 6 years ago
parent
commit
6f2e2c90f8

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

@@ -135,6 +135,15 @@ class OrganizationPinnedSearchPermission(OrganizationPermission):
     }
 
 
+class OrganizationSearchPermission(OrganizationPermission):
+    scope_map = {
+        'GET': ['org:read', 'org:write', 'org:admin'],
+        'POST': ['org:write', 'org:admin'],
+        'PUT': ['org:write', 'org:admin'],
+        'DELETE': ['org:write', 'org:admin'],
+    }
+
+
 class OrganizationEndpoint(Endpoint):
     permission_classes = (OrganizationPermission, )
 

+ 35 - 0
src/sentry/api/endpoints/organization_search_details.py

@@ -0,0 +1,35 @@
+from __future__ import absolute_import
+
+from rest_framework.response import Response
+
+from sentry.api.bases.organization import (
+    OrganizationEndpoint,
+    OrganizationSearchPermission,
+)
+from sentry.api.exceptions import ResourceDoesNotExist
+from sentry.models import SavedSearch
+
+
+class OrganizationSearchDetailsEndpoint(OrganizationEndpoint):
+    permission_classes = (OrganizationSearchPermission, )
+
+    def delete(self, request, organization, search_id):
+        """
+        Delete a saved search
+
+        Permanently remove a saved search.
+
+            {method} {path}
+
+        """
+        try:
+            search = SavedSearch.objects.get(
+                owner__isnull=True,
+                organization=organization,
+                id=search_id,
+            )
+        except SavedSearch.DoesNotExist:
+            raise ResourceDoesNotExist
+
+        search.delete()
+        return Response(status=204)

+ 37 - 1
src/sentry/api/endpoints/organization_searches.py

@@ -1,10 +1,14 @@
 from __future__ import absolute_import
 
+from rest_framework import serializers
 from rest_framework.response import Response
 from django.db.models import Q
 from django.utils import six
 
-from sentry.api.bases.organization import OrganizationEndpoint
+from sentry.api.bases.organization import (
+    OrganizationEndpoint,
+    OrganizationSearchPermission,
+)
 from sentry.api.serializers import serialize
 from sentry.models.savedsearch import (
     DEFAULT_SAVED_SEARCH_QUERIES,
@@ -13,7 +17,14 @@ from sentry.models.savedsearch import (
 from sentry.models.search_common import SearchType
 
 
+class OrganizationSearchSerializer(serializers.Serializer):
+    type = serializers.IntegerField(required=True)
+    name = serializers.CharField(required=True)
+    query = serializers.CharField(required=True, min_length=1)
+
+
 class OrganizationSearchesEndpoint(OrganizationEndpoint):
+    permission_classes = (OrganizationSearchPermission, )
 
     def get(self, request, organization):
         """
@@ -75,3 +86,28 @@ class OrganizationSearchesEndpoint(OrganizationEndpoint):
             ).order_by('name', 'project'))
 
         return Response(serialize(results, request.user))
+
+    def post(self, request, organization):
+        serializer = OrganizationSearchSerializer(data=request.DATA)
+
+        if serializer.is_valid():
+            result = serializer.object
+            # Prevent from creating duplicate queries
+            if SavedSearch.objects.filter(
+                Q(is_global=True) | Q(organization=organization, owner__isnull=True),
+                query=result['query'],
+            ).exists():
+                return Response(
+                    {'detail': u'Query {} already exists'.format(result['query'])},
+                    status=400,
+                )
+
+            saved_search = SavedSearch.objects.create(
+                organization=organization,
+                type=result['type'],
+                name=result['name'],
+                query=result['query'],
+            )
+
+            return Response(serialize(saved_search, request.user))
+        return Response(serializer.errors, status=400)

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

@@ -108,6 +108,7 @@ from .endpoints.organization_config_integrations import OrganizationConfigIntegr
 from .endpoints.organization_config_repositories import OrganizationConfigRepositoriesEndpoint
 from .endpoints.organization_repository_commits import OrganizationRepositoryCommitsEndpoint
 from .endpoints.organization_repository_details import OrganizationRepositoryDetailsEndpoint
+from .endpoints.organization_search_details import OrganizationSearchDetailsEndpoint
 from .endpoints.organization_searches import OrganizationSearchesEndpoint
 from .endpoints.organization_sentry_apps import OrganizationSentryAppsEndpoint
 from .endpoints.organization_tagkey_values import OrganizationTagKeyValuesEndpoint
@@ -598,6 +599,11 @@ urlpatterns = patterns(
         OrganizationRecentSearchesEndpoint.as_view(),
         name='sentry-api-0-organization-recent-searches'
     ),
+    url(
+        r'^organizations/(?P<organization_slug>[^\/]+)/searches/(?P<search_id>[^\/]+)/$',
+        OrganizationSearchDetailsEndpoint.as_view(),
+        name='sentry-api-0-organization-search-details'
+    ),
     url(
         r'^organizations/(?P<organization_slug>[^\/]+)/searches/$',
         OrganizationSearchesEndpoint.as_view(),

+ 71 - 0
tests/sentry/api/endpoints/test_organization_search_details.py

@@ -0,0 +1,71 @@
+from __future__ import absolute_import
+
+from exam import fixture
+
+from sentry.models import SavedSearch
+from sentry.testutils import APITestCase
+
+
+class DeleteOrganizationSearchTest(APITestCase):
+    endpoint = 'sentry-api-0-organization-search-details'
+    method = 'delete'
+
+    def setUp(self):
+        self.login_as(user=self.user)
+
+    @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(DeleteOrganizationSearchTest, self).get_response(
+            *((self.organization.slug,) + args),
+            **params
+        )
+
+    def test_owner_can_delete_org_searches(self):
+        search = SavedSearch.objects.create(
+            organization=self.organization,
+            name='foo',
+            query='',
+        )
+        response = self.get_response(search.id)
+        assert response.status_code == 204, response.content
+        assert not SavedSearch.objects.filter(id=search.id).exists()
+
+    def test_owners_cannot_delete_searches_they_do_not_own(self):
+        search = SavedSearch.objects.create(
+            organization=self.organization,
+            name='foo',
+            query='',
+            owner=self.create_user()
+        )
+
+        response = self.get_response(search.id)
+        assert response.status_code == 404, response.content
+        assert SavedSearch.objects.filter(id=search.id).exists()
+
+    def test_owners_cannot_delete_global_searches(self):
+        search = SavedSearch.objects.create(
+            name='foo',
+            query='',
+            is_global=True,
+        )
+
+        response = self.get_response(search.id)
+        assert response.status_code == 404, response.content
+        assert SavedSearch.objects.filter(id=search.id).exists()
+
+    def test_members_cannot_delete_shared_searches(self):
+        search = SavedSearch.objects.create(
+            organization=self.organization,
+            name='foo',
+            query=''
+        )
+
+        self.login_as(user=self.member)
+        response = self.get_response(search.id)
+        assert response.status_code == 403, response.content
+        assert SavedSearch.objects.filter(id=search.id).exists()

+ 87 - 0
tests/sentry/api/endpoints/test_organization_searches.py

@@ -5,6 +5,7 @@ from exam import fixture
 
 from sentry.api.serializers import serialize
 from sentry.models import SavedSearch
+from sentry.models.search_common import SearchType
 from sentry.models.savedsearch import DEFAULT_SAVED_SEARCHES
 from sentry.testutils import APITestCase
 
@@ -140,3 +141,89 @@ class OrgLevelOrganizationSearchesListTest(APITestCase):
         pinned_query.save()
         included[0] = to_be_pinned
         self.check_results(included)
+
+
+class CreateOrganizationSearchesTest(APITestCase):
+    endpoint = 'sentry-api-0-organization-searches'
+    method = 'post'
+
+    @fixture
+    def manager(self):
+        user = self.create_user('test@test.com')
+        self.create_member(organization=self.organization, user=user, role='manager')
+        return user
+
+    @fixture
+    def member(self):
+        user = self.create_user('test@test.com')
+        self.create_member(organization=self.organization, user=user)
+        return user
+
+    def test_simple(self):
+        search_type = SearchType.ISSUE.value
+        name = 'test'
+        query = 'hello'
+        self.login_as(user=self.manager)
+        resp = self.get_valid_response(
+            self.organization.slug,
+            type=search_type,
+            name=name,
+            query=query,
+        )
+        assert resp.data['name'] == name
+        assert resp.data['query'] == query
+        assert resp.data['type'] == search_type
+        assert SavedSearch.objects.filter(id=resp.data['id']).exists()
+
+    def test_perms(self):
+        self.login_as(user=self.member)
+        resp = self.get_response(
+            self.organization.slug,
+            type=SearchType.ISSUE.value,
+            name='hello',
+            query='test',
+        )
+        assert resp.status_code == 403
+
+    def test_exists(self):
+        global_search = SavedSearch.objects.create(
+            type=SearchType.ISSUE.value,
+            name='Some global search',
+            query='is:unresolved',
+            is_global=True,
+        )
+        self.login_as(user=self.manager)
+        resp = self.get_response(
+            self.organization.slug,
+            type=SearchType.ISSUE.value,
+            name='hello',
+            query=global_search.query,
+        )
+        assert resp.status_code == 400
+        assert 'already exists' in resp.data['detail']
+
+        org_search = SavedSearch.objects.create(
+            organization=self.organization,
+            type=SearchType.ISSUE.value,
+            name='Some org search',
+            query='org search',
+        )
+        resp = self.get_response(
+            self.organization.slug,
+            type=SearchType.ISSUE.value,
+            name='hello',
+            query=org_search.query,
+        )
+        assert resp.status_code == 400
+        assert 'already exists' in resp.data['detail']
+
+    def test_empty(self):
+        self.login_as(user=self.manager)
+        resp = self.get_response(
+            self.organization.slug,
+            type=SearchType.ISSUE.value,
+            name='hello',
+            query='',
+        )
+        assert resp.status_code == 400
+        assert 'This field is required' in resp.data['query'][0]