diff options
-rw-r--r-- | docs/api/schemas/latest/patchwork.yaml | 166 | ||||
-rw-r--r-- | docs/api/schemas/patchwork.j2 | 177 | ||||
-rw-r--r-- | docs/api/schemas/v1.0/patchwork.yaml | 5 | ||||
-rw-r--r-- | docs/api/schemas/v1.1/patchwork.yaml | 5 | ||||
-rw-r--r-- | docs/api/schemas/v1.2/patchwork.yaml | 166 | ||||
-rw-r--r-- | patchwork/api/bundle.py | 100 | ||||
-rw-r--r-- | patchwork/models.py | 11 | ||||
-rw-r--r-- | patchwork/settings/base.py | 1 | ||||
-rw-r--r-- | patchwork/tests/api/test_bundle.py | 153 | ||||
-rw-r--r-- | releasenotes/notes/update-bundle-via-api-2946d8c4e730d545.yaml | 4 |
10 files changed, 764 insertions, 24 deletions
diff --git a/docs/api/schemas/latest/patchwork.yaml b/docs/api/schemas/latest/patchwork.yaml index 45a6118..4696900 100644 --- a/docs/api/schemas/latest/patchwork.yaml +++ b/docs/api/schemas/latest/patchwork.yaml @@ -1,5 +1,6 @@ # DO NOT EDIT THIS FILE. It is generated from a template. Changes should be -# proposed against the template. +# proposed against the template and updated files generated using the +# 'generate-schemas.py' tool --- openapi: '3.0.0' info: @@ -72,6 +73,35 @@ paths: $ref: '#/components/schemas/Bundle' tags: - bundles + post: + description: Create a bundle. + operationId: bundles_create + security: + - basicAuth: [] + - apiKeyAuth: [] + requestBody: + $ref: '#/components/requestBodies/Bundle' + responses: + '201': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/Bundle' + '400': + description: Invalid Request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBundleCreateUpdate' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + tags: + - bundles /api/bundles/{id}/: get: description: Show a bundle. @@ -99,6 +129,92 @@ paths: $ref: '#/components/schemas/Error' tags: - bundles + patch: + description: Update a bundle (partial). + operationId: bundles_partial_update + security: + - basicAuth: [] + - apiKeyAuth: [] + parameters: + - in: path + name: id + description: A unique integer value identifying this bundle. + required: true + schema: + title: ID + type: integer + requestBody: + $ref: '#/components/requestBodies/Bundle' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/Bundle' + '400': + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBundleCreateUpdate' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + tags: + - bundles + put: + description: Update a bundle. + operationId: bundles_update + security: + - basicAuth: [] + - apiKeyAuth: [] + parameters: + - in: path + name: id + description: A unique integer value identifying this bundle. + required: true + schema: + title: ID + type: integer + requestBody: + $ref: '#/components/requestBodies/Bundle' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/Bundle' + '400': + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBundleCreateUpdate' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + tags: + - bundles /api/covers/: get: description: List cover letters. @@ -1131,6 +1247,18 @@ components: schema: type: string requestBodies: + Bundle: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BundleCreateUpdate' + multipart/form-data: + schema: + $ref: '#/components/schemas/BundleCreateUpdate' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/BundleCreateUpdate' Check: required: true content: @@ -1251,10 +1379,10 @@ components: allOf: - $ref: '#/components/schemas/UserEmbedded' patches: + title: Patches type: array items: $ref: '#/components/schemas/PatchEmbedded' - readOnly: true uniqueItems: true public: title: Public @@ -1264,6 +1392,25 @@ components: type: string format: uri readOnly: true + BundleCreateUpdate: + type: object + required: + - name + properties: + name: + title: Name + type: string + minLength: 1 + maxLength: 50 + patches: + title: Patches + type: array + items: + type: integer + uniqueItems: true + public: + title: Public + type: boolean Check: type: object properties: @@ -1961,6 +2108,7 @@ components: cover_letter: $ref: '#/components/schemas/CoverLetterEmbedded' patches: + title: Patches type: array items: $ref: '#/components/schemas/PatchEmbedded' @@ -2307,6 +2455,20 @@ components: title: Detail type: string readOnly: true + ErrorBundleCreateUpdate: + type: object + properties: + name: + title: Name + type: string + readOnly: true + patches: + title: Patches + type: string + readOnly: true + public: + title: Public + type: string ErrorCheckCreate: type: object properties: diff --git a/docs/api/schemas/patchwork.j2 b/docs/api/schemas/patchwork.j2 index 16d85a3..4fc100e 100644 --- a/docs/api/schemas/patchwork.j2 +++ b/docs/api/schemas/patchwork.j2 @@ -1,6 +1,7 @@ {# You can obviously ignore the below when editing this template #} # DO NOT EDIT THIS FILE. It is generated from a template. Changes should be -# proposed against the template. +# proposed against the template and updated files generated using the +# 'generate-schemas.py' tool --- openapi: '3.0.0' info: @@ -73,6 +74,37 @@ paths: $ref: '#/components/schemas/Bundle' tags: - bundles +{% if version >= (1, 2) %} + post: + description: Create a bundle. + operationId: bundles_create + security: + - basicAuth: [] + - apiKeyAuth: [] + requestBody: + $ref: '#/components/requestBodies/Bundle' + responses: + '201': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/Bundle' + '400': + description: Invalid Request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBundleCreateUpdate' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + tags: + - bundles +{% endif %} /api/{{ version_url }}bundles/{id}/: get: description: Show a bundle. @@ -100,6 +132,94 @@ paths: $ref: '#/components/schemas/Error' tags: - bundles +{% if version >= (1, 2) %} + patch: + description: Update a bundle (partial). + operationId: bundles_partial_update + security: + - basicAuth: [] + - apiKeyAuth: [] + parameters: + - in: path + name: id + description: A unique integer value identifying this bundle. + required: true + schema: + title: ID + type: integer + requestBody: + $ref: '#/components/requestBodies/Bundle' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/Bundle' + '400': + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBundleCreateUpdate' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + tags: + - bundles + put: + description: Update a bundle. + operationId: bundles_update + security: + - basicAuth: [] + - apiKeyAuth: [] + parameters: + - in: path + name: id + description: A unique integer value identifying this bundle. + required: true + schema: + title: ID + type: integer + requestBody: + $ref: '#/components/requestBodies/Bundle' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/Bundle' + '400': + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBundleCreateUpdate' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + tags: + - bundles +{% endif %} /api/{{ version_url }}covers/: get: description: List cover letters. @@ -1132,6 +1252,20 @@ components: schema: type: string requestBodies: +{% if version >= (1, 2) %} + Bundle: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BundleCreateUpdate' + multipart/form-data: + schema: + $ref: '#/components/schemas/BundleCreateUpdate' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/BundleCreateUpdate' +{% endif %} Check: required: true content: @@ -1254,10 +1388,13 @@ components: allOf: - $ref: '#/components/schemas/UserEmbedded' patches: + title: Patches type: array items: $ref: '#/components/schemas/PatchEmbedded' +{% if version < (1, 2) %} readOnly: true +{% endif %} uniqueItems: true public: title: Public @@ -1267,6 +1404,27 @@ components: type: string format: uri readOnly: true +{% if version >= (1, 2) %} + BundleCreateUpdate: + type: object + required: + - name + properties: + name: + title: Name + type: string + minLength: 1 + maxLength: 50 + patches: + title: Patches + type: array + items: + type: integer + uniqueItems: true + public: + title: Public + type: boolean +{% endif %} Check: type: object properties: @@ -1988,6 +2146,7 @@ components: cover_letter: $ref: '#/components/schemas/CoverLetterEmbedded' patches: + title: Patches type: array items: $ref: '#/components/schemas/PatchEmbedded' @@ -2346,6 +2505,22 @@ components: title: Detail type: string readOnly: true +{% if version >= (1, 2) %} + ErrorBundleCreateUpdate: + type: object + properties: + name: + title: Name + type: string + readOnly: true + patches: + title: Patches + type: string + readOnly: true + public: + title: Public + type: string +{% endif %} ErrorCheckCreate: type: object properties: diff --git a/docs/api/schemas/v1.0/patchwork.yaml b/docs/api/schemas/v1.0/patchwork.yaml index 02f3a15..e6adfdd 100644 --- a/docs/api/schemas/v1.0/patchwork.yaml +++ b/docs/api/schemas/v1.0/patchwork.yaml @@ -1,5 +1,6 @@ # DO NOT EDIT THIS FILE. It is generated from a template. Changes should be -# proposed against the template. +# proposed against the template and updated files generated using the +# 'generate-schemas.py' tool --- openapi: '3.0.0' info: @@ -1246,6 +1247,7 @@ components: allOf: - $ref: '#/components/schemas/UserEmbedded' patches: + title: Patches type: array items: $ref: '#/components/schemas/PatchEmbedded' @@ -1877,6 +1879,7 @@ components: cover_letter: $ref: '#/components/schemas/CoverLetterEmbedded' patches: + title: Patches type: array items: $ref: '#/components/schemas/PatchEmbedded' diff --git a/docs/api/schemas/v1.1/patchwork.yaml b/docs/api/schemas/v1.1/patchwork.yaml index 0c086ed..6af697c 100644 --- a/docs/api/schemas/v1.1/patchwork.yaml +++ b/docs/api/schemas/v1.1/patchwork.yaml @@ -1,5 +1,6 @@ # DO NOT EDIT THIS FILE. It is generated from a template. Changes should be -# proposed against the template. +# proposed against the template and updated files generated using the +# 'generate-schemas.py' tool --- openapi: '3.0.0' info: @@ -1251,6 +1252,7 @@ components: allOf: - $ref: '#/components/schemas/UserEmbedded' patches: + title: Patches type: array items: $ref: '#/components/schemas/PatchEmbedded' @@ -1928,6 +1930,7 @@ components: cover_letter: $ref: '#/components/schemas/CoverLetterEmbedded' patches: + title: Patches type: array items: $ref: '#/components/schemas/PatchEmbedded' diff --git a/docs/api/schemas/v1.2/patchwork.yaml b/docs/api/schemas/v1.2/patchwork.yaml index 3a96aa3..2ced470 100644 --- a/docs/api/schemas/v1.2/patchwork.yaml +++ b/docs/api/schemas/v1.2/patchwork.yaml @@ -1,5 +1,6 @@ # DO NOT EDIT THIS FILE. It is generated from a template. Changes should be -# proposed against the template. +# proposed against the template and updated files generated using the +# 'generate-schemas.py' tool --- openapi: '3.0.0' info: @@ -72,6 +73,35 @@ paths: $ref: '#/components/schemas/Bundle' tags: - bundles + post: + description: Create a bundle. + operationId: bundles_create + security: + - basicAuth: [] + - apiKeyAuth: [] + requestBody: + $ref: '#/components/requestBodies/Bundle' + responses: + '201': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/Bundle' + '400': + description: Invalid Request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBundleCreateUpdate' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + tags: + - bundles /api/1.2/bundles/{id}/: get: description: Show a bundle. @@ -99,6 +129,92 @@ paths: $ref: '#/components/schemas/Error' tags: - bundles + patch: + description: Update a bundle (partial). + operationId: bundles_partial_update + security: + - basicAuth: [] + - apiKeyAuth: [] + parameters: + - in: path + name: id + description: A unique integer value identifying this bundle. + required: true + schema: + title: ID + type: integer + requestBody: + $ref: '#/components/requestBodies/Bundle' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/Bundle' + '400': + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBundleCreateUpdate' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + tags: + - bundles + put: + description: Update a bundle. + operationId: bundles_update + security: + - basicAuth: [] + - apiKeyAuth: [] + parameters: + - in: path + name: id + description: A unique integer value identifying this bundle. + required: true + schema: + title: ID + type: integer + requestBody: + $ref: '#/components/requestBodies/Bundle' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/Bundle' + '400': + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBundleCreateUpdate' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + tags: + - bundles /api/1.2/covers/: get: description: List cover letters. @@ -1131,6 +1247,18 @@ components: schema: type: string requestBodies: + Bundle: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BundleCreateUpdate' + multipart/form-data: + schema: + $ref: '#/components/schemas/BundleCreateUpdate' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/BundleCreateUpdate' Check: required: true content: @@ -1251,10 +1379,10 @@ components: allOf: - $ref: '#/components/schemas/UserEmbedded' patches: + title: Patches type: array items: $ref: '#/components/schemas/PatchEmbedded' - readOnly: true uniqueItems: true public: title: Public @@ -1264,6 +1392,25 @@ components: type: string format: uri readOnly: true + BundleCreateUpdate: + type: object + required: + - name + properties: + name: + title: Name + type: string + minLength: 1 + maxLength: 50 + patches: + title: Patches + type: array + items: + type: integer + uniqueItems: true + public: + title: Public + type: boolean Check: type: object properties: @@ -1961,6 +2108,7 @@ components: cover_letter: $ref: '#/components/schemas/CoverLetterEmbedded' patches: + title: Patches type: array items: $ref: '#/components/schemas/PatchEmbedded' @@ -2307,6 +2455,20 @@ components: title: Detail type: string readOnly: true + ErrorBundleCreateUpdate: + type: object + properties: + name: + title: Name + type: string + readOnly: true + patches: + title: Patches + type: string + readOnly: true + public: + title: Public + type: string ErrorCheckCreate: type: object properties: diff --git a/patchwork/api/bundle.py b/patchwork/api/bundle.py index 2dec70d..b8c0f17 100644 --- a/patchwork/api/bundle.py +++ b/patchwork/api/bundle.py @@ -4,9 +4,12 @@ # SPDX-License-Identifier: GPL-2.0-or-later from django.db.models import Q -from rest_framework.generics import ListAPIView -from rest_framework.generics import RetrieveAPIView +from rest_framework import exceptions +from rest_framework.generics import ListCreateAPIView +from rest_framework.generics import RetrieveUpdateDestroyAPIView +from rest_framework import permissions from rest_framework.serializers import SerializerMethodField +from rest_framework.serializers import ValidationError from patchwork.api.base import BaseHyperlinkedModelSerializer from patchwork.api.base import PatchworkPermission @@ -14,16 +17,52 @@ from patchwork.api.filters import BundleFilterSet from patchwork.api.embedded import PatchSerializer from patchwork.api.embedded import ProjectSerializer from patchwork.api.embedded import UserSerializer +from patchwork.api import utils from patchwork.models import Bundle +class BundlePermission(permissions.BasePermission): + """Ensure the API version, if configured, is >= v1.2. + + Bundle creation/updating was only added in API v1.2 and we don't want to + change behavior in older API versions. + """ + def has_permission(self, request, view): + # read-only permission for everything + if request.method in permissions.SAFE_METHODS: + return True + + if not utils.has_version(request, '1.2'): + raise exceptions.MethodNotAllowed(request.method) + + if request.method == 'POST' and ( + not request.user or not request.user.is_authenticated): + return False + + # we have more to do but we can't do that until we have an object + return True + + def has_object_permission(self, request, view, obj): + if (request.user and + request.user.is_authenticated and + request.user == obj.owner): + return True + + if not obj.public: + # if the bundle isn't public, we don't want to leak the fact that + # it exists + raise exceptions.NotFound + + return request.method in permissions.SAFE_METHODS + + class BundleSerializer(BaseHyperlinkedModelSerializer): web_url = SerializerMethodField() project = ProjectSerializer(read_only=True) mbox = SerializerMethodField() owner = UserSerializer(read_only=True) - patches = PatchSerializer(many=True, read_only=True) + patches = PatchSerializer(many=True, required=True) def get_web_url(self, instance): request = self.context.get('request') @@ -33,11 +72,39 @@ class BundleSerializer(BaseHyperlinkedModelSerializer): request = self.context.get('request') return request.build_absolute_uri(instance.get_mbox_url()) + def create(self, validated_data): + patches = validated_data.pop('patches') + instance = super(BundleSerializer, self).create(validated_data) + instance.overwrite_patches(patches) + return instance + + def update(self, instance, validated_data): + patches = validated_data.pop('patches') + instance = super(BundleSerializer, self).update( + instance, validated_data) + instance.overwrite_patches(patches) + return instance + + def validate_patches(self, value): + if not len(value): + raise ValidationError('Bundles cannot be empty') + + if len(set([p.project.id for p in value])) > 1: + raise ValidationError('Bundle patches must belong to the same ' + 'project') + + return value + + def validate(self, data): + data['project'] = data['patches'][0].project + + return super(BundleSerializer, self).validate(data) + class Meta: model = Bundle fields = ('id', 'url', 'web_url', 'project', 'name', 'owner', 'patches', 'public', 'mbox') - read_only_fields = ('owner', 'patches', 'mbox') + read_only_fields = ('project', 'owner', 'mbox') versioned_fields = { '1.1': ('web_url', ), } @@ -48,7 +115,7 @@ class BundleSerializer(BaseHyperlinkedModelSerializer): class BundleMixin(object): - permission_classes = (PatchworkPermission,) + permission_classes = [PatchworkPermission & BundlePermission] serializer_class = BundleSerializer def get_queryset(self): @@ -63,16 +130,29 @@ class BundleMixin(object): .select_related('owner', 'project') -class BundleList(BundleMixin, ListAPIView): - """List bundles.""" +class BundleList(BundleMixin, ListCreateAPIView): + """List or create bundles.""" filter_class = filterset_class = BundleFilterSet search_fields = ('name',) ordering_fields = ('id', 'name', 'owner') ordering = 'id' + def perform_create(self, serializer): + serializer.save(owner=self.request.user) + + +class BundleDetail(BundleMixin, RetrieveUpdateDestroyAPIView): + """ + get: + Show a bundle. + + patch: + Update a bundle. -class BundleDetail(BundleMixin, RetrieveAPIView): - """Show a bundle.""" + put: + Update a bundle. - pass + delete: + Delete a bundle. + """ diff --git a/patchwork/models.py b/patchwork/models.py index c198bc2..a908dd5 100644 --- a/patchwork/models.py +++ b/patchwork/models.py @@ -804,6 +804,11 @@ class Bundle(models.Model): patches = models.ManyToManyField(Patch, through='BundlePatch') public = models.BooleanField(default=False) + def is_editable(self, user): + if not user.is_authenticated: + return False + return user == self.owner + def ordered_patches(self): return self.patches.order_by('bundlepatch__order') @@ -822,6 +827,12 @@ class Bundle(models.Model): return BundlePatch.objects.create(bundle=self, patch=patch, order=max_order + 1) + def overwrite_patches(self, patches): + BundlePatch.objects.filter(bundle=self).delete() + + for patch in patches: + self.append_patch(patch) + def get_absolute_url(self): return reverse('bundle-detail', kwargs={ 'username': self.owner.username, diff --git a/patchwork/settings/base.py b/patchwork/settings/base.py index 65cd721..b86cdc2 100644 --- a/patchwork/settings/base.py +++ b/patchwork/settings/base.py @@ -138,6 +138,7 @@ REST_FRAMEWORK = { ), 'SEARCH_PARAM': 'q', 'ORDERING_PARAM': 'order', + 'NON_FIELD_ERRORS_KEY': 'detail', } # diff --git a/patchwork/tests/api/test_bundle.py b/patchwork/tests/api/test_bundle.py index 303c500..d03f26f 100644 --- a/patchwork/tests/api/test_bundle.py +++ b/patchwork/tests/api/test_bundle.py @@ -8,9 +8,11 @@ import unittest from django.conf import settings from django.urls import reverse +from patchwork.models import Bundle from patchwork.tests.api import utils from patchwork.tests.utils import create_bundle from patchwork.tests.utils import create_maintainer +from patchwork.tests.utils import create_patch from patchwork.tests.utils import create_project from patchwork.tests.utils import create_user @@ -42,12 +44,15 @@ class TestBundleAPI(utils.APITestCase): # nested fields - self.assertEqual(bundle_obj.patches.count(), - len(bundle_json['patches'])) self.assertEqual(bundle_obj.owner.id, bundle_json['owner']['id']) self.assertEqual(bundle_obj.project.id, bundle_json['project']['id']) + self.assertEqual(bundle_obj.patches.count(), + len(bundle_json['patches'])) + for patch_obj, patch_json in zip( + bundle_obj.patches.all(), bundle_json['patches']): + self.assertEqual(patch_obj.id, patch_json['id']) def test_list_empty(self): """List bundles when none are present.""" @@ -179,18 +184,152 @@ class TestBundleAPI(utils.APITestCase): self.assertIn('url', resp.data) self.assertNotIn('web_url', resp.data) - def test_create_update_delete(self): - """Ensure creates, updates and deletes aren't allowed""" + def _test_create_update(self, authenticate=True): + user = create_user() + project = create_project() + patch_a = create_patch(project=project) + patch_b = create_patch(project=project) + + if authenticate: + self.client.force_authenticate(user=user) + + return user, project, patch_a, patch_b + + @utils.store_samples('bundle-create-error-forbidden') + def test_create_anonymous(self): + """Create a bundle when not signed in. + + Ensure creations can only be performed by signed in users. + """ + user, project, patch_a, patch_b = self._test_create_update( + authenticate=False) + bundle = { + 'name': 'test-bundle', + 'public': True, + 'patches': [patch_a.id, patch_b.id], + } + + resp = self.client.post(self.api_url(), bundle) + self.assertEqual(status.HTTP_403_FORBIDDEN, resp.status_code) + + @utils.store_samples('bundle-create') + def test_create(self): + """Validate we can create a new bundle.""" + user, project, patch_a, patch_b = self._test_create_update() + bundle = { + 'name': 'test-bundle', + 'public': True, + 'patches': [patch_a.id, patch_b.id], + } + + resp = self.client.post(self.api_url(), bundle) + self.assertEqual(status.HTTP_201_CREATED, resp.status_code) + self.assertEqual(1, Bundle.objects.all().count()) + self.assertSerialized(Bundle.objects.first(), resp.data) + + @utils.store_samples('bundle-create-invalid-patch') + def test_create_no_patches(self): + """Create a bundle with no patches. + + Ensure such requests are rejected. + """ + user, project, _, _ = self._test_create_update() + bundle = { + 'name': 'test-bundle', + 'public': True, + 'patches': [], + } + + resp = self.client.post(self.api_url(), bundle) + self.assertEqual(status.HTTP_400_BAD_REQUEST, resp.status_code) + + def test_create_invalid_patch(self): + """Create a bundle with patches that belong to another project. + + Ensure such requests are rejected. + """ + user, project, patch_a, patch_b = self._test_create_update() + patch_c = create_patch() + bundle = { + 'name': 'test-bundle', + 'public': True, + 'patches': [patch_a.id, patch_b.id, patch_c.id], + } + + resp = self.client.post(self.api_url(), bundle) + self.assertEqual(status.HTTP_400_BAD_REQUEST, resp.status_code) + + @utils.store_samples('bundle-update-not-found') + def test_update_anonymous(self): + """Update an existing bundle when not signed in. + + Ensure updates can only be performed by signed in users. + """ + user, project, patch_a, patch_b = self._test_create_update( + authenticate=False) + bundle = create_bundle(owner=user, project=project) + + resp = self.client.patch(self.api_url(bundle.id), { + 'name': 'hello-bundle', 'patches': [patch_a.id, patch_b.id]}) + self.assertEqual(status.HTTP_404_NOT_FOUND, resp.status_code) + + @utils.store_samples('bundle-update') + def test_update(self): + """Validate we can update an existing bundle.""" + user, project, patch_a, patch_b = self._test_create_update() + bundle = create_bundle(owner=user, project=project) + + self.assertEqual(1, Bundle.objects.all().count()) + self.assertEqual(0, len(Bundle.objects.first().patches.all())) + + resp = self.client.patch(self.api_url(bundle.id), { + 'name': 'hello-bundle', 'patches': [patch_a.id, patch_b.id] + }) + self.assertEqual(status.HTTP_200_OK, resp.status_code) + self.assertEqual(2, len(resp.data['patches'])) + self.assertEqual('hello-bundle', resp.data['name']) + self.assertEqual(1, Bundle.objects.all().count()) + self.assertEqual(2, len(Bundle.objects.first().patches.all())) + self.assertEqual('hello-bundle', Bundle.objects.first().name) + + @utils.store_samples('bundle-delete-not-found') + def test_delete_anonymous(self): + """Delete a bundle when not signed in. + + Ensure deletions can only be performed when signed in. + """ + user, project, patch_a, patch_b = self._test_create_update( + authenticate=False) + bundle = create_bundle(owner=user, project=project) + + resp = self.client.delete(self.api_url(bundle.id)) + self.assertEqual(status.HTTP_404_NOT_FOUND, resp.status_code) + + @utils.store_samples('bundle-delete') + def test_delete(self): + """Validate we can delete an existing bundle.""" + user = create_user() + bundle = create_bundle(owner=user) + + self.client.force_authenticate(user=user) + + resp = self.client.delete(self.api_url(bundle.id)) + self.assertEqual(status.HTTP_204_NO_CONTENT, resp.status_code) + self.assertEqual(0, Bundle.objects.all().count()) + + def test_create_update_delete_version_1_1(self): + """Ensure creates, updates and deletes aren't allowed with old API.""" user = create_maintainer() user.is_superuser = True user.save() self.client.force_authenticate(user=user) - resp = self.client.post(self.api_url(), {'email': 'foo@f.com'}) + resp = self.client.post(self.api_url(version='1.1'), {'name': 'test'}) self.assertEqual(status.HTTP_405_METHOD_NOT_ALLOWED, resp.status_code) - resp = self.client.patch(self.api_url(user.id), {'email': 'foo@f.com'}) + resp = self.client.patch(self.api_url(1, version='1.1'), + {'name': 'test'}) self.assertEqual(status.HTTP_405_METHOD_NOT_ALLOWED, resp.status_code) - resp = self.client.delete(self.api_url(1)) + resp = self.client.delete(self.api_url(1, version='1.1')) self.assertEqual(status.HTTP_405_METHOD_NOT_ALLOWED, resp.status_code) diff --git a/releasenotes/notes/update-bundle-via-api-2946d8c4e730d545.yaml b/releasenotes/notes/update-bundle-via-api-2946d8c4e730d545.yaml new file mode 100644 index 0000000..bfa1ef5 --- /dev/null +++ b/releasenotes/notes/update-bundle-via-api-2946d8c4e730d545.yaml @@ -0,0 +1,4 @@ +--- +api: + - | + Bundles can now be created, updated and deleted via the REST API. |