[PATCH 4/4] REST: Add submission relations

Stephen Finucane stephen at that.guru
Tue Jan 14 23:19:12 AEDT 2020


On Sun, 2020-01-12 at 00:20 +1100, Daniel Axtens wrote:
> Stephen Finucane <stephen at 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 at axtens.net>
> > > Signed-off-by: Mete Polat <metepolat2000 at 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
> > > +
> > > +
> > > + at unittest.skipUnless(settings.ENABLE_REST_API, 'requires ENABLE_REST_API')
> > > +class TestRelationAPI(utils.APITestCase):
> > > +    fixtures = ['default_tags']
> > > +
> > > +    @staticmethod
> > > +    def api_url(item=None):
> > > +        kwargs = {}
> > > +        if item is None:
> > > +            return reverse('api-relation-list', kwargs=kwargs)
> > > +        kwargs['pk'] = item
> > > +        return reverse('api-relation-detail', kwargs=kwargs)
> > > +
> > > +    def request_restricted(self, method, user_type):
> > > +        """Assert post/delete/patch requests on the relation API."""
> > > +        assert method in ['post', 'delete', 'patch']
> > > +
> > > +        # setup
> > > +
> > > +        project = create_project()
> > > +        maintainer = create_maintainer(project)
> > > +
> > > +        if user_type == UserType.ANONYMOUS:
> > > +            expected_status = status.HTTP_403_FORBIDDEN
> > > +        elif user_type == UserType.NON_MAINTAINER:
> > > +            expected_status = status.HTTP_403_FORBIDDEN
> > > +            self.client.force_authenticate(user=create_user())
> > > +        elif user_type == UserType.MAINTAINER:
> > > +            if method == 'post':
> > > +                expected_status = status.HTTP_201_CREATED
> > > +            elif method == 'delete':
> > > +                expected_status = status.HTTP_204_NO_CONTENT
> > > +            else:
> > > +                expected_status = status.HTTP_200_OK
> > > +            self.client.force_authenticate(user=maintainer)
> > > +        else:
> > > +            raise ValueError
> > > +
> > > +        resource_id = None
> > > +        req = None
> > > +
> > > +        if method == 'delete':
> > > +            resource_id = create_relation(project=project, by=maintainer).id
> > > +        elif method == 'post':
> > > +            patch_ids = [p.id for p in create_patches(2, project=project)]
> > > +            req = {'submissions': patch_ids}
> > > +        elif method == 'patch':
> > > +            resource_id = create_relation(project=project, by=maintainer).id
> > > +            patch_ids = [p.id for p in create_patches(2, project=project)]
> > > +            req = {'submissions': patch_ids}
> > > +        else:
> > > +            raise ValueError
> > > +
> > > +        # request
> > > +
> > > +        resp = getattr(self.client, method)(self.api_url(resource_id), req)
> > > +
> > > +        # check
> > > +
> > > +        self.assertEqual(expected_status, resp.status_code)
> > > +
> > > +        if resp.status_code in range(status.HTTP_200_OK,
> > > +                                     status.HTTP_204_NO_CONTENT):
> > > +            self.assertRequest(req, resp.data)
> > > +
> > > +    def assertRequest(self, request, resp):
> > > +        if request.get('id'):
> > > +            self.assertEqual(request['id'], resp['id'])
> > > +        send_ids = request['submissions']
> > > +        resp_ids = [s['id'] for s in resp['submissions']]
> > > +        six.assertCountEqual(self, resp_ids, send_ids)
> > > +
> > > +    def assertSerialized(self, obj, resp):
> > > +        self.assertEqual(obj.id, resp['id'])
> > > +        exp_ids = [s.id for s in obj.submissions.all()]
> > > +        act_ids = [s['id'] for s in resp['submissions']]
> > > +        six.assertCountEqual(self, exp_ids, act_ids)
> > > +
> > > +    def test_list_empty(self):
> > > +        """List relation when none are present."""
> > > +        resp = self.client.get(self.api_url())
> > > +        self.assertEqual(status.HTTP_200_OK, resp.status_code)
> > > +        self.assertEqual(0, len(resp.data))
> > > +
> > > +    @utils.store_samples('relation-list')
> > > +    def test_list(self):
> > > +        """List relations."""
> > > +        relation = create_relation()
> > > +
> > > +        resp = self.client.get(self.api_url())
> > > +        self.assertEqual(status.HTTP_200_OK, resp.status_code)
> > > +        self.assertEqual(1, len(resp.data))
> > > +        self.assertSerialized(relation, resp.data[0])
> > > +
> > > +    def test_detail(self):
> > > +        """Show relation."""
> > > +        relation = create_relation()
> > > +
> > > +        resp = self.client.get(self.api_url(relation.id))
> > > +        self.assertEqual(status.HTTP_200_OK, resp.status_code)
> > > +        self.assertSerialized(relation, resp.data)
> > > +
> > > +    @utils.store_samples('relation-create-error-forbidden')
> > > +    def test_create_anonymous(self):
> > > +        self.request_restricted('post', UserType.ANONYMOUS)
> > > +
> > > +    def test_create_non_maintainer(self):
> > > +        self.request_restricted('post', UserType.NON_MAINTAINER)
> > > +
> > > +    @utils.store_samples('relation-create')
> > > +    def test_create_maintainer(self):
> > > +        self.request_restricted('post', UserType.MAINTAINER)
> > > +
> > > +    @utils.store_samples('relation-update-error-forbidden')
> > > +    def test_update_anonymous(self):
> > > +        self.request_restricted('patch', UserType.ANONYMOUS)
> > > +
> > > +    def test_update_non_maintainer(self):
> > > +        self.request_restricted('patch', UserType.NON_MAINTAINER)
> > > +
> > > +    @utils.store_samples('relation-update')
> > > +    def test_update_maintainer(self):
> > > +        self.request_restricted('patch', UserType.MAINTAINER)
> > > +
> > > +    @utils.store_samples('relation-delete-error-forbidden')
> > > +    def test_delete_anonymous(self):
> > > +        self.request_restricted('delete', UserType.ANONYMOUS)
> > > +
> > > +    def test_delete_non_maintainer(self):
> > > +        self.request_restricted('delete', UserType.NON_MAINTAINER)
> > > +
> > > +    @utils.store_samples('relation-update')
> > > +    def test_delete_maintainer(self):
> > > +        self.request_restricted('delete', UserType.MAINTAINER)
> > > +
> > > +    def test_submission_conflict(self):
> > > +        project = create_project()
> > > +        maintainer = create_maintainer(project)
> > > +        self.client.force_authenticate(user=maintainer)
> > > +        relation = create_relation(by=maintainer, project=project)
> > > +        submission_ids = [s.id for s in relation.submissions.all()]
> > > +
> > > +        # try to create a new relation with a new submission (cover) and
> > > +        # submissions already bound to another relation
> > > +        cover = create_cover(project=project)
> > > +        submission_ids.append(cover.id)
> > > +        req = {'submissions': submission_ids}
> > > +        resp = self.client.post(self.api_url(), req)
> > > +        self.assertEqual(status.HTTP_409_CONFLICT, resp.status_code)
> > > +
> > > +        # try to patch relation
> > > +        resp = self.client.patch(self.api_url(relation.id), req)
> > > +        self.assertEqual(status.HTTP_200_OK, resp.status_code)
> > > diff --git a/patchwork/tests/utils.py b/patchwork/tests/utils.py
> > > index 577183d0986c..ffe90976233e 100644
> > > --- a/patchwork/tests/utils.py
> > > +++ b/patchwork/tests/utils.py
> > > @@ -16,6 +16,7 @@ from patchwork.models import Check
> > >  from patchwork.models import Comment
> > >  from patchwork.models import CoverLetter
> > >  from patchwork.models import Patch
> > > +from patchwork.models import SubmissionRelation
> > >  from patchwork.models import Person
> > >  from patchwork.models import Project
> > >  from patchwork.models import Series
> > > @@ -347,3 +348,17 @@ def create_covers(count=1, **kwargs):
> > >          kwargs (dict): Overrides for various cover letter fields
> > >      """
> > >      return _create_submissions(create_cover, count, **kwargs)
> > > +
> > > +
> > > +def create_relation(count_patches=2, by=None, **kwargs):
> > > +    if not by:
> > > +        project = create_project()
> > > +        kwargs['project'] = project
> > > +        by = create_maintainer(project)
> > > +    relation = SubmissionRelation.objects.create(by=by)
> > > +    values = {
> > > +        'related': relation
> > > +    }
> > > +    values.update(kwargs)
> > > +    create_patches(count_patches, **values)
> > > +    return relation
> > > diff --git a/patchwork/urls.py b/patchwork/urls.py
> > > index dcdcfb49e67e..92095f62c7b9 100644
> > > --- a/patchwork/urls.py
> > > +++ b/patchwork/urls.py
> > > @@ -187,6 +187,7 @@ if settings.ENABLE_REST_API:
> > >      from patchwork.api import patch as api_patch_views  # noqa
> > >      from patchwork.api import person as api_person_views  # noqa
> > >      from patchwork.api import project as api_project_views  # noqa
> > > +    from patchwork.api import relation as api_relation_views  # noqa
> > >      from patchwork.api import series as api_series_views  # noqa
> > >      from patchwork.api import user as api_user_views  # noqa
> > >  
> > > @@ -256,9 +257,19 @@ if settings.ENABLE_REST_API:
> > >              name='api-cover-comment-list'),
> > >      ]
> > >  
> > > +    api_1_2_patterns = [
> > > +        url(r'^relations/$',
> > > +            api_relation_views.SubmissionRelationList.as_view(),
> > > +            name='api-relation-list'),
> > > +        url(r'^relations/(?P<pk>[^/]+)/$',
> > > +            api_relation_views.SubmissionRelationDetail.as_view(),
> > > +            name='api-relation-detail'),
> > > +    ]
> > > +
> > >      urlpatterns += [
> > >          url(r'^api/(?:(?P<version>(1.0|1.1|1.2))/)?', include(api_patterns)),
> > >          url(r'^api/(?:(?P<version>(1.1|1.2))/)?', include(api_1_1_patterns)),
> > > +        url(r'^api/(?:(?P<version>1.2)/)?', include(api_1_2_patterns)),
> > >  
> > >          # token change
> > >          url(r'^user/generate-token/$', user_views.generate_token,
> > > diff --git a/releasenotes/notes/add-submission-relations-c96bb6c567b416d8.yaml b/releasenotes/notes/add-submission-relations-c96bb6c567b416d8.yaml
> > > new file mode 100644
> > > index 000000000000..cb877991cd55
> > > --- /dev/null
> > > +++ b/releasenotes/notes/add-submission-relations-c96bb6c567b416d8.yaml
> > > @@ -0,0 +1,10 @@
> > > +---
> > > +features:
> > > +  - |
> > > +    Submissions (cover letters or patches) can now be related to other ones
> > > +    (e.g. revisions). Relations can be set via the REST API by maintainers
> > > +    (currently only for submissions of projects they maintain)
> > > +api:
> > > +  - |
> > > +    Relations are available via ``/relations/`` and
> > > +    ``/relations/{relationID}/`` endpoints.
> > 
> > _______________________________________________
> > Patchwork mailing list
> > Patchwork at lists.ozlabs.org
> > https://lists.ozlabs.org/listinfo/patchwork



More information about the Patchwork mailing list