[4/4] REST: Add submission relations
diff mbox series

Message ID 20191207164621.24234-5-metepolat2000@gmail.com
State New
Headers show
Series
  • Add submission relations
Related show

Commit Message

Mete Polat Dec. 7, 2019, 4:46 p.m. UTC
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@axtens.net>
Signed-off-by: Mete Polat <metepolat2000@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

Comments

Stephen Finucane Dec. 27, 2019, 5:48 p.m. UTC | #1
On Sat, 2019-12-07 at 17:46 +0100, Mete Polat wrote:
> 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@axtens.net>
> Signed-off-by: Mete Polat <metepolat2000@gmail.com>

Why did you choose to expose this as a separate API rather than as a
field on the '/patches' resource? While a 'Series' objet has enough
separate metadata to warrant a separate '/series' resource, a
'SubmissionRelation' object is basically just a container. Including a
'related_patches' field on the detailed patch view would seem like more
than enough detail for me, anyway, and unless there's a reason not to
do this, I'd like to see it done that way. Is it possible?

Stephen

PS: I could have sworn I had asked this before, but I can't find any
mails about it so maybe I didn't. Please tell me to RTML (read the
mailing list) if so

> ---
> 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<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.
Mete Polat Dec. 30, 2019, 11:41 a.m. UTC | #2
Hi Stephen,

On 27.12.19 18:48, Stephen Finucane wrote:
> On Sat, 2019-12-07 at 17:46 +0100, Mete Polat wrote:
>> 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@axtens.net>
>> Signed-off-by: Mete Polat <metepolat2000@gmail.com>
> 
> Why did you choose to expose this as a separate API rather than as a
> field on the '/patches' resource? While a 'Series' objet has enough
> separate metadata to warrant a separate '/series' resource, a
> 'SubmissionRelation' object is basically just a container. Including a
> 'related_patches' field on the detailed patch view would seem like more
> than enough detail for me, anyway, and unless there's a reason not to
> do this, I'd like to see it done that way. Is it possible?
> 

The first version of the series supported bulk creating/updating of
relations which was only possible by moving relations into their own url
[1]. As we deciced against bulk operations, I aggree that exposing a
related_patches field is the better choice now.

Best regards,

Mete

[1] Or allow bulk operations on /api/patch/ in general.

> Stephen
> 
> PS: I could have sworn I had asked this before, but I can't find any
> mails about it so maybe I didn't. Please tell me to RTML (read the
> mailing list) if so
> 
>> ---
>> 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<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.
>
Lukas Bulwahn Dec. 30, 2019, 8:28 p.m. UTC | #3
On Mo., 30. Dez. 2019 at 11:41, Mete Polat <metepolat2000@gmail.com> wrote:

> Hi Stephen,
>
> On 27.12.19 18:48, Stephen Finucane wrote:
> > On Sat, 2019-12-07 at 17:46 +0100, Mete Polat wrote:
> >> 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@axtens.net>
> >> Signed-off-by: Mete Polat <metepolat2000@gmail.com>
> >
> > Why did you choose to expose this as a separate API rather than as a
> > field on the '/patches' resource? While a 'Series' objet has enough
> > separate metadata to warrant a separate '/series' resource, a
> > 'SubmissionRelation' object is basically just a container. Including a
> > 'related_patches' field on the detailed patch view would seem like more
> > than enough detail for me, anyway, and unless there's a reason not to
> > do this, I'd like to see it done that way. Is it possible?
> >
>
> The first version of the series supported bulk creating/updating of
> relations which was only possible by moving relations into their own url
> [1]. As we deciced against bulk operations, I aggree that exposing a
> related_patches field is the better choice now.
>


Mete, Stephen's proposal here is a simple quick refactoring of exposing
this API, right?
Could we get that change as a quick small v5 patch series for v2.2.0 ready?

Lukas


