From patchwork Wed Oct 16 18:55:44 2019 Content-Type: text/plain; charset="utf-8" MIME-Version: 1.0 Content-Transfer-Encoding: 7bit X-Patchwork-Submitter: Mete Polat X-Patchwork-Id: 1178079 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 46thQD3G6lz9sNx for ; Thu, 17 Oct 2019 05:58:12 +1100 (AEDT) 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="QzlzaRRj"; 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 46thQD28YWzDr34 for ; Thu, 17 Oct 2019 05:58:12 +1100 (AEDT) X-Original-To: patchwork@lists.ozlabs.org Delivered-To: patchwork@lists.ozlabs.org Authentication-Results: lists.ozlabs.org; spf=pass (sender SPF authorized) smtp.mailfrom=gmail.com (client-ip=2a00:1450:4864:20::430; helo=mail-wr1-x430.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="QzlzaRRj"; dkim-atps=neutral Received: from mail-wr1-x430.google.com (mail-wr1-x430.google.com [IPv6:2a00:1450:4864:20::430]) (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 46thNM41FSzDqXD for ; Thu, 17 Oct 2019 05:56:35 +1100 (AEDT) Received: by mail-wr1-x430.google.com with SMTP id p14so29262024wro.4 for ; Wed, 16 Oct 2019 11:56:35 -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=VIV5BkEO+DLVZUPb9rrX+U35MZiuyqL9qW3U7+IsQSY=; b=QzlzaRRjWC080GlT1avFOdXkAoIpq+IuuhFQzjnmVOxZWYZhHQa/iSqJFv867X1gUk HlUIANpqTJtcdm8Fo/iMxmqpvjjS0d1P0Cdpoflr7obBCUU7cGNiRxyLwNAcJvbxNz9H Pz/zAisyeqloK+e2trA7Pf9hWK86nixizAwpHNu3hqEg+FWAid4QgTBbPwsWtN1tNCCg 8gx81ETgEtrHC6QqJRb2sleWmFNHAqOWUFEjsZ9CTYPNNHcVtbbBSJT9fQhzDEobQinU Xh6+q7CzbQB+VpcQRiBfJeplu458S15S0nAZhjdIVjkMnNwDQIDMkZ+gFEu6tD/aWj2S 7M3w== 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=VIV5BkEO+DLVZUPb9rrX+U35MZiuyqL9qW3U7+IsQSY=; b=jpCpySOWosSn6kNv82HtRFK/5++CM6ljIsYulZ2RtRxCGUS9xGq3xbQI/XcBkbeo+d L5TJa15lY7bTnnpohhu5QzI7ik3Pij/cZjqWM2G17qqNdg04YyBfe4swBfVYO8PDSItJ YrTnvpoqfV/rBLcrS9DypoxUOK9chw4F2CkfWRWscVdRxkB0utC8Y8olWPRkF5LqbCUV KHXXzvQB7TxmCUNujwWPZWFAI8KucdjtgTkl1COC4VuGyhLMNYz2+3rXCy2tbdiRdw1b ZQBI09d1/Rjq4e4cVASbM2+nnDFUOZ2xy5shoMpHYwesm2ETEQMpWYFoMViE7EesSNJ6 kS5A== X-Gm-Message-State: APjAAAUfmarFOR4eN4nR98sUjU6hIq/kWYq962Ej3QcWeAYo9KobXAoE QQYgs1ZVQ+HmzOyPfjYFuYO2OhTYH/L8Tg== X-Google-Smtp-Source: APXvYqyHBlCCUW/WHHP4B4oob3aKYcGxQonDWTIKVsAYZpaR4kWHy9MkgvMJB6RGem08g3P3UynrHQ== X-Received: by 2002:adf:a4ce:: with SMTP id h14mr4217707wrb.263.1571252191051; Wed, 16 Oct 2019 11:56:31 -0700 (PDT) Received: from Metes-MBP.fritz.box (aftr-62-216-209-148.dynamic.mnet-online.de. [62.216.209.148]) by smtp.gmail.com with ESMTPSA id a9sm4277153wmf.14.2019.10.16.11.56.30 (version=TLS1_2 cipher=ECDHE-RSA-AES128-GCM-SHA256 bits=128/128); Wed, 16 Oct 2019 11:56:30 -0700 (PDT) From: Mete Polat X-Google-Original-From: Mete Polat To: patchwork@lists.ozlabs.org Subject: [PATCH 5/5] REST: Add submission relations Date: Wed, 16 Oct 2019 20:55:44 +0200 Message-Id: <20191016185544.49631-6-mete.polat@tuta.io> X-Mailer: git-send-email 2.23.0 In-Reply-To: <20191016185544.49631-1-mete.polat@tuta.io> References: <20191016185544.49631-1-mete.polat@tuta.io> 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 View relations or add/update/delete them as a maintainer. Maintainers can only create relations of sumbissions (patches/cover letters) which are part of a project they maintain. New REST API urls: api/relations/ api/relations// Signed-off-by: Mete Polat --- Previously it was possible to use the PatchSerializer. As we expanded the support for submissions in general, it isn't a simple task anymore for showing hyperlinked submissions as Patch and CoverLetter are not flattened into one model yet. Right now only the submission ids are shown. docs/api/schemas/latest/patchwork.yaml | 218 +++++++++++++++++ docs/api/schemas/patchwork.j2 | 230 ++++++++++++++++++ docs/api/schemas/v1.2/patchwork.yaml | 218 +++++++++++++++++ patchwork/api/index.py | 1 + patchwork/api/relation.py | 73 ++++++ patchwork/tests/api/test_relation.py | 194 +++++++++++++++ patchwork/tests/utils.py | 11 + patchwork/urls.py | 11 + ...submission-relations-c96bb6c567b416d8.yaml | 10 + 9 files changed, 966 insertions(+) create mode 100644 patchwork/api/relation.py create mode 100644 patchwork/tests/api/test_relation.py create mode 100644 releasenotes/notes/add-submission-relations-c96bb6c567b416d8.yaml diff --git a/docs/api/schemas/latest/patchwork.yaml b/docs/api/schemas/latest/patchwork.yaml index 11ea4a6..7f9df72 100644 --- a/docs/api/schemas/latest/patchwork.yaml +++ b/docs/api/schemas/latest/patchwork.yaml @@ -916,6 +916,176 @@ 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 + post: + description: Create a relation. + operationId: relations_create + security: + - basicAuth: [] + - apiKeyAuth: [] + requestBody: + $ref: '#/components/requestBodies/Relation' + responses: + '201': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/Relation' + '400': + description: Invalid Request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorRelation' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + tags: + - checks + /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 + patch: + description: Update a relation (partial). + operationId: relations_partial_update + security: + - basicAuth: [] + - apiKeyAuth: [] + parameters: + - in: path + name: id + description: A unique integer value identifying this relation. + required: true + schema: + title: ID + type: integer + requestBody: + $ref: '#/components/requestBodies/Relation' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/Relation' + '400': + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorRelation' + '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 + put: + description: Update a relation. + operationId: relations_update + security: + - basicAuth: [] + - apiKeyAuth: [] + parameters: + - in: path + name: id + description: A unique integer value identifying this relation. + required: true + schema: + title: ID + type: integer + requestBody: + $ref: '#/components/requestBodies/Relation' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/Relation' + '400': + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorRelation' + '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 /api/users/: get: description: List users. @@ -1179,6 +1349,18 @@ components: application/x-www-form-urlencoded: schema: $ref: '#/components/schemas/User' + Relation: + required: true + content: + application/json: + schema: + $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 @@ -1223,6 +1405,11 @@ components: type: string format: uri readOnly: true + relations: + title: Relations URL + type: string + format: uri + readOnly: true Bundle: required: - name @@ -1782,6 +1969,14 @@ components: title: Delegate type: integer nullable: true + RelationUpdate: + type: object + properties: + submissions: + title: Submission IDs + type: array + items: + type: integer Person: type: object properties: @@ -1971,6 +2166,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 + submissions: + type: array + items: + type: integer + uniqueItems: true User: type: object properties: @@ -2368,6 +2579,13 @@ components: type: string format: uri readOnly: true + ErrorRelation: + type: object + properties: + submissions: + type: array + items: + type: string ErrorUserUpdate: type: object properties: diff --git a/docs/api/schemas/patchwork.j2 b/docs/api/schemas/patchwork.j2 index 2b1e043..ea1aadd 100644 --- a/docs/api/schemas/patchwork.j2 +++ b/docs/api/schemas/patchwork.j2 @@ -917,6 +917,178 @@ 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 + post: + description: Create a relation. + operationId: relations_create + security: + - basicAuth: [] + - apiKeyAuth: [] + requestBody: + $ref: '#/components/requestBodies/Relation' + responses: + '201': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/Relation' + '400': + description: Invalid Request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorRelation' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + tags: + - checks + /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 + patch: + description: Update a relation (partial). + operationId: relations_partial_update + security: + - basicAuth: [] + - apiKeyAuth: [] + parameters: + - in: path + name: id + description: A unique integer value identifying this relation. + required: true + schema: + title: ID + type: integer + requestBody: + $ref: '#/components/requestBodies/Relation' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/Relation' + '400': + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorRelation' + '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 + put: + description: Update a relation. + operationId: relations_update + security: + - basicAuth: [] + - apiKeyAuth: [] + parameters: + - in: path + name: id + description: A unique integer value identifying this relation. + required: true + schema: + title: ID + type: integer + requestBody: + $ref: '#/components/requestBodies/Relation' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/Relation' + '400': + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorRelation' + '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 +{% endif %} /api/{{ version_url }}users/: get: description: List users. @@ -1180,6 +1352,20 @@ components: application/x-www-form-urlencoded: schema: $ref: '#/components/schemas/User' +{% if version >= (1, 2) %} + Relation: + required: true + content: + application/json: + schema: + $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 @@ -1224,6 +1410,13 @@ components: type: string format: uri readOnly: true +{% if version >= (1, 2) %} + relations: + title: Relations URL + type: string + format: uri + readOnly: true +{% endif %} Bundle: required: - name @@ -1803,6 +1996,16 @@ components: title: Delegate type: integer nullable: true +{% if version >= (1, 2) %} + RelationUpdate: + type: object + properties: + submissions: + title: Submission IDs + type: array + items: + type: integer +{% endif %} Person: type: object properties: @@ -1998,6 +2201,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 + submissions: + type: array + items: + type: integer + uniqueItems: true +{% endif %} User: type: object properties: @@ -2407,6 +2628,15 @@ components: type: string format: uri readOnly: true +{% if version >= (1, 2) %} + ErrorRelation: + type: object + properties: + submissions: + type: array + items: + type: string +{% endif %} ErrorUserUpdate: type: object properties: diff --git a/docs/api/schemas/v1.2/patchwork.yaml b/docs/api/schemas/v1.2/patchwork.yaml index 45018cc..a7d801f 100644 --- a/docs/api/schemas/v1.2/patchwork.yaml +++ b/docs/api/schemas/v1.2/patchwork.yaml @@ -916,6 +916,176 @@ 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 + post: + description: Create a relation. + operationId: relations_create + security: + - basicAuth: [] + - apiKeyAuth: [] + requestBody: + $ref: '#/components/requestBodies/Relation' + responses: + '201': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/Relation' + '400': + description: Invalid Request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorRelation' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + tags: + - checks + /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 + patch: + description: Update a relation (partial). + operationId: relations_partial_update + security: + - basicAuth: [] + - apiKeyAuth: [] + parameters: + - in: path + name: id + description: A unique integer value identifying this relation. + required: true + schema: + title: ID + type: integer + requestBody: + $ref: '#/components/requestBodies/Relation' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/Relation' + '400': + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorRelation' + '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 + put: + description: Update a relation. + operationId: relations_update + security: + - basicAuth: [] + - apiKeyAuth: [] + parameters: + - in: path + name: id + description: A unique integer value identifying this relation. + required: true + schema: + title: ID + type: integer + requestBody: + $ref: '#/components/requestBodies/Relation' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/Relation' + '400': + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorRelation' + '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 /api/1.2/users/: get: description: List users. @@ -1179,6 +1349,18 @@ components: application/x-www-form-urlencoded: schema: $ref: '#/components/schemas/User' + Relation: + required: true + content: + application/json: + schema: + $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 @@ -1223,6 +1405,11 @@ components: type: string format: uri readOnly: true + relations: + title: Relations URL + type: string + format: uri + readOnly: true Bundle: required: - name @@ -1782,6 +1969,14 @@ components: title: Delegate type: integer nullable: true + RelationUpdate: + type: object + properties: + submissions: + title: Submission IDs + type: array + items: + type: integer Person: type: object properties: @@ -1971,6 +2166,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 + submissions: + type: array + items: + type: integer + uniqueItems: true User: type: object properties: @@ -2368,6 +2579,13 @@ components: type: string format: uri readOnly: true + ErrorRelation: + type: object + properties: + submissions: + type: array + items: + type: string ErrorUserUpdate: 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..e7d002b --- /dev/null +++ b/patchwork/api/relation.py @@ -0,0 +1,73 @@ +# 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.models import Count +from rest_framework import permissions +from rest_framework.generics import ListCreateAPIView +from rest_framework.generics import RetrieveUpdateDestroyAPIView +from rest_framework.serializers import ModelSerializer + +from patchwork.models import SubmissionRelation + + +class MaintainerPermission(permissions.BasePermission): + + def has_object_permission(self, request, view, submissions): + if request.method in permissions.SAFE_METHODS: + return True + + user = request.user + if not user.is_authenticated: + return False + + if isinstance(submissions, SubmissionRelation): + submissions = list(submissions.submissions.all()) + maintaining = user.profile.maintainer_projects.all() + return all(s.project in maintaining for s in submissions) + + def has_permission(self, request, view): + return request.method in permissions.SAFE_METHODS or \ + (request.user.is_authenticated and + request.user.profile.maintainer_projects.count() > 0) + + +class SubmissionRelationSerializer(ModelSerializer): + class Meta: + model = SubmissionRelation + fields = ('id', 'url', 'submissions',) + read_only_fields = ('url',) + extra_kwargs = { + 'url': {'view_name': 'api-relation-detail'}, + } + + +class SubmissionRelationMixin: + serializer_class = SubmissionRelationSerializer + permission_classes = (MaintainerPermission,) + + def get_queryset(self): + return SubmissionRelation.objects.all() \ + .prefetch_related('submissions') + + +class SubmissionRelationList(SubmissionRelationMixin, ListCreateAPIView): + ordering = 'id' + ordering_fields = ['id', 'submission_count'] + + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + submissions = serializer.validated_data['submissions'] + self.check_object_permissions(request, submissions) + return super().create(request, *args, **kwargs) + + def get_queryset(self): + return super().get_queryset() \ + .annotate(submission_count=Count('submission')) + + +class SubmissionRelationDetail(SubmissionRelationMixin, + RetrieveUpdateDestroyAPIView): + pass diff --git a/patchwork/tests/api/test_relation.py b/patchwork/tests/api/test_relation.py new file mode 100644 index 0000000..296926d --- /dev/null +++ b/patchwork/tests/api/test_relation.py @@ -0,0 +1,194 @@ +# 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 enum import Enum +from enum import auto + +import six +from django.conf import settings +from django.urls import reverse + +from patchwork.models import SubmissionRelation +from patchwork.tests.api import utils +from patchwork.tests.utils import create_maintainer +from patchwork.tests.utils import create_patches +from patchwork.tests.utils import create_project +from patchwork.tests.utils import create_relation +from patchwork.tests.utils import create_user + +if settings.ENABLE_REST_API: + from rest_framework import status + + +class UserType(Enum): + ANONYMOUS = auto() + NON_MAINTAINER = auto() + MAINTAINER = auto() + +@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) + + def request_restricted(self, method, user_type: UserType): + # setup + + project = create_project() + + if user_type == UserType.ANONYMOUS: + expected_status = status.HTTP_403_FORBIDDEN + elif user_type == UserType.NON_MAINTAINER: + expected_status = status.HTTP_403_FORBIDDEN + self.client.force_authenticate(user=create_user()) + elif user_type == UserType.MAINTAINER: + if method == 'post': + expected_status = status.HTTP_201_CREATED + elif method == 'delete': + expected_status = status.HTTP_204_NO_CONTENT + else: + expected_status = status.HTTP_200_OK + user = create_maintainer(project) + self.client.force_authenticate(user=user) + else: + raise ValueError + + resource_id = None + send = None + + if method == 'delete': + resource_id = create_relation(project=project).id + elif method == 'post': + patch_ids = [p.id for p in create_patches(2, project=project)] + send = {'submissions': patch_ids} + elif method == 'patch': + resource_id = create_relation(project=project).id + patch_ids = [p.id for p in create_patches(2, project=project)] + send = {'submissions': patch_ids} + else: + raise ValueError + + # request + + resp = getattr(self.client, method)(self.api_url(resource_id), send) + + # check + + self.assertEqual(expected_status, resp.status_code) + + if resp.status_code not in range(200, 202): + return + + if resource_id: + self.assertEqual(resource_id, resp.data['id']) + + send_ids = send['submissions'] + resp_ids = resp.data['submissions'] + six.assertCountEqual(self, resp_ids, send_ids) + + def assertSerialized(self, obj: SubmissionRelation, resp: dict): + self.assertEqual(obj.id, resp['id']) + obj = [s.id for s in obj.submissions.all()] + six.assertCountEqual(self, obj, resp['submissions']) + + 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)) + self.assertSerialized(relation, resp.data[0]) + + 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) + + @utils.store_samples('relation-update-error-forbidden') + def test_update_anonymous(self): + """Update relation as anonymous user. + + Ensure updates can be performed by maintainers. + """ + self.request_restricted('patch', UserType.ANONYMOUS) + + def test_update_non_maintainer(self): + """Update relation as non-maintainer. + + Ensure updates can be performed by maintainers. + """ + self.request_restricted('patch', UserType.NON_MAINTAINER) + + @utils.store_samples('relation-update') + def test_update_maintainer(self): + """Update relation as maintainer. + + Ensure updates can be performed by maintainers. + """ + self.request_restricted('patch', UserType.MAINTAINER) + + @utils.store_samples('relation-delete-error-forbidden') + def test_delete_anonymous(self): + """Delete relation as anonymous user. + + Ensure deletes can be performed by maintainers. + """ + self.request_restricted('delete', UserType.ANONYMOUS) + + def test_delete_non_maintainer(self): + """Delete relation as non-maintainer. + + Ensure deletes can be performed by maintainers. + """ + self.request_restricted('delete', UserType.NON_MAINTAINER) + + @utils.store_samples('relation-update') + def test_delete_maintainer(self): + """Delete relation as maintainer. + + Ensure deletes can be performed by maintainers. + """ + self.request_restricted('delete', UserType.MAINTAINER) + + @utils.store_samples('relation-create-error-forbidden') + def test_create_anonymous(self): + """Create relation as anonymous user. + + Ensure creates can be performed by maintainers. + """ + self.request_restricted('post', UserType.ANONYMOUS) + + def test_create_non_maintainer(self): + """Create relation as non-maintainer. + + Ensure creates can be performed by maintainers. + """ + self.request_restricted('post', UserType.NON_MAINTAINER) + + @utils.store_samples('relation-create') + def test_create_maintainer(self): + """Create relation as maintainer. + + Ensure creates can be performed by maintainers. + """ + self.request_restricted('post', UserType.MAINTAINER) diff --git a/patchwork/tests/utils.py b/patchwork/tests/utils.py index 577183d..47149de 100644 --- a/patchwork/tests/utils.py +++ b/patchwork/tests/utils.py @@ -16,6 +16,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 SubmissionRelation from patchwork.models import Person from patchwork.models import Project from patchwork.models import Series @@ -347,3 +348,13 @@ def create_covers(count=1, **kwargs): kwargs (dict): Overrides for various cover letter fields """ return _create_submissions(create_cover, count, **kwargs) + + +def create_relation(count_patches=2, **kwargs): + relation = SubmissionRelation.objects.create() + values = { + 'related': relation + } + values.update(kwargs) + create_patches(count_patches, **values) + return relation diff --git a/patchwork/urls.py b/patchwork/urls.py index dcdcfb4..92095f6 100644 --- a/patchwork/urls.py +++ b/patchwork/urls.py @@ -187,6 +187,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 @@ -256,9 +257,19 @@ if settings.ENABLE_REST_API: name='api-cover-comment-list'), ] + api_1_2_patterns = [ + url(r'^relations/$', + api_relation_views.SubmissionRelationList.as_view(), + name='api-relation-list'), + url(r'^relations/(?P[^/]+)/$', + api_relation_views.SubmissionRelationDetail.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-submission-relations-c96bb6c567b416d8.yaml b/releasenotes/notes/add-submission-relations-c96bb6c567b416d8.yaml new file mode 100644 index 0000000..cb87799 --- /dev/null +++ b/releasenotes/notes/add-submission-relations-c96bb6c567b416d8.yaml @@ -0,0 +1,10 @@ +--- +features: + - | + Submissions (cover letters or patches) can now be related to other ones + (e.g. revisions). Relations can be set via the REST API by maintainers + (currently only for submissions of projects they maintain) +api: + - | + Relations are available via ``/relations/`` and + ``/relations/{relationID}/`` endpoints.