[PATCH 4/4] REST: Add submission relations
Daniel Axtens
dja at axtens.net
Thu Jan 16 00:32:37 AEDT 2020
Stephen Finucane <stephen at that.guru> writes:
> 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?
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
>> > > +
>> > > +
>> > > + 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