> Best regards,
>
> Mete
>
> [1] Or allow bulk operations on /api/patch/ in general.
>
> > Stephen
> >
> > PS: I could have sworn I had asked this before, but I can't find any
> > mails about it so maybe I didn't. Please tell me to RTML (read the
> > mailing list) if so
> >
> >> ---
> >> 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'),
> >>      ]
> >>
> >> +
Mete Polat Jan. 2, 2020, 5:23 p.m. UTC | #4
On 30.12.19 21:28, Lukas Bulwahn wrote:
> On Mo., 30. Dez. 2019 at 11:41, Mete Polat <metepolat2000@gmail.com> wrote:
> 
>> Hi Stephen,
>>
>> On 27.12.19 18:48, Stephen Finucane wrote:
>>> On Sat, 2019-12-07 at 17:46 +0100, Mete Polat wrote:
>>>> 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@axtens.net>
>>>> Signed-off-by: Mete Polat <metepolat2000@gmail.com>
>>>
>>> Why did you choose to expose this as a separate API rather than as a
>>> field on the '/patches' resource? While a 'Series' objet has enough
>>> separate metadata to warrant a separate '/series' resource, a
>>> 'SubmissionRelation' object is basically just a container. Including a
>>> 'related_patches' field on the detailed patch view would seem like more
>>> than enough detail for me, anyway, and unless there's a reason not to
>>> do this, I'd like to see it done that way. Is it possible?
>>>
>>
>> The first version of the series supported bulk creating/updating of
>> relations which was only possible by moving relations into their own url
>> [1]. As we deciced against bulk operations, I aggree that exposing a
>> related_patches field is the better choice now.
>>
> 
> 
> Mete, Stephen's proposal here is a simple quick refactoring of exposing
> this API, right?
> Could we get that change as a quick small v5 patch series for v2.2.0 ready?
> 

It's a small refractroring on the model site (patch 02/04) but not
really on the REST API. The Event API has to be extended and the tests +
permission model have to be adapted again as well. Unfortunately I won't
be available for this Lukas.

Best regards,

Mete

> Lukas
> 
> 
>> Best regards,
>>
>> Mete
>>
>> [1] Or allow bulk operations on /api/patch/ in general.
>>
>>> Stephen
>>>
>>> PS: I could have sworn I had asked this before, but I can't find any
>>> mails about it so maybe I didn't. Please tell me to RTML (read the
>>> mailing list) if so
>>>
>>>> ---
>>>> 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'),
>>>>      ]
>>>>
>>>> +
Daniel Axtens Jan. 11, 2020, 1:20 p.m. UTC | #5
Stephen Finucane <stephen@that.guru> writes:

> On Sat, 2019-12-07 at 17:46 +0100, Mete Polat wrote:
>> 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@axtens.net>
>> Signed-off-by: Mete Polat <metepolat2000@gmail.com>
>
> Why did you choose to expose this as a separate API rather than as a
> field on the '/patches' resource? While a 'Series' objet has enough
> separate metadata to warrant a separate '/series' resource, a
> 'SubmissionRelation' object is basically just a container. Including a
> 'related_patches' field on the detailed patch view would seem like more
> than enough detail for me, anyway, and unless there's a reason not to
> do this, I'd like to see it done that way. Is it possible?
>

How would creating an relation work then? currently you POST to
/api/relations/ with all the patch IDs you want to include in the
relation. I agree that viewing relations through /api/patch makes sense,
but I'm not sure how you create relations if that's the only endpoint
you have?

Regards,
Daniel

> Stephen
>
> PS: I could have sworn I had asked this before, but I can't find any
> mails about it so maybe I didn't. Please tell me to RTML (read the
> mailing list) if so
>
>> ---
>> 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<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.
>
> _______________________________________________
> Patchwork mailing list
> Patchwork@lists.ozlabs.org
> https://lists.ozlabs.org/listinfo/patchwork
Stephen Finucane Jan. 14, 2020, 12:19 p.m. UTC | #6
On Sun, 2020-01-12 at 00:20 +1100, Daniel Axtens wrote:
> Stephen Finucane <stephen@that.guru> writes:
> 
> > On Sat, 2019-12-07 at 17:46 +0100, Mete Polat wrote:
> > > 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@axtens.net>
> > > Signed-off-by: Mete Polat <metepolat2000@gmail.com>
> > 
> > Why did you choose to expose this as a separate API rather than as a
> > field on the '/patches' resource? While a 'Series' objet has enough
> > separate metadata to warrant a separate '/series' resource, a
> > 'SubmissionRelation' object is basically just a container. Including a
> > 'related_patches' field on the detailed patch view would seem like more
> > than enough detail for me, anyway, and unless there's a reason not to
> > do this, I'd like to see it done that way. Is it possible?
> > 
> 
> How would creating an relation work then? currently you POST to
> /api/relations/ with all the patch IDs you want to include in the
> relation. I agree that viewing relations through /api/patch makes sense,
> but I'm not sure how you create relations if that's the only endpoint
> you have?

