diff mbox series

[3/3] REST: Add patch relations

Message ID 20190923215012.4670-4-metepolat2000@gmail.com
State Superseded
Headers show
Series Add patch relations | expand

Commit Message

Mete Polat Sept. 23, 2019, 9:50 p.m. UTC
From: Mete Polat <metepolat2000@gmail.com>

Add, update or delete relations. The relations API has been designed for
manual maintenance of relations by an admin or user with respective
permissions (see below) as well as for tools which can automatically
detect patch relations and want to forward their results to patchwork.
Therefore bulk insert, update and deletion is supported beside the
traditional way of single resource operations.
PUT requests are supported instead of POST ones as they allow automation
tools to handle relation_ids on their own.

New urls in REST API v1.2:
api/relations/
api/relations/<relation_id>/

Needed permissions:
GET: None
PUT: "change_patchrelation"
DELETE: "delete_patchrelation"

Signed-off-by: Mete Polat <metepolat2000@gmail.com>
---
 docs/api/schemas/latest/patchwork.yaml        | 149 +++++++++++++++++
 docs/api/schemas/patchwork.j2                 | 157 ++++++++++++++++++
 docs/api/schemas/v1.2/patchwork.yaml          | 149 +++++++++++++++++
 patchwork/api/index.py                        |   1 +
 patchwork/api/relation.py                     |  95 +++++++++++
 patchwork/tests/api/test_relation.py          | 154 +++++++++++++++++
 patchwork/tests/utils.py                      |  24 ++-
 patchwork/urls.py                             |  11 ++
 .../add-patch-relations-c96bb6c567b416d8.yaml |   9 +
 requirements-dev.txt                          |   1 +
 requirements-prod.txt                         |   1 +
 tox.ini                                       |   3 +-
 12 files changed, 752 insertions(+), 2 deletions(-)
 create mode 100644 patchwork/api/relation.py
 create mode 100644 patchwork/tests/api/test_relation.py
 create mode 100644 releasenotes/notes/add-patch-relations-c96bb6c567b416d8.yaml
diff mbox series

Patch

diff --git a/docs/api/schemas/latest/patchwork.yaml b/docs/api/schemas/latest/patchwork.yaml
index 45a6118..faa0e4e 100644
--- a/docs/api/schemas/latest/patchwork.yaml
+++ b/docs/api/schemas/latest/patchwork.yaml
@@ -916,6 +916,112 @@  paths:
                 $ref: '#/components/schemas/Error'
       tags:
         - series
+  /api/relations/:
+    get:
+      description: List relations.
+      operationId: relations_list
+      parameters:
+        - $ref: '#/components/parameters/Page'
+        - $ref: '#/components/parameters/PageSize'
+        - $ref: '#/components/parameters/Order'
+      responses:
+        '200':
+          description: ''
+          headers:
+            Link:
+              $ref: '#/components/headers/Link'
+          content:
+            application/json:
+              schema:
+                type: array
+                items:
+                  $ref: '#/components/schemas/Relation'
+      tags:
+        - relations
+    put:
+      description: Update one or multiple relations.
+      operationId: relations_update
+      security:
+        - basicAuth: []
+        - apiKeyAuth: []
+      requestBody:
+        $ref: '#/components/requestBodies/Relation'
+      responses:
+        '200':
+          description: ''
+          content:
+            application/json:
+              schema:
+                type: array
+                items:
+                  $ref: '#/components/schemas/Relation'
+        '201':
+          description: Created
+        '400':
+          description: Bad request
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+        '403':
+          description: Forbidden
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+        '404':
+          description: Not found
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+      tags:
+        - relations
+    delete:
+      description: Delete all relations.
+      operationId: relations_delete
+      security:
+        - basicAuth: []
+        - apiKeyAuth: []
+      responses:
+        '204':
+          description: ''
+        '403':
+          description: Forbidden
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+      tags:
+        - relations
+
+  /api/relations/{id}/:
+    get:
+      description: Show a relation.
+      operationId: relation_read
+      parameters:
+        - in: path
+          name: id
+          description: A unique integer value identifying this relation.
+          required: true
+          schema:
+            title: ID
+            type: integer
+      responses:
+        '200':
+          description: ''
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Relation'
+        '404':
+          description: Not found
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+      tags:
+        - relations
   /api/users/:
     get:
       description: List users.
