[PATCH 2/2] REST: Allow creating, updating, deleting of bundles
Stephen Finucane
stephen at that.guru
Mon Sep 9 08:31:47 AEST 2019
Allow users to create a new bundle, change the name, public flag and
patches of an existing bundle, and delete an existing bundle.
Some small nits with existing tests are resolved.
Signed-off-by: Stephen Finucane <stephen at that.guru>
---
docs/api/schemas/latest/patchwork.yaml | 170 +++++++++++++++-
docs/api/schemas/patchwork.j2 | 181 +++++++++++++++++-
docs/api/schemas/v1.0/patchwork.yaml | 5 +-
docs/api/schemas/v1.1/patchwork.yaml | 5 +-
docs/api/schemas/v1.2/patchwork.yaml | 170 +++++++++++++++-
patchwork/api/bundle.py | 84 +++++++-
patchwork/models.py | 11 ++
patchwork/tests/api/test_bundle.py | 118 +++++++++++-
patchwork/tests/api/utils.py | 16 +-
...pdate-bundle-via-api-2946d8c4e730d545.yaml | 4 +
10 files changed, 737 insertions(+), 27 deletions(-)
create mode 100644 releasenotes/notes/update-bundle-via-api-2946d8c4e730d545.yaml
diff --git a/docs/api/schemas/latest/patchwork.yaml b/docs/api/schemas/latest/patchwork.yaml
index 45a61180..e6c6bb4a 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_schema.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,24 @@ components:
title: Detail
type: string
readOnly: true
+ ErrorBundleCreateUpdate:
+ type: object
+ properties:
+ name:
+ title: Name
+ type: string
+ minLength: 1
+ maxLength: 50
+ patches:
+ title: Patches
+ type: array
+ items:
+ $ref: '#/components/schemas/PatchEmbedded'
+ readOnly: true
+ uniqueItems: true
+ public:
+ title: Public
+ type: boolean
ErrorCheckCreate:
type: object
properties:
diff --git a/docs/api/schemas/patchwork.j2 b/docs/api/schemas/patchwork.j2
index 843981f8..5f7510da 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_schema.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,26 @@ components:
title: Detail
type: string
readOnly: true
+{% if version >= (1, 2) %}
+ ErrorBundleCreateUpdate:
+ type: object
+ properties:
+ name:
+ title: Name
+ type: string
+ minLength: 1
+ maxLength: 50
+ patches:
+ title: Patches
+ type: array
+ items:
+ $ref: '#/components/schemas/PatchEmbedded'
+ readOnly: true
+ uniqueItems: true
+ public:
+ title: Public
+ type: boolean
+{% endif %}
ErrorCheckCreate:
type: object
properties:
diff --git a/docs/api/schemas/v1.0/patchwork.yaml b/docs/api/schemas/v1.0/patchwork.yaml
index 02f3a156..cafef5ee 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_schema.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 0c086eda..9d45d058 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_schema.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 3a96aa3a..5c03bf1e 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_schema.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,24 @@ components:
title: Detail
type: string
readOnly: true
+ ErrorBundleCreateUpdate:
+ type: object
+ properties:
+ name:
+ title: Name
+ type: string
+ minLength: 1
+ maxLength: 50
+ patches:
+ title: Patches
+ type: array
+ items:
+ $ref: '#/components/schemas/PatchEmbedded'
+ readOnly: true
+ uniqueItems: true
+ public:
+ title: Public
+ type: boolean
ErrorCheckCreate:
type: object
properties:
diff --git a/patchwork/api/bundle.py b/patchwork/api/bundle.py
index 2dec70d1..c5885aae 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)
def get_web_url(self, instance):
request = self.context.get('request')
@@ -33,11 +72,35 @@ 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(self, data):
+ if not data.get('patches'):
+ raise ValidationError('Bundles cannot be empty')
+
+ if len(set([p.project.id for p in data['patches']])) > 1:
+ raise ValidationError('Patches must belong to the same project')
+
+ 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 +111,7 @@ class BundleSerializer(BaseHyperlinkedModelSerializer):
class BundleMixin(object):
- permission_classes = (PatchworkPermission,)
+ permission_classes = [PatchworkPermission & BundlePermission]
serializer_class = BundleSerializer
def get_queryset(self):
@@ -63,16 +126,19 @@ 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, RetrieveAPIView):
- """Show a bundle."""
+class BundleDetail(BundleMixin, RetrieveUpdateDestroyAPIView):
+ """Show, update or delete a bundle."""
pass
diff --git a/patchwork/models.py b/patchwork/models.py
index 32d1b3c2..631de85d 100644
--- a/patchwork/models.py
+++ b/patchwork/models.py
@@ -788,6 +788,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')
@@ -806,6 +811,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/tests/api/test_bundle.py b/patchwork/tests/api/test_bundle.py
index 303c500c..a3a0c113 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,117 @@ 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-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)
+
+ 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(1, Bundle.objects.all().count())
+ self.assertEqual(len(resp.data['patches']), 2)
+ self.assertEqual(resp.data['name'], 'hello-bundle')
+
+ @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 at f.com'})
+ resp = self.client.post(self.api_url(version='1.1'), {'name': 'test'},
+ validate_schema=False)
self.assertEqual(status.HTTP_405_METHOD_NOT_ALLOWED, resp.status_code)
- resp = self.client.patch(self.api_url(user.id), {'email': 'foo at f.com'})
+ resp = self.client.patch(self.api_url(1, version='1.1'),
+ {'name': 'test'},
+ validate_schema=False)
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'),
+ validate_schema=False)
self.assertEqual(status.HTTP_405_METHOD_NOT_ALLOWED, resp.status_code)
diff --git a/patchwork/tests/api/utils.py b/patchwork/tests/api/utils.py
index 0c232d04..ce83ce2b 100644
--- a/patchwork/tests/api/utils.py
+++ b/patchwork/tests/api/utils.py
@@ -112,44 +112,52 @@ class APIClient(BaseAPIClient):
self.factory = APIRequestFactory()
def get(self, path, data=None, follow=False, **extra):
+ validate_schema = extra.pop('validate_schema', True)
request = self.factory.get(
path, data=data, SERVER_NAME='example.com', **extra)
response = super(APIClient, self).get(
path, data=data, follow=follow, SERVER_NAME='example.com', **extra)
- validator.validate_data(path, request, response)
+ if validate_schema:
+ validator.validate_data(path, request, response)
return response
def post(self, path, data=None, format=None, content_type=None,
follow=False, **extra):
+ validate_schema = extra.pop('validate_schema', True)
request = self.factory.post(
path, data=data, format='json', content_type=content_type,
SERVER_NAME='example.com', **extra)
response = super(APIClient, self).post(
path, data=data, format='json', content_type=content_type,
follow=follow, SERVER_NAME='example.com', **extra)
- validator.validate_data(path, request, response)
+ if validate_schema:
+ validator.validate_data(path, request, response)
return response
def put(self, path, data=None, format=None, content_type=None,
follow=False, **extra):
+ validate_schema = extra.pop('validate_schema', True)
request = self.factory.put(
path, data=data, format='json', content_type=content_type,
SERVER_NAME='example.com', **extra)
response = super(APIClient, self).put(
path, data=data, format='json', content_type=content_type,
follow=follow, SERVER_NAME='example.com', **extra)
- validator.validate_data(path, request, response)
+ if validate_schema:
+ validator.validate_data(path, request, response)
return response
def patch(self, path, data=None, format=None, content_type=None,
follow=False, **extra):
+ validate_schema = extra.pop('validate_schema', True)
request = self.factory.patch(
path, data=data, format='json', content_type=content_type,
SERVER_NAME='example.com', **extra)
response = super(APIClient, self).patch(
path, data=data, format='json', content_type=content_type,
follow=follow, SERVER_NAME='example.com', **extra)
- validator.validate_data(path, request, response)
+ if validate_schema:
+ validator.validate_data(path, request, response)
return response
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 00000000..bfa1ef55
--- /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.
--
2.21.0
More information about the Patchwork
mailing list