'PATCH /api/patch/{patchID}' (or 'PUT'), surely?

> Regards,
> Daniel
> 
> > Stephen
> > 
> > PS: I could have sworn I had asked this before, but I can't find any
> > mails about it so maybe I didn't. Please tell me to RTML (read the
> > mailing list) if so
> > 
> > > ---
> > > 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<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.
> > 
> > _______________________________________________
> > Patchwork mailing list
> > Patchwork@lists.ozlabs.org
> > https://lists.ozlabs.org/listinfo/patchwork
Daniel Axtens Jan. 15, 2020, 1:32 p.m. UTC | #7
Stephen Finucane <stephen@that.guru> writes:

> On Sun, 2020-01-12 at 00:20 +1100, Daniel Axtens wrote:
>> Stephen Finucane <stephen@that.guru> writes:
>> 
>> > On Sat, 2019-12-07 at 17:46 +0100, Mete Polat wrote:
>> > > 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@axtens.net>
>> > > Signed-off-by: Mete Polat <metepolat2000@gmail.com>
>> > 
>> > Why did you choose to expose this as a separate API rather than as a
>> > field on the '/patches' resource? While a 'Series' objet has enough
>> > separate metadata to warrant a separate '/series' resource, a
>> > 'SubmissionRelation' object is basically just a container. Including a
>> > 'related_patches' field on the detailed patch view would seem like more
>> > than enough detail for me, anyway, and unless there's a reason not to
>> > do this, I'd like to see it done that way. Is it possible?
>> > 
>> 
>> How would creating an relation work then? currently you POST to
>> /api/relations/ with all the patch IDs you want to include in the
>> relation. I agree that viewing relations through /api/patch makes sense,
>> but I'm not sure how you create relations if that's the only endpoint
>> you have?
>
> 'PATCH /api/patch/{patchID}' (or 'PUT'), surely?

Sorry, that was bashed out too quickly.

There are a few cases I'm thinking about. On reflection you're right that
we can do it without a separate relations endpoint, if we're careful,
but I think it can be a bit unintuitive.

** relating patches for the first time
 
If you want to relate say patches 7, 21 and 42, I can see
   PATCH /api/patch/7 related=[21, 42]
or PATCH /api/patch/21 related=[7, 42] etc working

I would have gone with POST /api/relations patches=[7, 21, 42] returning
an ID of a relation (say 1).

** adding a patch to a relation

Say we want to add patch 9 to the relation, I guess we'd do:

PATCH /api/patch/9 related=[7] (or 21, or 42, or a combination)

We probably don't want to be trying to do that by patching 7 or 21 or
42, you'd need a read-modify-write cycle so you risk wiping out a change
that came through in the mean time...

I would have gone with PATCH /api/patch/9 related=1
(We don't want to PATCH /api/relation/1 because of the same RMW issue)

** removal of a patch

What happens when you want to remove patch 21 from the relation?

I guess we could do PATCH /api/patch/21 related=[]

Again we don't want to do this by patching 7 or 42 or 9 as we'd need
a RMW loop that's even more non-atomic than usual

I would have gone with PATCH /api/patch/21 related=null
again, not wanting to PATCH /api/relation/1 for RMW reasons

So yeah, I guess not having an API view for relations would work. I
think it's a bit trickier to get right from an implementation point of
view, but I'm not going to go to the mat over it.

Regards,
Daniel

>
>> Regards,
>> Daniel
>> 
>> > Stephen
>> > 
>> > PS: I could have sworn I had asked this before, but I can't find any
>> > mails about it so maybe I didn't. Please tell me to RTML (read the
>> > mailing list) if so
>> > 
>> > > ---
>> > > 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<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.
>> > 
>> > _______________________________________________
>> > Patchwork mailing list
>> > Patchwork@lists.ozlabs.org
>> > https://lists.ozlabs.org/listinfo/patchwork

Patch
diff mbox series

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<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.