@@ -1179,6 +1285,22 @@  components:
         application/x-www-form-urlencoded:
           schema:
             $ref: '#/components/schemas/User'
+    Relation:
+      required: true
+      content:
+        application/json:
+          schema:
+            oneOf:
+              - type: array
+                items:
+                  $ref: '#/components/schemas/RelationUpdate'
+              - $ref: '#/components/schemas/RelationUpdate'
+        multipart/form-data:
+          schema:
+            $ref: '#/components/schemas/RelationUpdate'
+        application/x-www-form-urlencoded:
+          schema:
+            $ref: '#/components/schemas/RelationUpdate'
   schemas:
     Index:
       type: object
@@ -1777,6 +1899,17 @@  components:
           title: Delegate
           type: integer
           nullable: true
+    RelationUpdate:
+      type: object
+      properties:
+        id:
+          title: ID
+          type: integer
+        patches:
+          title: Patch IDs
+          type: array
+          items:
+            type: integer
     Person:
       type: object
       properties:
@@ -1966,6 +2099,22 @@  components:
             $ref: '#/components/schemas/PatchEmbedded'
           readOnly: true
           uniqueItems: true
+    Relation:
+      type: object
+      properties:
+        id:
+          title: ID
+          type: integer
+        url:
+          title: URL
+          type: string
+          format: uri
+          readOnly: true
+        patches:
+          type: array
+          items:
+            $ref: '#/components/schemas/PatchEmbedded'
+          uniqueItems: true
     User:
       type: object
       properties:
diff --git a/docs/api/schemas/patchwork.j2 b/docs/api/schemas/patchwork.j2
index 16d85a3..dd85ca6 100644
--- a/docs/api/schemas/patchwork.j2
+++ b/docs/api/schemas/patchwork.j2
@@ -917,6 +917,114 @@  paths:
                 $ref: '#/components/schemas/Error'
       tags:
         - series
+{% if version >= (1, 2) %}
+  /api/{{ version_url }}relations/:
+    get:
+      description: List relations.
+      operationId: relations_list
+      parameters:
+        - $ref: '#/components/parameters/Page'
+        - $ref: '#/components/parameters/PageSize'
+        - $ref: '#/components/parameters/Order'
+      responses:
+        '200':
+          description: ''
+          headers:
+            Link:
+              $ref: '#/components/headers/Link'
+          content:
+            application/json:
+              schema:
+                type: array
+                items:
+                  $ref: '#/components/schemas/Relation'
+      tags:
+        - relations
+    put:
+      description: Update one or multiple relations.
+      operationId: relations_update
+      security:
+        - basicAuth: []
+        - apiKeyAuth: []
+      requestBody:
+        $ref: '#/components/requestBodies/Relation'
+      responses:
+        '200':
+          description: ''
+          content:
+            application/json:
+              schema:
+                type: array
+                items:
+                  $ref: '#/components/schemas/Relation'
+        '201':
+          description: Created
+        '400':
+          description: Bad request
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+        '403':
+          description: Forbidden
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+        '404':
+          description: Not found
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+      tags:
+        - relations
+    delete:
+      description: Delete all relations.
+      operationId: relations_delete
+      security:
+        - basicAuth: []
+        - apiKeyAuth: []
+      responses:
+        '204':
+          description: ''
+        '403':
+          description: Forbidden
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+      tags:
+        - relations
+
+  /api/{{ version_url }}relations/{id}/:
+    get:
+      description: Show a relation.
+      operationId: relation_read
+      parameters:
+        - in: path
+          name: id
+          description: A unique integer value identifying this relation.
+          required: true
+          schema:
+            title: ID
+            type: integer
+      responses:
+        '200':
+          description: ''
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Relation'
+        '404':
+          description: Not found
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+      tags:
+        - relations
+{% endif %}
   /api/{{ version_url }}users/:
     get:
       description: List users.
@@ -1180,6 +1288,24 @@  components:
         application/x-www-form-urlencoded:
           schema:
             $ref: '#/components/schemas/User'
+{% if version >= (1, 2) %}
+    Relation:
+      required: true
+      content:
+        application/json:
+          schema:
+            oneOf:
+              - type: array
+                items:
+                  $ref: '#/components/schemas/RelationUpdate'
+              - $ref: '#/components/schemas/RelationUpdate'
+        multipart/form-data:
+          schema:
+            $ref: '#/components/schemas/RelationUpdate'
+        application/x-www-form-urlencoded:
+          schema:
+            $ref: '#/components/schemas/RelationUpdate'
+{% endif %}
   schemas:
     Index:
       type: object
