[PATCH 4/4] REST: Add submission relations
Lukas Bulwahn
lukas.bulwahn at gmail.com
Tue Dec 31 07:28:38 AEDT 2019
On Mo., 30. Dez. 2019 at 11:41, Mete Polat <metepolat2000 at gmail.com> wrote:
> Hi Stephen,
>
> On 27.12.19 18:48, Stephen Finucane wrote:
> > On Sat, 2019-12-07 at 17:46 +0100, Mete Polat wrote:
> >> View relations and add/update/delete them as a maintainer. Maintainers
> >> can only create relations of submissions (patches/cover letters) which
> >> are part of a project they maintain.
> >>
> >> New REST API urls:
> >> api/relations/
> >> api/relations/<relation_id>/
> >>
> >> Co-authored-by: Daniel Axtens <dja 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?
> >
>
> The first version of the series supported bulk creating/updating of
> relations which was only possible by moving relations into their own url
> [1]. As we deciced against bulk operations, I aggree that exposing a
> related_patches field is the better choice now.
>
Mete, Stephen's proposal here is a simple quick refactoring of exposing
this API, right?
Could we get that change as a quick small v5 patch series for v2.2.0 ready?
Lukas
> Best regards,
>
> Mete
>
> [1] Or allow bulk operations on /api/patch/ in general.
>
> > Stephen
> >
> > PS: I could have sworn I had asked this before, but I can't find any
> > mails about it so maybe I didn't. Please tell me to RTML (read the
> > mailing list) if so
> >
> >> ---
> >> Optimize db queries:
> >> I have spent quite a lot of time in optimizing the db queries for the
> REST API
> >> (thanks for the tip with the Django toolbar). Daniel stated that
> >> prefetch_related is possibly hitting the database for every relation
> when
> >> prefetching submissions but it turns out that we can tell Django to
> use a
> >> statement like:
> >> SELECT *
> >> FROM `patchwork_patch`
> >> INNER JOIN `patchwork_submission`
> >> ON (`patchwork_patch`.`submission_ptr_id` =
> `patchwork_submission`.`id`)
> >> WHERE `patchwork_patch`.`submission_ptr_id` IN
> (LIST_OF_ALL_SUBMISSION_IDS)
> >>
> >> We do the same for `patchwork_coverletter`.
> >> This means we only hit the db two times for casting _all_
> submissions to a
> >> patch or cover-letter.
> >>
> >> Prefetching submissions__project eliminates similar and duplicate
> queries
> >> that are used to determine whether a logged in user is at least
> maintainer
> >> of one submission's project.
> >>
> >> docs/api/schemas/latest/patchwork.yaml | 273 +++++++++++++++++
> >> docs/api/schemas/patchwork.j2 | 285 ++++++++++++++++++
> >> docs/api/schemas/v1.2/patchwork.yaml | 273 +++++++++++++++++
> >> patchwork/api/embedded.py | 39 +++
> >> patchwork/api/index.py | 1 +
> >> patchwork/api/relation.py | 121 ++++++++
> >> patchwork/models.py | 6 +
> >> patchwork/tests/api/test_relation.py | 181 +++++++++++
> >> patchwork/tests/utils.py | 15 +
> >> patchwork/urls.py | 11 +
> >> ...submission-relations-c96bb6c567b416d8.yaml | 10 +
> >> 11 files changed, 1215 insertions(+)
> >> create mode 100644 patchwork/api/relation.py
> >> create mode 100644 patchwork/tests/api/test_relation.py
> >> create mode 100644
> releasenotes/notes/add-submission-relations-c96bb6c567b416d8.yaml
> >>
> >> diff --git a/docs/api/schemas/latest/patchwork.yaml
> b/docs/api/schemas/latest/patchwork.yaml
> >> index a5e235be936d..7dd24fd700d5 100644
> >> --- a/docs/api/schemas/latest/patchwork.yaml
> >> +++ b/docs/api/schemas/latest/patchwork.yaml
> >> @@ -1039,6 +1039,188 @@ paths:
> >> $ref: '#/components/schemas/Error'
> >> tags:
> >> - series
> >> + /api/relations/:
> >> + get:
> >> + description: List relations.
> >> + operationId: relations_list
> >> + parameters:
> >> + - $ref: '#/components/parameters/Page'
> >> + - $ref: '#/components/parameters/PageSize'
> >> + - $ref: '#/components/parameters/Order'
> >> + responses:
> >> + '200':
> >> + description: ''
> >> + headers:
> >> + Link:
> >> + $ref: '#/components/headers/Link'
> >> + content:
> >> + application/json:
> >> + schema:
> >> + type: array
> >> + items:
> >> + $ref: '#/components/schemas/Relation'
> >> + tags:
> >> + - relations
> >> + post:
> >> + description: Create a relation.
> >> + operationId: relations_create
> >> + security:
> >> + - basicAuth: []
> >> + - apiKeyAuth: []
> >> + requestBody:
> >> + $ref: '#/components/requestBodies/Relation'
> >> + responses:
> >> + '201':
> >> + description: ''
> >> + content:
> >> + application/json:
> >> + schema:
> >> + $ref: '#/components/schemas/Relation'
> >> + '400':
> >> + description: Invalid Request
> >> + content:
> >> + application/json:
> >> + schema:
> >> + $ref: '#/components/schemas/Error'
> >> + '403':
> >> + description: Forbidden
> >> + content:
> >> + application/json:
> >> + schema:
> >> + $ref: '#/components/schemas/Error'
> >> + '404':
> >> + description: Not found
> >> + content:
> >> + application/json:
> >> + schema:
> >> + $ref: '#/components/schemas/Error'
> >> + '409':
> >> + description: Conflict
> >> + content:
> >> + application/json:
> >> + schema:
> >> + $ref: '#/components/schemas/Error'
> >> + tags:
> >> + - checks
> >> + /api/relations/{id}/:
> >> + get:
> >> + description: Show a relation.
> >> + operationId: relation_read
> >> + parameters:
> >> + - in: path
> >> + name: id
> >> + description: A unique integer value identifying this
> relation.
> >> + required: true
> >> + schema:
> >> + title: ID
> >> + type: integer
> >> + responses:
> >> + '200':
> >> + description: ''
> >> + content:
> >> + application/json:
> >> + schema:
> >> + $ref: '#/components/schemas/Relation'
> >> + '404':
> >> + description: Not found
> >> + content:
> >> + application/json:
> >> + schema:
> >> + $ref: '#/components/schemas/Error'
> >> + '409':
> >> + description: Conflict
> >> + content:
> >> + application/json:
> >> + schema:
> >> + $ref: '#/components/schemas/Error'
> >> + tags:
> >> + - relations
> >> + patch:
> >> + description: Update a relation (partial).
> >> + operationId: relations_partial_update
> >> + security:
> >> + - basicAuth: []
> >> + - apiKeyAuth: []
> >> + parameters:
> >> + - in: path
> >> + name: id
> >> + description: A unique integer value identifying this
> relation.
> >> + required: true
> >> + schema:
> >> + title: ID
> >> + type: integer
> >> + requestBody:
> >> + $ref: '#/components/requestBodies/Relation'
> >> + responses:
> >> + '200':
> >> + description: ''
> >> + content:
> >> + application/json:
> >> + schema:
> >> + $ref: '#/components/schemas/Relation'
> >> + '400':
> >> + description: Bad request
> >> + content:
> >> + application/json:
> >> + schema:
> >> + $ref: '#/components/schemas/Error'
> >> + '403':
> >> + description: Forbidden
> >> + content:
> >> + application/json:
> >> + schema:
> >> + $ref: '#/components/schemas/Error'
> >> + '404':
> >> + description: Not found
> >> + content:
> >> + application/json:
> >> + schema:
> >> + $ref: '#/components/schemas/Error'
> >> + tags:
> >> + - relations
> >> + put:
> >> + description: Update a relation.
> >> + operationId: relations_update
> >> + security:
> >> + - basicAuth: []
> >> + - apiKeyAuth: []
> >> + parameters:
> >> + - in: path
> >> + name: id
> >> + description: A unique integer value identifying this
> relation.
> >> + required: true
> >> + schema:
> >> + title: ID
> >> + type: integer
> >> + requestBody:
> >> + $ref: '#/components/requestBodies/Relation'
> >> + responses:
> >> + '200':
> >> + description: ''
> >> + content:
> >> + application/json:
> >> + schema:
> >> + $ref: '#/components/schemas/Relation'
> >> + '400':
> >> + description: Bad request
> >> + content:
> >> + application/json:
> >> + schema:
> >> + $ref: '#/components/schemas/Error'
> >> + '403':
> >> + description: Forbidden
> >> + content:
> >> + application/json:
> >> + schema:
> >> + $ref: '#/components/schemas/Error'
> >> + '404':
> >> + description: Not found
> >> + content:
> >> + application/json:
> >> + schema:
> >> + $ref: '#/components/schemas/Error'
> >> + tags:
> >> + - relations
> >> /api/users/:
> >> get:
> >> description: List users.
> >> @@ -1314,6 +1496,18 @@ components:
> >> application/x-www-form-urlencoded:
> >> schema:
> >> $ref: '#/components/schemas/User'
> >> + Relation:
> >> + required: true
> >> + content:
> >> + application/json:
> >> + schema:
> >> + $ref: '#/components/schemas/RelationUpdate'
> >> + multipart/form-data:
> >> + schema:
> >> + $ref: '#/components/schemas/RelationUpdate'
> >> + application/x-www-form-urlencoded:
> >> + schema:
> >> + $ref: '#/components/schemas/RelationUpdate'
> >> schemas:
> >> Index:
> >> type: object
> >> @@ -1358,6 +1552,11 @@ components:
> >> type: string
> >> format: uri
> >> readOnly: true
> >> + relations:
> >> + title: Relations URL
> >> + type: string
> >> + format: uri
> >> + readOnly: true
> >> Bundle:
> >> required:
> >> - name
> >> @@ -1943,6 +2142,14 @@ components:
> >> title: Delegate
> >> type: integer
> >> nullable: true
> >> + RelationUpdate:
> >> + type: object
> >> + properties:
> >> + submissions:
> >> + title: Submission IDs
> >> + type: array
> >> + items:
> >> + type: integer
> >> Person:
> >> type: object
> >> properties:
> >> @@ -2133,6 +2340,30 @@ components:
> >> $ref: '#/components/schemas/PatchEmbedded'
> >> readOnly: true
> >> uniqueItems: true
> >> + Relation:
> >> + type: object
> >> + properties:
> >> + id:
> >> + title: ID
> >> + type: integer
> >> + url:
> >> + title: URL
> >> + type: string
> >> + format: uri
> >> + readOnly: true
> >> + by:
> >> + type: object
> >> + title: By
> >> + readOnly: true
> >> + allOf:
> >> + - $ref: '#/components/schemas/UserEmbedded'
> >> + submissions:
> >> + title: Submissions
> >> + type: array
> >> + items:
> >> + $ref: '#/components/schemas/SubmissionEmbedded'
> >> + readOnly: true
> >> + uniqueItems: true
> >> User:
> >> type: object
> >> properties:
> >> @@ -2211,6 +2442,48 @@ components:
> >> maxLength: 255
> >> minLength: 1
> >> readOnly: true
> >> + SubmissionEmbedded:
> >> + type: object
> >> + properties:
> >> + id:
> >> + title: ID
> >> + type: integer
> >> + readOnly: true
> >> + url:
> >> + title: URL
> >> + type: string
> >> + format: uri
> >> + readOnly: true
> >> + web_url:
> >> + title: Web URL
> >> + type: string
> >> + format: uri
> >> + readOnly: true
> >> + msgid:
> >> + title: Message ID
> >> + type: string
> >> + readOnly: true
> >> + minLength: 1
> >> + list_archive_url:
> >> + title: List archive URL
> >> + type: string
> >> + readOnly: true
> >> + nullable: true
> >> + date:
> >> + title: Date
> >> + type: string
> >> + format: iso8601
> >> + readOnly: true
> >> + name:
> >> + title: Name
> >> + type: string
> >> + readOnly: true
> >> + minLength: 1
> >> + mbox:
> >> + title: Mbox
> >> + type: string
> >> + format: uri
> >> + readOnly: true
> >> CoverLetterEmbedded:
> >> type: object
> >> properties:
> >> diff --git a/docs/api/schemas/patchwork.j2
> b/docs/api/schemas/patchwork.j2
> >> index 196d78466b55..a034029accf9 100644
> >> --- a/docs/api/schemas/patchwork.j2
> >> +++ b/docs/api/schemas/patchwork.j2
> >> @@ -1048,6 +1048,190 @@ paths:
> >> $ref: '#/components/schemas/Error'
> >> tags:
> >> - series
> >> +{% if version >= (1, 2) %}
> >> + /api/{{ version_url }}relations/:
> >> + get:
> >> + description: List relations.
> >> + operationId: relations_list
> >> + parameters:
> >> + - $ref: '#/components/parameters/Page'
> >> + - $ref: '#/components/parameters/PageSize'
> >> + - $ref: '#/components/parameters/Order'
> >> + responses:
> >> + '200':
> >> + description: ''
> >> + headers:
> >> + Link:
> >> + $ref: '#/components/headers/Link'
> >> + content:
> >> + application/json:
> >> + schema:
> >> + type: array
> >> + items:
> >> + $ref: '#/components/schemas/Relation'
> >> + tags:
> >> + - relations
> >> + post:
> >> + description: Create a relation.
> >> + operationId: relations_create
> >> + security:
> >> + - basicAuth: []
> >> + - apiKeyAuth: []
> >> + requestBody:
> >> + $ref: '#/components/requestBodies/Relation'
> >> + responses:
> >> + '201':
> >> + description: ''
> >> + content:
> >> + application/json:
> >> + schema:
> >> + $ref: '#/components/schemas/Relation'
> >> + '400':
> >> + description: Invalid Request
> >> + content:
> >> + application/json:
> >> + schema:
> >> + $ref: '#/components/schemas/Error'
> >> + '403':
> >> + description: Forbidden
> >> + content:
> >> + application/json:
> >> + schema:
> >> + $ref: '#/components/schemas/Error'
> >> + '404':
> >> + description: Not found
> >> + content:
> >> + application/json:
> >> + schema:
> >> + $ref: '#/components/schemas/Error'
> >> + '409':
> >> + description: Conflict
> >> + content:
> >> + application/json:
> >> + schema:
> >> + $ref: '#/components/schemas/Error'
> >> + tags:
> >> + - checks
> >> + /api/{{ version_url }}relations/{id}/:
> >> + get:
> >> + description: Show a relation.
> >> + operationId: relation_read
> >> + parameters:
> >> + - in: path
> >> + name: id
> >> + description: A unique integer value identifying this
> relation.
> >> + required: true
> >> + schema:
> >> + title: ID
> >> + type: integer
> >> + responses:
> >> + '200':
> >> + description: ''
> >> + content:
> >> + application/json:
> >> + schema:
> >> + $ref: '#/components/schemas/Relation'
> >> + '404':
> >> + description: Not found
> >> + content:
> >> + application/json:
> >> + schema:
> >> + $ref: '#/components/schemas/Error'
> >> + '409':
> >> + description: Conflict
> >> + content:
> >> + application/json:
> >> + schema:
> >> + $ref: '#/components/schemas/Error'
> >> + tags:
> >> + - relations
> >> + patch:
> >> + description: Update a relation (partial).
> >> + operationId: relations_partial_update
> >> + security:
> >> + - basicAuth: []
> >> + - apiKeyAuth: []
> >> + parameters:
> >> + - in: path
> >> + name: id
> >> + description: A unique integer value identifying this
> relation.
> >> + required: true
> >> + schema:
> >> + title: ID
> >> + type: integer
> >> + requestBody:
> >> + $ref: '#/components/requestBodies/Relation'
> >> + responses:
> >> + '200':
> >> + description: ''
> >> + content:
> >> + application/json:
> >> + schema:
> >> + $ref: '#/components/schemas/Relation'
> >> + '400':
> >> + description: Bad request
> >> + content:
> >> + application/json:
> >> + schema:
> >> + $ref: '#/components/schemas/Error'
> >> + '403':
> >> + description: Forbidden
> >> + content:
> >> + application/json:
> >> + schema:
> >> + $ref: '#/components/schemas/Error'
> >> + '404':
> >> + description: Not found
> >> + content:
> >> + application/json:
> >> + schema:
> >> + $ref: '#/components/schemas/Error'
> >> + tags:
> >> + - relations
> >> + put:
> >> + description: Update a relation.
> >> + operationId: relations_update
> >> + security:
> >> + - basicAuth: []
> >> + - apiKeyAuth: []
> >> + parameters:
> >> + - in: path
> >> + name: id
> >> + description: A unique integer value identifying this
> relation.
> >> + required: true
> >> + schema:
> >> + title: ID
> >> + type: integer
> >> + requestBody:
> >> + $ref: '#/components/requestBodies/Relation'
> >> + responses:
> >> + '200':
> >> + description: ''
> >> + content:
> >> + application/json:
> >> + schema:
> >> + $ref: '#/components/schemas/Relation'
> >> + '400':
> >> + description: Bad request
> >> + content:
> >> + application/json:
> >> + schema:
> >> + $ref: '#/components/schemas/Error'
> >> + '403':
> >> + description: Forbidden
> >> + content:
> >> + application/json:
> >> + schema:
> >> + $ref: '#/components/schemas/Error'
> >> + '404':
> >> + description: Not found
> >> + content:
> >> + application/json:
> >> + schema:
> >> + $ref: '#/components/schemas/Error'
> >> + tags:
> >> + - relations
> >> +{% endif %}
> >> /api/{{ version_url }}users/:
> >> get:
> >> description: List users.
> >> @@ -1325,6 +1509,20 @@ components:
> >> application/x-www-form-urlencoded:
> >> schema:
> >> $ref: '#/components/schemas/User'
> >> +{% if version >= (1, 2) %}
> >> + Relation:
> >> + required: true
> >> + content:
> >> + application/json:
> >> + schema:
> >> + $ref: '#/components/schemas/RelationUpdate'
> >> + multipart/form-data:
> >> + schema:
> >> + $ref: '#/components/schemas/RelationUpdate'
> >> + application/x-www-form-urlencoded:
> >> + schema:
> >> + $ref: '#/components/schemas/RelationUpdate'
> >> +{% endif %}
> >> schemas:
> >> Index:
> >> type: object
> >> @@ -1369,6 +1567,13 @@ components:
> >> type: string
> >> format: uri
> >> readOnly: true
> >> +{% if version >= (1, 2) %}
> >> + relations:
> >> + title: Relations URL
> >> + type: string
> >> + format: uri
> >> + readOnly: true
> >> +{% endif %}
> >> Bundle:
> >> required:
> >> - name
> >> @@ -1981,6 +2186,16 @@ components:
> >> title: Delegate
> >> type: integer
> >> nullable: true
> >> +{% if version >= (1, 2) %}
> >> + RelationUpdate:
> >> + type: object
> >> + properties:
> >> + submissions:
> >> + title: Submission IDs
> >> + type: array
> >> + items:
> >> + type: integer
> >> +{% endif %}
> >> Person:
> >> type: object
> >> properties:
> >> @@ -2177,6 +2392,32 @@ components:
> >> $ref: '#/components/schemas/PatchEmbedded'
> >> readOnly: true
> >> uniqueItems: true
> >> +{% if version >= (1, 2) %}
> >> + Relation:
> >> + type: object
> >> + properties:
> >> + id:
> >> + title: ID
> >> + type: integer
> >> + url:
> >> + title: URL
> >> + type: string
> >> + format: uri
> >> + readOnly: true
> >> + by:
> >> + type: object
> >> + title: By
> >> + readOnly: true
> >> + allOf:
> >> + - $ref: '#/components/schemas/UserEmbedded'
> >> + submissions:
> >> + title: Submissions
> >> + type: array
> >> + items:
> >> + $ref: '#/components/schemas/SubmissionEmbedded'
> >> + readOnly: true
> >> + uniqueItems: true
> >> +{% endif %}
> >> User:
> >> type: object
> >> properties:
> >> @@ -2255,6 +2496,50 @@ components:
> >> maxLength: 255
> >> minLength: 1
> >> readOnly: true
> >> +{% if version >= (1, 2) %}
> >> + SubmissionEmbedded:
> >> + type: object
> >> + properties:
> >> + id:
> >> + title: ID
> >> + type: integer
> >> + readOnly: true
> >> + url:
> >> + title: URL
> >> + type: string
> >> + format: uri
> >> + readOnly: true
> >> + web_url:
> >> + title: Web URL
> >> + type: string
> >> + format: uri
> >> + readOnly: true
> >> + msgid:
> >> + title: Message ID
> >> + type: string
> >> + readOnly: true
> >> + minLength: 1
> >> + list_archive_url:
> >> + title: List archive URL
> >> + type: string
> >> + readOnly: true
> >> + nullable: true
> >> + date:
> >> + title: Date
> >> + type: string
> >> + format: iso8601
> >> + readOnly: true
> >> + name:
> >> + title: Name
> >> + type: string
> >> + readOnly: true
> >> + minLength: 1
> >> + mbox:
> >> + title: Mbox
> >> + type: string
> >> + format: uri
> >> + readOnly: true
> >> +{% endif %}
> >> CoverLetterEmbedded:
> >> type: object
> >> properties:
> >> diff --git a/docs/api/schemas/v1.2/patchwork.yaml
> b/docs/api/schemas/v1.2/patchwork.yaml
> >> index d7b4d2957cff..99425e968881 100644
> >> --- a/docs/api/schemas/v1.2/patchwork.yaml
> >> +++ b/docs/api/schemas/v1.2/patchwork.yaml
> >> @@ -1039,6 +1039,188 @@ paths:
> >> $ref: '#/components/schemas/Error'
> >> tags:
> >> - series
> >> + /api/1.2/relations/:
> >> + get:
> >> + description: List relations.
> >> + operationId: relations_list
> >> + parameters:
> >> + - $ref: '#/components/parameters/Page'
> >> + - $ref: '#/components/parameters/PageSize'
> >> + - $ref: '#/components/parameters/Order'
> >> + responses:
> >> + '200':
> >> + description: ''
> >> + headers:
> >> + Link:
> >> + $ref: '#/components/headers/Link'
> >> + content:
> >> + application/json:
> >> + schema:
> >> + type: array
> >> + items:
> >> + $ref: '#/components/schemas/Relation'
> >> + tags:
> >> + - relations
> >> + post:
> >> + description: Create a relation.
> >> + operationId: relations_create
> >> + security:
> >> + - basicAuth: []
> >> + - apiKeyAuth: []
> >> + requestBody:
> >> + $ref: '#/components/requestBodies/Relation'
> >> + responses:
> >> + '201':
> >> + description: ''
> >> + content:
> >> + application/json:
> >> + schema:
> >> + $ref: '#/components/schemas/Relation'
> >> + '400':
> >> + description: Invalid Request
> >> + content:
> >> + application/json:
> >> + schema:
> >> + $ref: '#/components/schemas/Error'
> >> + '403':
> >> + description: Forbidden
> >> + content:
> >> + application/json:
> >> + schema:
> >> + $ref: '#/components/schemas/Error'
> >> + '404':
> >> + description: Not found
> >> + content:
> >> + application/json:
> >> + schema:
> >> + $ref: '#/components/schemas/Error'
> >> + '409':
> >> + description: Conflict
> >> + content:
> >> + application/json:
> >> + schema:
> >> + $ref: '#/components/schemas/Error'
> >> + tags:
> >> + - checks
> >> + /api/1.2/relations/{id}/:
> >> + get:
> >> + description: Show a relation.
> >> + operationId: relation_read
> >> + parameters:
> >> + - in: path
> >> + name: id
> >> + description: A unique integer value identifying this
> relation.
> >> + required: true
> >> + schema:
> >> + title: ID
> >> + type: integer
> >> + responses:
> >> + '200':
> >> + description: ''
> >> + content:
> >> + application/json:
> >> + schema:
> >> + $ref: '#/components/schemas/Relation'
> >> + '404':
> >> + description: Not found
> >> + content:
> >> + application/json:
> >> + schema:
> >> + $ref: '#/components/schemas/Error'
> >> + '409':
> >> + description: Conflict
> >> + content:
> >> + application/json:
> >> + schema:
> >> + $ref: '#/components/schemas/Error'
> >> + tags:
> >> + - relations
> >> + patch:
> >> + description: Update a relation (partial).
> >> + operationId: relations_partial_update
> >> + security:
> >> + - basicAuth: []
> >> + - apiKeyAuth: []
> >> + parameters:
> >> + - in: path
> >> + name: id
> >> + description: A unique integer value identifying this
> relation.
> >> + required: true
> >> + schema:
> >> + title: ID
> >> + type: integer
> >> + requestBody:
> >> + $ref: '#/components/requestBodies/Relation'
> >> + responses:
> >> + '200':
> >> + description: ''
> >> + content:
> >> + application/json:
> >> + schema:
> >> + $ref: '#/components/schemas/Relation'
> >> + '400':
> >> + description: Bad request
> >> + content:
> >> + application/json:
> >> + schema:
> >> + $ref: '#/components/schemas/Error'
> >> + '403':
> >> + description: Forbidden
> >> + content:
> >> + application/json:
> >> + schema:
> >> + $ref: '#/components/schemas/Error'
> >> + '404':
> >> + description: Not found
> >> + content:
> >> + application/json:
> >> + schema:
> >> + $ref: '#/components/schemas/Error'
> >> + tags:
> >> + - relations
> >> + put:
> >> + description: Update a relation.
> >> + operationId: relations_update
> >> + security:
> >> + - basicAuth: []
> >> + - apiKeyAuth: []
> >> + parameters:
> >> + - in: path
> >> + name: id
> >> + description: A unique integer value identifying this
> relation.
> >> + required: true
> >> + schema:
> >> + title: ID
> >> + type: integer
> >> + requestBody:
> >> + $ref: '#/components/requestBodies/Relation'
> >> + responses:
> >> + '200':
> >> + description: ''
> >> + content:
> >> + application/json:
> >> + schema:
> >> + $ref: '#/components/schemas/Relation'
> >> + '400':
> >> + description: Bad request
> >> + content:
> >> + application/json:
> >> + schema:
> >> + $ref: '#/components/schemas/Error'
> >> + '403':
> >> + description: Forbidden
> >> + content:
> >> + application/json:
> >> + schema:
> >> + $ref: '#/components/schemas/Error'
> >> + '404':
> >> + description: Not found
> >> + content:
> >> + application/json:
> >> + schema:
> >> + $ref: '#/components/schemas/Error'
> >> + tags:
> >> + - relations
> >> /api/1.2/users/:
> >> get:
> >> description: List users.
> >> @@ -1314,6 +1496,18 @@ components:
> >> application/x-www-form-urlencoded:
> >> schema:
> >> $ref: '#/components/schemas/User'
> >> + Relation:
> >> + required: true
> >> + content:
> >> + application/json:
> >> + schema:
> >> + $ref: '#/components/schemas/RelationUpdate'
> >> + multipart/form-data:
> >> + schema:
> >> + $ref: '#/components/schemas/RelationUpdate'
> >> + application/x-www-form-urlencoded:
> >> + schema:
> >> + $ref: '#/components/schemas/RelationUpdate'
> >> schemas:
> >> Index:
> >> type: object
> >> @@ -1358,6 +1552,11 @@ components:
> >> type: string
> >> format: uri
> >> readOnly: true
> >> + relations:
> >> + title: Relations URL
> >> + type: string
> >> + format: uri
> >> + readOnly: true
> >> Bundle:
> >> required:
> >> - name
> >> @@ -1943,6 +2142,14 @@ components:
> >> title: Delegate
> >> type: integer
> >> nullable: true
> >> + RelationUpdate:
> >> + type: object
> >> + properties:
> >> + submissions:
> >> + title: Submission IDs
> >> + type: array
> >> + items:
> >> + type: integer
> >> Person:
> >> type: object
> >> properties:
> >> @@ -2133,6 +2340,30 @@ components:
> >> $ref: '#/components/schemas/PatchEmbedded'
> >> readOnly: true
> >> uniqueItems: true
> >> + Relation:
> >> + type: object
> >> + properties:
> >> + id:
> >> + title: ID
> >> + type: integer
> >> + url:
> >> + title: URL
> >> + type: string
> >> + format: uri
> >> + readOnly: true
> >> + by:
> >> + type: object
> >> + title: By
> >> + readOnly: true
> >> + allOf:
> >> + - $ref: '#/components/schemas/UserEmbedded'
> >> + submissions:
> >> + title: Submissions
> >> + type: array
> >> + items:
> >> + $ref: '#/components/schemas/SubmissionEmbedded'
> >> + readOnly: true
> >> + uniqueItems: true
> >> User:
> >> type: object
> >> properties:
> >> @@ -2211,6 +2442,48 @@ components:
> >> maxLength: 255
> >> minLength: 1
> >> readOnly: true
> >> + SubmissionEmbedded:
> >> + type: object
> >> + properties:
> >> + id:
> >> + title: ID
> >> + type: integer
> >> + readOnly: true
> >> + url:
> >> + title: URL
> >> + type: string
> >> + format: uri
> >> + readOnly: true
> >> + web_url:
> >> + title: Web URL
> >> + type: string
> >> + format: uri
> >> + readOnly: true
> >> + msgid:
> >> + title: Message ID
> >> + type: string
> >> + readOnly: true
> >> + minLength: 1
> >> + list_archive_url:
> >> + title: List archive URL
> >> + type: string
> >> + readOnly: true
> >> + nullable: true
> >> + date:
> >> + title: Date
> >> + type: string
> >> + format: iso8601
> >> + readOnly: true
> >> + name:
> >> + title: Name
> >> + type: string
> >> + readOnly: true
> >> + minLength: 1
> >> + mbox:
> >> + title: Mbox
> >> + type: string
> >> + format: uri
> >> + readOnly: true
> >> CoverLetterEmbedded:
> >> type: object
> >> properties:
> >> diff --git a/patchwork/api/embedded.py b/patchwork/api/embedded.py
> >> index de4f31165ee7..0fba291b62b8 100644
> >> --- a/patchwork/api/embedded.py
> >> +++ b/patchwork/api/embedded.py
> >> @@ -102,6 +102,45 @@ class CheckSerializer(SerializedRelatedField):
> >> }
> >>
> >>
> >> +def _upgrade_instance(instance):
> >> + if hasattr(instance, 'patch'):
> >> + return instance.patch
> >> + else:
> >> + return instance.coverletter
> >> +
> >> +
> >> +class SubmissionSerializer(SerializedRelatedField):
> >> +
> >> + class _Serializer(BaseHyperlinkedModelSerializer):
> >> + """We need to 'upgrade' or specialise the submission to the
> relevant
> >> + subclass, so we can't use the mixins. This is gross but can go
> away
> >> + once we flatten the models."""
> >> + url = SerializerMethodField()
> >> + web_url = SerializerMethodField()
> >> + mbox = SerializerMethodField()
> >> +
> >> + def get_url(self, instance):
> >> + instance = _upgrade_instance(instance)
> >> + request = self.context.get('request')
> >> + return
> request.build_absolute_uri(instance.get_absolute_api_url())
> >> +
> >> + def get_web_url(self, instance):
> >> + instance = _upgrade_instance(instance)
> >> + request = self.context.get('request')
> >> + return
> request.build_absolute_uri(instance.get_absolute_url())
> >> +
> >> + def get_mbox(self, instance):
> >> + instance = _upgrade_instance(instance)
> >> + request = self.context.get('request')
> >> + return request.build_absolute_uri(instance.get_mbox_url())
> >> +
> >> + class Meta:
> >> + model = models.Submission
> >> + fields = ('id', 'url', 'web_url', 'msgid',
> 'list_archive_url',
> >> + 'date', 'name', 'mbox')
> >> + read_only_fields = fields
> >> +
> >> +
> >> class CoverLetterSerializer(SerializedRelatedField):
> >>
> >> class _Serializer(MboxMixin, WebURLMixin,
> BaseHyperlinkedModelSerializer):
> >> diff --git a/patchwork/api/index.py b/patchwork/api/index.py
> >> index 45485c9106f6..cf1845393835 100644
> >> --- a/patchwork/api/index.py
> >> +++ b/patchwork/api/index.py
> >> @@ -21,4 +21,5 @@ class IndexView(APIView):
> >> 'series': reverse('api-series-list', request=request),
> >> 'events': reverse('api-event-list', request=request),
> >> 'bundles': reverse('api-bundle-list', request=request),
> >> + 'relations': reverse('api-relation-list', request=request),
> >> })
> >> diff --git a/patchwork/api/relation.py b/patchwork/api/relation.py
> >> new file mode 100644
> >> index 000000000000..37640d62e9cc
> >> --- /dev/null
> >> +++ b/patchwork/api/relation.py
> >> @@ -0,0 +1,121 @@
> >> +# Patchwork - automated patch tracking system
> >> +# Copyright (C) 2019, Bayerische Motoren Werke Aktiengesellschaft (BMW
> AG)
> >> +#
> >> +# SPDX-License-Identifier: GPL-2.0-or-later
> >> +
> >> +from rest_framework import permissions
> >> +from rest_framework import status
> >> +from rest_framework.exceptions import PermissionDenied, APIException
> >> +from rest_framework.generics import GenericAPIView
> >> +from rest_framework.generics import ListCreateAPIView
> >> +from rest_framework.generics import RetrieveUpdateDestroyAPIView
> >> +from rest_framework.serializers import ModelSerializer
> >> +
> >> +from patchwork.api.base import PatchworkPermission
> >> +from patchwork.api.embedded import SubmissionSerializer
> >> +from patchwork.api.embedded import UserSerializer
> >> +from patchwork.models import SubmissionRelation
> >> +
> >> +
> >> +class MaintainerPermission(PatchworkPermission):
> >> +
> >> + def has_permission(self, request, view):
> >> + if request.method in permissions.SAFE_METHODS:
> >> + return True
> >> +
> >> + # Prevent showing an HTML POST form in the browseable API for
> logged in
> >> + # users who are not maintainers.
> >> + return len(request.user.maintains) > 0
> >> +
> >> + def has_object_permission(self, request, view, relation):
> >> + if request.method in permissions.SAFE_METHODS:
> >> + return True
> >> +
> >> + maintains = request.user.maintains
> >> + submissions = relation.submissions.all()
> >> + # user has to be maintainer of every project a submission is
> part of
> >> + return self.check_user_maintains_all(maintains, submissions)
> >> +
> >> + @staticmethod
> >> + def check_user_maintains_all(maintains, submissions):
> >> + if any(s.project not in maintains for s in submissions):
> >> + detail = 'At least one submission is part of a project you
> are ' \
> >> + 'not maintaining.'
> >> + raise PermissionDenied(detail=detail)
> >> + return True
> >> +
> >> +
> >> +class SubmissionConflict(APIException):
> >> + status_code = status.HTTP_409_CONFLICT
> >> + default_detail = 'At least one submission is already part of
> another ' \
> >> + 'relation. You have to explicitly remove a
> submission ' \
> >> + 'from its existing relation before moving it to
> this one.'
> >> +
> >> +
> >> +class SubmissionRelationSerializer(ModelSerializer):
> >> + by = UserSerializer(read_only=True)
> >> + submissions = SubmissionSerializer(many=True)
> >> +
> >> + def create(self, validated_data):
> >> + submissions = validated_data['submissions']
> >> + if any(submission.related_id is not None
> >> + for submission in submissions):
> >> + raise SubmissionConflict()
> >> + return super(SubmissionRelationSerializer,
> self).create(validated_data)
> >> +
> >> + def update(self, instance, validated_data):
> >> + submissions = validated_data['submissions']
> >> + if any(submission.related_id is not None and
> >> + submission.related_id != instance.id
> >> + for submission in submissions):
> >> + raise SubmissionConflict()
> >> + return super(SubmissionRelationSerializer, self) \
> >> + .update(instance, validated_data)
> >> +
> >> + class Meta:
> >> + model = SubmissionRelation
> >> + fields = ('id', 'url', 'by', 'submissions',)
> >> + read_only_fields = ('url', 'by', )
> >> + extra_kwargs = {
> >> + 'url': {'view_name': 'api-relation-detail'},
> >> + }
> >> +
> >> +
> >> +class SubmissionRelationMixin(GenericAPIView):
> >> + serializer_class = SubmissionRelationSerializer
> >> + permission_classes = (MaintainerPermission,)
> >> +
> >> + def initial(self, request, *args, **kwargs):
> >> + user = request.user
> >> + if not hasattr(user, 'maintains'):
> >> + if user.is_authenticated:
> >> + user.maintains = user.profile.maintainer_projects.all()
> >> + else:
> >> + user.maintains = []
> >> + super(SubmissionRelationMixin, self).initial(request, *args,
> **kwargs)
> >> +
> >> + def get_queryset(self):
> >> + return SubmissionRelation.objects.all() \
> >> + .select_related('by') \
> >> + .prefetch_related('submissions__patch',
> >> + 'submissions__coverletter',
> >> + 'submissions__project')
> >> +
> >> +
> >> +class SubmissionRelationList(SubmissionRelationMixin,
> ListCreateAPIView):
> >> + ordering = 'id'
> >> + ordering_fields = ['id']
> >> +
> >> + def perform_create(self, serializer):
> >> + # has_object_permission() is not called when creating a new
> relation.
> >> + # Check whether user is maintainer of every project a
> submission is
> >> + # part of
> >> + maintains = self.request.user.maintains
> >> + submissions = serializer.validated_data['submissions']
> >> + MaintainerPermission.check_user_maintains_all(maintains,
> submissions)
> >> + serializer.save(by=self.request.user)
> >> +
> >> +
> >> +class SubmissionRelationDetail(SubmissionRelationMixin,
> >> + RetrieveUpdateDestroyAPIView):
> >> + pass
> >> diff --git a/patchwork/models.py b/patchwork/models.py
> >> index a92203b24ff2..9ae3370e896b 100644
> >> --- a/patchwork/models.py
> >> +++ b/patchwork/models.py
> >> @@ -415,6 +415,9 @@ class CoverLetter(Submission):
> >> kwargs={'project_id': self.project.linkname,
> >> 'msgid': self.url_msgid})
> >>
> >> + def get_absolute_api_url(self):
> >> + return reverse('api-cover-detail', kwargs={'pk': self.id})
> >> +
> >> def get_mbox_url(self):
> >> return reverse('cover-mbox',
> >> kwargs={'project_id': self.project.linkname,
> >> @@ -604,6 +607,9 @@ class Patch(Submission):
> >> kwargs={'project_id': self.project.linkname,
> >> 'msgid': self.url_msgid})
> >>
> >> + def get_absolute_api_url(self):
> >> + return reverse('api-patch-detail', kwargs={'pk': self.id})
> >> +
> >> def get_mbox_url(self):
> >> return reverse('patch-mbox',
> >> kwargs={'project_id': self.project.linkname,
> >> diff --git a/patchwork/tests/api/test_relation.py
> b/patchwork/tests/api/test_relation.py
> >> new file mode 100644
> >> index 000000000000..5b1a04f13670
> >> --- /dev/null
> >> +++ b/patchwork/tests/api/test_relation.py
> >> @@ -0,0 +1,181 @@
> >> +# Patchwork - automated patch tracking system
> >> +# Copyright (C) 2019, Bayerische Motoren Werke Aktiengesellschaft (BMW
> AG)
> >> +#
> >> +# SPDX-License-Identifier: GPL-2.0-or-later
> >> +
> >> +import unittest
> >> +
> >> +import six
> >> +from django.conf import settings
> >> +from django.urls import reverse
> >> +
> >> +from patchwork.tests.api import utils
> >> +from patchwork.tests.utils import create_cover
> >> +from patchwork.tests.utils import create_maintainer
> >> +from patchwork.tests.utils import create_patches
> >> +from patchwork.tests.utils import create_project
> >> +from patchwork.tests.utils import create_relation
> >> +from patchwork.tests.utils import create_user
> >> +
> >> +if settings.ENABLE_REST_API:
> >> + from rest_framework import status
> >> +
> >> +
> >> +class UserType:
> >> + ANONYMOUS = 1
> >> + NON_MAINTAINER = 2
> >> + MAINTAINER = 3
> >> +
> >> +
> >> + 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'),
> >> ]
> >>
> >> +
-------------- next part --------------
An HTML attachment was scrubbed...
URL: <http://lists.ozlabs.org/pipermail/patchwork/attachments/20191230/ebb90654/attachment-0001.htm>
More information about the Patchwork
mailing list