]> git.ipfire.org Git - thirdparty/patchwork.git/commitdiff
REST: Allow creating, updating, deleting of bundles
authorStephen Finucane <stephen@that.guru>
Sun, 8 Sep 2019 22:31:47 +0000 (23:31 +0100)
committerStephen Finucane <stephen@that.guru>
Thu, 17 Oct 2019 17:51:02 +0000 (18:51 +0100)
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
docs/api/schemas/latest/patchwork.yaml
docs/api/schemas/patchwork.j2
docs/api/schemas/v1.0/patchwork.yaml
docs/api/schemas/v1.1/patchwork.yaml
docs/api/schemas/v1.2/patchwork.yaml
patchwork/api/bundle.py
patchwork/models.py
patchwork/settings/base.py
patchwork/tests/api/test_bundle.py
releasenotes/notes/update-bundle-via-api-2946d8c4e730d545.yaml [new file with mode: 0644]

index 45a6118092059e6a9a267c69067de8b377cbafc9..46969000a65b713ad17847cb037aae940712be2a 100644 (file)
@@ -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:
index 16d85a33d188de89d9a8c1b978f3b2f387ab4bcc..4fc100eb4a922b6ddee1bf61a8617bc18ef580ea 100644 (file)
@@ -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:
index 02f3a1561b7bbcc50b03c3b5cce74e3f93fbb747..e6adfdddc7bf6194b2ab9d0efea448fc63bcad73 100644 (file)
@@ -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'
index 0c086edaa776414f2f683c60368c36f75e7cf757..6af697c8a5bb514edaf7b305ff855b03487fcac5 100644 (file)
@@ -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'
index 3a96aa3a4fbbd3098f5e22f1e8eddede7d8e63c3..2ced470b7dc03f6370e727341b9c9c3c6123508d 100644 (file)
@@ -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:
index 2dec70d187375e616646629dd649a7e0b22e658e..b8c0f17817861fc2a8c8746986028d890efdf1cf 100644 (file)
@@ -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.
+    """
index c198bc2c15cafcf61f90f460a131d49b21ff8b6a..a908dd5cff40c218bd1c9af3cad908c2efec6a48 100644 (file)
@@ -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,
index 65cd721c05c7118a840554951cf7d3b3240cf370..b86cdc276d5a692490537149bdd1690cf39203f2 100644 (file)
@@ -138,6 +138,7 @@ REST_FRAMEWORK = {
     ),
     'SEARCH_PARAM': 'q',
     'ORDERING_PARAM': 'order',
+    'NON_FIELD_ERRORS_KEY': 'detail',
 }
 
 #
index 303c500c395bc8821611b8626d9742e26a8d5fe3..d03f26f15e42e377347817e0233523cf215e0176 100644 (file)
@@ -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 (file)
index 0000000..bfa1ef5
--- /dev/null
@@ -0,0 +1,4 @@
+---
+api:
+  - |
+    Bundles can now be created, updated and deleted via the REST API.