@@ -1798,6 +1924,19 @@  components:
           title: Delegate
           type: integer
           nullable: true
+{% if version >= (1, 2) %}
+    RelationUpdate:
+      type: object
+      properties:
+        id:
+          title: ID
+          type: integer
+        patches:
+          title: Patch IDs
+          type: array
+          items:
+            type: integer
+{% endif %}
     Person:
       type: object
       properties:
@@ -1993,6 +2132,24 @@  components:
             $ref: '#/components/schemas/PatchEmbedded'
           readOnly: true
           uniqueItems: true
+{% if version >= (1, 2) %}
+    Relation:
+      type: object
+      properties:
+        id:
+          title: ID
+          type: integer
+        url:
+          title: URL
+          type: string
+          format: uri
+          readOnly: true
+        patches:
+          type: array
+          items:
+            $ref: '#/components/schemas/PatchEmbedded'
+          uniqueItems: true
+{% endif %}
     User:
       type: object
       properties:
diff --git a/docs/api/schemas/v1.2/patchwork.yaml b/docs/api/schemas/v1.2/patchwork.yaml
index 3a96aa3..17412d2 100644
--- a/docs/api/schemas/v1.2/patchwork.yaml
+++ b/docs/api/schemas/v1.2/patchwork.yaml
@@ -916,6 +916,112 @@  paths:
                 $ref: '#/components/schemas/Error'
       tags:
         - series
+  /api/1.2/relations/:
+    get:
+      description: List relations.
+      operationId: relations_list
+      parameters:
+        - $ref: '#/components/parameters/Page'
+        - $ref: '#/components/parameters/PageSize'
+        - $ref: '#/components/parameters/Order'
+      responses:
+        '200':
+          description: ''
+          headers:
+            Link:
+              $ref: '#/components/headers/Link'
+          content:
+            application/json:
+              schema:
+                type: array
+                items:
+                  $ref: '#/components/schemas/Relation'
+      tags:
+        - relations
+    put:
+      description: Update one or multiple relations.
+      operationId: relations_update
+      security:
+        - basicAuth: []
+        - apiKeyAuth: []
+      requestBody:
+        $ref: '#/components/requestBodies/Relation'
+      responses:
+        '200':
+          description: ''
+          content:
+            application/json:
+              schema:
+                type: array
+                items:
+                  $ref: '#/components/schemas/Relation'
+        '201':
+          description: Created
+        '400':
+          description: Bad request
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+        '403':
+          description: Forbidden
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+        '404':
+          description: Not found
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+      tags:
+        - relations
+    delete:
+      description: Delete all relations.
+      operationId: relations_delete
+      security:
+        - basicAuth: []
+        - apiKeyAuth: []
+      responses:
+        '204':
+          description: ''
+        '403':
+          description: Forbidden
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+      tags:
+        - relations
+
+  /api/1.2/relations/{id}/:
+    get:
+      description: Show a relation.
+      operationId: relation_read
+      parameters:
+        - in: path
+          name: id
+          description: A unique integer value identifying this relation.
+          required: true
+          schema:
+            title: ID
+            type: integer
+      responses:
+        '200':
+          description: ''
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Relation'
+        '404':
+          description: Not found
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+      tags:
+        - relations
   /api/1.2/users/:
     get:
       description: List users.
@@ -1179,6 +1285,22 @@  components:
         application/x-www-form-urlencoded:
           schema:
             $ref: '#/components/schemas/User'
+    Relation:
+      required: true
+      content:
+        application/json:
+          schema:
+            oneOf:
+              - type: array
+                items:
+                  $ref: '#/components/schemas/RelationUpdate'
+              - $ref: '#/components/schemas/RelationUpdate'
+        multipart/form-data:
+          schema:
+            $ref: '#/components/schemas/RelationUpdate'
+        application/x-www-form-urlencoded:
+          schema:
+            $ref: '#/components/schemas/RelationUpdate'
   schemas:
     Index:
       type: object
@@ -1777,6 +1899,17 @@  components:
           title: Delegate
           type: integer
           nullable: true
