From patchwork Sun Sep 8 22:31:46 2019 Content-Type: text/plain; charset="utf-8" MIME-Version: 1.0 Content-Transfer-Encoding: 7bit X-Patchwork-Submitter: Stephen Finucane X-Patchwork-Id: 1159558 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) server-digest SHA256) (No client certificate requested) by ozlabs.org (Postfix) with ESMTPS id 46RQyw65p1z9s4Y for ; Mon, 9 Sep 2019 08:32:24 +1000 (AEST) Authentication-Results: ozlabs.org; dmarc=none (p=none dis=none) header.from=that.guru Authentication-Results: ozlabs.org; dkim=fail reason="key not found in DNS" (0-bit key; unprotected) header.d=that.guru header.i=@that.guru header.b="jbktHH8T"; 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 46RQyw54ttzDqR2 for ; Mon, 9 Sep 2019 08:32:24 +1000 (AEST) X-Original-To: patchwork@lists.ozlabs.org Delivered-To: patchwork@lists.ozlabs.org Authentication-Results: lists.ozlabs.org; spf=none (mailfrom) smtp.mailfrom=that.guru (client-ip=172.82.139.203; helo=qrelay203.mxroute.com; envelope-from=stephen@that.guru; receiver=) Authentication-Results: lists.ozlabs.org; dmarc=none (p=none dis=none) header.from=that.guru Authentication-Results: lists.ozlabs.org; dkim=fail reason="key not found in DNS" (0-bit key; unprotected) header.d=that.guru header.i=@that.guru header.b="jbktHH8T"; dkim-atps=neutral Received: from qrelay203.mxroute.com (qrelay203.mxroute.com [172.82.139.203]) (using TLSv1.2 with cipher ECDHE-RSA-AES256-GCM-SHA384 (256/256 bits)) (No client certificate requested) by lists.ozlabs.org (Postfix) with ESMTPS id 46RQyT5B0WzDqLC for ; Mon, 9 Sep 2019 08:32:01 +1000 (AEST) Received: from filter001.mxrelay.co (unknown [116.203.155.46]) by qrelay203.mxroute.com (Postfix) with ESMTP id DAACC1C1505 for ; Sun, 8 Sep 2019 18:31:57 -0400 (EDT) Received: from one.mxroute.com (one.mxroute.com [195.201.59.211]) by filter001.mxrelay.co (Postfix) with ESMTPS id 2A55B1009E5 for ; Sun, 8 Sep 2019 22:31:56 +0000 (UTC) DKIM-Signature: v=1; a=rsa-sha256; q=dns/txt; c=relaxed/relaxed; d=that.guru; s=default; h=Content-Transfer-Encoding:MIME-Version:References:In-Reply-To: Message-Id:Date:Subject:Cc:To:From:Sender:Reply-To:Content-Type:Content-ID: Content-Description:Resent-Date:Resent-From:Resent-Sender:Resent-To:Resent-Cc :Resent-Message-ID:List-Id:List-Help:List-Unsubscribe:List-Subscribe: List-Post:List-Owner:List-Archive; bh=LTXpFYJeHDNuF2u+8rIMHIUK7cSv6AzlgQN4Mgmlvv8=; b=jbktHH8Tklp3Wr6tpM8gswbOWM xTj963u6U/HzwBAKFqQbf5OcOlmdZ1ded/1AXxz+Kb9qlVdktDaliTGzaUNWUcvt0M249L2z+yVIf /6ueM3BiWmhLGgLNQWcGFquPoGYLi92Fo9lerhj2B3XVzM/Zx2mMC0K1HZCyIRg+FIj3oRzIefB2l ++wRFSKIwpUZmybjBx8pMbDB+c9sVzJGeMPT5L/rRWaVUsdMe8zIXWX133PSMMr2zRkjA49ZGUsrd ainnZtdUQdeHzIzyFw2l3wTt2ILRiVtS5x9dBeAtX6KwZ6tVaRkVjl1E580hdI7bm+2Qb5RE4f3Ok MAHW0Yng==; From: Stephen Finucane To: patchwork@lists.ozlabs.org Subject: [PATCH 1/2] REST: Add 'has_version' helper Date: Sun, 8 Sep 2019 23:31:46 +0100 Message-Id: <20190908223147.25789-2-stephen@that.guru> X-Mailer: git-send-email 2.21.0 In-Reply-To: <20190908223147.25789-1-stephen@that.guru> References: <20190908223147.25789-1-stephen@that.guru> MIME-Version: 1.0 X-AuthUser: stephen@that.guru 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: , Errors-To: patchwork-bounces+incoming=patchwork.ozlabs.org@lists.ozlabs.org Sender: "Patchwork" We're going to use this functionality elsewhere shortly. Signed-off-by: Stephen Finucane --- patchwork/api/base.py | 12 +++--------- patchwork/api/utils.py | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 9 deletions(-) create mode 100644 patchwork/api/utils.py diff --git a/patchwork/api/base.py b/patchwork/api/base.py index 943afa21..89a43114 100644 --- a/patchwork/api/base.py +++ b/patchwork/api/base.py @@ -3,7 +3,6 @@ # # SPDX-License-Identifier: GPL-2.0-or-later -from distutils.version import StrictVersion from django.conf import settings from django.shortcuts import get_object_or_404 @@ -13,6 +12,8 @@ from rest_framework.response import Response from rest_framework.serializers import HyperlinkedIdentityField from rest_framework.serializers import HyperlinkedModelSerializer +from patchwork.api import utils + class LinkHeaderPagination(PageNumberPagination): """Provide pagination based on rfc5988. @@ -90,17 +91,10 @@ class BaseHyperlinkedModelSerializer(HyperlinkedModelSerializer): instance) request = self.context.get('request') - if not request or not request.version: - # without version information, we have to assume the latest - return data - - requested_version = StrictVersion(request.version) - for version in getattr(self.Meta, 'versioned_fields', {}): # if the user has requested a version lower that than in which the # field was added, we drop it - required_version = StrictVersion(version) - if required_version > requested_version: + if not utils.has_version(request, version): for field in self.Meta.versioned_fields[version]: data.pop(field) diff --git a/patchwork/api/utils.py b/patchwork/api/utils.py new file mode 100644 index 00000000..7d46e846 --- /dev/null +++ b/patchwork/api/utils.py @@ -0,0 +1,14 @@ +# Patchwork - automated patch tracking system +# Copyright (C) 2018 Stephen Finucane +# +# SPDX-License-Identifier: GPL-2.0-or-later + +from distutils.version import StrictVersion + + +def has_version(request, version): + if not request.version: + # without version information, we have to assume the latest + return True + + return StrictVersion(request.version) >= StrictVersion(version) From patchwork Sun Sep 8 22:31:47 2019 Content-Type: text/plain; charset="utf-8" MIME-Version: 1.0 Content-Transfer-Encoding: 7bit X-Patchwork-Submitter: Stephen Finucane X-Patchwork-Id: 1159559 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 46RQzD3c2Vz9s4Y for ; Mon, 9 Sep 2019 08:32:40 +1000 (AEST) Authentication-Results: ozlabs.org; dmarc=none (p=none dis=none) header.from=that.guru Authentication-Results: ozlabs.org; dkim=fail reason="key not found in DNS" (0-bit key; unprotected) header.d=that.guru header.i=@that.guru header.b="mN5WvbMV"; 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 46RQzD1v6mzDqQc for ; Mon, 9 Sep 2019 08:32:40 +1000 (AEST) X-Original-To: patchwork@lists.ozlabs.org Delivered-To: patchwork@lists.ozlabs.org Authentication-Results: lists.ozlabs.org; spf=none (mailfrom) smtp.mailfrom=that.guru (client-ip=172.82.139.213; helo=qrelay213.mxroute.com; envelope-from=stephen@that.guru; receiver=) Authentication-Results: lists.ozlabs.org; dmarc=none (p=none dis=none) header.from=that.guru Authentication-Results: lists.ozlabs.org; dkim=fail reason="key not found in DNS" (0-bit key; unprotected) header.d=that.guru header.i=@that.guru header.b="mN5WvbMV"; dkim-atps=neutral Received: from qrelay213.mxroute.com (qrelay213.mxroute.com [172.82.139.213]) (using TLSv1.2 with cipher ECDHE-RSA-AES256-GCM-SHA384 (256/256 bits)) (No client certificate requested) by lists.ozlabs.org (Postfix) with ESMTPS id 46RQyY1mr7zDqH1 for ; Mon, 9 Sep 2019 08:32:05 +1000 (AEST) Received: from filter002.mxroute.com (unknown [116.203.155.46]) by qrelay213.mxroute.com (Postfix) with ESMTP id 49307220A3E for ; Sun, 8 Sep 2019 18:32:02 -0400 (EDT) Received: from one.mxroute.com (one.mxroute.com [195.201.59.211]) by filter002.mxroute.com (Postfix) with ESMTPS id 8A3CF3F0FC for ; Sun, 8 Sep 2019 22:31:56 +0000 (UTC) DKIM-Signature: v=1; a=rsa-sha256; q=dns/txt; c=relaxed/relaxed; d=that.guru; s=default; h=Content-Transfer-Encoding:MIME-Version:References:In-Reply-To: Message-Id:Date:Subject:Cc:To:From:Sender:Reply-To:Content-Type:Content-ID: Content-Description:Resent-Date:Resent-From:Resent-Sender:Resent-To:Resent-Cc :Resent-Message-ID:List-Id:List-Help:List-Unsubscribe:List-Subscribe: List-Post:List-Owner:List-Archive; bh=uTzUY0oOG6hJKv7C9AXc+kQjLUCEZcmYThb1xFLyIDM=; b=mN5WvbMVhdF9Ij0sRbFRJGN3D4 iHWq1tyEIeMhgdQPEh5Ivc5WPN2/mOky1XXWWHhZzXU2WP4UrqdJW+uXtkKfVd3GlbQ1rvyTKd8bY dthzS4qYgzq6Dl3tBJQ6BBgwy7YqoNwTwkCgKKDEvoX8L+X6N7uJ7C+EyY5qqYlNexZENYNSjR5pR jaU4dBLXgHahe8EGo+07iD9hm9omNAw8SL1qsedK7ZLkBDeJ2KyyiIXnmy/DYLo2parDwxMyFd4uS hdyKVhX9PTFxP0Wws5Z4CSTQ0Vv9DpWwWAFM/uwMsRNxeZNI+2VE3pjPcuB5dBlJsFF2lMPRnOX6d IjGnZvbw==; From: Stephen Finucane To: patchwork@lists.ozlabs.org Subject: [PATCH 2/2] REST: Allow creating, updating, deleting of bundles Date: Sun, 8 Sep 2019 23:31:47 +0100 Message-Id: <20190908223147.25789-3-stephen@that.guru> X-Mailer: git-send-email 2.21.0 In-Reply-To: <20190908223147.25789-1-stephen@that.guru> References: <20190908223147.25789-1-stephen@that.guru> MIME-Version: 1.0 X-AuthUser: stephen@that.guru 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: , Errors-To: patchwork-bounces+incoming=patchwork.ozlabs.org@lists.ozlabs.org Sender: "Patchwork" Allow users to create a new bundle, change the name, public flag and patches of an existing bundle, and delete an existing bundle. Some small nits with existing tests are resolved. Signed-off-by: Stephen Finucane --- docs/api/schemas/latest/patchwork.yaml | 170 +++++++++++++++- docs/api/schemas/patchwork.j2 | 181 +++++++++++++++++- docs/api/schemas/v1.0/patchwork.yaml | 5 +- docs/api/schemas/v1.1/patchwork.yaml | 5 +- docs/api/schemas/v1.2/patchwork.yaml | 170 +++++++++++++++- patchwork/api/bundle.py | 84 +++++++- patchwork/models.py | 11 ++ patchwork/tests/api/test_bundle.py | 118 +++++++++++- patchwork/tests/api/utils.py | 16 +- ...pdate-bundle-via-api-2946d8c4e730d545.yaml | 4 + 10 files changed, 737 insertions(+), 27 deletions(-) create mode 100644 releasenotes/notes/update-bundle-via-api-2946d8c4e730d545.yaml diff --git a/docs/api/schemas/latest/patchwork.yaml b/docs/api/schemas/latest/patchwork.yaml index 45a61180..e6c6bb4a 100644 --- a/docs/api/schemas/latest/patchwork.yaml +++ b/docs/api/schemas/latest/patchwork.yaml @@ -1,5 +1,6 @@ # DO NOT EDIT THIS FILE. It is generated from a template. Changes should be -# proposed against the template. +# proposed against the template and updated files generated using the +# 'generate_schema.py' tool --- openapi: '3.0.0' info: @@ -72,6 +73,35 @@ paths: $ref: '#/components/schemas/Bundle' tags: - bundles + post: + description: Create a bundle. + operationId: bundles_create + security: + - basicAuth: [] + - apiKeyAuth: [] + requestBody: + $ref: '#/components/requestBodies/Bundle' + responses: + '201': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/Bundle' + '400': + description: Invalid Request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBundleCreateUpdate' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + tags: + - bundles /api/bundles/{id}/: get: description: Show a bundle. @@ -99,6 +129,92 @@ paths: $ref: '#/components/schemas/Error' tags: - bundles + patch: + description: Update a bundle (partial). + operationId: bundles_partial_update + security: + - basicAuth: [] + - apiKeyAuth: [] + parameters: + - in: path + name: id + description: A unique integer value identifying this bundle. + required: true + schema: + title: ID + type: integer + requestBody: + $ref: '#/components/requestBodies/Bundle' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/Bundle' + '400': + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBundleCreateUpdate' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + tags: + - bundles + put: + description: Update a bundle. + operationId: bundles_update + security: + - basicAuth: [] + - apiKeyAuth: [] + parameters: + - in: path + name: id + description: A unique integer value identifying this bundle. + required: true + schema: + title: ID + type: integer + requestBody: + $ref: '#/components/requestBodies/Bundle' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/Bundle' + '400': + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBundleCreateUpdate' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + tags: + - bundles /api/covers/: get: description: List cover letters. @@ -1131,6 +1247,18 @@ components: schema: type: string requestBodies: + Bundle: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BundleCreateUpdate' + multipart/form-data: + schema: + $ref: '#/components/schemas/BundleCreateUpdate' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/BundleCreateUpdate' Check: required: true content: @@ -1251,10 +1379,10 @@ components: allOf: - $ref: '#/components/schemas/UserEmbedded' patches: + title: Patches type: array items: $ref: '#/components/schemas/PatchEmbedded' - readOnly: true uniqueItems: true public: title: Public @@ -1264,6 +1392,25 @@ components: type: string format: uri readOnly: true + BundleCreateUpdate: + type: object + required: + - name + properties: + name: + title: Name + type: string + minLength: 1 + maxLength: 50 + patches: + title: Patches + type: array + items: + type: integer + uniqueItems: true + public: + title: Public + type: boolean Check: type: object properties: @@ -1961,6 +2108,7 @@ components: cover_letter: $ref: '#/components/schemas/CoverLetterEmbedded' patches: + title: Patches type: array items: $ref: '#/components/schemas/PatchEmbedded' @@ -2307,6 +2455,24 @@ components: title: Detail type: string readOnly: true + ErrorBundleCreateUpdate: + type: object + properties: + name: + title: Name + type: string + minLength: 1 + maxLength: 50 + patches: + title: Patches + type: array + items: + $ref: '#/components/schemas/PatchEmbedded' + readOnly: true + uniqueItems: true + public: + title: Public + type: boolean ErrorCheckCreate: type: object properties: diff --git a/docs/api/schemas/patchwork.j2 b/docs/api/schemas/patchwork.j2 index 843981f8..5f7510da 100644 --- a/docs/api/schemas/patchwork.j2 +++ b/docs/api/schemas/patchwork.j2 @@ -1,6 +1,7 @@ {# You can obviously ignore the below when editing this template #} # DO NOT EDIT THIS FILE. It is generated from a template. Changes should be -# proposed against the template. +# proposed against the template and updated files generated using the +# 'generate_schema.py' tool --- openapi: '3.0.0' info: @@ -73,6 +74,37 @@ paths: $ref: '#/components/schemas/Bundle' tags: - bundles +{% if version >= (1, 2) %} + post: + description: Create a bundle. + operationId: bundles_create + security: + - basicAuth: [] + - apiKeyAuth: [] + requestBody: + $ref: '#/components/requestBodies/Bundle' + responses: + '201': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/Bundle' + '400': + description: Invalid Request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBundleCreateUpdate' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + tags: + - bundles +{% endif %} /api/{{ version_url }}bundles/{id}/: get: description: Show a bundle. @@ -100,6 +132,94 @@ paths: $ref: '#/components/schemas/Error' tags: - bundles +{% if version >= (1, 2) %} + patch: + description: Update a bundle (partial). + operationId: bundles_partial_update + security: + - basicAuth: [] + - apiKeyAuth: [] + parameters: + - in: path + name: id + description: A unique integer value identifying this bundle. + required: true + schema: + title: ID + type: integer + requestBody: + $ref: '#/components/requestBodies/Bundle' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/Bundle' + '400': + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBundleCreateUpdate' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + tags: + - bundles + put: + description: Update a bundle. + operationId: bundles_update + security: + - basicAuth: [] + - apiKeyAuth: [] + parameters: + - in: path + name: id + description: A unique integer value identifying this bundle. + required: true + schema: + title: ID + type: integer + requestBody: + $ref: '#/components/requestBodies/Bundle' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/Bundle' + '400': + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBundleCreateUpdate' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + tags: + - bundles +{% endif %} /api/{{ version_url }}covers/: get: description: List cover letters. @@ -1132,6 +1252,20 @@ components: schema: type: string requestBodies: +{% if version >= (1, 2) %} + Bundle: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BundleCreateUpdate' + multipart/form-data: + schema: + $ref: '#/components/schemas/BundleCreateUpdate' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/BundleCreateUpdate' +{% endif %} Check: required: true content: @@ -1254,10 +1388,13 @@ components: allOf: - $ref: '#/components/schemas/UserEmbedded' patches: + title: Patches type: array items: $ref: '#/components/schemas/PatchEmbedded' +{% if version < (1, 2) %} readOnly: true +{% endif %} uniqueItems: true public: title: Public @@ -1267,6 +1404,27 @@ components: type: string format: uri readOnly: true +{% if version >= (1, 2) %} + BundleCreateUpdate: + type: object + required: + - name + properties: + name: + title: Name + type: string + minLength: 1 + maxLength: 50 + patches: + title: Patches + type: array + items: + type: integer + uniqueItems: true + public: + title: Public + type: boolean +{% endif %} Check: type: object properties: @@ -1988,6 +2146,7 @@ components: cover_letter: $ref: '#/components/schemas/CoverLetterEmbedded' patches: + title: Patches type: array items: $ref: '#/components/schemas/PatchEmbedded' @@ -2346,6 +2505,26 @@ components: title: Detail type: string readOnly: true +{% if version >= (1, 2) %} + ErrorBundleCreateUpdate: + type: object + properties: + name: + title: Name + type: string + minLength: 1 + maxLength: 50 + patches: + title: Patches + type: array + items: + $ref: '#/components/schemas/PatchEmbedded' + readOnly: true + uniqueItems: true + public: + title: Public + type: boolean +{% endif %} ErrorCheckCreate: type: object properties: diff --git a/docs/api/schemas/v1.0/patchwork.yaml b/docs/api/schemas/v1.0/patchwork.yaml index 02f3a156..cafef5ee 100644 --- a/docs/api/schemas/v1.0/patchwork.yaml +++ b/docs/api/schemas/v1.0/patchwork.yaml @@ -1,5 +1,6 @@ # DO NOT EDIT THIS FILE. It is generated from a template. Changes should be -# proposed against the template. +# proposed against the template and updated files generated using the +# 'generate_schema.py' tool --- openapi: '3.0.0' info: @@ -1246,6 +1247,7 @@ components: allOf: - $ref: '#/components/schemas/UserEmbedded' patches: + title: Patches type: array items: $ref: '#/components/schemas/PatchEmbedded' @@ -1877,6 +1879,7 @@ components: cover_letter: $ref: '#/components/schemas/CoverLetterEmbedded' patches: + title: Patches type: array items: $ref: '#/components/schemas/PatchEmbedded' diff --git a/docs/api/schemas/v1.1/patchwork.yaml b/docs/api/schemas/v1.1/patchwork.yaml index 0c086eda..9d45d058 100644 --- a/docs/api/schemas/v1.1/patchwork.yaml +++ b/docs/api/schemas/v1.1/patchwork.yaml @@ -1,5 +1,6 @@ # DO NOT EDIT THIS FILE. It is generated from a template. Changes should be -# proposed against the template. +# proposed against the template and updated files generated using the +# 'generate_schema.py' tool --- openapi: '3.0.0' info: @@ -1251,6 +1252,7 @@ components: allOf: - $ref: '#/components/schemas/UserEmbedded' patches: + title: Patches type: array items: $ref: '#/components/schemas/PatchEmbedded' @@ -1928,6 +1930,7 @@ components: cover_letter: $ref: '#/components/schemas/CoverLetterEmbedded' patches: + title: Patches type: array items: $ref: '#/components/schemas/PatchEmbedded' diff --git a/docs/api/schemas/v1.2/patchwork.yaml b/docs/api/schemas/v1.2/patchwork.yaml index 3a96aa3a..5c03bf1e 100644 --- a/docs/api/schemas/v1.2/patchwork.yaml +++ b/docs/api/schemas/v1.2/patchwork.yaml @@ -1,5 +1,6 @@ # DO NOT EDIT THIS FILE. It is generated from a template. Changes should be -# proposed against the template. +# proposed against the template and updated files generated using the +# 'generate_schema.py' tool --- openapi: '3.0.0' info: @@ -72,6 +73,35 @@ paths: $ref: '#/components/schemas/Bundle' tags: - bundles + post: + description: Create a bundle. + operationId: bundles_create + security: + - basicAuth: [] + - apiKeyAuth: [] + requestBody: + $ref: '#/components/requestBodies/Bundle' + responses: + '201': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/Bundle' + '400': + description: Invalid Request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBundleCreateUpdate' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + tags: + - bundles /api/1.2/bundles/{id}/: get: description: Show a bundle. @@ -99,6 +129,92 @@ paths: $ref: '#/components/schemas/Error' tags: - bundles + patch: + description: Update a bundle (partial). + operationId: bundles_partial_update + security: + - basicAuth: [] + - apiKeyAuth: [] + parameters: + - in: path + name: id + description: A unique integer value identifying this bundle. + required: true + schema: + title: ID + type: integer + requestBody: + $ref: '#/components/requestBodies/Bundle' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/Bundle' + '400': + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBundleCreateUpdate' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + tags: + - bundles + put: + description: Update a bundle. + operationId: bundles_update + security: + - basicAuth: [] + - apiKeyAuth: [] + parameters: + - in: path + name: id + description: A unique integer value identifying this bundle. + required: true + schema: + title: ID + type: integer + requestBody: + $ref: '#/components/requestBodies/Bundle' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/Bundle' + '400': + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBundleCreateUpdate' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + tags: + - bundles /api/1.2/covers/: get: description: List cover letters. @@ -1131,6 +1247,18 @@ components: schema: type: string requestBodies: + Bundle: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BundleCreateUpdate' + multipart/form-data: + schema: + $ref: '#/components/schemas/BundleCreateUpdate' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/BundleCreateUpdate' Check: required: true content: @@ -1251,10 +1379,10 @@ components: allOf: - $ref: '#/components/schemas/UserEmbedded' patches: + title: Patches type: array items: $ref: '#/components/schemas/PatchEmbedded' - readOnly: true uniqueItems: true public: title: Public @@ -1264,6 +1392,25 @@ components: type: string format: uri readOnly: true + BundleCreateUpdate: + type: object + required: + - name + properties: + name: + title: Name + type: string + minLength: 1 + maxLength: 50 + patches: + title: Patches + type: array + items: + type: integer + uniqueItems: true + public: + title: Public + type: boolean Check: type: object properties: @@ -1961,6 +2108,7 @@ components: cover_letter: $ref: '#/components/schemas/CoverLetterEmbedded' patches: + title: Patches type: array items: $ref: '#/components/schemas/PatchEmbedded' @@ -2307,6 +2455,24 @@ components: title: Detail type: string readOnly: true + ErrorBundleCreateUpdate: + type: object + properties: + name: + title: Name + type: string + minLength: 1 + maxLength: 50 + patches: + title: Patches + type: array + items: + $ref: '#/components/schemas/PatchEmbedded' + readOnly: true + uniqueItems: true + public: + title: Public + type: boolean ErrorCheckCreate: type: object properties: diff --git a/patchwork/api/bundle.py b/patchwork/api/bundle.py index 2dec70d1..c5885aae 100644 --- a/patchwork/api/bundle.py +++ b/patchwork/api/bundle.py @@ -4,9 +4,12 @@ # SPDX-License-Identifier: GPL-2.0-or-later from django.db.models import Q -from rest_framework.generics import ListAPIView -from rest_framework.generics import RetrieveAPIView +from rest_framework import exceptions +from rest_framework.generics import ListCreateAPIView +from rest_framework.generics import RetrieveUpdateDestroyAPIView +from rest_framework import permissions from rest_framework.serializers import SerializerMethodField +from rest_framework.serializers import ValidationError from patchwork.api.base import BaseHyperlinkedModelSerializer from patchwork.api.base import PatchworkPermission @@ -14,16 +17,52 @@ from patchwork.api.filters import BundleFilterSet from patchwork.api.embedded import PatchSerializer from patchwork.api.embedded import ProjectSerializer from patchwork.api.embedded import UserSerializer +from patchwork.api import utils from patchwork.models import Bundle +class BundlePermission(permissions.BasePermission): + """Ensure the API version, if configured, is >= v1.2. + + Bundle creation/updating was only added in API v1.2 and we don't want to + change behavior in older API versions. + """ + def has_permission(self, request, view): + # read-only permission for everything + if request.method in permissions.SAFE_METHODS: + return True + + if not utils.has_version(request, '1.2'): + raise exceptions.MethodNotAllowed(request.method) + + if request.method == 'POST' and ( + not request.user or not request.user.is_authenticated): + return False + + # we have more to do but we can't do that until we have an object + return True + + def has_object_permission(self, request, view, obj): + if (request.user and + request.user.is_authenticated and + request.user == obj.owner): + return True + + if not obj.public: + # if the bundle isn't public, we don't want to leak the fact that + # it exists + raise exceptions.NotFound + + return request.method in permissions.SAFE_METHODS + + class BundleSerializer(BaseHyperlinkedModelSerializer): web_url = SerializerMethodField() project = ProjectSerializer(read_only=True) mbox = SerializerMethodField() owner = UserSerializer(read_only=True) - patches = PatchSerializer(many=True, read_only=True) + patches = PatchSerializer(many=True) def get_web_url(self, instance): request = self.context.get('request') @@ -33,11 +72,35 @@ class BundleSerializer(BaseHyperlinkedModelSerializer): request = self.context.get('request') return request.build_absolute_uri(instance.get_mbox_url()) + def create(self, validated_data): + patches = validated_data.pop('patches') + instance = super(BundleSerializer, self).create(validated_data) + instance.overwrite_patches(patches) + return instance + + def update(self, instance, validated_data): + patches = validated_data.pop('patches') + instance = super(BundleSerializer, self).update( + instance, validated_data) + instance.overwrite_patches(patches) + return instance + + def validate(self, data): + if not data.get('patches'): + raise ValidationError('Bundles cannot be empty') + + if len(set([p.project.id for p in data['patches']])) > 1: + raise ValidationError('Patches must belong to the same project') + + data['project'] = data['patches'][0].project + + return super(BundleSerializer, self).validate(data) + class Meta: model = Bundle fields = ('id', 'url', 'web_url', 'project', 'name', 'owner', 'patches', 'public', 'mbox') - read_only_fields = ('owner', 'patches', 'mbox') + read_only_fields = ('project', 'owner', 'mbox') versioned_fields = { '1.1': ('web_url', ), } @@ -48,7 +111,7 @@ class BundleSerializer(BaseHyperlinkedModelSerializer): class BundleMixin(object): - permission_classes = (PatchworkPermission,) + permission_classes = [PatchworkPermission & BundlePermission] serializer_class = BundleSerializer def get_queryset(self): @@ -63,16 +126,19 @@ class BundleMixin(object): .select_related('owner', 'project') -class BundleList(BundleMixin, ListAPIView): - """List bundles.""" +class BundleList(BundleMixin, ListCreateAPIView): + """List or create bundles.""" filter_class = filterset_class = BundleFilterSet search_fields = ('name',) ordering_fields = ('id', 'name', 'owner') ordering = 'id' + def perform_create(self, serializer): + serializer.save(owner=self.request.user) + -class BundleDetail(BundleMixin, RetrieveAPIView): - """Show a bundle.""" +class BundleDetail(BundleMixin, RetrieveUpdateDestroyAPIView): + """Show, update or delete a bundle.""" pass diff --git a/patchwork/models.py b/patchwork/models.py index 32d1b3c2..631de85d 100644 --- a/patchwork/models.py +++ b/patchwork/models.py @@ -788,6 +788,11 @@ class Bundle(models.Model): patches = models.ManyToManyField(Patch, through='BundlePatch') public = models.BooleanField(default=False) + def is_editable(self, user): + if not user.is_authenticated: + return False + return user == self.owner + def ordered_patches(self): return self.patches.order_by('bundlepatch__order') @@ -806,6 +811,12 @@ class Bundle(models.Model): return BundlePatch.objects.create(bundle=self, patch=patch, order=max_order + 1) + def overwrite_patches(self, patches): + BundlePatch.objects.filter(bundle=self).delete() + + for patch in patches: + self.append_patch(patch) + def get_absolute_url(self): return reverse('bundle-detail', kwargs={ 'username': self.owner.username, diff --git a/patchwork/tests/api/test_bundle.py b/patchwork/tests/api/test_bundle.py index 303c500c..a3a0c113 100644 --- a/patchwork/tests/api/test_bundle.py +++ b/patchwork/tests/api/test_bundle.py @@ -8,9 +8,11 @@ import unittest from django.conf import settings from django.urls import reverse +from patchwork.models import Bundle from patchwork.tests.api import utils from patchwork.tests.utils import create_bundle from patchwork.tests.utils import create_maintainer +from patchwork.tests.utils import create_patch from patchwork.tests.utils import create_project from patchwork.tests.utils import create_user @@ -42,12 +44,15 @@ class TestBundleAPI(utils.APITestCase): # nested fields - self.assertEqual(bundle_obj.patches.count(), - len(bundle_json['patches'])) self.assertEqual(bundle_obj.owner.id, bundle_json['owner']['id']) self.assertEqual(bundle_obj.project.id, bundle_json['project']['id']) + self.assertEqual(bundle_obj.patches.count(), + len(bundle_json['patches'])) + for patch_obj, patch_json in zip( + bundle_obj.patches.all(), bundle_json['patches']): + self.assertEqual(patch_obj.id, patch_json['id']) def test_list_empty(self): """List bundles when none are present.""" @@ -179,18 +184,117 @@ class TestBundleAPI(utils.APITestCase): self.assertIn('url', resp.data) self.assertNotIn('web_url', resp.data) - def test_create_update_delete(self): - """Ensure creates, updates and deletes aren't allowed""" + def _test_create_update(self, authenticate=True): + user = create_user() + project = create_project() + patch_a = create_patch(project=project) + patch_b = create_patch(project=project) + + if authenticate: + self.client.force_authenticate(user=user) + + return user, project, patch_a, patch_b + + @utils.store_samples('bundle-create-error-forbidden') + def test_create_anonymous(self): + """Create a bundle when not signed in. + + Ensure creations can only be performed by signed in users. + """ + user, project, patch_a, patch_b = self._test_create_update( + authenticate=False) + bundle = { + 'name': 'test-bundle', + 'public': True, + 'patches': [patch_a.id, patch_b.id], + } + + resp = self.client.post(self.api_url(), bundle) + self.assertEqual(status.HTTP_403_FORBIDDEN, resp.status_code) + + @utils.store_samples('bundle-create') + def test_create(self): + """Validate we can create a new bundle.""" + user, project, patch_a, patch_b = self._test_create_update() + bundle = { + 'name': 'test-bundle', + 'public': True, + 'patches': [patch_a.id, patch_b.id], + } + + resp = self.client.post(self.api_url(), bundle) + self.assertEqual(status.HTTP_201_CREATED, resp.status_code) + self.assertEqual(1, Bundle.objects.all().count()) + self.assertSerialized(Bundle.objects.first(), resp.data) + + @utils.store_samples('bundle-update-not-found') + def test_update_anonymous(self): + """Update an existing bundle when not signed in. + + Ensure updates can only be performed by signed in users. + """ + user, project, patch_a, patch_b = self._test_create_update( + authenticate=False) + bundle = create_bundle(owner=user, project=project) + + resp = self.client.patch(self.api_url(bundle.id), { + 'name': 'hello-bundle', 'patches': [patch_a.id, patch_b.id]}) + self.assertEqual(status.HTTP_404_NOT_FOUND, resp.status_code) + + @utils.store_samples('bundle-update') + def test_update(self): + """Validate we can update an existing bundle.""" + user, project, patch_a, patch_b = self._test_create_update() + bundle = create_bundle(owner=user, project=project) + + resp = self.client.patch(self.api_url(bundle.id), { + 'name': 'hello-bundle', 'patches': [patch_a.id, patch_b.id]}) + self.assertEqual(status.HTTP_200_OK, resp.status_code) + self.assertEqual(1, Bundle.objects.all().count()) + self.assertEqual(len(resp.data['patches']), 2) + self.assertEqual(resp.data['name'], 'hello-bundle') + + @utils.store_samples('bundle-delete-not-found') + def test_delete_anonymous(self): + """Delete a bundle when not signed in. + + Ensure deletions can only be performed when signed in. + """ + user, project, patch_a, patch_b = self._test_create_update( + authenticate=False) + bundle = create_bundle(owner=user, project=project) + + resp = self.client.delete(self.api_url(bundle.id)) + self.assertEqual(status.HTTP_404_NOT_FOUND, resp.status_code) + + @utils.store_samples('bundle-delete') + def test_delete(self): + """Validate we can delete an existing bundle.""" + user = create_user() + bundle = create_bundle(owner=user) + + self.client.force_authenticate(user=user) + + resp = self.client.delete(self.api_url(bundle.id)) + self.assertEqual(status.HTTP_204_NO_CONTENT, resp.status_code) + self.assertEqual(0, Bundle.objects.all().count()) + + def test_create_update_delete_version_1_1(self): + """Ensure creates, updates and deletes aren't allowed with old API.""" user = create_maintainer() user.is_superuser = True user.save() self.client.force_authenticate(user=user) - resp = self.client.post(self.api_url(), {'email': 'foo@f.com'}) + resp = self.client.post(self.api_url(version='1.1'), {'name': 'test'}, + validate_schema=False) self.assertEqual(status.HTTP_405_METHOD_NOT_ALLOWED, resp.status_code) - resp = self.client.patch(self.api_url(user.id), {'email': 'foo@f.com'}) + resp = self.client.patch(self.api_url(1, version='1.1'), + {'name': 'test'}, + validate_schema=False) self.assertEqual(status.HTTP_405_METHOD_NOT_ALLOWED, resp.status_code) - resp = self.client.delete(self.api_url(1)) + resp = self.client.delete(self.api_url(1, version='1.1'), + validate_schema=False) self.assertEqual(status.HTTP_405_METHOD_NOT_ALLOWED, resp.status_code) diff --git a/patchwork/tests/api/utils.py b/patchwork/tests/api/utils.py index 0c232d04..ce83ce2b 100644 --- a/patchwork/tests/api/utils.py +++ b/patchwork/tests/api/utils.py @@ -112,44 +112,52 @@ class APIClient(BaseAPIClient): self.factory = APIRequestFactory() def get(self, path, data=None, follow=False, **extra): + validate_schema = extra.pop('validate_schema', True) request = self.factory.get( path, data=data, SERVER_NAME='example.com', **extra) response = super(APIClient, self).get( path, data=data, follow=follow, SERVER_NAME='example.com', **extra) - validator.validate_data(path, request, response) + if validate_schema: + validator.validate_data(path, request, response) return response def post(self, path, data=None, format=None, content_type=None, follow=False, **extra): + validate_schema = extra.pop('validate_schema', True) request = self.factory.post( path, data=data, format='json', content_type=content_type, SERVER_NAME='example.com', **extra) response = super(APIClient, self).post( path, data=data, format='json', content_type=content_type, follow=follow, SERVER_NAME='example.com', **extra) - validator.validate_data(path, request, response) + if validate_schema: + validator.validate_data(path, request, response) return response def put(self, path, data=None, format=None, content_type=None, follow=False, **extra): + validate_schema = extra.pop('validate_schema', True) request = self.factory.put( path, data=data, format='json', content_type=content_type, SERVER_NAME='example.com', **extra) response = super(APIClient, self).put( path, data=data, format='json', content_type=content_type, follow=follow, SERVER_NAME='example.com', **extra) - validator.validate_data(path, request, response) + if validate_schema: + validator.validate_data(path, request, response) return response def patch(self, path, data=None, format=None, content_type=None, follow=False, **extra): + validate_schema = extra.pop('validate_schema', True) request = self.factory.patch( path, data=data, format='json', content_type=content_type, SERVER_NAME='example.com', **extra) response = super(APIClient, self).patch( path, data=data, format='json', content_type=content_type, follow=follow, SERVER_NAME='example.com', **extra) - validator.validate_data(path, request, response) + if validate_schema: + validator.validate_data(path, request, response) return response diff --git a/releasenotes/notes/update-bundle-via-api-2946d8c4e730d545.yaml b/releasenotes/notes/update-bundle-via-api-2946d8c4e730d545.yaml new file mode 100644 index 00000000..bfa1ef55 --- /dev/null +++ b/releasenotes/notes/update-bundle-via-api-2946d8c4e730d545.yaml @@ -0,0 +1,4 @@ +--- +api: + - | + Bundles can now be created, updated and deleted via the REST API.