From patchwork Mon Sep 23 21:50:12 2019 Content-Type: text/plain; charset="utf-8" MIME-Version: 1.0 Content-Transfer-Encoding: 7bit X-Patchwork-Submitter: Mete Polat X-Patchwork-Id: 1166217 Return-Path: X-Original-To: incoming@patchwork.ozlabs.org Delivered-To: patchwork-incoming@bilbo.ozlabs.org Received: from lists.ozlabs.org (lists.ozlabs.org [203.11.71.2]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (4096 bits)) (No client certificate requested) by ozlabs.org (Postfix) with ESMTPS id 46cdMj2tzVz9sCJ for ; Tue, 24 Sep 2019 07:52:17 +1000 (AEST) Authentication-Results: ozlabs.org; dmarc=fail (p=none dis=none) header.from=gmail.com Authentication-Results: ozlabs.org; dkim=fail reason="signature verification failed" (2048-bit key; unprotected) header.d=gmail.com header.i=@gmail.com header.b="AbOsfEa/"; dkim-atps=neutral Received: from bilbo.ozlabs.org (lists.ozlabs.org [IPv6:2401:3900:2:1::3]) by lists.ozlabs.org (Postfix) with ESMTP id 46cdMj25tGzDqPv for ; Tue, 24 Sep 2019 07:52:17 +1000 (AEST) X-Original-To: patchwork@lists.ozlabs.org Delivered-To: patchwork@lists.ozlabs.org Authentication-Results: lists.ozlabs.org; spf=pass (mailfrom) smtp.mailfrom=gmail.com (client-ip=2a00:1450:4864:20::344; helo=mail-wm1-x344.google.com; envelope-from=metepolat2000@gmail.com; receiver=) Authentication-Results: lists.ozlabs.org; dmarc=pass (p=none dis=none) header.from=gmail.com Authentication-Results: lists.ozlabs.org; dkim=pass (2048-bit key; unprotected) header.d=gmail.com header.i=@gmail.com header.b="AbOsfEa/"; dkim-atps=neutral Received: from mail-wm1-x344.google.com (mail-wm1-x344.google.com [IPv6:2a00:1450:4864:20::344]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits) server-digest SHA256) (No client certificate requested) by lists.ozlabs.org (Postfix) with ESMTPS id 46cdLY4KFRzDqF3 for ; Tue, 24 Sep 2019 07:51:17 +1000 (AEST) Received: by mail-wm1-x344.google.com with SMTP id i16so11623005wmd.3 for ; Mon, 23 Sep 2019 14:51:17 -0700 (PDT) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=gmail.com; s=20161025; h=from:to:cc:subject:date:message-id:in-reply-to:references :mime-version:content-transfer-encoding; bh=AAX7P5R4torNG1IY5i8P5ZTklfHU1wArpJu3wqwWkvw=; b=AbOsfEa/piSE/3vhdWui4D4Ute4esMP3yVjlzLmNAGiF5wU0X+pMwdq3m+1CkacqUh /NN7jWGKtvksx8c1i33BI61vmCiD9oix7vvQz5CZdiPk3Qa1+xAct0n9cmDnKgP4luli qvJNdxQbglXOCMb05BOQ+Vs6kPCR7p7ik0JOm05x1FjknsSfvjB1f7GOcIp4R91ZiDM0 vGmD4kAg+zU2SAtWYLtFwgVdQ/+S2UgXQSdnOb31VP9RwvmK8eFLzIAN3VCQk1ItwpMp Y58tYpesmBMV+uYo9QV/8hF73rCMeOEDnn3sYe1HpI6LL9KNV3/7Tm0oSRsUbqXmuEBX 8MoQ== X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=1e100.net; s=20161025; h=x-gm-message-state:from:to:cc:subject:date:message-id:in-reply-to :references:mime-version:content-transfer-encoding; bh=AAX7P5R4torNG1IY5i8P5ZTklfHU1wArpJu3wqwWkvw=; b=ftQLC5j1vuTAP7LePEd8fJNp77Hu1MrU4dtGze9+C/ozVnRM7LFDutcbmYzZPg+Wpt RDe57kgTigpY0waG3+mfuvo1R9A5MA17upF1PuVUQLUscE9kSvyKyq530B4IU3mRxx43 MLnhGIlOA5L4iBgD9IvuzU1Ul/jE4E/tt3m1+lsHL21h12CK94+M8T/AjeFss1SnQrus 2ERZzYw4p8nc45RSKF90TlFwhCfDgpTxP+J0Y374FhCEccEncReajkM1Iw0fV+/5u6p3 RfDzQoHiXp28K0Lnqh1wuyqAPgF6ZR66R925mCPWFG7uepEm+CSAjr0o0yU+AoqZ3iil gC5Q== X-Gm-Message-State: APjAAAUXz81mt7Bkc7IOMKLo8afRlszuvI1EUEvTorECUBqjdrquTzV6 JhYVWXPp4N1EzaOPUYr6F8g4NtoSvIjC1g== X-Google-Smtp-Source: APXvYqx7+HyDO/N6es3w60QGqccgoKym6MVZEhK21OLO1kTUv3/tSEJFE/e/j12liD1sbHWL/MxMcQ== X-Received: by 2002:a05:600c:20e:: with SMTP id 14mr1146970wmi.73.1569275473324; Mon, 23 Sep 2019 14:51:13 -0700 (PDT) Received: from Metes-MBP.fritz.box (aftr-62-216-202-68.dynamic.mnet-online.de. [62.216.202.68]) by smtp.gmail.com with ESMTPSA id d9sm16601782wrf.62.2019.09.23.14.51.12 (version=TLS1_2 cipher=ECDHE-RSA-AES128-SHA bits=128/128); Mon, 23 Sep 2019 14:51:12 -0700 (PDT) From: metepolat2000@gmail.com To: patchwork@lists.ozlabs.org Subject: [PATCH 3/3] REST: Add patch relations Date: Mon, 23 Sep 2019 23:50:12 +0200 Message-Id: <20190923215012.4670-4-metepolat2000@gmail.com> X-Mailer: git-send-email 2.23.0 In-Reply-To: <20190923215012.4670-1-metepolat2000@gmail.com> References: <20190923215012.4670-1-metepolat2000@gmail.com> MIME-Version: 1.0 X-BeenThere: patchwork@lists.ozlabs.org X-Mailman-Version: 2.1.29 Precedence: list List-Id: Patchwork development List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , Cc: ralf.ramsauer@oth-regensburg.de Errors-To: patchwork-bounces+incoming=patchwork.ozlabs.org@lists.ozlabs.org Sender: "Patchwork" From: Mete Polat 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// Needed permissions: GET: None PUT: "change_patchrelation" DELETE: "delete_patchrelation" Signed-off-by: Mete Polat --- 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 --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[^/]+)/$', + api_relation_views.PatchRelationDetail.as_view(), + name='api-relation-detail'), + ] + urlpatterns += [ url(r'^api/(?:(?P(1.0|1.1|1.2))/)?', include(api_patterns)), url(r'^api/(?:(?P(1.1|1.2))/)?', include(api_1_1_patterns)), + url(r'^api/(?:(?P1.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