+    RelationUpdate:
+      type: object
+      properties:
+        id:
+          title: ID
+          type: integer
+        patches:
+          title: Patch IDs
+          type: array
+          items:
+            type: integer
     Person:
       type: object
       properties:
@@ -1966,6 +2099,22 @@  components:
             $ref: '#/components/schemas/PatchEmbedded'
           readOnly: true
           uniqueItems: true
+    Relation:
+      type: object
+      properties:
+        id:
+          title: ID
+          type: integer
+        url:
+          title: URL
+          type: string
+          format: uri
+          readOnly: true
+        patches:
+          type: array
+          items:
+            $ref: '#/components/schemas/PatchEmbedded'
+          uniqueItems: true
     User:
       type: object
       properties:
diff --git a/patchwork/api/index.py b/patchwork/api/index.py
index 45485c9..cf18453 100644
--- a/patchwork/api/index.py
+++ b/patchwork/api/index.py
@@ -21,4 +21,5 @@  class IndexView(APIView):
             'series': reverse('api-series-list', request=request),
             'events': reverse('api-event-list', request=request),
             'bundles': reverse('api-bundle-list', request=request),
+            'relations': reverse('api-relation-list', request=request),
         })
diff --git a/patchwork/api/relation.py b/patchwork/api/relation.py
new file mode 100644
index 0000000..1eff61e
--- /dev/null
+++ b/patchwork/api/relation.py
@@ -0,0 +1,95 @@ 
+# Patchwork - automated patch tracking system
+# Copyright (C) 2019, Bayerische Motoren Werke Aktiengesellschaft (BMW AG)
+#
+# SPDX-License-Identifier: GPL-2.0-or-later
+
+from django.db import transaction
+from django.db.models import Count
+from rest_framework import status
+from rest_framework.generics import GenericAPIView
+from rest_framework.generics import RetrieveDestroyAPIView
+from rest_framework.mixins import ListModelMixin
+from rest_framework.permissions import DjangoModelPermissionsOrAnonReadOnly
+from rest_framework.response import Response
+from rest_framework_bulk import BulkCreateModelMixin
+from rest_framework_bulk import BulkDestroyModelMixin
+from rest_framework_bulk import BulkListSerializer
+from rest_framework_bulk import BulkUpdateModelMixin
+
+from patchwork.api.base import BaseHyperlinkedModelSerializer
+from patchwork.api.embedded import PatchSerializer
+from patchwork.models import PatchRelation
+
+
+class PatchRelationSerializer(BaseHyperlinkedModelSerializer):
+    patches = PatchSerializer(many=True)
+
+    class Meta:
+        model = PatchRelation
+        list_serializer_class = BulkListSerializer
+        fields = ('id', 'url', 'patches',)
+        read_only_fields = ('url',)
+        extra_kwargs = {
+            'id': {'read_only': False},
+            'url': {'view_name': 'api-relation-detail'},
+        }
+
+
+class PatchRelationMixin:
+    serializer_class = PatchRelationSerializer
+    permission_classes = [DjangoModelPermissionsOrAnonReadOnly]
+    queryset = PatchRelation.objects.all()\
+        .prefetch_related('patches')\
+        .annotate(patches_count=Count('patch'))
+
+
+class PatchRelationList(PatchRelationMixin, ListModelMixin,
+                        BulkCreateModelMixin, BulkUpdateModelMixin,
+                        BulkDestroyModelMixin, GenericAPIView):
+    ordering = 'id'
+    ordering_fields = ['id', 'patches_count']
+
+    def get(self, request, *args, **kwargs):
+        return self.list(request, *args, **kwargs)
+
+    @transaction.atomic(savepoint=False)
+    def delete(self, request, *args, **kwargs):
+        return self.bulk_destroy(request, *args, **kwargs)
+
+    @transaction.atomic(savepoint=False)
+    def put(self, request, *args, **kwargs):
+        """Put single or multiple relation objects, whether or not their ids
+        already exist"""
+        queryset = self.filter_queryset(self.get_queryset())
+        create = []
+        update = []
+
+        data = request.data if isinstance(request.data, list) \
+            else [request.data]
+
+        for d in data:
+            if queryset.filter(pk=d['id']).exists():
+                update.append(d)
+            else:
+                create.append(d)
+
+        response1 = None
+        if len(update) > 0:
+            request._full_data = update
+            response1 = self.bulk_update(request, *args, **kwargs)
+            if len(create) == 0:
+                return response1
+
+        response2 = None
+        if len(create) > 0:
+            request._full_data = create
+            response2 = self.create(request, *args, **kwargs)
+            if len(update) == 0:
+                return response2
+
+        data = getattr(response1, 'data', []) + getattr(response2, 'data', [])
+        return Response(data, status=status.HTTP_200_OK)
+
+
+class PatchRelationDetail(PatchRelationMixin, RetrieveDestroyAPIView):
+    pass
diff --git a/patchwork/tests/api/test_relation.py b/patchwork/tests/api/test_relation.py
new file mode 100644
index 0000000..8c02291
--- /dev/null
+++ b/patchwork/tests/api/test_relation.py
@@ -0,0 +1,154 @@ 
+# Patchwork - automated patch tracking system
+# Copyright (C) 2019, Bayerische Motoren Werke Aktiengesellschaft (BMW AG)
+#
+# SPDX-License-Identifier: GPL-2.0-or-later
+
+import unittest
+
+from django.conf import settings
+from django.urls import reverse
+
+from patchwork.tests.api import utils
+from patchwork.tests.utils import create_patch
+from patchwork.tests.utils import create_patches
+from patchwork.tests.utils import create_relation
+from patchwork.tests.utils import create_relations
+from patchwork.tests.utils import create_user
+
+if settings.ENABLE_REST_API:
+    from rest_framework import status
+
+
+@unittest.skipUnless(settings.ENABLE_REST_API, 'requires ENABLE_REST_API')
+class TestRelationAPI(utils.APITestCase):
+    fixtures = ['default_tags']
+
+    @staticmethod
+    def api_url(item=None):
+        kwargs = {}
+        if item is None:
+            return reverse('api-relation-list', kwargs=kwargs)
+        kwargs['pk'] = item
+        return reverse('api-relation-detail', kwargs=kwargs)
+
+    @staticmethod
+    def create_relation_obj(pk=1, patch_count=2):
+        class Relation:
+            def __init__(self, id, patches):
+                self.id = id
+                self.patches = patches
+
+        return Relation(pk, [p.id for p in create_patches(patch_count)])
+
+    def assertSerialized(self, relation_obj, relation_json):
+        self.failIf(isinstance(relation_obj, list) !=
+                    isinstance(relation_json, list))
+
+        self.assertEqual(relation_obj.id, relation_json['id'])
+
+        patch_ids_obj = relation_obj.patches
+        if not isinstance(patch_ids_obj, list):
+            # patch_ids_obj is a queryset
+            patch_ids_obj = [patch.id for patch in patch_ids_obj.all()]
+        patch_ids_json = [patch['id'] for patch in relation_json['patches']]
+        self.assertCountEqual(patch_ids_obj, patch_ids_json)
+
+    def test_list_empty(self):
+        """List relation when none are present."""
+        resp = self.client.get(self.api_url())
+        self.assertEqual(status.HTTP_200_OK, resp.status_code)
+        self.assertEqual(0, len(resp.data))
+
+    @utils.store_samples('relation-list')
+    def test_list(self):
+        """List relations."""
+        relation = create_relation()
+
+        resp = self.client.get(self.api_url())
+        self.assertEqual(status.HTTP_200_OK, resp.status_code)
+        self.assertEqual(1, len(resp.data))
+        relation_rsp = resp.data[0]
+        self.assertSerialized(relation, relation_rsp)
+
+    @utils.store_samples('relation-bulk-delete')
+    def test_bulk_delete(self):
+        """Delete all relations."""
+        create_relations(count=3)
+
+        resp = self.client.delete(self.api_url())
+        self.assertEqual(status.HTTP_403_FORBIDDEN, resp.status_code)
+
+        user = create_user(permissions=['delete_patchrelation'])
+        self.client.force_login(user)
+
+        resp = self.client.delete(self.api_url())
+        self.assertEqual(status.HTTP_204_NO_CONTENT, resp.status_code)
+        self.assertIsNone(resp.data)
+
+        resp = self.client.get(self.api_url())
+        self.assertEqual(0, len(resp.data))
+
+    @utils.store_samples('relation-bulk-put-single')
+    def test_bulk_put_single(self):
+        """Add and update a new relation."""
+
+        def send_and_check(expected_response_code):
+            resp = self.client.put(self.api_url(), relation.__dict__)
+            self.assertEqual(expected_response_code, resp.status_code)
+            if expected_response_code == status.HTTP_403_FORBIDDEN:
+                return
+            self.assertEqual(1, len(resp.data))
+            relation_rsp = resp.data[0]
+            self.assertSerialized(relation, relation_rsp)
+
+        user = create_user(permissions=['change_patchrelation'])
+        self.client.force_login(user)
+
+        relation = self.create_relation_obj()
+        send_and_check(status.HTTP_201_CREATED)
+
+        # update the relation
+        relation.patches.append(create_patch().id)
+        send_and_check(status.HTTP_200_OK)
+
+        # Forbid put action for anonymous user
+        self.client.logout()
+        send_and_check(status.HTTP_403_FORBIDDEN)
+
+    @utils.store_samples('relation-bulk-put-multiple')
+    def test_bulk_put_multiple(self):
+        """Add and update multiple relations."""
+
+        def send_and_check(expected_response_code):
+            data = [relation.__dict__ for relation in relations]
+            resp = self.client.put(self.api_url(), data)
+            self.assertEqual(expected_response_code, resp.status_code)
+            if expected_response_code == status.HTTP_403_FORBIDDEN:
+                return
+            self.assertEqual(len(relations), len(resp.data))
+
+            for i in range(len(relations)):
+                self.assertSerialized(relations[i], resp.data[i])
+
+        user = create_user(permissions=['change_patchrelation'])
+        self.client.force_login(user)
+
+        relations = [self.create_relation_obj(pk=i) for i in range(1, 3)]
+        send_and_check(status.HTTP_201_CREATED)
+
+        # update one relation and create another one
+        relations[0].patches.append(create_patch().id)
+        relations.append(self.create_relation_obj(pk=len(relations) + 1))
+        send_and_check(status.HTTP_200_OK)
+
+        # Forbid put action for anonymous user
+        self.client.logout()
+        send_and_check(status.HTTP_403_FORBIDDEN)
+
+    def test_detail(self):
+        """Show relation."""
+        relation = create_relation()
+
+        resp = self.client.get(self.api_url(relation.id))
+        self.assertEqual(status.HTTP_200_OK, resp.status_code)
+        self.assertSerialized(relation, resp.data)
diff --git a/patchwork/tests/utils.py b/patchwork/tests/utils.py
index 4ac9afe..290e410 100644
--- a/patchwork/tests/utils.py
+++ b/patchwork/tests/utils.py
@@ -9,6 +9,7 @@  from datetime import timedelta
 from email.utils import make_msgid
 import os
 
