[PATCH 4/4] REST: Add submission relations

Mete Polat metepolat2000 at gmail.com
Sun Dec 8 03:46:21 AEDT 2019


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/<relation_id>/

Co-authored-by: Daniel Axtens <dja at axtens.net>
Signed-off-by: Mete Polat <metepolat2000 at gmail.com>
---
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
+
+
+ at 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<pk>[^/]+)/$',
+            api_relation_views.SubmissionRelationDetail.as_view(),
+            name='api-relation-detail'),
+    ]
+
     urlpatterns += [
         url(r'^api/(?:(?P<version>(1.0|1.1|1.2))/)?', include(api_patterns)),
         url(r'^api/(?:(?P<version>(1.1|1.2))/)?', include(api_1_1_patterns)),
+        url(r'^api/(?:(?P<version>1.2)/)?', include(api_1_2_patterns)),
 
         # token change
         url(r'^user/generate-token/$', user_views.generate_token,
diff --git a/releasenotes/notes/add-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.
-- 
2.24.0



More information about the Patchwork mailing list