summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorStephen Finucane <stephen@that.guru>2019-09-08 23:31:47 +0100
committerStephen Finucane <stephen@that.guru>2019-10-17 18:51:02 +0100
commit9795ec7505d78de5819ef3e1ebbd446f4010d7aa (patch)
tree8a390065ee9c7fa00c2d2e26ca946a3728fb6a01
parent3589c00edd0a5e5214196da22a5af4848cb9023b (diff)
downloadpatchwork-9795ec7505d78de5819ef3e1ebbd446f4010d7aa.tar
patchwork-9795ec7505d78de5819ef3e1ebbd446f4010d7aa.tar.gz
REST: Allow creating, updating, deleting of bundles
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@that.guru> Closes: #316
-rw-r--r--docs/api/schemas/latest/patchwork.yaml166
-rw-r--r--docs/api/schemas/patchwork.j2177
-rw-r--r--docs/api/schemas/v1.0/patchwork.yaml5
-rw-r--r--docs/api/schemas/v1.1/patchwork.yaml5
-rw-r--r--docs/api/schemas/v1.2/patchwork.yaml166
-rw-r--r--patchwork/api/bundle.py100
-rw-r--r--patchwork/models.py11
-rw-r--r--patchwork/settings/base.py1
-rw-r--r--patchwork/tests/api/test_bundle.py153
-rw-r--r--releasenotes/notes/update-bundle-via-api-2946d8c4e730d545.yaml4
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.