+from django.contrib.auth.models import Permission
 from django.contrib.auth.models import User
 
 from patchwork.models import Bundle
@@ -16,6 +17,7 @@  from patchwork.models import Check
 from patchwork.models import Comment
 from patchwork.models import CoverLetter
 from patchwork.models import Patch
+from patchwork.models import PatchRelation
 from patchwork.models import Person
 from patchwork.models import Project
 from patchwork.models import Series
@@ -79,11 +81,12 @@  def create_person(**kwargs):
     return Person.objects.create(**values)
 
 
-def create_user(link_person=True, **kwargs):
+def create_user(link_person=True, permissions=None, **kwargs):
     """Create a 'User' object.
 
     Args:
         link_person (bool): If true, create a linked Person object.
+        permissions (list): Permissions to add to user by codename.
     """
     num = User.objects.count()
 
@@ -101,6 +104,12 @@  def create_user(link_person=True, **kwargs):
                                     first_name=values['first_name'],
                                     last_name=values['last_name'])
 
+    if permissions is not None:
+        for codename in permissions:
+            perm = Permission.objects.get(codename=codename)
+            user.user_permissions.add(perm)
+    user.save()
+
     if link_person:
         # unfortunately we don't split on these
         values['name'] = ' '.join([values.pop('first_name'),
@@ -347,3 +356,16 @@  def create_covers(count=1, **kwargs):
         kwargs (dict): Overrides for various cover letter fields
     """
     return _create_submissions(create_cover, count, **kwargs)
+
+
+def create_relation(pk=1, count_patches=2):
+    relation = PatchRelation.objects.create(id=pk)
+    for _ in range(count_patches):
+        # relations can span over multiple projects
+        create_patch(project=create_project(), related=relation)
+    return relation
+
+
+def create_relations(count=2):
+    for i in range(1, count + 1):
+        create_relation(pk=i)
diff --git a/patchwork/urls.py b/patchwork/urls.py
index c24bf55..69717ad 100644
--- a/patchwork/urls.py
+++ b/patchwork/urls.py
@@ -164,6 +164,7 @@  if settings.ENABLE_REST_API:
     from patchwork.api import patch as api_patch_views  # noqa
     from patchwork.api import person as api_person_views  # noqa
     from patchwork.api import project as api_project_views  # noqa
+    from patchwork.api import relation as api_relation_views  # noqa
     from patchwork.api import series as api_series_views  # noqa
     from patchwork.api import user as api_user_views  # noqa
 
@@ -233,9 +234,19 @@  if settings.ENABLE_REST_API:
             name='api-cover-comment-list'),
     ]
 
+    api_1_2_patterns = [
+        url(r'^relations/$',
+            api_relation_views.PatchRelationList.as_view(),
+            name='api-relation-list'),
+        url(r'^relations/(?P<pk>[^/]+)/$',
+            api_relation_views.PatchRelationDetail.as_view(),
+            name='api-relation-detail'),
+    ]
+
     urlpatterns += [
         url(r'^api/(?:(?P<version>(1.0|1.1|1.2))/)?', include(api_patterns)),
         url(r'^api/(?:(?P<version>(1.1|1.2))/)?', include(api_1_1_patterns)),
+        url(r'^api/(?:(?P<version>1.2)/)?', include(api_1_2_patterns)),
 
         # token change
         url(r'^user/generate-token/$', user_views.generate_token,
diff --git a/releasenotes/notes/add-patch-relations-c96bb6c567b416d8.yaml b/releasenotes/notes/add-patch-relations-c96bb6c567b416d8.yaml
new file mode 100644
index 0000000..6cc6ce8
--- /dev/null
+++ b/releasenotes/notes/add-patch-relations-c96bb6c567b416d8.yaml
@@ -0,0 +1,9 @@ 
+---
+features:
+  - |
+    A patch can now be related to other ones (e.g. revisions). Patch relations
+    can be set via the REST API for automating this task.
+api:
+  - |
+    Patch relations are available via ``/relations/`` and
+    ``/relations/{relationID}/`` endpoints and support bulk operations.
diff --git a/requirements-dev.txt b/requirements-dev.txt
index 60eb8a6..8a5a0fe 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -2,6 +2,7 @@  Django~=2.2.0; python_version >= '3.5'
 Django~=1.11.0; python_version < '3.0'  # pyup: ignore
 djangorestframework~=3.10.0; python_version >= '3.5'
 djangorestframework~=3.9.0; python_version < '3.0'  # pyup: ignore
+djangorestframework-bulk~=0.2.1
 django-filter~=2.2.0; python_version >= '3.5'
 django-filter~=1.1.0; python_version < '3.0'  # pyup: ignore
 django-debug-toolbar~=2.0.0; python_version >= '3.5'  # pyup: ignore
diff --git a/requirements-prod.txt b/requirements-prod.txt
index 797d30b..9164dc4 100644
--- a/requirements-prod.txt
+++ b/requirements-prod.txt
@@ -2,6 +2,7 @@  Django~=2.2.0; python_version >= '3.5'
 Django~=1.11.0; python_version < '3.0'  # pyup: ignore
 djangorestframework~=3.10.0; python_version >= '3.5'
 djangorestframework~=3.9.0; python_version < '3.0'  # pyup: ignore
+djangorestframework-bulk~=0.2.1
 django-filter~=2.2.0; python_version >= '3.5'
 django-filter~=1.1.0; python_version < '3.0'  # pyup: ignore
 psycopg2-binary~=2.8.0
diff --git a/tox.ini b/tox.ini
index 0c03857..1751f85 100644
--- a/tox.ini
+++ b/tox.ini
@@ -17,7 +17,8 @@  deps =
     django{20,21}: django-filter>=2.0,<3.0
     django22: django>=2.2,<2.3
     django22: djangorestframework>=3.10,<3.11
-    django22: django-filter>=2.1,<3.0
+    django{111,20,21,22}: djangorestframework-bulk>=0.2,<0.3
+
 setenv =
     DJANGO_SETTINGS_MODULE = patchwork.settings.dev
     PYTHONDONTWRITEBYTECODE = 1