From patchwork Sat Dec 7 16:46:21 2019 Content-Type: text/plain; charset="utf-8" MIME-Version: 1.0 Content-Transfer-Encoding: 7bit X-Patchwork-Submitter: Mete Polat X-Patchwork-Id: 1205490 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 47Vb4V4Z9Nz9sPL for ; Sun, 8 Dec 2019 03:48:26 +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="ZR6Ous7Y"; dkim-atps=neutral Received: from lists.ozlabs.org (lists.ozlabs.org [IPv6:2401:3900:2:1::3]) by lists.ozlabs.org (Postfix) with ESMTP id 47Vb4V3QgvzDqbC for ; Sun, 8 Dec 2019 03:48:26 +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::333; helo=mail-wm1-x333.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="ZR6Ous7Y"; dkim-atps=neutral Received: from mail-wm1-x333.google.com (mail-wm1-x333.google.com [IPv6:2a00:1450:4864:20::333]) (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 47Vb331HMrzDqYK for ; Sun, 8 Dec 2019 03:47:10 +1100 (AEDT) Received: by mail-wm1-x333.google.com with SMTP id f129so10486354wmf.2 for ; Sat, 07 Dec 2019 08:47:10 -0800 (PST) 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=5bTi3gk/38jw3cWfOPQDZRCkQhQIdi3mcPOforydq3M=; b=ZR6Ous7YrGocrrqh7Jj2yQR6k9YA7Fa/S65jIod3nczub29hiRHWG9JkermFx9iQb1 W6PIuplVI4gArZ6IQcQDMzJhI8a5wc48GWIdu1qjQxIRgFasfJnP9zMq/nY6CbOww5aS 1qmt1iEHfFjFrbQAgyHukmyJ3CrAUGD8AhQYyQLZwGdpSftgmYpozVryQSpjNOmN+hMS 0ukmY7+Aq7DdQiw0MD+/U+Q2Eff8r6Jl50XfvdP5PWfkfxikp/lxiRR4qEmAs3iIEtLP t6y26tbPHK/iJ6aSJC4y4c7LjARn3UvbgzWJC1wEBviBK/Ejtd+WxKkoa31IWqBD4FzB nkbA== 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=5bTi3gk/38jw3cWfOPQDZRCkQhQIdi3mcPOforydq3M=; b=pTDVLh2s6Fmuso8uDdsnSj+DL2mLSAd/xITB7BTyDdj5qGN7TVWjKbYxzPCr6LpKDj Ewliwo8lVKuMuP/EMMgSe/DzVf4z1sx0hna3zUj0ZTe6bQ35rWkn6Qwgs6yI+EqIGTy0 Hp1ayy/O2m1WTYg4MrrF6B88ivZKRmMdiDQkK/tYdwXN4QMEwDNXbu285ynP9LzIYMyB GQOrIawIuenKHWgIcij+1ObLAyjw8IKBqLsZMs+8M4flg35m06saQcTx4+Z6zrgfe7hj zCX6oiLkeynm0imxSy4eRTbtz8SnrEKbsjGOyoLPz5zFBDZswr1VawvcIH2C1h1WUMjU bShg== X-Gm-Message-State: APjAAAWt3nzrbg+JntTyYhDiS2IRc0IVB9iQ4qsQ3ly3dwGWGoGIezE/ 1Zm6sZ+zQRsHpPLdbMsQ+Pl/6S4KthL0Qg== X-Google-Smtp-Source: APXvYqwuseHdir6AASbKXK2W9uHS4lXvvr32qlj5Z68N8OLLrWt1caQHrZj11Sr4rqxPCqt/Hq1m1A== X-Received: by 2002:a05:600c:2947:: with SMTP id n7mr15247304wmd.156.1575737226709; Sat, 07 Dec 2019 08:47:06 -0800 (PST) Received: from Metes-MBP.fritz.box (aftr-62-216-202-157.dynamic.mnet-online.de. [62.216.202.157]) by smtp.gmail.com with ESMTPSA id u22sm22083886wru.30.2019.12.07.08.47.05 (version=TLS1_2 cipher=ECDHE-RSA-AES128-GCM-SHA256 bits=128/128); Sat, 07 Dec 2019 08:47:06 -0800 (PST) From: Mete Polat To: patchwork@lists.ozlabs.org Subject: [PATCH 4/4] REST: Add submission relations Date: Sat, 7 Dec 2019 17:46:21 +0100 Message-Id: <20191207164621.24234-5-metepolat2000@gmail.com> X-Mailer: git-send-email 2.24.0 In-Reply-To: <20191207164621.24234-1-metepolat2000@gmail.com> References: <20191207164621.24234-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" View relations and add/update/delete them as a maintainer. Maintainers can only create relations of submissions (patches/cover letters) which are part of a project they maintain. New REST API urls: api/relations/ api/relations// Co-authored-by: Daniel Axtens Signed-off-by: Mete Polat --- Optimize db queries: I have spent quite a lot of time in optimizing the db queries for the REST API (thanks for the tip with the Django toolbar). Daniel stated that prefetch_related is possibly hitting the database for every relation when prefetching submissions but it turns out that we can tell Django to use a statement like: SELECT * FROM `patchwork_patch` INNER JOIN `patchwork_submission` ON (`patchwork_patch`.`submission_ptr_id` = `patchwork_submission`.`id`) WHERE `patchwork_patch`.`submission_ptr_id` IN (LIST_OF_ALL_SUBMISSION_IDS) We do the same for `patchwork_coverletter`. This means we only hit the db two times for casting _all_ submissions to a patch or cover-letter. Prefetching submissions__project eliminates similar and duplicate queries that are used to determine whether a logged in user is at least maintainer of one submission's project. docs/api/schemas/latest/patchwork.yaml | 273 +++++++++++++++++ docs/api/schemas/patchwork.j2 | 285 ++++++++++++++++++ docs/api/schemas/v1.2/patchwork.yaml | 273 +++++++++++++++++ patchwork/api/embedded.py | 39 +++ patchwork/api/index.py | 1 + patchwork/api/relation.py | 121 ++++++++ patchwork/models.py | 6 + patchwork/tests/api/test_relation.py | 181 +++++++++++ patchwork/tests/utils.py | 15 + patchwork/urls.py | 11 + ...submission-relations-c96bb6c567b416d8.yaml | 10 + 11 files changed, 1215 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 a5e235be936d..7dd24fd700d5 100644 --- a/docs/api/schemas/latest/patchwork.yaml +++ b/docs/api/schemas/latest/patchwork.yaml @@ -1039,6 +1039,188 @@ 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/Error' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '409': + description: Conflict + 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' + '409': + description: Conflict + 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/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 + 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/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 /api/users/: get: description: List users. @@ -1314,6 +1496,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 @@ -1358,6 +1552,11 @@ components: type: string format: uri readOnly: true + relations: + title: Relations URL + type: string + format: uri + readOnly: true Bundle: required: - name @@ -1943,6 +2142,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: @@ -2133,6 +2340,30 @@ 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 + by: + type: object + title: By + readOnly: true + allOf: + - $ref: '#/components/schemas/UserEmbedded' + submissions: + title: Submissions + type: array + items: + $ref: '#/components/schemas/SubmissionEmbedded' + readOnly: true + uniqueItems: true User: type: object properties: @@ -2211,6 +2442,48 @@ components: maxLength: 255 minLength: 1 readOnly: true + SubmissionEmbedded: + type: object + properties: + id: + title: ID + type: integer + readOnly: true + url: + title: URL + type: string + format: uri + readOnly: true + web_url: + title: Web URL + type: string + format: uri + readOnly: true + msgid: + title: Message ID + type: string + readOnly: true + minLength: 1 + list_archive_url: + title: List archive URL + type: string + readOnly: true + nullable: true + date: + title: Date + type: string + format: iso8601 + readOnly: true + name: + title: Name + type: string + readOnly: true + minLength: 1 + mbox: + title: Mbox + type: string + format: uri + readOnly: true CoverLetterEmbedded: type: object properties: diff --git a/docs/api/schemas/patchwork.j2 b/docs/api/schemas/patchwork.j2 index 196d78466b55..a034029accf9 100644 --- a/docs/api/schemas/patchwork.j2 +++ b/docs/api/schemas/patchwork.j2 @@ -1048,6 +1048,190 @@ 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/Error' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '409': + description: Conflict + 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' + '409': + description: Conflict + 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/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 + 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/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 +{% endif %} /api/{{ version_url }}users/: get: description: List users. @@ -1325,6 +1509,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 @@ -1369,6 +1567,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 @@ -1981,6 +2186,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: @@ -2177,6 +2392,32 @@ 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 + by: + type: object + title: By + readOnly: true + allOf: + - $ref: '#/components/schemas/UserEmbedded' + submissions: + title: Submissions + type: array + items: + $ref: '#/components/schemas/SubmissionEmbedded' + readOnly: true + uniqueItems: true +{% endif %} User: type: object properties: @@ -2255,6 +2496,50 @@ components: maxLength: 255 minLength: 1 readOnly: true +{% if version >= (1, 2) %} + SubmissionEmbedded: + type: object + properties: + id: + title: ID + type: integer + readOnly: true + url: + title: URL + type: string + format: uri + readOnly: true + web_url: + title: Web URL + type: string + format: uri + readOnly: true + msgid: + title: Message ID + type: string + readOnly: true + minLength: 1 + list_archive_url: + title: List archive URL + type: string + readOnly: true + nullable: true + date: + title: Date + type: string + format: iso8601 + readOnly: true + name: + title: Name + type: string + readOnly: true + minLength: 1 + mbox: + title: Mbox + type: string + format: uri + readOnly: true +{% endif %} CoverLetterEmbedded: type: object properties: diff --git a/docs/api/schemas/v1.2/patchwork.yaml b/docs/api/schemas/v1.2/patchwork.yaml index d7b4d2957cff..99425e968881 100644 --- a/docs/api/schemas/v1.2/patchwork.yaml +++ b/docs/api/schemas/v1.2/patchwork.yaml @@ -1039,6 +1039,188 @@ 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/Error' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '409': + description: Conflict + 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' + '409': + description: Conflict + 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/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 + 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/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 /api/1.2/users/: get: description: List users. @@ -1314,6 +1496,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 @@ -1358,6 +1552,11 @@ components: type: string format: uri readOnly: true + relations: + title: Relations URL + type: string + format: uri + readOnly: true Bundle: required: - name @@ -1943,6 +2142,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: @@ -2133,6 +2340,30 @@ 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 + by: + type: object + title: By + readOnly: true + allOf: + - $ref: '#/components/schemas/UserEmbedded' + submissions: + title: Submissions + type: array + items: + $ref: '#/components/schemas/SubmissionEmbedded' + readOnly: true + uniqueItems: true User: type: object properties: @@ -2211,6 +2442,48 @@ components: maxLength: 255 minLength: 1 readOnly: true + SubmissionEmbedded: + type: object + properties: + id: + title: ID + type: integer + readOnly: true + url: + title: URL + type: string + format: uri + readOnly: true + web_url: + title: Web URL + type: string + format: uri + readOnly: true + msgid: + title: Message ID + type: string + readOnly: true + minLength: 1 + list_archive_url: + title: List archive URL + type: string + readOnly: true + nullable: true + date: + title: Date + type: string + format: iso8601 + readOnly: true + name: + title: Name + type: string + readOnly: true + minLength: 1 + mbox: + title: Mbox + type: string + format: uri + readOnly: true CoverLetterEmbedded: type: object properties: diff --git a/patchwork/api/embedded.py b/patchwork/api/embedded.py index de4f31165ee7..0fba291b62b8 100644 --- a/patchwork/api/embedded.py +++ b/patchwork/api/embedded.py @@ -102,6 +102,45 @@ class CheckSerializer(SerializedRelatedField): } +def _upgrade_instance(instance): + if hasattr(instance, 'patch'): + return instance.patch + else: + return instance.coverletter + + +class SubmissionSerializer(SerializedRelatedField): + + class _Serializer(BaseHyperlinkedModelSerializer): + """We need to 'upgrade' or specialise the submission to the relevant + subclass, so we can't use the mixins. This is gross but can go away + once we flatten the models.""" + url = SerializerMethodField() + web_url = SerializerMethodField() + mbox = SerializerMethodField() + + def get_url(self, instance): + instance = _upgrade_instance(instance) + request = self.context.get('request') + return request.build_absolute_uri(instance.get_absolute_api_url()) + + def get_web_url(self, instance): + instance = _upgrade_instance(instance) + request = self.context.get('request') + return request.build_absolute_uri(instance.get_absolute_url()) + + def get_mbox(self, instance): + instance = _upgrade_instance(instance) + request = self.context.get('request') + return request.build_absolute_uri(instance.get_mbox_url()) + + class Meta: + model = models.Submission + fields = ('id', 'url', 'web_url', 'msgid', 'list_archive_url', + 'date', 'name', 'mbox') + read_only_fields = fields + + class CoverLetterSerializer(SerializedRelatedField): class _Serializer(MboxMixin, WebURLMixin, BaseHyperlinkedModelSerializer): diff --git a/patchwork/api/index.py b/patchwork/api/index.py index 45485c9106f6..cf1845393835 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 000000000000..37640d62e9cc --- /dev/null +++ b/patchwork/api/relation.py @@ -0,0 +1,121 @@ +# Patchwork - automated patch tracking system +# Copyright (C) 2019, Bayerische Motoren Werke Aktiengesellschaft (BMW AG) +# +# SPDX-License-Identifier: GPL-2.0-or-later + +from rest_framework import permissions +from rest_framework import status +from rest_framework.exceptions import PermissionDenied, APIException +from rest_framework.generics import GenericAPIView +from rest_framework.generics import ListCreateAPIView +from rest_framework.generics import RetrieveUpdateDestroyAPIView +from rest_framework.serializers import ModelSerializer + +from patchwork.api.base import PatchworkPermission +from patchwork.api.embedded import SubmissionSerializer +from patchwork.api.embedded import UserSerializer +from patchwork.models import SubmissionRelation + + +class MaintainerPermission(PatchworkPermission): + + def has_permission(self, request, view): + if request.method in permissions.SAFE_METHODS: + return True + + # Prevent showing an HTML POST form in the browseable API for logged in + # users who are not maintainers. + return len(request.user.maintains) > 0 + + def has_object_permission(self, request, view, relation): + if request.method in permissions.SAFE_METHODS: + return True + + maintains = request.user.maintains + submissions = relation.submissions.all() + # user has to be maintainer of every project a submission is part of + return self.check_user_maintains_all(maintains, submissions) + + @staticmethod + def check_user_maintains_all(maintains, submissions): + if any(s.project not in maintains for s in submissions): + detail = 'At least one submission is part of a project you are ' \ + 'not maintaining.' + raise PermissionDenied(detail=detail) + return True + + +class SubmissionConflict(APIException): + status_code = status.HTTP_409_CONFLICT + default_detail = 'At least one submission is already part of another ' \ + 'relation. You have to explicitly remove a submission ' \ + 'from its existing relation before moving it to this one.' + + +class SubmissionRelationSerializer(ModelSerializer): + by = UserSerializer(read_only=True) + submissions = SubmissionSerializer(many=True) + + def create(self, validated_data): + submissions = validated_data['submissions'] + if any(submission.related_id is not None + for submission in submissions): + raise SubmissionConflict() + return super(SubmissionRelationSerializer, self).create(validated_data) + + def update(self, instance, validated_data): + submissions = validated_data['submissions'] + if any(submission.related_id is not None and + submission.related_id != instance.id + for submission in submissions): + raise SubmissionConflict() + return super(SubmissionRelationSerializer, self) \ + .update(instance, validated_data) + + class Meta: + model = SubmissionRelation + fields = ('id', 'url', 'by', 'submissions',) + read_only_fields = ('url', 'by', ) + extra_kwargs = { + 'url': {'view_name': 'api-relation-detail'}, + } + + +class SubmissionRelationMixin(GenericAPIView): + serializer_class = SubmissionRelationSerializer + permission_classes = (MaintainerPermission,) + + def initial(self, request, *args, **kwargs): + user = request.user + if not hasattr(user, 'maintains'): + if user.is_authenticated: + user.maintains = user.profile.maintainer_projects.all() + else: + user.maintains = [] + super(SubmissionRelationMixin, self).initial(request, *args, **kwargs) + + def get_queryset(self): + return SubmissionRelation.objects.all() \ + .select_related('by') \ + .prefetch_related('submissions__patch', + 'submissions__coverletter', + 'submissions__project') + + +class SubmissionRelationList(SubmissionRelationMixin, ListCreateAPIView): + ordering = 'id' + ordering_fields = ['id'] + + def perform_create(self, serializer): + # has_object_permission() is not called when creating a new relation. + # Check whether user is maintainer of every project a submission is + # part of + maintains = self.request.user.maintains + submissions = serializer.validated_data['submissions'] + MaintainerPermission.check_user_maintains_all(maintains, submissions) + serializer.save(by=self.request.user) + + +class SubmissionRelationDetail(SubmissionRelationMixin, + RetrieveUpdateDestroyAPIView): + pass diff --git a/patchwork/models.py b/patchwork/models.py index a92203b24ff2..9ae3370e896b 100644 --- a/patchwork/models.py +++ b/patchwork/models.py @@ -415,6 +415,9 @@ class CoverLetter(Submission): kwargs={'project_id': self.project.linkname, 'msgid': self.url_msgid}) + def get_absolute_api_url(self): + return reverse('api-cover-detail', kwargs={'pk': self.id}) + def get_mbox_url(self): return reverse('cover-mbox', kwargs={'project_id': self.project.linkname, @@ -604,6 +607,9 @@ class Patch(Submission): kwargs={'project_id': self.project.linkname, 'msgid': self.url_msgid}) + def get_absolute_api_url(self): + return reverse('api-patch-detail', kwargs={'pk': self.id}) + def get_mbox_url(self): return reverse('patch-mbox', kwargs={'project_id': self.project.linkname, diff --git a/patchwork/tests/api/test_relation.py b/patchwork/tests/api/test_relation.py new file mode 100644 index 000000000000..5b1a04f13670 --- /dev/null +++ b/patchwork/tests/api/test_relation.py @@ -0,0 +1,181 @@ +# Patchwork - automated patch tracking system +# Copyright (C) 2019, Bayerische Motoren Werke Aktiengesellschaft (BMW AG) +# +# SPDX-License-Identifier: GPL-2.0-or-later + +import unittest + +import six +from django.conf import settings +from django.urls import reverse + +from patchwork.tests.api import utils +from patchwork.tests.utils import create_cover +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: + ANONYMOUS = 1 + NON_MAINTAINER = 2 + MAINTAINER = 3 + + +@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): + """Assert post/delete/patch requests on the relation API.""" + assert method in ['post', 'delete', 'patch'] + + # setup + + project = create_project() + maintainer = create_maintainer(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 + self.client.force_authenticate(user=maintainer) + else: + raise ValueError + + resource_id = None + req = None + + if method == 'delete': + resource_id = create_relation(project=project, by=maintainer).id + elif method == 'post': + patch_ids = [p.id for p in create_patches(2, project=project)] + req = {'submissions': patch_ids} + elif method == 'patch': + resource_id = create_relation(project=project, by=maintainer).id + patch_ids = [p.id for p in create_patches(2, project=project)] + req = {'submissions': patch_ids} + else: + raise ValueError + + # request + + resp = getattr(self.client, method)(self.api_url(resource_id), req) + + # check + + self.assertEqual(expected_status, resp.status_code) + + if resp.status_code in range(status.HTTP_200_OK, + status.HTTP_204_NO_CONTENT): + self.assertRequest(req, resp.data) + + def assertRequest(self, request, resp): + if request.get('id'): + self.assertEqual(request['id'], resp['id']) + send_ids = request['submissions'] + resp_ids = [s['id'] for s in resp['submissions']] + six.assertCountEqual(self, resp_ids, send_ids) + + def assertSerialized(self, obj, resp): + self.assertEqual(obj.id, resp['id']) + exp_ids = [s.id for s in obj.submissions.all()] + act_ids = [s['id'] for s in resp['submissions']] + six.assertCountEqual(self, exp_ids, act_ids) + + 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-create-error-forbidden') + def test_create_anonymous(self): + self.request_restricted('post', UserType.ANONYMOUS) + + def test_create_non_maintainer(self): + self.request_restricted('post', UserType.NON_MAINTAINER) + + @utils.store_samples('relation-create') + def test_create_maintainer(self): + self.request_restricted('post', UserType.MAINTAINER) + + @utils.store_samples('relation-update-error-forbidden') + def test_update_anonymous(self): + self.request_restricted('patch', UserType.ANONYMOUS) + + def test_update_non_maintainer(self): + self.request_restricted('patch', UserType.NON_MAINTAINER) + + @utils.store_samples('relation-update') + def test_update_maintainer(self): + self.request_restricted('patch', UserType.MAINTAINER) + + @utils.store_samples('relation-delete-error-forbidden') + def test_delete_anonymous(self): + self.request_restricted('delete', UserType.ANONYMOUS) + + def test_delete_non_maintainer(self): + self.request_restricted('delete', UserType.NON_MAINTAINER) + + @utils.store_samples('relation-update') + def test_delete_maintainer(self): + self.request_restricted('delete', UserType.MAINTAINER) + + def test_submission_conflict(self): + project = create_project() + maintainer = create_maintainer(project) + self.client.force_authenticate(user=maintainer) + relation = create_relation(by=maintainer, project=project) + submission_ids = [s.id for s in relation.submissions.all()] + + # try to create a new relation with a new submission (cover) and + # submissions already bound to another relation + cover = create_cover(project=project) + submission_ids.append(cover.id) + req = {'submissions': submission_ids} + resp = self.client.post(self.api_url(), req) + self.assertEqual(status.HTTP_409_CONFLICT, resp.status_code) + + # try to patch relation + resp = self.client.patch(self.api_url(relation.id), req) + self.assertEqual(status.HTTP_200_OK, resp.status_code) diff --git a/patchwork/tests/utils.py b/patchwork/tests/utils.py index 577183d0986c..ffe90976233e 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,17 @@ 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, by=None, **kwargs): + if not by: + project = create_project() + kwargs['project'] = project + by = create_maintainer(project) + relation = SubmissionRelation.objects.create(by=by) + values = { + 'related': relation + } + values.update(kwargs) + create_patches(count_patches, **values) + return relation diff --git a/patchwork/urls.py b/patchwork/urls.py index dcdcfb49e67e..92095f62c7b9 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 000000000000..cb877991cd55 --- /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.