Message ID | 20191207164621.24234-5-metepolat2000@gmail.com |
---|---|
State | Superseded |
Headers | show |
Series | Add submission relations | expand |
On Sat, 2019-12-07 at 17:46 +0100, Mete Polat wrote: > View relations and add/update/delete them as a maintainer. Maintainers > can only create relations of submissions (patches/cover letters) which > are part of a project they maintain. > > New REST API urls: > api/relations/ > api/relations/<relation_id>/ > > Co-authored-by: Daniel Axtens <dja@axtens.net> > Signed-off-by: Mete Polat <metepolat2000@gmail.com> Why did you choose to expose this as a separate API rather than as a field on the '/patches' resource? While a 'Series' objet has enough separate metadata to warrant a separate '/series' resource, a 'SubmissionRelation' object is basically just a container. Including a 'related_patches' field on the detailed patch view would seem like more than enough detail for me, anyway, and unless there's a reason not to do this, I'd like to see it done that way. Is it possible? Stephen PS: I could have sworn I had asked this before, but I can't find any mails about it so maybe I didn't. Please tell me to RTML (read the mailing list) if so > --- > Optimize db queries: > I have spent quite a lot of time in optimizing the db queries for the REST API > (thanks for the tip with the Django toolbar). Daniel stated that > prefetch_related is possibly hitting the database for every relation when > prefetching submissions but it turns out that we can tell Django to use a > statement like: > SELECT * > FROM `patchwork_patch` > INNER JOIN `patchwork_submission` > ON (`patchwork_patch`.`submission_ptr_id` = `patchwork_submission`.`id`) > WHERE `patchwork_patch`.`submission_ptr_id` IN (LIST_OF_ALL_SUBMISSION_IDS) > > We do the same for `patchwork_coverletter`. > This means we only hit the db two times for casting _all_ submissions to a > patch or cover-letter. > > Prefetching submissions__project eliminates similar and duplicate queries > that are used to determine whether a logged in user is at least maintainer > of one submission's project. > > docs/api/schemas/latest/patchwork.yaml | 273 +++++++++++++++++ > docs/api/schemas/patchwork.j2 | 285 ++++++++++++++++++ > docs/api/schemas/v1.2/patchwork.yaml | 273 +++++++++++++++++ > patchwork/api/embedded.py | 39 +++ > patchwork/api/index.py | 1 + > patchwork/api/relation.py | 121 ++++++++ > patchwork/models.py | 6 + > patchwork/tests/api/test_relation.py | 181 +++++++++++ > patchwork/tests/utils.py | 15 + > patchwork/urls.py | 11 + > ...submission-relations-c96bb6c567b416d8.yaml | 10 + > 11 files changed, 1215 insertions(+) > create mode 100644 patchwork/api/relation.py > create mode 100644 patchwork/tests/api/test_relation.py > create mode 100644 releasenotes/notes/add-submission-relations-c96bb6c567b416d8.yaml > > diff --git a/docs/api/schemas/latest/patchwork.yaml b/docs/api/schemas/latest/patchwork.yaml > index a5e235be936d..7dd24fd700d5 100644 > --- a/docs/api/schemas/latest/patchwork.yaml > +++ b/docs/api/schemas/latest/patchwork.yaml > @@ -1039,6 +1039,188 @@ paths: > $ref: '#/components/schemas/Error' > tags: > - series > + /api/relations/: > + get: > + description: List relations. > + operationId: relations_list > + parameters: > + - $ref: '#/components/parameters/Page' > + - $ref: '#/components/parameters/PageSize' > + - $ref: '#/components/parameters/Order' > + responses: > + '200': > + description: '' > + headers: > + Link: > + $ref: '#/components/headers/Link' > + content: > + application/json: > + schema: > + type: array > + items: > + $ref: '#/components/schemas/Relation' > + tags: > + - relations > + post: > + description: Create a relation. > + operationId: relations_create > + security: > + - basicAuth: [] > + - apiKeyAuth: [] > + requestBody: > + $ref: '#/components/requestBodies/Relation' > + responses: > + '201': > + description: '' > + content: > + application/json: > + schema: > + $ref: '#/components/schemas/Relation' > + '400': > + description: Invalid Request > + content: > + application/json: > + schema: > + $ref: '#/components/schemas/Error' > + '403': > + description: Forbidden > + content: > + application/json: > + schema: > + $ref: '#/components/schemas/Error' > + '404': > + description: Not found > + content: > + application/json: > + schema: > + $ref: '#/components/schemas/Error' > + '409': > + description: Conflict > + content: > + application/json: > + schema: > + $ref: '#/components/schemas/Error' > + tags: > + - checks > + /api/relations/{id}/: > + get: > + description: Show a relation. > + operationId: relation_read > + parameters: > + - in: path > + name: id > + description: A unique integer value identifying this relation. > + required: true > + schema: > + title: ID > + type: integer > + responses: > + '200': > + description: '' > + content: > + application/json: > + schema: > + $ref: '#/components/schemas/Relation' > + '404': > + description: Not found > + content: > + application/json: > + schema: > + $ref: '#/components/schemas/Error' > + '409': > + description: Conflict > + content: > + application/json: > + schema: > + $ref: '#/components/schemas/Error' > + tags: > + - relations > + patch: > + description: Update a relation (partial). > + operationId: relations_partial_update > + security: > + - basicAuth: [] > + - apiKeyAuth: [] > + parameters: > + - in: path > + name: id > + description: A unique integer value identifying this relation. > + required: true > + schema: > + title: ID > + type: integer > + requestBody: > + $ref: '#/components/requestBodies/Relation' > + responses: > + '200': > + description: '' > + content: > + application/json: > + schema: > + $ref: '#/components/schemas/Relation' > + '400': > + description: Bad request > + content: > + application/json: > + schema: > + $ref: '#/components/schemas/Error' > + '403': > + description: Forbidden > + content: > + application/json: > + schema: > + $ref: '#/components/schemas/Error' > + '404': > + description: Not found > + content: > + application/json: > + schema: > + $ref: '#/components/schemas/Error' > + tags: > + - relations > + put: > + description: Update a relation. > + operationId: relations_update > + security: > + - basicAuth: [] > + - apiKeyAuth: [] > + parameters: > + - in: path > + name: id > + description: A unique integer value identifying this relation. > + required: true > + schema: > + title: ID > + type: integer > + requestBody: > + $ref: '#/components/requestBodies/Relation' > + responses: > + '200': > + description: '' > + content: > + application/json: > + schema: > + $ref: '#/components/schemas/Relation' > + '400': > + description: Bad request > + content: > + application/json: > + schema: > + $ref: '#/components/schemas/Error' > + '403': > + description: Forbidden > + content: > + application/json: > + schema: > + $ref: '#/components/schemas/Error' > + '404': > + description: Not found > + content: > + application/json: > + schema: > + $ref: '#/components/schemas/Error' > + tags: > + - relations > /api/users/: > get: > description: List users. > @@ -1314,6 +1496,18 @@ components: > application/x-www-form-urlencoded: > schema: > $ref: '#/components/schemas/User' > + Relation: > + required: true > + content: > + application/json: > + schema: > + $ref: '#/components/schemas/RelationUpdate' > + multipart/form-data: > + schema: > + $ref: '#/components/schemas/RelationUpdate' > + application/x-www-form-urlencoded: > + schema: > + $ref: '#/components/schemas/RelationUpdate' > schemas: > Index: > type: object > @@ -1358,6 +1552,11 @@ components: > type: string > format: uri > readOnly: true > + relations: > + title: Relations URL > + type: string > + format: uri > + readOnly: true > Bundle: > required: > - name > @@ -1943,6 +2142,14 @@ components: > title: Delegate > type: integer > nullable: true > + RelationUpdate: > + type: object > + properties: > + submissions: > + title: Submission IDs > + type: array > + items: > + type: integer > Person: > type: object > properties: > @@ -2133,6 +2340,30 @@ components: > $ref: '#/components/schemas/PatchEmbedded' > readOnly: true > uniqueItems: true > + Relation: > + type: object > + properties: > + id: > + title: ID > + type: integer > + url: > + title: URL > + type: string > + format: uri > + readOnly: true > + by: > + type: object > + title: By > + readOnly: true > + allOf: > + - $ref: '#/components/schemas/UserEmbedded' > + submissions: > + title: Submissions > + type: array > + items: > + $ref: '#/components/schemas/SubmissionEmbedded' > + readOnly: true > + uniqueItems: true > User: > type: object > properties: > @@ -2211,6 +2442,48 @@ components: > maxLength: 255 > minLength: 1 > readOnly: true > + SubmissionEmbedded: > + type: object > + properties: > + id: > + title: ID > + type: integer > + readOnly: true > + url: > + title: URL > + type: string > + format: uri > + readOnly: true > + web_url: > + title: Web URL > + type: string > + format: uri > + readOnly: true > + msgid: > + title: Message ID > + type: string > + readOnly: true > + minLength: 1 > + list_archive_url: > + title: List archive URL > + type: string > + readOnly: true > + nullable: true > + date: > + title: Date > + type: string > + format: iso8601 > + readOnly: true > + name: > + title: Name > + type: string > + readOnly: true > + minLength: 1 > + mbox: > + title: Mbox > + type: string > + format: uri > + readOnly: true > CoverLetterEmbedded: > type: object > properties: > diff --git a/docs/api/schemas/patchwork.j2 b/docs/api/schemas/patchwork.j2 > index 196d78466b55..a034029accf9 100644 > --- a/docs/api/schemas/patchwork.j2 > +++ b/docs/api/schemas/patchwork.j2 > @@ -1048,6 +1048,190 @@ paths: > $ref: '#/components/schemas/Error' > tags: > - series > +{% if version >= (1, 2) %} > + /api/{{ version_url }}relations/: > + get: > + description: List relations. > + operationId: relations_list > + parameters: > + - $ref: '#/components/parameters/Page' > + - $ref: '#/components/parameters/PageSize' > + - $ref: '#/components/parameters/Order' > + responses: > + '200': > + description: '' > + headers: > + Link: > + $ref: '#/components/headers/Link' > + content: > + application/json: > + schema: > + type: array > + items: > + $ref: '#/components/schemas/Relation' > + tags: > + - relations > + post: > + description: Create a relation. > + operationId: relations_create > + security: > + - basicAuth: [] > + - apiKeyAuth: [] > + requestBody: > + $ref: '#/components/requestBodies/Relation' > + responses: > + '201': > + description: '' > + content: > + application/json: > + schema: > + $ref: '#/components/schemas/Relation' > + '400': > + description: Invalid Request > + content: > + application/json: > + schema: > + $ref: '#/components/schemas/Error' > + '403': > + description: Forbidden > + content: > + application/json: > + schema: > + $ref: '#/components/schemas/Error' > + '404': > + description: Not found > + content: > + application/json: > + schema: > + $ref: '#/components/schemas/Error' > + '409': > + description: Conflict > + content: > + application/json: > + schema: > + $ref: '#/components/schemas/Error' > + tags: > + - checks > + /api/{{ version_url }}relations/{id}/: > + get: > + description: Show a relation. > + operationId: relation_read > + parameters: > + - in: path > + name: id > + description: A unique integer value identifying this relation. > + required: true > + schema: > + title: ID > + type: integer > + responses: > + '200': > + description: '' > + content: > + application/json: > + schema: > + $ref: '#/components/schemas/Relation' > + '404': > + description: Not found > + content: > + application/json: > + schema: > + $ref: '#/components/schemas/Error' > + '409': > + description: Conflict > + content: > + application/json: > + schema: > + $ref: '#/components/schemas/Error' > + tags: > + - relations > + patch: > + description: Update a relation (partial). > + operationId: relations_partial_update > + security: > + - basicAuth: [] > + - apiKeyAuth: [] > + parameters: > + - in: path > + name: id > + description: A unique integer value identifying this relation. > + required: true > + schema: > + title: ID > + type: integer > + requestBody: > + $ref: '#/components/requestBodies/Relation' > + responses: > + '200': > + description: '' > + content: > + application/json: > + schema: > + $ref: '#/components/schemas/Relation' > + '400': > + description: Bad request > + content: > + application/json: > + schema: > + $ref: '#/components/schemas/Error' > + '403': > + description: Forbidden > + content: > + application/json: > + schema: > + $ref: '#/components/schemas/Error' > + '404': > + description: Not found > + content: > + application/json: > + schema: > + $ref: '#/components/schemas/Error' > + tags: > + - relations > + put: > + description: Update a relation. > + operationId: relations_update > + security: > + - basicAuth: [] > + - apiKeyAuth: [] > + parameters: > + - in: path > + name: id > + description: A unique integer value identifying this relation. > + required: true > + schema: > + title: ID > + type: integer > + requestBody: > + $ref: '#/components/requestBodies/Relation' > + responses: > + '200': > + description: '' > + content: > + application/json: > + schema: > + $ref: '#/components/schemas/Relation' > + '400': > + description: Bad request > + content: > + application/json: > + schema: > + $ref: '#/components/schemas/Error' > + '403': > + description: Forbidden > + content: > + application/json: > + schema: > + $ref: '#/components/schemas/Error' > + '404': > + description: Not found > + content: > + application/json: > + schema: > + $ref: '#/components/schemas/Error' > + tags: > + - relations > +{% endif %} > /api/{{ version_url }}users/: > get: > description: List users. > @@ -1325,6 +1509,20 @@ components: > application/x-www-form-urlencoded: > schema: > $ref: '#/components/schemas/User' > +{% if version >= (1, 2) %} > + Relation: > + required: true > + content: > + application/json: > + schema: > + $ref: '#/components/schemas/RelationUpdate' > + multipart/form-data: > + schema: > + $ref: '#/components/schemas/RelationUpdate' > + application/x-www-form-urlencoded: > + schema: > + $ref: '#/components/schemas/RelationUpdate' > +{% endif %} > schemas: > Index: > type: object > @@ -1369,6 +1567,13 @@ components: > type: string > format: uri > readOnly: true > +{% if version >= (1, 2) %} > + relations: > + title: Relations URL > + type: string > + format: uri > + readOnly: true > +{% endif %} > Bundle: > required: > - name > @@ -1981,6 +2186,16 @@ components: > title: Delegate > type: integer > nullable: true > +{% if version >= (1, 2) %} > + RelationUpdate: > + type: object > + properties: > + submissions: > + title: Submission IDs > + type: array > + items: > + type: integer > +{% endif %} > Person: > type: object > properties: > @@ -2177,6 +2392,32 @@ components: > $ref: '#/components/schemas/PatchEmbedded' > readOnly: true > uniqueItems: true > +{% if version >= (1, 2) %} > + Relation: > + type: object > + properties: > + id: > + title: ID > + type: integer > + url: > + title: URL > + type: string > + format: uri > + readOnly: true > + by: > + type: object > + title: By > + readOnly: true > + allOf: > + - $ref: '#/components/schemas/UserEmbedded' > + submissions: > + title: Submissions > + type: array > + items: > + $ref: '#/components/schemas/SubmissionEmbedded' > + readOnly: true > + uniqueItems: true > +{% endif %} > User: > type: object > properties: > @@ -2255,6 +2496,50 @@ components: > maxLength: 255 > minLength: 1 > readOnly: true > +{% if version >= (1, 2) %} > + SubmissionEmbedded: > + type: object > + properties: > + id: > + title: ID > + type: integer > + readOnly: true > + url: > + title: URL > + type: string > + format: uri > + readOnly: true > + web_url: > + title: Web URL > + type: string > + format: uri > + readOnly: true > + msgid: > + title: Message ID > + type: string > + readOnly: true > + minLength: 1 > + list_archive_url: > + title: List archive URL > + type: string > + readOnly: true > + nullable: true > + date: > + title: Date > + type: string > + format: iso8601 > + readOnly: true > + name: > + title: Name > + type: string > + readOnly: true > + minLength: 1 > + mbox: > + title: Mbox > + type: string > + format: uri > + readOnly: true > +{% endif %} > CoverLetterEmbedded: > type: object > properties: > diff --git a/docs/api/schemas/v1.2/patchwork.yaml b/docs/api/schemas/v1.2/patchwork.yaml > index d7b4d2957cff..99425e968881 100644 > --- a/docs/api/schemas/v1.2/patchwork.yaml > +++ b/docs/api/schemas/v1.2/patchwork.yaml > @@ -1039,6 +1039,188 @@ paths: > $ref: '#/components/schemas/Error' > tags: > - series > + /api/1.2/relations/: > + get: > + description: List relations. > + operationId: relations_list > + parameters: > + - $ref: '#/components/parameters/Page' > + - $ref: '#/components/parameters/PageSize' > + - $ref: '#/components/parameters/Order' > + responses: > + '200': > + description: '' > + headers: > + Link: > + $ref: '#/components/headers/Link' > + content: > + application/json: > + schema: > + type: array > + items: > + $ref: '#/components/schemas/Relation' > + tags: > + - relations > + post: > + description: Create a relation. > + operationId: relations_create > + security: > + - basicAuth: [] > + - apiKeyAuth: [] > + requestBody: > + $ref: '#/components/requestBodies/Relation' > + responses: > + '201': > + description: '' > + content: > + application/json: > + schema: > + $ref: '#/components/schemas/Relation' > + '400': > + description: Invalid Request > + content: > + application/json: > + schema: > + $ref: '#/components/schemas/Error' > + '403': > + description: Forbidden > + content: > + application/json: > + schema: > + $ref: '#/components/schemas/Error' > + '404': > + description: Not found > + content: > + application/json: > + schema: > + $ref: '#/components/schemas/Error' > + '409': > + description: Conflict > + content: > + application/json: > + schema: > + $ref: '#/components/schemas/Error' > + tags: > + - checks > + /api/1.2/relations/{id}/: > + get: > + description: Show a relation. > + operationId: relation_read > + parameters: > + - in: path > + name: id > + description: A unique integer value identifying this relation. > + required: true > + schema: > + title: ID > + type: integer > + responses: > + '200': > + description: '' > + content: > + application/json: > + schema: > + $ref: '#/components/schemas/Relation' > + '404': > + description: Not found > + content: > + application/json: > + schema: > + $ref: '#/components/schemas/Error' > + '409': > + description: Conflict > + content: > + application/json: > + schema: > + $ref: '#/components/schemas/Error' > + tags: > + - relations > + patch: > + description: Update a relation (partial). > + operationId: relations_partial_update > + security: > + - basicAuth: [] > + - apiKeyAuth: [] > + parameters: > + - in: path > + name: id > + description: A unique integer value identifying this relation. > + required: true > + schema: > + title: ID > + type: integer > + requestBody: > + $ref: '#/components/requestBodies/Relation' > + responses: > + '200': > + description: '' > + content: > + application/json: > + schema: > + $ref: '#/components/schemas/Relation' > + '400': > + description: Bad request > + content: > + application/json: > + schema: > + $ref: '#/components/schemas/Error' > + '403': > + description: Forbidden > + content: > + application/json: > + schema: > + $ref: '#/components/schemas/Error' > + '404': > + description: Not found > + content: > + application/json: > + schema: > + $ref: '#/components/schemas/Error' > + tags: > + - relations > + put: > + description: Update a relation. > + operationId: relations_update > + security: > + - basicAuth: [] > + - apiKeyAuth: [] > + parameters: > + - in: path > + name: id > + description: A unique integer value identifying this relation. > + required: true > + schema: > + title: ID > + type: integer > + requestBody: > + $ref: '#/components/requestBodies/Relation' > + responses: > + '200': > + description: '' > + content: > + application/json: > + schema: > + $ref: '#/components/schemas/Relation' > + '400': > + description: Bad request > + content: > + application/json: > + schema: > + $ref: '#/components/schemas/Error' > + '403': > + description: Forbidden > + content: > + application/json: > + schema: > + $ref: '#/components/schemas/Error' > + '404': > + description: Not found > + content: > + application/json: > + schema: > + $ref: '#/components/schemas/Error' > + tags: > + - relations > /api/1.2/users/: > get: > description: List users. > @@ -1314,6 +1496,18 @@ components: > application/x-www-form-urlencoded: > schema: > $ref: '#/components/schemas/User' > + Relation: > + required: true > + content: > + application/json: > + schema: > + $ref: '#/components/schemas/RelationUpdate' > + multipart/form-data: > + schema: > + $ref: '#/components/schemas/RelationUpdate' > + application/x-www-form-urlencoded: > + schema: > + $ref: '#/components/schemas/RelationUpdate' > schemas: > Index: > type: object > @@ -1358,6 +1552,11 @@ components: > type: string > format: uri > readOnly: true > + relations: > + title: Relations URL > + type: string > + format: uri > + readOnly: true > Bundle: > required: > - name > @@ -1943,6 +2142,14 @@ components: > title: Delegate > type: integer > nullable: true > + RelationUpdate: > + type: object > + properties: > + submissions: > + title: Submission IDs > + type: array > + items: > + type: integer > Person: > type: object > properties: > @@ -2133,6 +2340,30 @@ components: > $ref: '#/components/schemas/PatchEmbedded' > readOnly: true > uniqueItems: true > + Relation: > + type: object > + properties: > + id: > + title: ID > + type: integer > + url: > + title: URL > + type: string > + format: uri > + readOnly: true > + by: > + type: object > + title: By > + readOnly: true > + allOf: > + - $ref: '#/components/schemas/UserEmbedded' > + submissions: > + title: Submissions > + type: array > + items: > + $ref: '#/components/schemas/SubmissionEmbedded' > + readOnly: true > + uniqueItems: true > User: > type: object > properties: > @@ -2211,6 +2442,48 @@ components: > maxLength: 255 > minLength: 1 > readOnly: true > + SubmissionEmbedded: > + type: object > + properties: > + id: > + title: ID > + type: integer > + readOnly: true > + url: > + title: URL > + type: string > + format: uri > + readOnly: true > + web_url: > + title: Web URL > + type: string > + format: uri > + readOnly: true > + msgid: > + title: Message ID > + type: string > + readOnly: true > + minLength: 1 > + list_archive_url: > + title: List archive URL > + type: string > + readOnly: true > + nullable: true > + date: > + title: Date > + type: string > + format: iso8601 > + readOnly: true > + name: > + title: Name > + type: string > + readOnly: true > + minLength: 1 > + mbox: > + title: Mbox > + type: string > + format: uri > + readOnly: true > CoverLetterEmbedded: > type: object > properties: > diff --git a/patchwork/api/embedded.py b/patchwork/api/embedded.py > index de4f31165ee7..0fba291b62b8 100644 > --- a/patchwork/api/embedded.py > +++ b/patchwork/api/embedded.py > @@ -102,6 +102,45 @@ class CheckSerializer(SerializedRelatedField): > } > > > +def _upgrade_instance(instance): > + if hasattr(instance, 'patch'): > + return instance.patch > + else: > + return instance.coverletter > + > + > +class SubmissionSerializer(SerializedRelatedField): > + > + class _Serializer(BaseHyperlinkedModelSerializer): > + """We need to 'upgrade' or specialise the submission to the relevant > + subclass, so we can't use the mixins. This is gross but can go away > + once we flatten the models.""" > + url = SerializerMethodField() > + web_url = SerializerMethodField() > + mbox = SerializerMethodField() > + > + def get_url(self, instance): > + instance = _upgrade_instance(instance) > + request = self.context.get('request') > + return request.build_absolute_uri(instance.get_absolute_api_url()) > + > + def get_web_url(self, instance): > + instance = _upgrade_instance(instance) > + request = self.context.get('request') > + return request.build_absolute_uri(instance.get_absolute_url()) > + > + def get_mbox(self, instance): > + instance = _upgrade_instance(instance) > + request = self.context.get('request') > + return request.build_absolute_uri(instance.get_mbox_url()) > + > + class Meta: > + model = models.Submission > + fields = ('id', 'url', 'web_url', 'msgid', 'list_archive_url', > + 'date', 'name', 'mbox') > + read_only_fields = fields > + > + > class CoverLetterSerializer(SerializedRelatedField): > > class _Serializer(MboxMixin, WebURLMixin, BaseHyperlinkedModelSerializer): > diff --git a/patchwork/api/index.py b/patchwork/api/index.py > index 45485c9106f6..cf1845393835 100644 > --- a/patchwork/api/index.py > +++ b/patchwork/api/index.py > @@ -21,4 +21,5 @@ class IndexView(APIView): > 'series': reverse('api-series-list', request=request), > 'events': reverse('api-event-list', request=request), > 'bundles': reverse('api-bundle-list', request=request), > + 'relations': reverse('api-relation-list', request=request), > }) > diff --git a/patchwork/api/relation.py b/patchwork/api/relation.py > new file mode 100644 > index 000000000000..37640d62e9cc > --- /dev/null > +++ b/patchwork/api/relation.py > @@ -0,0 +1,121 @@ > +# Patchwork - automated patch tracking system > +# Copyright (C) 2019, Bayerische Motoren Werke Aktiengesellschaft (BMW AG) > +# > +# SPDX-License-Identifier: GPL-2.0-or-later > + > +from rest_framework import permissions > +from rest_framework import status > +from rest_framework.exceptions import PermissionDenied, APIException > +from rest_framework.generics import GenericAPIView > +from rest_framework.generics import ListCreateAPIView > +from rest_framework.generics import RetrieveUpdateDestroyAPIView > +from rest_framework.serializers import ModelSerializer > + > +from patchwork.api.base import PatchworkPermission > +from patchwork.api.embedded import SubmissionSerializer > +from patchwork.api.embedded import UserSerializer > +from patchwork.models import SubmissionRelation > + > + > +class MaintainerPermission(PatchworkPermission): > + > + def has_permission(self, request, view): > + if request.method in permissions.SAFE_METHODS: > + return True > + > + # Prevent showing an HTML POST form in the browseable API for logged in > + # users who are not maintainers. > + return len(request.user.maintains) > 0 > + > + def has_object_permission(self, request, view, relation): > + if request.method in permissions.SAFE_METHODS: > + return True > + > + maintains = request.user.maintains > + submissions = relation.submissions.all() > + # user has to be maintainer of every project a submission is part of > + return self.check_user_maintains_all(maintains, submissions) > + > + @staticmethod > + def check_user_maintains_all(maintains, submissions): > + if any(s.project not in maintains for s in submissions): > + detail = 'At least one submission is part of a project you are ' \ > + 'not maintaining.' > + raise PermissionDenied(detail=detail) > + return True > + > + > +class SubmissionConflict(APIException): > + status_code = status.HTTP_409_CONFLICT > + default_detail = 'At least one submission is already part of another ' \ > + 'relation. You have to explicitly remove a submission ' \ > + 'from its existing relation before moving it to this one.' > + > + > +class SubmissionRelationSerializer(ModelSerializer): > + by = UserSerializer(read_only=True) > + submissions = SubmissionSerializer(many=True) > + > + def create(self, validated_data): > + submissions = validated_data['submissions'] > + if any(submission.related_id is not None > + for submission in submissions): > + raise SubmissionConflict() > + return super(SubmissionRelationSerializer, self).create(validated_data) > + > + def update(self, instance, validated_data): > + submissions = validated_data['submissions'] > + if any(submission.related_id is not None and > + submission.related_id != instance.id > + for submission in submissions): > + raise SubmissionConflict() > + return super(SubmissionRelationSerializer, self) \ > + .update(instance, validated_data) > + > + class Meta: > + model = SubmissionRelation > + fields = ('id', 'url', 'by', 'submissions',) > + read_only_fields = ('url', 'by', ) > + extra_kwargs = { > + 'url': {'view_name': 'api-relation-detail'}, > + } > + > + > +class SubmissionRelationMixin(GenericAPIView): > + serializer_class = SubmissionRelationSerializer > + permission_classes = (MaintainerPermission,) > + > + def initial(self, request, *args, **kwargs): > + user = request.user > + if not hasattr(user, 'maintains'): > + if user.is_authenticated: > + user.maintains = user.profile.maintainer_projects.all() > + else: > + user.maintains = [] > + super(SubmissionRelationMixin, self).initial(request, *args, **kwargs) > + > + def get_queryset(self): > + return SubmissionRelation.objects.all() \ > + .select_related('by') \ > + .prefetch_related('submissions__patch', > + 'submissions__coverletter', > + 'submissions__project') > + > + > +class SubmissionRelationList(SubmissionRelationMixin, ListCreateAPIView): > + ordering = 'id' > + ordering_fields = ['id'] > + > + def perform_create(self, serializer): > + # has_object_permission() is not called when creating a new relation. > + # Check whether user is maintainer of every project a submission is > + # part of > + maintains = self.request.user.maintains > + submissions = serializer.validated_data['submissions'] > + MaintainerPermission.check_user_maintains_all(maintains, submissions) > + serializer.save(by=self.request.user) > + > + > +class SubmissionRelationDetail(SubmissionRelationMixin, > + RetrieveUpdateDestroyAPIView): > + pass > diff --git a/patchwork/models.py b/patchwork/models.py > index a92203b24ff2..9ae3370e896b 100644 > --- a/patchwork/models.py > +++ b/patchwork/models.py > @@ -415,6 +415,9 @@ class CoverLetter(Submission): > kwargs={'project_id': self.project.linkname, > 'msgid': self.url_msgid}) > > + def get_absolute_api_url(self): > + return reverse('api-cover-detail', kwargs={'pk': self.id}) > + > def get_mbox_url(self): > return reverse('cover-mbox', > kwargs={'project_id': self.project.linkname, > @@ -604,6 +607,9 @@ class Patch(Submission): > kwargs={'project_id': self.project.linkname, > 'msgid': self.url_msgid}) > > + def get_absolute_api_url(self): > + return reverse('api-patch-detail', kwargs={'pk': self.id}) > + > def get_mbox_url(self): > return reverse('patch-mbox', > kwargs={'project_id': self.project.linkname, > diff --git a/patchwork/tests/api/test_relation.py b/patchwork/tests/api/test_relation.py > new file mode 100644 > index 000000000000..5b1a04f13670 > --- /dev/null > +++ b/patchwork/tests/api/test_relation.py > @@ -0,0 +1,181 @@ > +# Patchwork - automated patch tracking system > +# Copyright (C) 2019, Bayerische Motoren Werke Aktiengesellschaft (BMW AG) > +# > +# SPDX-License-Identifier: GPL-2.0-or-later > + > +import unittest > + > +import six > +from django.conf import settings > +from django.urls import reverse > + > +from patchwork.tests.api import utils > +from patchwork.tests.utils import create_cover > +from patchwork.tests.utils import create_maintainer > +from patchwork.tests.utils import create_patches > +from patchwork.tests.utils import create_project > +from patchwork.tests.utils import create_relation > +from patchwork.tests.utils import create_user > + > +if settings.ENABLE_REST_API: > + from rest_framework import status > + > + > +class UserType: > + ANONYMOUS = 1 > + NON_MAINTAINER = 2 > + MAINTAINER = 3 > + > + > +@unittest.skipUnless(settings.ENABLE_REST_API, 'requires ENABLE_REST_API') > +class TestRelationAPI(utils.APITestCase): > + fixtures = ['default_tags'] > + > + @staticmethod > + def api_url(item=None): > + kwargs = {} > + if item is None: > + return reverse('api-relation-list', kwargs=kwargs) > + kwargs['pk'] = item > + return reverse('api-relation-detail', kwargs=kwargs) > + > + def request_restricted(self, method, user_type): > + """Assert post/delete/patch requests on the relation API.""" > + assert method in ['post', 'delete', 'patch'] > + > + # setup > + > + project = create_project() > + maintainer = create_maintainer(project) > + > + if user_type == UserType.ANONYMOUS: > + expected_status = status.HTTP_403_FORBIDDEN > + elif user_type == UserType.NON_MAINTAINER: > + expected_status = status.HTTP_403_FORBIDDEN > + self.client.force_authenticate(user=create_user()) > + elif user_type == UserType.MAINTAINER: > + if method == 'post': > + expected_status = status.HTTP_201_CREATED > + elif method == 'delete': > + expected_status = status.HTTP_204_NO_CONTENT > + else: > + expected_status = status.HTTP_200_OK > + self.client.force_authenticate(user=maintainer) > + else: > + raise ValueError > + > + resource_id = None > + req = None > + > + if method == 'delete': > + resource_id = create_relation(project=project, by=maintainer).id > + elif method == 'post': > + patch_ids = [p.id for p in create_patches(2, project=project)] > + req = {'submissions': patch_ids} > + elif method == 'patch': > + resource_id = create_relation(project=project, by=maintainer).id > + patch_ids = [p.id for p in create_patches(2, project=project)] > + req = {'submissions': patch_ids} > + else: > + raise ValueError > + > + # request > + > + resp = getattr(self.client, method)(self.api_url(resource_id), req) > + > + # check > + > + self.assertEqual(expected_status, resp.status_code) > + > + if resp.status_code in range(status.HTTP_200_OK, > + status.HTTP_204_NO_CONTENT): > + self.assertRequest(req, resp.data) > + > + def assertRequest(self, request, resp): > + if request.get('id'): > + self.assertEqual(request['id'], resp['id']) > + send_ids = request['submissions'] > + resp_ids = [s['id'] for s in resp['submissions']] > + six.assertCountEqual(self, resp_ids, send_ids) > + > + def assertSerialized(self, obj, resp): > + self.assertEqual(obj.id, resp['id']) > + exp_ids = [s.id for s in obj.submissions.all()] > + act_ids = [s['id'] for s in resp['submissions']] > + six.assertCountEqual(self, exp_ids, act_ids) > + > + def test_list_empty(self): > + """List relation when none are present.""" > + resp = self.client.get(self.api_url()) > + self.assertEqual(status.HTTP_200_OK, resp.status_code) > + self.assertEqual(0, len(resp.data)) > + > + @utils.store_samples('relation-list') > + def test_list(self): > + """List relations.""" > + relation = create_relation() > + > + resp = self.client.get(self.api_url()) > + self.assertEqual(status.HTTP_200_OK, resp.status_code) > + self.assertEqual(1, len(resp.data)) > + self.assertSerialized(relation, resp.data[0]) > + > + def test_detail(self): > + """Show relation.""" > + relation = create_relation() > + > + resp = self.client.get(self.api_url(relation.id)) > + self.assertEqual(status.HTTP_200_OK, resp.status_code) > + self.assertSerialized(relation, resp.data) > + > + @utils.store_samples('relation-create-error-forbidden') > + def test_create_anonymous(self): > + self.request_restricted('post', UserType.ANONYMOUS) > + > + def test_create_non_maintainer(self): > + self.request_restricted('post', UserType.NON_MAINTAINER) > + > + @utils.store_samples('relation-create') > + def test_create_maintainer(self): > + self.request_restricted('post', UserType.MAINTAINER) > + > + @utils.store_samples('relation-update-error-forbidden') > + def test_update_anonymous(self): > + self.request_restricted('patch', UserType.ANONYMOUS) > + > + def test_update_non_maintainer(self): > + self.request_restricted('patch', UserType.NON_MAINTAINER) > + > + @utils.store_samples('relation-update') > + def test_update_maintainer(self): > + self.request_restricted('patch', UserType.MAINTAINER) > + > + @utils.store_samples('relation-delete-error-forbidden') > + def test_delete_anonymous(self): > + self.request_restricted('delete', UserType.ANONYMOUS) > + > + def test_delete_non_maintainer(self): > + self.request_restricted('delete', UserType.NON_MAINTAINER) > + > + @utils.store_samples('relation-update') > + def test_delete_maintainer(self): > + self.request_restricted('delete', UserType.MAINTAINER) > + > + def test_submission_conflict(self): > + project = create_project() > + maintainer = create_maintainer(project) > + self.client.force_authenticate(user=maintainer) > + relation = create_relation(by=maintainer, project=project) > + submission_ids = [s.id for s in relation.submissions.all()] > + > + # try to create a new relation with a new submission (cover) and > + # submissions already bound to another relation > + cover = create_cover(project=project) > + submission_ids.append(cover.id) > + req = {'submissions': submission_ids} > + resp = self.client.post(self.api_url(), req) > + self.assertEqual(status.HTTP_409_CONFLICT, resp.status_code) > + > + # try to patch relation > + resp = self.client.patch(self.api_url(relation.id), req) > + self.assertEqual(status.HTTP_200_OK, resp.status_code) > diff --git a/patchwork/tests/utils.py b/patchwork/tests/utils.py > index 577183d0986c..ffe90976233e 100644 > --- a/patchwork/tests/utils.py > +++ b/patchwork/tests/utils.py > @@ -16,6 +16,7 @@ from patchwork.models import Check > from patchwork.models import Comment > from patchwork.models import CoverLetter > from patchwork.models import Patch > +from patchwork.models import SubmissionRelation > from patchwork.models import Person > from patchwork.models import Project > from patchwork.models import Series > @@ -347,3 +348,17 @@ def create_covers(count=1, **kwargs): > kwargs (dict): Overrides for various cover letter fields > """ > return _create_submissions(create_cover, count, **kwargs) > + > + > +def create_relation(count_patches=2, by=None, **kwargs): > + if not by: > + project = create_project() > + kwargs['project'] = project > + by = create_maintainer(project) > + relation = SubmissionRelation.objects.create(by=by) > + values = { > + 'related': relation > + } > + values.update(kwargs) > + create_patches(count_patches, **values) > + return relation > diff --git a/patchwork/urls.py b/patchwork/urls.py > index dcdcfb49e67e..92095f62c7b9 100644 > --- a/patchwork/urls.py > +++ b/patchwork/urls.py > @@ -187,6 +187,7 @@ if settings.ENABLE_REST_API: > from patchwork.api import patch as api_patch_views # noqa > from patchwork.api import person as api_person_views # noqa > from patchwork.api import project as api_project_views # noqa > + from patchwork.api import relation as api_relation_views # noqa > from patchwork.api import series as api_series_views # noqa > from patchwork.api import user as api_user_views # noqa > > @@ -256,9 +257,19 @@ if settings.ENABLE_REST_API: > name='api-cover-comment-list'), > ] > > + api_1_2_patterns = [ > + url(r'^relations/$', > + api_relation_views.SubmissionRelationList.as_view(), > + name='api-relation-list'), > + url(r'^relations/(?P<pk>[^/]+)/$', > + api_relation_views.SubmissionRelationDetail.as_view(), > + name='api-relation-detail'), > + ] > + > urlpatterns += [ > url(r'^api/(?:(?P<version>(1.0|1.1|1.2))/)?', include(api_patterns)), > url(r'^api/(?:(?P<version>(1.1|1.2))/)?', include(api_1_1_patterns)), > + url(r'^api/(?:(?P<version>1.2)/)?', include(api_1_2_patterns)), > > # token change > url(r'^user/generate-token/$', user_views.generate_token, > diff --git a/releasenotes/notes/add-submission-relations-c96bb6c567b416d8.yaml b/releasenotes/notes/add-submission-relations-c96bb6c567b416d8.yaml > new file mode 100644 > index 000000000000..cb877991cd55 > --- /dev/null > +++ b/releasenotes/notes/add-submission-relations-c96bb6c567b416d8.yaml > @@ -0,0 +1,10 @@ > +--- > +features: > + - | > + Submissions (cover letters or patches) can now be related to other ones > + (e.g. revisions). Relations can be set via the REST API by maintainers > + (currently only for submissions of projects they maintain) > +api: > + - | > + Relations are available via ``/relations/`` and > + ``/relations/{relationID}/`` endpoints.
Hi Stephen, On 27.12.19 18:48, Stephen Finucane wrote: > On Sat, 2019-12-07 at 17:46 +0100, Mete Polat wrote: >> View relations and add/update/delete them as a maintainer. Maintainers >> can only create relations of submissions (patches/cover letters) which >> are part of a project they maintain. >> >> New REST API urls: >> api/relations/ >> api/relations/<relation_id>/ >> >> Co-authored-by: Daniel Axtens <dja@axtens.net> >> Signed-off-by: Mete Polat <metepolat2000@gmail.com> > > Why did you choose to expose this as a separate API rather than as a > field on the '/patches' resource? While a 'Series' objet has enough > separate metadata to warrant a separate '/series' resource, a > 'SubmissionRelation' object is basically just a container. Including a > 'related_patches' field on the detailed patch view would seem like more > than enough detail for me, anyway, and unless there's a reason not to > do this, I'd like to see it done that way. Is it possible? > The first version of the series supported bulk creating/updating of relations which was only possible by moving relations into their own url [1]. As we deciced against bulk operations, I aggree that exposing a related_patches field is the better choice now. Best regards, Mete [1] Or allow bulk operations on /api/patch/ in general. > Stephen > > PS: I could have sworn I had asked this before, but I can't find any > mails about it so maybe I didn't. Please tell me to RTML (read the > mailing list) if so > >> --- >> Optimize db queries: >> I have spent quite a lot of time in optimizing the db queries for the REST API >> (thanks for the tip with the Django toolbar). Daniel stated that >> prefetch_related is possibly hitting the database for every relation when >> prefetching submissions but it turns out that we can tell Django to use a >> statement like: >> SELECT * >> FROM `patchwork_patch` >> INNER JOIN `patchwork_submission` >> ON (`patchwork_patch`.`submission_ptr_id` = `patchwork_submission`.`id`) >> WHERE `patchwork_patch`.`submission_ptr_id` IN (LIST_OF_ALL_SUBMISSION_IDS) >> >> We do the same for `patchwork_coverletter`. >> This means we only hit the db two times for casting _all_ submissions to a >> patch or cover-letter. >> >> Prefetching submissions__project eliminates similar and duplicate queries >> that are used to determine whether a logged in user is at least maintainer >> of one submission's project. >> >> docs/api/schemas/latest/patchwork.yaml | 273 +++++++++++++++++ >> docs/api/schemas/patchwork.j2 | 285 ++++++++++++++++++ >> docs/api/schemas/v1.2/patchwork.yaml | 273 +++++++++++++++++ >> patchwork/api/embedded.py | 39 +++ >> patchwork/api/index.py | 1 + >> patchwork/api/relation.py | 121 ++++++++ >> patchwork/models.py | 6 + >> patchwork/tests/api/test_relation.py | 181 +++++++++++ >> patchwork/tests/utils.py | 15 + >> patchwork/urls.py | 11 + >> ...submission-relations-c96bb6c567b416d8.yaml | 10 + >> 11 files changed, 1215 insertions(+) >> create mode 100644 patchwork/api/relation.py >> create mode 100644 patchwork/tests/api/test_relation.py >> create mode 100644 releasenotes/notes/add-submission-relations-c96bb6c567b416d8.yaml >> >> diff --git a/docs/api/schemas/latest/patchwork.yaml b/docs/api/schemas/latest/patchwork.yaml >> index a5e235be936d..7dd24fd700d5 100644 >> --- a/docs/api/schemas/latest/patchwork.yaml >> +++ b/docs/api/schemas/latest/patchwork.yaml >> @@ -1039,6 +1039,188 @@ paths: >> $ref: '#/components/schemas/Error' >> tags: >> - series >> + /api/relations/: >> + get: >> + description: List relations. >> + operationId: relations_list >> + parameters: >> + - $ref: '#/components/parameters/Page' >> + - $ref: '#/components/parameters/PageSize' >> + - $ref: '#/components/parameters/Order' >> + responses: >> + '200': >> + description: '' >> + headers: >> + Link: >> + $ref: '#/components/headers/Link' >> + content: >> + application/json: >> + schema: >> + type: array >> + items: >> + $ref: '#/components/schemas/Relation' >> + tags: >> + - relations >> + post: >> + description: Create a relation. >> + operationId: relations_create >> + security: >> + - basicAuth: [] >> + - apiKeyAuth: [] >> + requestBody: >> + $ref: '#/components/requestBodies/Relation' >> + responses: >> + '201': >> + description: '' >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Relation' >> + '400': >> + description: Invalid Request >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Error' >> + '403': >> + description: Forbidden >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Error' >> + '404': >> + description: Not found >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Error' >> + '409': >> + description: Conflict >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Error' >> + tags: >> + - checks >> + /api/relations/{id}/: >> + get: >> + description: Show a relation. >> + operationId: relation_read >> + parameters: >> + - in: path >> + name: id >> + description: A unique integer value identifying this relation. >> + required: true >> + schema: >> + title: ID >> + type: integer >> + responses: >> + '200': >> + description: '' >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Relation' >> + '404': >> + description: Not found >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Error' >> + '409': >> + description: Conflict >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Error' >> + tags: >> + - relations >> + patch: >> + description: Update a relation (partial). >> + operationId: relations_partial_update >> + security: >> + - basicAuth: [] >> + - apiKeyAuth: [] >> + parameters: >> + - in: path >> + name: id >> + description: A unique integer value identifying this relation. >> + required: true >> + schema: >> + title: ID >> + type: integer >> + requestBody: >> + $ref: '#/components/requestBodies/Relation' >> + responses: >> + '200': >> + description: '' >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Relation' >> + '400': >> + description: Bad request >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Error' >> + '403': >> + description: Forbidden >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Error' >> + '404': >> + description: Not found >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Error' >> + tags: >> + - relations >> + put: >> + description: Update a relation. >> + operationId: relations_update >> + security: >> + - basicAuth: [] >> + - apiKeyAuth: [] >> + parameters: >> + - in: path >> + name: id >> + description: A unique integer value identifying this relation. >> + required: true >> + schema: >> + title: ID >> + type: integer >> + requestBody: >> + $ref: '#/components/requestBodies/Relation' >> + responses: >> + '200': >> + description: '' >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Relation' >> + '400': >> + description: Bad request >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Error' >> + '403': >> + description: Forbidden >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Error' >> + '404': >> + description: Not found >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Error' >> + tags: >> + - relations >> /api/users/: >> get: >> description: List users. >> @@ -1314,6 +1496,18 @@ components: >> application/x-www-form-urlencoded: >> schema: >> $ref: '#/components/schemas/User' >> + Relation: >> + required: true >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/RelationUpdate' >> + multipart/form-data: >> + schema: >> + $ref: '#/components/schemas/RelationUpdate' >> + application/x-www-form-urlencoded: >> + schema: >> + $ref: '#/components/schemas/RelationUpdate' >> schemas: >> Index: >> type: object >> @@ -1358,6 +1552,11 @@ components: >> type: string >> format: uri >> readOnly: true >> + relations: >> + title: Relations URL >> + type: string >> + format: uri >> + readOnly: true >> Bundle: >> required: >> - name >> @@ -1943,6 +2142,14 @@ components: >> title: Delegate >> type: integer >> nullable: true >> + RelationUpdate: >> + type: object >> + properties: >> + submissions: >> + title: Submission IDs >> + type: array >> + items: >> + type: integer >> Person: >> type: object >> properties: >> @@ -2133,6 +2340,30 @@ components: >> $ref: '#/components/schemas/PatchEmbedded' >> readOnly: true >> uniqueItems: true >> + Relation: >> + type: object >> + properties: >> + id: >> + title: ID >> + type: integer >> + url: >> + title: URL >> + type: string >> + format: uri >> + readOnly: true >> + by: >> + type: object >> + title: By >> + readOnly: true >> + allOf: >> + - $ref: '#/components/schemas/UserEmbedded' >> + submissions: >> + title: Submissions >> + type: array >> + items: >> + $ref: '#/components/schemas/SubmissionEmbedded' >> + readOnly: true >> + uniqueItems: true >> User: >> type: object >> properties: >> @@ -2211,6 +2442,48 @@ components: >> maxLength: 255 >> minLength: 1 >> readOnly: true >> + SubmissionEmbedded: >> + type: object >> + properties: >> + id: >> + title: ID >> + type: integer >> + readOnly: true >> + url: >> + title: URL >> + type: string >> + format: uri >> + readOnly: true >> + web_url: >> + title: Web URL >> + type: string >> + format: uri >> + readOnly: true >> + msgid: >> + title: Message ID >> + type: string >> + readOnly: true >> + minLength: 1 >> + list_archive_url: >> + title: List archive URL >> + type: string >> + readOnly: true >> + nullable: true >> + date: >> + title: Date >> + type: string >> + format: iso8601 >> + readOnly: true >> + name: >> + title: Name >> + type: string >> + readOnly: true >> + minLength: 1 >> + mbox: >> + title: Mbox >> + type: string >> + format: uri >> + readOnly: true >> CoverLetterEmbedded: >> type: object >> properties: >> diff --git a/docs/api/schemas/patchwork.j2 b/docs/api/schemas/patchwork.j2 >> index 196d78466b55..a034029accf9 100644 >> --- a/docs/api/schemas/patchwork.j2 >> +++ b/docs/api/schemas/patchwork.j2 >> @@ -1048,6 +1048,190 @@ paths: >> $ref: '#/components/schemas/Error' >> tags: >> - series >> +{% if version >= (1, 2) %} >> + /api/{{ version_url }}relations/: >> + get: >> + description: List relations. >> + operationId: relations_list >> + parameters: >> + - $ref: '#/components/parameters/Page' >> + - $ref: '#/components/parameters/PageSize' >> + - $ref: '#/components/parameters/Order' >> + responses: >> + '200': >> + description: '' >> + headers: >> + Link: >> + $ref: '#/components/headers/Link' >> + content: >> + application/json: >> + schema: >> + type: array >> + items: >> + $ref: '#/components/schemas/Relation' >> + tags: >> + - relations >> + post: >> + description: Create a relation. >> + operationId: relations_create >> + security: >> + - basicAuth: [] >> + - apiKeyAuth: [] >> + requestBody: >> + $ref: '#/components/requestBodies/Relation' >> + responses: >> + '201': >> + description: '' >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Relation' >> + '400': >> + description: Invalid Request >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Error' >> + '403': >> + description: Forbidden >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Error' >> + '404': >> + description: Not found >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Error' >> + '409': >> + description: Conflict >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Error' >> + tags: >> + - checks >> + /api/{{ version_url }}relations/{id}/: >> + get: >> + description: Show a relation. >> + operationId: relation_read >> + parameters: >> + - in: path >> + name: id >> + description: A unique integer value identifying this relation. >> + required: true >> + schema: >> + title: ID >> + type: integer >> + responses: >> + '200': >> + description: '' >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Relation' >> + '404': >> + description: Not found >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Error' >> + '409': >> + description: Conflict >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Error' >> + tags: >> + - relations >> + patch: >> + description: Update a relation (partial). >> + operationId: relations_partial_update >> + security: >> + - basicAuth: [] >> + - apiKeyAuth: [] >> + parameters: >> + - in: path >> + name: id >> + description: A unique integer value identifying this relation. >> + required: true >> + schema: >> + title: ID >> + type: integer >> + requestBody: >> + $ref: '#/components/requestBodies/Relation' >> + responses: >> + '200': >> + description: '' >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Relation' >> + '400': >> + description: Bad request >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Error' >> + '403': >> + description: Forbidden >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Error' >> + '404': >> + description: Not found >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Error' >> + tags: >> + - relations >> + put: >> + description: Update a relation. >> + operationId: relations_update >> + security: >> + - basicAuth: [] >> + - apiKeyAuth: [] >> + parameters: >> + - in: path >> + name: id >> + description: A unique integer value identifying this relation. >> + required: true >> + schema: >> + title: ID >> + type: integer >> + requestBody: >> + $ref: '#/components/requestBodies/Relation' >> + responses: >> + '200': >> + description: '' >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Relation' >> + '400': >> + description: Bad request >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Error' >> + '403': >> + description: Forbidden >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Error' >> + '404': >> + description: Not found >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Error' >> + tags: >> + - relations >> +{% endif %} >> /api/{{ version_url }}users/: >> get: >> description: List users. >> @@ -1325,6 +1509,20 @@ components: >> application/x-www-form-urlencoded: >> schema: >> $ref: '#/components/schemas/User' >> +{% if version >= (1, 2) %} >> + Relation: >> + required: true >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/RelationUpdate' >> + multipart/form-data: >> + schema: >> + $ref: '#/components/schemas/RelationUpdate' >> + application/x-www-form-urlencoded: >> + schema: >> + $ref: '#/components/schemas/RelationUpdate' >> +{% endif %} >> schemas: >> Index: >> type: object >> @@ -1369,6 +1567,13 @@ components: >> type: string >> format: uri >> readOnly: true >> +{% if version >= (1, 2) %} >> + relations: >> + title: Relations URL >> + type: string >> + format: uri >> + readOnly: true >> +{% endif %} >> Bundle: >> required: >> - name >> @@ -1981,6 +2186,16 @@ components: >> title: Delegate >> type: integer >> nullable: true >> +{% if version >= (1, 2) %} >> + RelationUpdate: >> + type: object >> + properties: >> + submissions: >> + title: Submission IDs >> + type: array >> + items: >> + type: integer >> +{% endif %} >> Person: >> type: object >> properties: >> @@ -2177,6 +2392,32 @@ components: >> $ref: '#/components/schemas/PatchEmbedded' >> readOnly: true >> uniqueItems: true >> +{% if version >= (1, 2) %} >> + Relation: >> + type: object >> + properties: >> + id: >> + title: ID >> + type: integer >> + url: >> + title: URL >> + type: string >> + format: uri >> + readOnly: true >> + by: >> + type: object >> + title: By >> + readOnly: true >> + allOf: >> + - $ref: '#/components/schemas/UserEmbedded' >> + submissions: >> + title: Submissions >> + type: array >> + items: >> + $ref: '#/components/schemas/SubmissionEmbedded' >> + readOnly: true >> + uniqueItems: true >> +{% endif %} >> User: >> type: object >> properties: >> @@ -2255,6 +2496,50 @@ components: >> maxLength: 255 >> minLength: 1 >> readOnly: true >> +{% if version >= (1, 2) %} >> + SubmissionEmbedded: >> + type: object >> + properties: >> + id: >> + title: ID >> + type: integer >> + readOnly: true >> + url: >> + title: URL >> + type: string >> + format: uri >> + readOnly: true >> + web_url: >> + title: Web URL >> + type: string >> + format: uri >> + readOnly: true >> + msgid: >> + title: Message ID >> + type: string >> + readOnly: true >> + minLength: 1 >> + list_archive_url: >> + title: List archive URL >> + type: string >> + readOnly: true >> + nullable: true >> + date: >> + title: Date >> + type: string >> + format: iso8601 >> + readOnly: true >> + name: >> + title: Name >> + type: string >> + readOnly: true >> + minLength: 1 >> + mbox: >> + title: Mbox >> + type: string >> + format: uri >> + readOnly: true >> +{% endif %} >> CoverLetterEmbedded: >> type: object >> properties: >> diff --git a/docs/api/schemas/v1.2/patchwork.yaml b/docs/api/schemas/v1.2/patchwork.yaml >> index d7b4d2957cff..99425e968881 100644 >> --- a/docs/api/schemas/v1.2/patchwork.yaml >> +++ b/docs/api/schemas/v1.2/patchwork.yaml >> @@ -1039,6 +1039,188 @@ paths: >> $ref: '#/components/schemas/Error' >> tags: >> - series >> + /api/1.2/relations/: >> + get: >> + description: List relations. >> + operationId: relations_list >> + parameters: >> + - $ref: '#/components/parameters/Page' >> + - $ref: '#/components/parameters/PageSize' >> + - $ref: '#/components/parameters/Order' >> + responses: >> + '200': >> + description: '' >> + headers: >> + Link: >> + $ref: '#/components/headers/Link' >> + content: >> + application/json: >> + schema: >> + type: array >> + items: >> + $ref: '#/components/schemas/Relation' >> + tags: >> + - relations >> + post: >> + description: Create a relation. >> + operationId: relations_create >> + security: >> + - basicAuth: [] >> + - apiKeyAuth: [] >> + requestBody: >> + $ref: '#/components/requestBodies/Relation' >> + responses: >> + '201': >> + description: '' >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Relation' >> + '400': >> + description: Invalid Request >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Error' >> + '403': >> + description: Forbidden >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Error' >> + '404': >> + description: Not found >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Error' >> + '409': >> + description: Conflict >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Error' >> + tags: >> + - checks >> + /api/1.2/relations/{id}/: >> + get: >> + description: Show a relation. >> + operationId: relation_read >> + parameters: >> + - in: path >> + name: id >> + description: A unique integer value identifying this relation. >> + required: true >> + schema: >> + title: ID >> + type: integer >> + responses: >> + '200': >> + description: '' >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Relation' >> + '404': >> + description: Not found >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Error' >> + '409': >> + description: Conflict >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Error' >> + tags: >> + - relations >> + patch: >> + description: Update a relation (partial). >> + operationId: relations_partial_update >> + security: >> + - basicAuth: [] >> + - apiKeyAuth: [] >> + parameters: >> + - in: path >> + name: id >> + description: A unique integer value identifying this relation. >> + required: true >> + schema: >> + title: ID >> + type: integer >> + requestBody: >> + $ref: '#/components/requestBodies/Relation' >> + responses: >> + '200': >> + description: '' >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Relation' >> + '400': >> + description: Bad request >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Error' >> + '403': >> + description: Forbidden >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Error' >> + '404': >> + description: Not found >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Error' >> + tags: >> + - relations >> + put: >> + description: Update a relation. >> + operationId: relations_update >> + security: >> + - basicAuth: [] >> + - apiKeyAuth: [] >> + parameters: >> + - in: path >> + name: id >> + description: A unique integer value identifying this relation. >> + required: true >> + schema: >> + title: ID >> + type: integer >> + requestBody: >> + $ref: '#/components/requestBodies/Relation' >> + responses: >> + '200': >> + description: '' >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Relation' >> + '400': >> + description: Bad request >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Error' >> + '403': >> + description: Forbidden >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Error' >> + '404': >> + description: Not found >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Error' >> + tags: >> + - relations >> /api/1.2/users/: >> get: >> description: List users. >> @@ -1314,6 +1496,18 @@ components: >> application/x-www-form-urlencoded: >> schema: >> $ref: '#/components/schemas/User' >> + Relation: >> + required: true >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/RelationUpdate' >> + multipart/form-data: >> + schema: >> + $ref: '#/components/schemas/RelationUpdate' >> + application/x-www-form-urlencoded: >> + schema: >> + $ref: '#/components/schemas/RelationUpdate' >> schemas: >> Index: >> type: object >> @@ -1358,6 +1552,11 @@ components: >> type: string >> format: uri >> readOnly: true >> + relations: >> + title: Relations URL >> + type: string >> + format: uri >> + readOnly: true >> Bundle: >> required: >> - name >> @@ -1943,6 +2142,14 @@ components: >> title: Delegate >> type: integer >> nullable: true >> + RelationUpdate: >> + type: object >> + properties: >> + submissions: >> + title: Submission IDs >> + type: array >> + items: >> + type: integer >> Person: >> type: object >> properties: >> @@ -2133,6 +2340,30 @@ components: >> $ref: '#/components/schemas/PatchEmbedded' >> readOnly: true >> uniqueItems: true >> + Relation: >> + type: object >> + properties: >> + id: >> + title: ID >> + type: integer >> + url: >> + title: URL >> + type: string >> + format: uri >> + readOnly: true >> + by: >> + type: object >> + title: By >> + readOnly: true >> + allOf: >> + - $ref: '#/components/schemas/UserEmbedded' >> + submissions: >> + title: Submissions >> + type: array >> + items: >> + $ref: '#/components/schemas/SubmissionEmbedded' >> + readOnly: true >> + uniqueItems: true >> User: >> type: object >> properties: >> @@ -2211,6 +2442,48 @@ components: >> maxLength: 255 >> minLength: 1 >> readOnly: true >> + SubmissionEmbedded: >> + type: object >> + properties: >> + id: >> + title: ID >> + type: integer >> + readOnly: true >> + url: >> + title: URL >> + type: string >> + format: uri >> + readOnly: true >> + web_url: >> + title: Web URL >> + type: string >> + format: uri >> + readOnly: true >> + msgid: >> + title: Message ID >> + type: string >> + readOnly: true >> + minLength: 1 >> + list_archive_url: >> + title: List archive URL >> + type: string >> + readOnly: true >> + nullable: true >> + date: >> + title: Date >> + type: string >> + format: iso8601 >> + readOnly: true >> + name: >> + title: Name >> + type: string >> + readOnly: true >> + minLength: 1 >> + mbox: >> + title: Mbox >> + type: string >> + format: uri >> + readOnly: true >> CoverLetterEmbedded: >> type: object >> properties: >> diff --git a/patchwork/api/embedded.py b/patchwork/api/embedded.py >> index de4f31165ee7..0fba291b62b8 100644 >> --- a/patchwork/api/embedded.py >> +++ b/patchwork/api/embedded.py >> @@ -102,6 +102,45 @@ class CheckSerializer(SerializedRelatedField): >> } >> >> >> +def _upgrade_instance(instance): >> + if hasattr(instance, 'patch'): >> + return instance.patch >> + else: >> + return instance.coverletter >> + >> + >> +class SubmissionSerializer(SerializedRelatedField): >> + >> + class _Serializer(BaseHyperlinkedModelSerializer): >> + """We need to 'upgrade' or specialise the submission to the relevant >> + subclass, so we can't use the mixins. This is gross but can go away >> + once we flatten the models.""" >> + url = SerializerMethodField() >> + web_url = SerializerMethodField() >> + mbox = SerializerMethodField() >> + >> + def get_url(self, instance): >> + instance = _upgrade_instance(instance) >> + request = self.context.get('request') >> + return request.build_absolute_uri(instance.get_absolute_api_url()) >> + >> + def get_web_url(self, instance): >> + instance = _upgrade_instance(instance) >> + request = self.context.get('request') >> + return request.build_absolute_uri(instance.get_absolute_url()) >> + >> + def get_mbox(self, instance): >> + instance = _upgrade_instance(instance) >> + request = self.context.get('request') >> + return request.build_absolute_uri(instance.get_mbox_url()) >> + >> + class Meta: >> + model = models.Submission >> + fields = ('id', 'url', 'web_url', 'msgid', 'list_archive_url', >> + 'date', 'name', 'mbox') >> + read_only_fields = fields >> + >> + >> class CoverLetterSerializer(SerializedRelatedField): >> >> class _Serializer(MboxMixin, WebURLMixin, BaseHyperlinkedModelSerializer): >> diff --git a/patchwork/api/index.py b/patchwork/api/index.py >> index 45485c9106f6..cf1845393835 100644 >> --- a/patchwork/api/index.py >> +++ b/patchwork/api/index.py >> @@ -21,4 +21,5 @@ class IndexView(APIView): >> 'series': reverse('api-series-list', request=request), >> 'events': reverse('api-event-list', request=request), >> 'bundles': reverse('api-bundle-list', request=request), >> + 'relations': reverse('api-relation-list', request=request), >> }) >> diff --git a/patchwork/api/relation.py b/patchwork/api/relation.py >> new file mode 100644 >> index 000000000000..37640d62e9cc >> --- /dev/null >> +++ b/patchwork/api/relation.py >> @@ -0,0 +1,121 @@ >> +# Patchwork - automated patch tracking system >> +# Copyright (C) 2019, Bayerische Motoren Werke Aktiengesellschaft (BMW AG) >> +# >> +# SPDX-License-Identifier: GPL-2.0-or-later >> + >> +from rest_framework import permissions >> +from rest_framework import status >> +from rest_framework.exceptions import PermissionDenied, APIException >> +from rest_framework.generics import GenericAPIView >> +from rest_framework.generics import ListCreateAPIView >> +from rest_framework.generics import RetrieveUpdateDestroyAPIView >> +from rest_framework.serializers import ModelSerializer >> + >> +from patchwork.api.base import PatchworkPermission >> +from patchwork.api.embedded import SubmissionSerializer >> +from patchwork.api.embedded import UserSerializer >> +from patchwork.models import SubmissionRelation >> + >> + >> +class MaintainerPermission(PatchworkPermission): >> + >> + def has_permission(self, request, view): >> + if request.method in permissions.SAFE_METHODS: >> + return True >> + >> + # Prevent showing an HTML POST form in the browseable API for logged in >> + # users who are not maintainers. >> + return len(request.user.maintains) > 0 >> + >> + def has_object_permission(self, request, view, relation): >> + if request.method in permissions.SAFE_METHODS: >> + return True >> + >> + maintains = request.user.maintains >> + submissions = relation.submissions.all() >> + # user has to be maintainer of every project a submission is part of >> + return self.check_user_maintains_all(maintains, submissions) >> + >> + @staticmethod >> + def check_user_maintains_all(maintains, submissions): >> + if any(s.project not in maintains for s in submissions): >> + detail = 'At least one submission is part of a project you are ' \ >> + 'not maintaining.' >> + raise PermissionDenied(detail=detail) >> + return True >> + >> + >> +class SubmissionConflict(APIException): >> + status_code = status.HTTP_409_CONFLICT >> + default_detail = 'At least one submission is already part of another ' \ >> + 'relation. You have to explicitly remove a submission ' \ >> + 'from its existing relation before moving it to this one.' >> + >> + >> +class SubmissionRelationSerializer(ModelSerializer): >> + by = UserSerializer(read_only=True) >> + submissions = SubmissionSerializer(many=True) >> + >> + def create(self, validated_data): >> + submissions = validated_data['submissions'] >> + if any(submission.related_id is not None >> + for submission in submissions): >> + raise SubmissionConflict() >> + return super(SubmissionRelationSerializer, self).create(validated_data) >> + >> + def update(self, instance, validated_data): >> + submissions = validated_data['submissions'] >> + if any(submission.related_id is not None and >> + submission.related_id != instance.id >> + for submission in submissions): >> + raise SubmissionConflict() >> + return super(SubmissionRelationSerializer, self) \ >> + .update(instance, validated_data) >> + >> + class Meta: >> + model = SubmissionRelation >> + fields = ('id', 'url', 'by', 'submissions',) >> + read_only_fields = ('url', 'by', ) >> + extra_kwargs = { >> + 'url': {'view_name': 'api-relation-detail'}, >> + } >> + >> + >> +class SubmissionRelationMixin(GenericAPIView): >> + serializer_class = SubmissionRelationSerializer >> + permission_classes = (MaintainerPermission,) >> + >> + def initial(self, request, *args, **kwargs): >> + user = request.user >> + if not hasattr(user, 'maintains'): >> + if user.is_authenticated: >> + user.maintains = user.profile.maintainer_projects.all() >> + else: >> + user.maintains = [] >> + super(SubmissionRelationMixin, self).initial(request, *args, **kwargs) >> + >> + def get_queryset(self): >> + return SubmissionRelation.objects.all() \ >> + .select_related('by') \ >> + .prefetch_related('submissions__patch', >> + 'submissions__coverletter', >> + 'submissions__project') >> + >> + >> +class SubmissionRelationList(SubmissionRelationMixin, ListCreateAPIView): >> + ordering = 'id' >> + ordering_fields = ['id'] >> + >> + def perform_create(self, serializer): >> + # has_object_permission() is not called when creating a new relation. >> + # Check whether user is maintainer of every project a submission is >> + # part of >> + maintains = self.request.user.maintains >> + submissions = serializer.validated_data['submissions'] >> + MaintainerPermission.check_user_maintains_all(maintains, submissions) >> + serializer.save(by=self.request.user) >> + >> + >> +class SubmissionRelationDetail(SubmissionRelationMixin, >> + RetrieveUpdateDestroyAPIView): >> + pass >> diff --git a/patchwork/models.py b/patchwork/models.py >> index a92203b24ff2..9ae3370e896b 100644 >> --- a/patchwork/models.py >> +++ b/patchwork/models.py >> @@ -415,6 +415,9 @@ class CoverLetter(Submission): >> kwargs={'project_id': self.project.linkname, >> 'msgid': self.url_msgid}) >> >> + def get_absolute_api_url(self): >> + return reverse('api-cover-detail', kwargs={'pk': self.id}) >> + >> def get_mbox_url(self): >> return reverse('cover-mbox', >> kwargs={'project_id': self.project.linkname, >> @@ -604,6 +607,9 @@ class Patch(Submission): >> kwargs={'project_id': self.project.linkname, >> 'msgid': self.url_msgid}) >> >> + def get_absolute_api_url(self): >> + return reverse('api-patch-detail', kwargs={'pk': self.id}) >> + >> def get_mbox_url(self): >> return reverse('patch-mbox', >> kwargs={'project_id': self.project.linkname, >> diff --git a/patchwork/tests/api/test_relation.py b/patchwork/tests/api/test_relation.py >> new file mode 100644 >> index 000000000000..5b1a04f13670 >> --- /dev/null >> +++ b/patchwork/tests/api/test_relation.py >> @@ -0,0 +1,181 @@ >> +# Patchwork - automated patch tracking system >> +# Copyright (C) 2019, Bayerische Motoren Werke Aktiengesellschaft (BMW AG) >> +# >> +# SPDX-License-Identifier: GPL-2.0-or-later >> + >> +import unittest >> + >> +import six >> +from django.conf import settings >> +from django.urls import reverse >> + >> +from patchwork.tests.api import utils >> +from patchwork.tests.utils import create_cover >> +from patchwork.tests.utils import create_maintainer >> +from patchwork.tests.utils import create_patches >> +from patchwork.tests.utils import create_project >> +from patchwork.tests.utils import create_relation >> +from patchwork.tests.utils import create_user >> + >> +if settings.ENABLE_REST_API: >> + from rest_framework import status >> + >> + >> +class UserType: >> + ANONYMOUS = 1 >> + NON_MAINTAINER = 2 >> + MAINTAINER = 3 >> + >> + >> +@unittest.skipUnless(settings.ENABLE_REST_API, 'requires ENABLE_REST_API') >> +class TestRelationAPI(utils.APITestCase): >> + fixtures = ['default_tags'] >> + >> + @staticmethod >> + def api_url(item=None): >> + kwargs = {} >> + if item is None: >> + return reverse('api-relation-list', kwargs=kwargs) >> + kwargs['pk'] = item >> + return reverse('api-relation-detail', kwargs=kwargs) >> + >> + def request_restricted(self, method, user_type): >> + """Assert post/delete/patch requests on the relation API.""" >> + assert method in ['post', 'delete', 'patch'] >> + >> + # setup >> + >> + project = create_project() >> + maintainer = create_maintainer(project) >> + >> + if user_type == UserType.ANONYMOUS: >> + expected_status = status.HTTP_403_FORBIDDEN >> + elif user_type == UserType.NON_MAINTAINER: >> + expected_status = status.HTTP_403_FORBIDDEN >> + self.client.force_authenticate(user=create_user()) >> + elif user_type == UserType.MAINTAINER: >> + if method == 'post': >> + expected_status = status.HTTP_201_CREATED >> + elif method == 'delete': >> + expected_status = status.HTTP_204_NO_CONTENT >> + else: >> + expected_status = status.HTTP_200_OK >> + self.client.force_authenticate(user=maintainer) >> + else: >> + raise ValueError >> + >> + resource_id = None >> + req = None >> + >> + if method == 'delete': >> + resource_id = create_relation(project=project, by=maintainer).id >> + elif method == 'post': >> + patch_ids = [p.id for p in create_patches(2, project=project)] >> + req = {'submissions': patch_ids} >> + elif method == 'patch': >> + resource_id = create_relation(project=project, by=maintainer).id >> + patch_ids = [p.id for p in create_patches(2, project=project)] >> + req = {'submissions': patch_ids} >> + else: >> + raise ValueError >> + >> + # request >> + >> + resp = getattr(self.client, method)(self.api_url(resource_id), req) >> + >> + # check >> + >> + self.assertEqual(expected_status, resp.status_code) >> + >> + if resp.status_code in range(status.HTTP_200_OK, >> + status.HTTP_204_NO_CONTENT): >> + self.assertRequest(req, resp.data) >> + >> + def assertRequest(self, request, resp): >> + if request.get('id'): >> + self.assertEqual(request['id'], resp['id']) >> + send_ids = request['submissions'] >> + resp_ids = [s['id'] for s in resp['submissions']] >> + six.assertCountEqual(self, resp_ids, send_ids) >> + >> + def assertSerialized(self, obj, resp): >> + self.assertEqual(obj.id, resp['id']) >> + exp_ids = [s.id for s in obj.submissions.all()] >> + act_ids = [s['id'] for s in resp['submissions']] >> + six.assertCountEqual(self, exp_ids, act_ids) >> + >> + def test_list_empty(self): >> + """List relation when none are present.""" >> + resp = self.client.get(self.api_url()) >> + self.assertEqual(status.HTTP_200_OK, resp.status_code) >> + self.assertEqual(0, len(resp.data)) >> + >> + @utils.store_samples('relation-list') >> + def test_list(self): >> + """List relations.""" >> + relation = create_relation() >> + >> + resp = self.client.get(self.api_url()) >> + self.assertEqual(status.HTTP_200_OK, resp.status_code) >> + self.assertEqual(1, len(resp.data)) >> + self.assertSerialized(relation, resp.data[0]) >> + >> + def test_detail(self): >> + """Show relation.""" >> + relation = create_relation() >> + >> + resp = self.client.get(self.api_url(relation.id)) >> + self.assertEqual(status.HTTP_200_OK, resp.status_code) >> + self.assertSerialized(relation, resp.data) >> + >> + @utils.store_samples('relation-create-error-forbidden') >> + def test_create_anonymous(self): >> + self.request_restricted('post', UserType.ANONYMOUS) >> + >> + def test_create_non_maintainer(self): >> + self.request_restricted('post', UserType.NON_MAINTAINER) >> + >> + @utils.store_samples('relation-create') >> + def test_create_maintainer(self): >> + self.request_restricted('post', UserType.MAINTAINER) >> + >> + @utils.store_samples('relation-update-error-forbidden') >> + def test_update_anonymous(self): >> + self.request_restricted('patch', UserType.ANONYMOUS) >> + >> + def test_update_non_maintainer(self): >> + self.request_restricted('patch', UserType.NON_MAINTAINER) >> + >> + @utils.store_samples('relation-update') >> + def test_update_maintainer(self): >> + self.request_restricted('patch', UserType.MAINTAINER) >> + >> + @utils.store_samples('relation-delete-error-forbidden') >> + def test_delete_anonymous(self): >> + self.request_restricted('delete', UserType.ANONYMOUS) >> + >> + def test_delete_non_maintainer(self): >> + self.request_restricted('delete', UserType.NON_MAINTAINER) >> + >> + @utils.store_samples('relation-update') >> + def test_delete_maintainer(self): >> + self.request_restricted('delete', UserType.MAINTAINER) >> + >> + def test_submission_conflict(self): >> + project = create_project() >> + maintainer = create_maintainer(project) >> + self.client.force_authenticate(user=maintainer) >> + relation = create_relation(by=maintainer, project=project) >> + submission_ids = [s.id for s in relation.submissions.all()] >> + >> + # try to create a new relation with a new submission (cover) and >> + # submissions already bound to another relation >> + cover = create_cover(project=project) >> + submission_ids.append(cover.id) >> + req = {'submissions': submission_ids} >> + resp = self.client.post(self.api_url(), req) >> + self.assertEqual(status.HTTP_409_CONFLICT, resp.status_code) >> + >> + # try to patch relation >> + resp = self.client.patch(self.api_url(relation.id), req) >> + self.assertEqual(status.HTTP_200_OK, resp.status_code) >> diff --git a/patchwork/tests/utils.py b/patchwork/tests/utils.py >> index 577183d0986c..ffe90976233e 100644 >> --- a/patchwork/tests/utils.py >> +++ b/patchwork/tests/utils.py >> @@ -16,6 +16,7 @@ from patchwork.models import Check >> from patchwork.models import Comment >> from patchwork.models import CoverLetter >> from patchwork.models import Patch >> +from patchwork.models import SubmissionRelation >> from patchwork.models import Person >> from patchwork.models import Project >> from patchwork.models import Series >> @@ -347,3 +348,17 @@ def create_covers(count=1, **kwargs): >> kwargs (dict): Overrides for various cover letter fields >> """ >> return _create_submissions(create_cover, count, **kwargs) >> + >> + >> +def create_relation(count_patches=2, by=None, **kwargs): >> + if not by: >> + project = create_project() >> + kwargs['project'] = project >> + by = create_maintainer(project) >> + relation = SubmissionRelation.objects.create(by=by) >> + values = { >> + 'related': relation >> + } >> + values.update(kwargs) >> + create_patches(count_patches, **values) >> + return relation >> diff --git a/patchwork/urls.py b/patchwork/urls.py >> index dcdcfb49e67e..92095f62c7b9 100644 >> --- a/patchwork/urls.py >> +++ b/patchwork/urls.py >> @@ -187,6 +187,7 @@ if settings.ENABLE_REST_API: >> from patchwork.api import patch as api_patch_views # noqa >> from patchwork.api import person as api_person_views # noqa >> from patchwork.api import project as api_project_views # noqa >> + from patchwork.api import relation as api_relation_views # noqa >> from patchwork.api import series as api_series_views # noqa >> from patchwork.api import user as api_user_views # noqa >> >> @@ -256,9 +257,19 @@ if settings.ENABLE_REST_API: >> name='api-cover-comment-list'), >> ] >> >> + api_1_2_patterns = [ >> + url(r'^relations/$', >> + api_relation_views.SubmissionRelationList.as_view(), >> + name='api-relation-list'), >> + url(r'^relations/(?P<pk>[^/]+)/$', >> + api_relation_views.SubmissionRelationDetail.as_view(), >> + name='api-relation-detail'), >> + ] >> + >> urlpatterns += [ >> url(r'^api/(?:(?P<version>(1.0|1.1|1.2))/)?', include(api_patterns)), >> url(r'^api/(?:(?P<version>(1.1|1.2))/)?', include(api_1_1_patterns)), >> + url(r'^api/(?:(?P<version>1.2)/)?', include(api_1_2_patterns)), >> >> # token change >> url(r'^user/generate-token/$', user_views.generate_token, >> diff --git a/releasenotes/notes/add-submission-relations-c96bb6c567b416d8.yaml b/releasenotes/notes/add-submission-relations-c96bb6c567b416d8.yaml >> new file mode 100644 >> index 000000000000..cb877991cd55 >> --- /dev/null >> +++ b/releasenotes/notes/add-submission-relations-c96bb6c567b416d8.yaml >> @@ -0,0 +1,10 @@ >> +--- >> +features: >> + - | >> + Submissions (cover letters or patches) can now be related to other ones >> + (e.g. revisions). Relations can be set via the REST API by maintainers >> + (currently only for submissions of projects they maintain) >> +api: >> + - | >> + Relations are available via ``/relations/`` and >> + ``/relations/{relationID}/`` endpoints. >
On Mo., 30. Dez. 2019 at 11:41, Mete Polat <metepolat2000@gmail.com> wrote: > Hi Stephen, > > On 27.12.19 18:48, Stephen Finucane wrote: > > On Sat, 2019-12-07 at 17:46 +0100, Mete Polat wrote: > >> View relations and add/update/delete them as a maintainer. Maintainers > >> can only create relations of submissions (patches/cover letters) which > >> are part of a project they maintain. > >> > >> New REST API urls: > >> api/relations/ > >> api/relations/<relation_id>/ > >> > >> Co-authored-by: Daniel Axtens <dja@axtens.net> > >> Signed-off-by: Mete Polat <metepolat2000@gmail.com> > > > > Why did you choose to expose this as a separate API rather than as a > > field on the '/patches' resource? While a 'Series' objet has enough > > separate metadata to warrant a separate '/series' resource, a > > 'SubmissionRelation' object is basically just a container. Including a > > 'related_patches' field on the detailed patch view would seem like more > > than enough detail for me, anyway, and unless there's a reason not to > > do this, I'd like to see it done that way. Is it possible? > > > > The first version of the series supported bulk creating/updating of > relations which was only possible by moving relations into their own url > [1]. As we deciced against bulk operations, I aggree that exposing a > related_patches field is the better choice now. > Mete, Stephen's proposal here is a simple quick refactoring of exposing this API, right? Could we get that change as a quick small v5 patch series for v2.2.0 ready? Lukas > Best regards, > > Mete > > [1] Or allow bulk operations on /api/patch/ in general. > > > Stephen > > > > PS: I could have sworn I had asked this before, but I can't find any > > mails about it so maybe I didn't. Please tell me to RTML (read the > > mailing list) if so > > > >> --- > >> Optimize db queries: > >> I have spent quite a lot of time in optimizing the db queries for the > REST API > >> (thanks for the tip with the Django toolbar). Daniel stated that > >> prefetch_related is possibly hitting the database for every relation > when > >> prefetching submissions but it turns out that we can tell Django to > use a > >> statement like: > >> SELECT * > >> FROM `patchwork_patch` > >> INNER JOIN `patchwork_submission` > >> ON (`patchwork_patch`.`submission_ptr_id` = > `patchwork_submission`.`id`) > >> WHERE `patchwork_patch`.`submission_ptr_id` IN > (LIST_OF_ALL_SUBMISSION_IDS) > >> > >> We do the same for `patchwork_coverletter`. > >> This means we only hit the db two times for casting _all_ > submissions to a > >> patch or cover-letter. > >> > >> Prefetching submissions__project eliminates similar and duplicate > queries > >> that are used to determine whether a logged in user is at least > maintainer > >> of one submission's project. > >> > >> docs/api/schemas/latest/patchwork.yaml | 273 +++++++++++++++++ > >> docs/api/schemas/patchwork.j2 | 285 ++++++++++++++++++ > >> docs/api/schemas/v1.2/patchwork.yaml | 273 +++++++++++++++++ > >> patchwork/api/embedded.py | 39 +++ > >> patchwork/api/index.py | 1 + > >> patchwork/api/relation.py | 121 ++++++++ > >> patchwork/models.py | 6 + > >> patchwork/tests/api/test_relation.py | 181 +++++++++++ > >> patchwork/tests/utils.py | 15 + > >> patchwork/urls.py | 11 + > >> ...submission-relations-c96bb6c567b416d8.yaml | 10 + > >> 11 files changed, 1215 insertions(+) > >> create mode 100644 patchwork/api/relation.py > >> create mode 100644 patchwork/tests/api/test_relation.py > >> create mode 100644 > releasenotes/notes/add-submission-relations-c96bb6c567b416d8.yaml > >> > >> diff --git a/docs/api/schemas/latest/patchwork.yaml > b/docs/api/schemas/latest/patchwork.yaml > >> index a5e235be936d..7dd24fd700d5 100644 > >> --- a/docs/api/schemas/latest/patchwork.yaml > >> +++ b/docs/api/schemas/latest/patchwork.yaml > >> @@ -1039,6 +1039,188 @@ paths: > >> $ref: '#/components/schemas/Error' > >> tags: > >> - series > >> + /api/relations/: > >> + get: > >> + description: List relations. > >> + operationId: relations_list > >> + parameters: > >> + - $ref: '#/components/parameters/Page' > >> + - $ref: '#/components/parameters/PageSize' > >> + - $ref: '#/components/parameters/Order' > >> + responses: > >> + '200': > >> + description: '' > >> + headers: > >> + Link: > >> + $ref: '#/components/headers/Link' > >> + content: > >> + application/json: > >> + schema: > >> + type: array > >> + items: > >> + $ref: '#/components/schemas/Relation' > >> + tags: > >> + - relations > >> + post: > >> + description: Create a relation. > >> + operationId: relations_create > >> + security: > >> + - basicAuth: [] > >> + - apiKeyAuth: [] > >> + requestBody: > >> + $ref: '#/components/requestBodies/Relation' > >> + responses: > >> + '201': > >> + description: '' > >> + content: > >> + application/json: > >> + schema: > >> + $ref: '#/components/schemas/Relation' > >> + '400': > >> + description: Invalid Request > >> + content: > >> + application/json: > >> + schema: > >> + $ref: '#/components/schemas/Error' > >> + '403': > >> + description: Forbidden > >> + content: > >> + application/json: > >> + schema: > >> + $ref: '#/components/schemas/Error' > >> + '404': > >> + description: Not found > >> + content: > >> + application/json: > >> + schema: > >> + $ref: '#/components/schemas/Error' > >> + '409': > >> + description: Conflict > >> + content: > >> + application/json: > >> + schema: > >> + $ref: '#/components/schemas/Error' > >> + tags: > >> + - checks > >> + /api/relations/{id}/: > >> + get: > >> + description: Show a relation. > >> + operationId: relation_read > >> + parameters: > >> + - in: path > >> + name: id > >> + description: A unique integer value identifying this > relation. > >> + required: true > >> + schema: > >> + title: ID > >> + type: integer > >> + responses: > >> + '200': > >> + description: '' > >> + content: > >> + application/json: > >> + schema: > >> + $ref: '#/components/schemas/Relation' > >> + '404': > >> + description: Not found > >> + content: > >> + application/json: > >> + schema: > >> + $ref: '#/components/schemas/Error' > >> + '409': > >> + description: Conflict > >> + content: > >> + application/json: > >> + schema: > >> + $ref: '#/components/schemas/Error' > >> + tags: > >> + - relations > >> + patch: > >> + description: Update a relation (partial). > >> + operationId: relations_partial_update > >> + security: > >> + - basicAuth: [] > >> + - apiKeyAuth: [] > >> + parameters: > >> + - in: path > >> + name: id > >> + description: A unique integer value identifying this > relation. > >> + required: true > >> + schema: > >> + title: ID > >> + type: integer > >> + requestBody: > >> + $ref: '#/components/requestBodies/Relation' > >> + responses: > >> + '200': > >> + description: '' > >> + content: > >> + application/json: > >> + schema: > >> + $ref: '#/components/schemas/Relation' > >> + '400': > >> + description: Bad request > >> + content: > >> + application/json: > >> + schema: > >> + $ref: '#/components/schemas/Error' > >> + '403': > >> + description: Forbidden > >> + content: > >> + application/json: > >> + schema: > >> + $ref: '#/components/schemas/Error' > >> + '404': > >> + description: Not found > >> + content: > >> + application/json: > >> + schema: > >> + $ref: '#/components/schemas/Error' > >> + tags: > >> + - relations > >> + put: > >> + description: Update a relation. > >> + operationId: relations_update > >> + security: > >> + - basicAuth: [] > >> + - apiKeyAuth: [] > >> + parameters: > >> + - in: path > >> + name: id > >> + description: A unique integer value identifying this > relation. > >> + required: true > >> + schema: > >> + title: ID > >> + type: integer > >> + requestBody: > >> + $ref: '#/components/requestBodies/Relation' > >> + responses: > >> + '200': > >> + description: '' > >> + content: > >> + application/json: > >> + schema: > >> + $ref: '#/components/schemas/Relation' > >> + '400': > >> + description: Bad request > >> + content: > >> + application/json: > >> + schema: > >> + $ref: '#/components/schemas/Error' > >> + '403': > >> + description: Forbidden > >> + content: > >> + application/json: > >> + schema: > >> + $ref: '#/components/schemas/Error' > >> + '404': > >> + description: Not found > >> + content: > >> + application/json: > >> + schema: > >> + $ref: '#/components/schemas/Error' > >> + tags: > >> + - relations > >> /api/users/: > >> get: > >> description: List users. > >> @@ -1314,6 +1496,18 @@ components: > >> application/x-www-form-urlencoded: > >> schema: > >> $ref: '#/components/schemas/User' > >> + Relation: > >> + required: true > >> + content: > >> + application/json: > >> + schema: > >> + $ref: '#/components/schemas/RelationUpdate' > >> + multipart/form-data: > >> + schema: > >> + $ref: '#/components/schemas/RelationUpdate' > >> + application/x-www-form-urlencoded: > >> + schema: > >> + $ref: '#/components/schemas/RelationUpdate' > >> schemas: > >> Index: > >> type: object > >> @@ -1358,6 +1552,11 @@ components: > >> type: string > >> format: uri > >> readOnly: true > >> + relations: > >> + title: Relations URL > >> + type: string > >> + format: uri > >> + readOnly: true > >> Bundle: > >> required: > >> - name > >> @@ -1943,6 +2142,14 @@ components: > >> title: Delegate > >> type: integer > >> nullable: true > >> + RelationUpdate: > >> + type: object > >> + properties: > >> + submissions: > >> + title: Submission IDs > >> + type: array > >> + items: > >> + type: integer > >> Person: > >> type: object > >> properties: > >> @@ -2133,6 +2340,30 @@ components: > >> $ref: '#/components/schemas/PatchEmbedded' > >> readOnly: true > >> uniqueItems: true > >> + Relation: > >> + type: object > >> + properties: > >> + id: > >> + title: ID > >> + type: integer > >> + url: > >> + title: URL > >> + type: string > >> + format: uri > >> + readOnly: true > >> + by: > >> + type: object > >> + title: By > >> + readOnly: true > >> + allOf: > >> + - $ref: '#/components/schemas/UserEmbedded' > >> + submissions: > >> + title: Submissions > >> + type: array > >> + items: > >> + $ref: '#/components/schemas/SubmissionEmbedded' > >> + readOnly: true > >> + uniqueItems: true > >> User: > >> type: object > >> properties: > >> @@ -2211,6 +2442,48 @@ components: > >> maxLength: 255 > >> minLength: 1 > >> readOnly: true > >> + SubmissionEmbedded: > >> + type: object > >> + properties: > >> + id: > >> + title: ID > >> + type: integer > >> + readOnly: true > >> + url: > >> + title: URL > >> + type: string > >> + format: uri > >> + readOnly: true > >> + web_url: > >> + title: Web URL > >> + type: string > >> + format: uri > >> + readOnly: true > >> + msgid: > >> + title: Message ID > >> + type: string > >> + readOnly: true > >> + minLength: 1 > >> + list_archive_url: > >> + title: List archive URL > >> + type: string > >> + readOnly: true > >> + nullable: true > >> + date: > >> + title: Date > >> + type: string > >> + format: iso8601 > >> + readOnly: true > >> + name: > >> + title: Name > >> + type: string > >> + readOnly: true > >> + minLength: 1 > >> + mbox: > >> + title: Mbox > >> + type: string > >> + format: uri > >> + readOnly: true > >> CoverLetterEmbedded: > >> type: object > >> properties: > >> diff --git a/docs/api/schemas/patchwork.j2 > b/docs/api/schemas/patchwork.j2 > >> index 196d78466b55..a034029accf9 100644 > >> --- a/docs/api/schemas/patchwork.j2 > >> +++ b/docs/api/schemas/patchwork.j2 > >> @@ -1048,6 +1048,190 @@ paths: > >> $ref: '#/components/schemas/Error' > >> tags: > >> - series > >> +{% if version >= (1, 2) %} > >> + /api/{{ version_url }}relations/: > >> + get: > >> + description: List relations. > >> + operationId: relations_list > >> + parameters: > >> + - $ref: '#/components/parameters/Page' > >> + - $ref: '#/components/parameters/PageSize' > >> + - $ref: '#/components/parameters/Order' > >> + responses: > >> + '200': > >> + description: '' > >> + headers: > >> + Link: > >> + $ref: '#/components/headers/Link' > >> + content: > >> + application/json: > >> + schema: > >> + type: array > >> + items: > >> + $ref: '#/components/schemas/Relation' > >> + tags: > >> + - relations > >> + post: > >> + description: Create a relation. > >> + operationId: relations_create > >> + security: > >> + - basicAuth: [] > >> + - apiKeyAuth: [] > >> + requestBody: > >> + $ref: '#/components/requestBodies/Relation' > >> + responses: > >> + '201': > >> + description: '' > >> + content: > >> + application/json: > >> + schema: > >> + $ref: '#/components/schemas/Relation' > >> + '400': > >> + description: Invalid Request > >> + content: > >> + application/json: > >> + schema: > >> + $ref: '#/components/schemas/Error' > >> + '403': > >> + description: Forbidden > >> + content: > >> + application/json: > >> + schema: > >> + $ref: '#/components/schemas/Error' > >> + '404': > >> + description: Not found > >> + content: > >> + application/json: > >> + schema: > >> + $ref: '#/components/schemas/Error' > >> + '409': > >> + description: Conflict > >> + content: > >> + application/json: > >> + schema: > >> + $ref: '#/components/schemas/Error' > >> + tags: > >> + - checks > >> + /api/{{ version_url }}relations/{id}/: > >> + get: > >> + description: Show a relation. > >> + operationId: relation_read > >> + parameters: > >> + - in: path > >> + name: id > >> + description: A unique integer value identifying this > relation. > >> + required: true > >> + schema: > >> + title: ID > >> + type: integer > >> + responses: > >> + '200': > >> + description: '' > >> + content: > >> + application/json: > >> + schema: > >> + $ref: '#/components/schemas/Relation' > >> + '404': > >> + description: Not found > >> + content: > >> + application/json: > >> + schema: > >> + $ref: '#/components/schemas/Error' > >> + '409': > >> + description: Conflict > >> + content: > >> + application/json: > >> + schema: > >> + $ref: '#/components/schemas/Error' > >> + tags: > >> + - relations > >> + patch: > >> + description: Update a relation (partial). > >> + operationId: relations_partial_update > >> + security: > >> + - basicAuth: [] > >> + - apiKeyAuth: [] > >> + parameters: > >> + - in: path > >> + name: id > >> + description: A unique integer value identifying this > relation. > >> + required: true > >> + schema: > >> + title: ID > >> + type: integer > >> + requestBody: > >> + $ref: '#/components/requestBodies/Relation' > >> + responses: > >> + '200': > >> + description: '' > >> + content: > >> + application/json: > >> + schema: > >> + $ref: '#/components/schemas/Relation' > >> + '400': > >> + description: Bad request > >> + content: > >> + application/json: > >> + schema: > >> + $ref: '#/components/schemas/Error' > >> + '403': > >> + description: Forbidden > >> + content: > >> + application/json: > >> + schema: > >> + $ref: '#/components/schemas/Error' > >> + '404': > >> + description: Not found > >> + content: > >> + application/json: > >> + schema: > >> + $ref: '#/components/schemas/Error' > >> + tags: > >> + - relations > >> + put: > >> + description: Update a relation. > >> + operationId: relations_update > >> + security: > >> + - basicAuth: [] > >> + - apiKeyAuth: [] > >> + parameters: > >> + - in: path > >> + name: id > >> + description: A unique integer value identifying this > relation. > >> + required: true > >> + schema: > >> + title: ID > >> + type: integer > >> + requestBody: > >> + $ref: '#/components/requestBodies/Relation' > >> + responses: > >> + '200': > >> + description: '' > >> + content: > >> + application/json: > >> + schema: > >> + $ref: '#/components/schemas/Relation' > >> + '400': > >> + description: Bad request > >> + content: > >> + application/json: > >> + schema: > >> + $ref: '#/components/schemas/Error' > >> + '403': > >> + description: Forbidden > >> + content: > >> + application/json: > >> + schema: > >> + $ref: '#/components/schemas/Error' > >> + '404': > >> + description: Not found > >> + content: > >> + application/json: > >> + schema: > >> + $ref: '#/components/schemas/Error' > >> + tags: > >> + - relations > >> +{% endif %} > >> /api/{{ version_url }}users/: > >> get: > >> description: List users. > >> @@ -1325,6 +1509,20 @@ components: > >> application/x-www-form-urlencoded: > >> schema: > >> $ref: '#/components/schemas/User' > >> +{% if version >= (1, 2) %} > >> + Relation: > >> + required: true > >> + content: > >> + application/json: > >> + schema: > >> + $ref: '#/components/schemas/RelationUpdate' > >> + multipart/form-data: > >> + schema: > >> + $ref: '#/components/schemas/RelationUpdate' > >> + application/x-www-form-urlencoded: > >> + schema: > >> + $ref: '#/components/schemas/RelationUpdate' > >> +{% endif %} > >> schemas: > >> Index: > >> type: object > >> @@ -1369,6 +1567,13 @@ components: > >> type: string > >> format: uri > >> readOnly: true > >> +{% if version >= (1, 2) %} > >> + relations: > >> + title: Relations URL > >> + type: string > >> + format: uri > >> + readOnly: true > >> +{% endif %} > >> Bundle: > >> required: > >> - name > >> @@ -1981,6 +2186,16 @@ components: > >> title: Delegate > >> type: integer > >> nullable: true > >> +{% if version >= (1, 2) %} > >> + RelationUpdate: > >> + type: object > >> + properties: > >> + submissions: > >> + title: Submission IDs > >> + type: array > >> + items: > >> + type: integer > >> +{% endif %} > >> Person: > >> type: object > >> properties: > >> @@ -2177,6 +2392,32 @@ components: > >> $ref: '#/components/schemas/PatchEmbedded' > >> readOnly: true > >> uniqueItems: true > >> +{% if version >= (1, 2) %} > >> + Relation: > >> + type: object > >> + properties: > >> + id: > >> + title: ID > >> + type: integer > >> + url: > >> + title: URL > >> + type: string > >> + format: uri > >> + readOnly: true > >> + by: > >> + type: object > >> + title: By > >> + readOnly: true > >> + allOf: > >> + - $ref: '#/components/schemas/UserEmbedded' > >> + submissions: > >> + title: Submissions > >> + type: array > >> + items: > >> + $ref: '#/components/schemas/SubmissionEmbedded' > >> + readOnly: true > >> + uniqueItems: true > >> +{% endif %} > >> User: > >> type: object > >> properties: > >> @@ -2255,6 +2496,50 @@ components: > >> maxLength: 255 > >> minLength: 1 > >> readOnly: true > >> +{% if version >= (1, 2) %} > >> + SubmissionEmbedded: > >> + type: object > >> + properties: > >> + id: > >> + title: ID > >> + type: integer > >> + readOnly: true > >> + url: > >> + title: URL > >> + type: string > >> + format: uri > >> + readOnly: true > >> + web_url: > >> + title: Web URL > >> + type: string > >> + format: uri > >> + readOnly: true > >> + msgid: > >> + title: Message ID > >> + type: string > >> + readOnly: true > >> + minLength: 1 > >> + list_archive_url: > >> + title: List archive URL > >> + type: string > >> + readOnly: true > >> + nullable: true > >> + date: > >> + title: Date > >> + type: string > >> + format: iso8601 > >> + readOnly: true > >> + name: > >> + title: Name > >> + type: string > >> + readOnly: true > >> + minLength: 1 > >> + mbox: > >> + title: Mbox > >> + type: string > >> + format: uri > >> + readOnly: true > >> +{% endif %} > >> CoverLetterEmbedded: > >> type: object > >> properties: > >> diff --git a/docs/api/schemas/v1.2/patchwork.yaml > b/docs/api/schemas/v1.2/patchwork.yaml > >> index d7b4d2957cff..99425e968881 100644 > >> --- a/docs/api/schemas/v1.2/patchwork.yaml > >> +++ b/docs/api/schemas/v1.2/patchwork.yaml > >> @@ -1039,6 +1039,188 @@ paths: > >> $ref: '#/components/schemas/Error' > >> tags: > >> - series > >> + /api/1.2/relations/: > >> + get: > >> + description: List relations. > >> + operationId: relations_list > >> + parameters: > >> + - $ref: '#/components/parameters/Page' > >> + - $ref: '#/components/parameters/PageSize' > >> + - $ref: '#/components/parameters/Order' > >> + responses: > >> + '200': > >> + description: '' > >> + headers: > >> + Link: > >> + $ref: '#/components/headers/Link' > >> + content: > >> + application/json: > >> + schema: > >> + type: array > >> + items: > >> + $ref: '#/components/schemas/Relation' > >> + tags: > >> + - relations > >> + post: > >> + description: Create a relation. > >> + operationId: relations_create > >> + security: > >> + - basicAuth: [] > >> + - apiKeyAuth: [] > >> + requestBody: > >> + $ref: '#/components/requestBodies/Relation' > >> + responses: > >> + '201': > >> + description: '' > >> + content: > >> + application/json: > >> + schema: > >> + $ref: '#/components/schemas/Relation' > >> + '400': > >> + description: Invalid Request > >> + content: > >> + application/json: > >> + schema: > >> + $ref: '#/components/schemas/Error' > >> + '403': > >> + description: Forbidden > >> + content: > >> + application/json: > >> + schema: > >> + $ref: '#/components/schemas/Error' > >> + '404': > >> + description: Not found > >> + content: > >> + application/json: > >> + schema: > >> + $ref: '#/components/schemas/Error' > >> + '409': > >> + description: Conflict > >> + content: > >> + application/json: > >> + schema: > >> + $ref: '#/components/schemas/Error' > >> + tags: > >> + - checks > >> + /api/1.2/relations/{id}/: > >> + get: > >> + description: Show a relation. > >> + operationId: relation_read > >> + parameters: > >> + - in: path > >> + name: id > >> + description: A unique integer value identifying this > relation. > >> + required: true > >> + schema: > >> + title: ID > >> + type: integer > >> + responses: > >> + '200': > >> + description: '' > >> + content: > >> + application/json: > >> + schema: > >> + $ref: '#/components/schemas/Relation' > >> + '404': > >> + description: Not found > >> + content: > >> + application/json: > >> + schema: > >> + $ref: '#/components/schemas/Error' > >> + '409': > >> + description: Conflict > >> + content: > >> + application/json: > >> + schema: > >> + $ref: '#/components/schemas/Error' > >> + tags: > >> + - relations > >> + patch: > >> + description: Update a relation (partial). > >> + operationId: relations_partial_update > >> + security: > >> + - basicAuth: [] > >> + - apiKeyAuth: [] > >> + parameters: > >> + - in: path > >> + name: id > >> + description: A unique integer value identifying this > relation. > >> + required: true > >> + schema: > >> + title: ID > >> + type: integer > >> + requestBody: > >> + $ref: '#/components/requestBodies/Relation' > >> + responses: > >> + '200': > >> + description: '' > >> + content: > >> + application/json: > >> + schema: > >> + $ref: '#/components/schemas/Relation' > >> + '400': > >> + description: Bad request > >> + content: > >> + application/json: > >> + schema: > >> + $ref: '#/components/schemas/Error' > >> + '403': > >> + description: Forbidden > >> + content: > >> + application/json: > >> + schema: > >> + $ref: '#/components/schemas/Error' > >> + '404': > >> + description: Not found > >> + content: > >> + application/json: > >> + schema: > >> + $ref: '#/components/schemas/Error' > >> + tags: > >> + - relations > >> + put: > >> + description: Update a relation. > >> + operationId: relations_update > >> + security: > >> + - basicAuth: [] > >> + - apiKeyAuth: [] > >> + parameters: > >> + - in: path > >> + name: id > >> + description: A unique integer value identifying this > relation. > >> + required: true > >> + schema: > >> + title: ID > >> + type: integer > >> + requestBody: > >> + $ref: '#/components/requestBodies/Relation' > >> + responses: > >> + '200': > >> + description: '' > >> + content: > >> + application/json: > >> + schema: > >> + $ref: '#/components/schemas/Relation' > >> + '400': > >> + description: Bad request > >> + content: > >> + application/json: > >> + schema: > >> + $ref: '#/components/schemas/Error' > >> + '403': > >> + description: Forbidden > >> + content: > >> + application/json: > >> + schema: > >> + $ref: '#/components/schemas/Error' > >> + '404': > >> + description: Not found > >> + content: > >> + application/json: > >> + schema: > >> + $ref: '#/components/schemas/Error' > >> + tags: > >> + - relations > >> /api/1.2/users/: > >> get: > >> description: List users. > >> @@ -1314,6 +1496,18 @@ components: > >> application/x-www-form-urlencoded: > >> schema: > >> $ref: '#/components/schemas/User' > >> + Relation: > >> + required: true > >> + content: > >> + application/json: > >> + schema: > >> + $ref: '#/components/schemas/RelationUpdate' > >> + multipart/form-data: > >> + schema: > >> + $ref: '#/components/schemas/RelationUpdate' > >> + application/x-www-form-urlencoded: > >> + schema: > >> + $ref: '#/components/schemas/RelationUpdate' > >> schemas: > >> Index: > >> type: object > >> @@ -1358,6 +1552,11 @@ components: > >> type: string > >> format: uri > >> readOnly: true > >> + relations: > >> + title: Relations URL > >> + type: string > >> + format: uri > >> + readOnly: true > >> Bundle: > >> required: > >> - name > >> @@ -1943,6 +2142,14 @@ components: > >> title: Delegate > >> type: integer > >> nullable: true > >> + RelationUpdate: > >> + type: object > >> + properties: > >> + submissions: > >> + title: Submission IDs > >> + type: array > >> + items: > >> + type: integer > >> Person: > >> type: object > >> properties: > >> @@ -2133,6 +2340,30 @@ components: > >> $ref: '#/components/schemas/PatchEmbedded' > >> readOnly: true > >> uniqueItems: true > >> + Relation: > >> + type: object > >> + properties: > >> + id: > >> + title: ID > >> + type: integer > >> + url: > >> + title: URL > >> + type: string > >> + format: uri > >> + readOnly: true > >> + by: > >> + type: object > >> + title: By > >> + readOnly: true > >> + allOf: > >> + - $ref: '#/components/schemas/UserEmbedded' > >> + submissions: > >> + title: Submissions > >> + type: array > >> + items: > >> + $ref: '#/components/schemas/SubmissionEmbedded' > >> + readOnly: true > >> + uniqueItems: true > >> User: > >> type: object > >> properties: > >> @@ -2211,6 +2442,48 @@ components: > >> maxLength: 255 > >> minLength: 1 > >> readOnly: true > >> + SubmissionEmbedded: > >> + type: object > >> + properties: > >> + id: > >> + title: ID > >> + type: integer > >> + readOnly: true > >> + url: > >> + title: URL > >> + type: string > >> + format: uri > >> + readOnly: true > >> + web_url: > >> + title: Web URL > >> + type: string > >> + format: uri > >> + readOnly: true > >> + msgid: > >> + title: Message ID > >> + type: string > >> + readOnly: true > >> + minLength: 1 > >> + list_archive_url: > >> + title: List archive URL > >> + type: string > >> + readOnly: true > >> + nullable: true > >> + date: > >> + title: Date > >> + type: string > >> + format: iso8601 > >> + readOnly: true > >> + name: > >> + title: Name > >> + type: string > >> + readOnly: true > >> + minLength: 1 > >> + mbox: > >> + title: Mbox > >> + type: string > >> + format: uri > >> + readOnly: true > >> CoverLetterEmbedded: > >> type: object > >> properties: > >> diff --git a/patchwork/api/embedded.py b/patchwork/api/embedded.py > >> index de4f31165ee7..0fba291b62b8 100644 > >> --- a/patchwork/api/embedded.py > >> +++ b/patchwork/api/embedded.py > >> @@ -102,6 +102,45 @@ class CheckSerializer(SerializedRelatedField): > >> } > >> > >> > >> +def _upgrade_instance(instance): > >> + if hasattr(instance, 'patch'): > >> + return instance.patch > >> + else: > >> + return instance.coverletter > >> + > >> + > >> +class SubmissionSerializer(SerializedRelatedField): > >> + > >> + class _Serializer(BaseHyperlinkedModelSerializer): > >> + """We need to 'upgrade' or specialise the submission to the > relevant > >> + subclass, so we can't use the mixins. This is gross but can go > away > >> + once we flatten the models.""" > >> + url = SerializerMethodField() > >> + web_url = SerializerMethodField() > >> + mbox = SerializerMethodField() > >> + > >> + def get_url(self, instance): > >> + instance = _upgrade_instance(instance) > >> + request = self.context.get('request') > >> + return > request.build_absolute_uri(instance.get_absolute_api_url()) > >> + > >> + def get_web_url(self, instance): > >> + instance = _upgrade_instance(instance) > >> + request = self.context.get('request') > >> + return > request.build_absolute_uri(instance.get_absolute_url()) > >> + > >> + def get_mbox(self, instance): > >> + instance = _upgrade_instance(instance) > >> + request = self.context.get('request') > >> + return request.build_absolute_uri(instance.get_mbox_url()) > >> + > >> + class Meta: > >> + model = models.Submission > >> + fields = ('id', 'url', 'web_url', 'msgid', > 'list_archive_url', > >> + 'date', 'name', 'mbox') > >> + read_only_fields = fields > >> + > >> + > >> class CoverLetterSerializer(SerializedRelatedField): > >> > >> class _Serializer(MboxMixin, WebURLMixin, > BaseHyperlinkedModelSerializer): > >> diff --git a/patchwork/api/index.py b/patchwork/api/index.py > >> index 45485c9106f6..cf1845393835 100644 > >> --- a/patchwork/api/index.py > >> +++ b/patchwork/api/index.py > >> @@ -21,4 +21,5 @@ class IndexView(APIView): > >> 'series': reverse('api-series-list', request=request), > >> 'events': reverse('api-event-list', request=request), > >> 'bundles': reverse('api-bundle-list', request=request), > >> + 'relations': reverse('api-relation-list', request=request), > >> }) > >> diff --git a/patchwork/api/relation.py b/patchwork/api/relation.py > >> new file mode 100644 > >> index 000000000000..37640d62e9cc > >> --- /dev/null > >> +++ b/patchwork/api/relation.py > >> @@ -0,0 +1,121 @@ > >> +# Patchwork - automated patch tracking system > >> +# Copyright (C) 2019, Bayerische Motoren Werke Aktiengesellschaft (BMW > AG) > >> +# > >> +# SPDX-License-Identifier: GPL-2.0-or-later > >> + > >> +from rest_framework import permissions > >> +from rest_framework import status > >> +from rest_framework.exceptions import PermissionDenied, APIException > >> +from rest_framework.generics import GenericAPIView > >> +from rest_framework.generics import ListCreateAPIView > >> +from rest_framework.generics import RetrieveUpdateDestroyAPIView > >> +from rest_framework.serializers import ModelSerializer > >> + > >> +from patchwork.api.base import PatchworkPermission > >> +from patchwork.api.embedded import SubmissionSerializer > >> +from patchwork.api.embedded import UserSerializer > >> +from patchwork.models import SubmissionRelation > >> + > >> + > >> +class MaintainerPermission(PatchworkPermission): > >> + > >> + def has_permission(self, request, view): > >> + if request.method in permissions.SAFE_METHODS: > >> + return True > >> + > >> + # Prevent showing an HTML POST form in the browseable API for > logged in > >> + # users who are not maintainers. > >> + return len(request.user.maintains) > 0 > >> + > >> + def has_object_permission(self, request, view, relation): > >> + if request.method in permissions.SAFE_METHODS: > >> + return True > >> + > >> + maintains = request.user.maintains > >> + submissions = relation.submissions.all() > >> + # user has to be maintainer of every project a submission is > part of > >> + return self.check_user_maintains_all(maintains, submissions) > >> + > >> + @staticmethod > >> + def check_user_maintains_all(maintains, submissions): > >> + if any(s.project not in maintains for s in submissions): > >> + detail = 'At least one submission is part of a project you > are ' \ > >> + 'not maintaining.' > >> + raise PermissionDenied(detail=detail) > >> + return True > >> + > >> + > >> +class SubmissionConflict(APIException): > >> + status_code = status.HTTP_409_CONFLICT > >> + default_detail = 'At least one submission is already part of > another ' \ > >> + 'relation. You have to explicitly remove a > submission ' \ > >> + 'from its existing relation before moving it to > this one.' > >> + > >> + > >> +class SubmissionRelationSerializer(ModelSerializer): > >> + by = UserSerializer(read_only=True) > >> + submissions = SubmissionSerializer(many=True) > >> + > >> + def create(self, validated_data): > >> + submissions = validated_data['submissions'] > >> + if any(submission.related_id is not None > >> + for submission in submissions): > >> + raise SubmissionConflict() > >> + return super(SubmissionRelationSerializer, > self).create(validated_data) > >> + > >> + def update(self, instance, validated_data): > >> + submissions = validated_data['submissions'] > >> + if any(submission.related_id is not None and > >> + submission.related_id != instance.id > >> + for submission in submissions): > >> + raise SubmissionConflict() > >> + return super(SubmissionRelationSerializer, self) \ > >> + .update(instance, validated_data) > >> + > >> + class Meta: > >> + model = SubmissionRelation > >> + fields = ('id', 'url', 'by', 'submissions',) > >> + read_only_fields = ('url', 'by', ) > >> + extra_kwargs = { > >> + 'url': {'view_name': 'api-relation-detail'}, > >> + } > >> + > >> + > >> +class SubmissionRelationMixin(GenericAPIView): > >> + serializer_class = SubmissionRelationSerializer > >> + permission_classes = (MaintainerPermission,) > >> + > >> + def initial(self, request, *args, **kwargs): > >> + user = request.user > >> + if not hasattr(user, 'maintains'): > >> + if user.is_authenticated: > >> + user.maintains = user.profile.maintainer_projects.all() > >> + else: > >> + user.maintains = [] > >> + super(SubmissionRelationMixin, self).initial(request, *args, > **kwargs) > >> + > >> + def get_queryset(self): > >> + return SubmissionRelation.objects.all() \ > >> + .select_related('by') \ > >> + .prefetch_related('submissions__patch', > >> + 'submissions__coverletter', > >> + 'submissions__project') > >> + > >> + > >> +class SubmissionRelationList(SubmissionRelationMixin, > ListCreateAPIView): > >> + ordering = 'id' > >> + ordering_fields = ['id'] > >> + > >> + def perform_create(self, serializer): > >> + # has_object_permission() is not called when creating a new > relation. > >> + # Check whether user is maintainer of every project a > submission is > >> + # part of > >> + maintains = self.request.user.maintains > >> + submissions = serializer.validated_data['submissions'] > >> + MaintainerPermission.check_user_maintains_all(maintains, > submissions) > >> + serializer.save(by=self.request.user) > >> + > >> + > >> +class SubmissionRelationDetail(SubmissionRelationMixin, > >> + RetrieveUpdateDestroyAPIView): > >> + pass > >> diff --git a/patchwork/models.py b/patchwork/models.py > >> index a92203b24ff2..9ae3370e896b 100644 > >> --- a/patchwork/models.py > >> +++ b/patchwork/models.py > >> @@ -415,6 +415,9 @@ class CoverLetter(Submission): > >> kwargs={'project_id': self.project.linkname, > >> 'msgid': self.url_msgid}) > >> > >> + def get_absolute_api_url(self): > >> + return reverse('api-cover-detail', kwargs={'pk': self.id}) > >> + > >> def get_mbox_url(self): > >> return reverse('cover-mbox', > >> kwargs={'project_id': self.project.linkname, > >> @@ -604,6 +607,9 @@ class Patch(Submission): > >> kwargs={'project_id': self.project.linkname, > >> 'msgid': self.url_msgid}) > >> > >> + def get_absolute_api_url(self): > >> + return reverse('api-patch-detail', kwargs={'pk': self.id}) > >> + > >> def get_mbox_url(self): > >> return reverse('patch-mbox', > >> kwargs={'project_id': self.project.linkname, > >> diff --git a/patchwork/tests/api/test_relation.py > b/patchwork/tests/api/test_relation.py > >> new file mode 100644 > >> index 000000000000..5b1a04f13670 > >> --- /dev/null > >> +++ b/patchwork/tests/api/test_relation.py > >> @@ -0,0 +1,181 @@ > >> +# Patchwork - automated patch tracking system > >> +# Copyright (C) 2019, Bayerische Motoren Werke Aktiengesellschaft (BMW > AG) > >> +# > >> +# SPDX-License-Identifier: GPL-2.0-or-later > >> + > >> +import unittest > >> + > >> +import six > >> +from django.conf import settings > >> +from django.urls import reverse > >> + > >> +from patchwork.tests.api import utils > >> +from patchwork.tests.utils import create_cover > >> +from patchwork.tests.utils import create_maintainer > >> +from patchwork.tests.utils import create_patches > >> +from patchwork.tests.utils import create_project > >> +from patchwork.tests.utils import create_relation > >> +from patchwork.tests.utils import create_user > >> + > >> +if settings.ENABLE_REST_API: > >> + from rest_framework import status > >> + > >> + > >> +class UserType: > >> + ANONYMOUS = 1 > >> + NON_MAINTAINER = 2 > >> + MAINTAINER = 3 > >> + > >> + > >> +@unittest.skipUnless(settings.ENABLE_REST_API, 'requires > ENABLE_REST_API') > >> +class TestRelationAPI(utils.APITestCase): > >> + fixtures = ['default_tags'] > >> + > >> + @staticmethod > >> + def api_url(item=None): > >> + kwargs = {} > >> + if item is None: > >> + return reverse('api-relation-list', kwargs=kwargs) > >> + kwargs['pk'] = item > >> + return reverse('api-relation-detail', kwargs=kwargs) > >> + > >> + def request_restricted(self, method, user_type): > >> + """Assert post/delete/patch requests on the relation API.""" > >> + assert method in ['post', 'delete', 'patch'] > >> + > >> + # setup > >> + > >> + project = create_project() > >> + maintainer = create_maintainer(project) > >> + > >> + if user_type == UserType.ANONYMOUS: > >> + expected_status = status.HTTP_403_FORBIDDEN > >> + elif user_type == UserType.NON_MAINTAINER: > >> + expected_status = status.HTTP_403_FORBIDDEN > >> + self.client.force_authenticate(user=create_user()) > >> + elif user_type == UserType.MAINTAINER: > >> + if method == 'post': > >> + expected_status = status.HTTP_201_CREATED > >> + elif method == 'delete': > >> + expected_status = status.HTTP_204_NO_CONTENT > >> + else: > >> + expected_status = status.HTTP_200_OK > >> + self.client.force_authenticate(user=maintainer) > >> + else: > >> + raise ValueError > >> + > >> + resource_id = None > >> + req = None > >> + > >> + if method == 'delete': > >> + resource_id = create_relation(project=project, > by=maintainer).id > >> + elif method == 'post': > >> + patch_ids = [p.id for p in create_patches(2, > project=project)] > >> + req = {'submissions': patch_ids} > >> + elif method == 'patch': > >> + resource_id = create_relation(project=project, > by=maintainer).id > >> + patch_ids = [p.id for p in create_patches(2, > project=project)] > >> + req = {'submissions': patch_ids} > >> + else: > >> + raise ValueError > >> + > >> + # request > >> + > >> + resp = getattr(self.client, method)(self.api_url(resource_id), > req) > >> + > >> + # check > >> + > >> + self.assertEqual(expected_status, resp.status_code) > >> + > >> + if resp.status_code in range(status.HTTP_200_OK, > >> + status.HTTP_204_NO_CONTENT): > >> + self.assertRequest(req, resp.data) > >> + > >> + def assertRequest(self, request, resp): > >> + if request.get('id'): > >> + self.assertEqual(request['id'], resp['id']) > >> + send_ids = request['submissions'] > >> + resp_ids = [s['id'] for s in resp['submissions']] > >> + six.assertCountEqual(self, resp_ids, send_ids) > >> + > >> + def assertSerialized(self, obj, resp): > >> + self.assertEqual(obj.id, resp['id']) > >> + exp_ids = [s.id for s in obj.submissions.all()] > >> + act_ids = [s['id'] for s in resp['submissions']] > >> + six.assertCountEqual(self, exp_ids, act_ids) > >> + > >> + def test_list_empty(self): > >> + """List relation when none are present.""" > >> + resp = self.client.get(self.api_url()) > >> + self.assertEqual(status.HTTP_200_OK, resp.status_code) > >> + self.assertEqual(0, len(resp.data)) > >> + > >> + @utils.store_samples('relation-list') > >> + def test_list(self): > >> + """List relations.""" > >> + relation = create_relation() > >> + > >> + resp = self.client.get(self.api_url()) > >> + self.assertEqual(status.HTTP_200_OK, resp.status_code) > >> + self.assertEqual(1, len(resp.data)) > >> + self.assertSerialized(relation, resp.data[0]) > >> + > >> + def test_detail(self): > >> + """Show relation.""" > >> + relation = create_relation() > >> + > >> + resp = self.client.get(self.api_url(relation.id)) > >> + self.assertEqual(status.HTTP_200_OK, resp.status_code) > >> + self.assertSerialized(relation, resp.data) > >> + > >> + @utils.store_samples('relation-create-error-forbidden') > >> + def test_create_anonymous(self): > >> + self.request_restricted('post', UserType.ANONYMOUS) > >> + > >> + def test_create_non_maintainer(self): > >> + self.request_restricted('post', UserType.NON_MAINTAINER) > >> + > >> + @utils.store_samples('relation-create') > >> + def test_create_maintainer(self): > >> + self.request_restricted('post', UserType.MAINTAINER) > >> + > >> + @utils.store_samples('relation-update-error-forbidden') > >> + def test_update_anonymous(self): > >> + self.request_restricted('patch', UserType.ANONYMOUS) > >> + > >> + def test_update_non_maintainer(self): > >> + self.request_restricted('patch', UserType.NON_MAINTAINER) > >> + > >> + @utils.store_samples('relation-update') > >> + def test_update_maintainer(self): > >> + self.request_restricted('patch', UserType.MAINTAINER) > >> + > >> + @utils.store_samples('relation-delete-error-forbidden') > >> + def test_delete_anonymous(self): > >> + self.request_restricted('delete', UserType.ANONYMOUS) > >> + > >> + def test_delete_non_maintainer(self): > >> + self.request_restricted('delete', UserType.NON_MAINTAINER) > >> + > >> + @utils.store_samples('relation-update') > >> + def test_delete_maintainer(self): > >> + self.request_restricted('delete', UserType.MAINTAINER) > >> + > >> + def test_submission_conflict(self): > >> + project = create_project() > >> + maintainer = create_maintainer(project) > >> + self.client.force_authenticate(user=maintainer) > >> + relation = create_relation(by=maintainer, project=project) > >> + submission_ids = [s.id for s in relation.submissions.all()] > >> + > >> + # try to create a new relation with a new submission (cover) > and > >> + # submissions already bound to another relation > >> + cover = create_cover(project=project) > >> + submission_ids.append(cover.id) > >> + req = {'submissions': submission_ids} > >> + resp = self.client.post(self.api_url(), req) > >> + self.assertEqual(status.HTTP_409_CONFLICT, resp.status_code) > >> + > >> + # try to patch relation > >> + resp = self.client.patch(self.api_url(relation.id), req) > >> + self.assertEqual(status.HTTP_200_OK, resp.status_code) > >> diff --git a/patchwork/tests/utils.py b/patchwork/tests/utils.py > >> index 577183d0986c..ffe90976233e 100644 > >> --- a/patchwork/tests/utils.py > >> +++ b/patchwork/tests/utils.py > >> @@ -16,6 +16,7 @@ from patchwork.models import Check > >> from patchwork.models import Comment > >> from patchwork.models import CoverLetter > >> from patchwork.models import Patch > >> +from patchwork.models import SubmissionRelation > >> from patchwork.models import Person > >> from patchwork.models import Project > >> from patchwork.models import Series > >> @@ -347,3 +348,17 @@ def create_covers(count=1, **kwargs): > >> kwargs (dict): Overrides for various cover letter fields > >> """ > >> return _create_submissions(create_cover, count, **kwargs) > >> + > >> + > >> +def create_relation(count_patches=2, by=None, **kwargs): > >> + if not by: > >> + project = create_project() > >> + kwargs['project'] = project > >> + by = create_maintainer(project) > >> + relation = SubmissionRelation.objects.create(by=by) > >> + values = { > >> + 'related': relation > >> + } > >> + values.update(kwargs) > >> + create_patches(count_patches, **values) > >> + return relation > >> diff --git a/patchwork/urls.py b/patchwork/urls.py > >> index dcdcfb49e67e..92095f62c7b9 100644 > >> --- a/patchwork/urls.py > >> +++ b/patchwork/urls.py > >> @@ -187,6 +187,7 @@ if settings.ENABLE_REST_API: > >> from patchwork.api import patch as api_patch_views # noqa > >> from patchwork.api import person as api_person_views # noqa > >> from patchwork.api import project as api_project_views # noqa > >> + from patchwork.api import relation as api_relation_views # noqa > >> from patchwork.api import series as api_series_views # noqa > >> from patchwork.api import user as api_user_views # noqa > >> > >> @@ -256,9 +257,19 @@ if settings.ENABLE_REST_API: > >> name='api-cover-comment-list'), > >> ] > >> > >> +
On 30.12.19 21:28, Lukas Bulwahn wrote: > On Mo., 30. Dez. 2019 at 11:41, Mete Polat <metepolat2000@gmail.com> wrote: > >> Hi Stephen, >> >> On 27.12.19 18:48, Stephen Finucane wrote: >>> On Sat, 2019-12-07 at 17:46 +0100, Mete Polat wrote: >>>> View relations and add/update/delete them as a maintainer. Maintainers >>>> can only create relations of submissions (patches/cover letters) which >>>> are part of a project they maintain. >>>> >>>> New REST API urls: >>>> api/relations/ >>>> api/relations/<relation_id>/ >>>> >>>> Co-authored-by: Daniel Axtens <dja@axtens.net> >>>> Signed-off-by: Mete Polat <metepolat2000@gmail.com> >>> >>> Why did you choose to expose this as a separate API rather than as a >>> field on the '/patches' resource? While a 'Series' objet has enough >>> separate metadata to warrant a separate '/series' resource, a >>> 'SubmissionRelation' object is basically just a container. Including a >>> 'related_patches' field on the detailed patch view would seem like more >>> than enough detail for me, anyway, and unless there's a reason not to >>> do this, I'd like to see it done that way. Is it possible? >>> >> >> The first version of the series supported bulk creating/updating of >> relations which was only possible by moving relations into their own url >> [1]. As we deciced against bulk operations, I aggree that exposing a >> related_patches field is the better choice now. >> > > > Mete, Stephen's proposal here is a simple quick refactoring of exposing > this API, right? > Could we get that change as a quick small v5 patch series for v2.2.0 ready? > It's a small refractroring on the model site (patch 02/04) but not really on the REST API. The Event API has to be extended and the tests + permission model have to be adapted again as well. Unfortunately I won't be available for this Lukas. Best regards, Mete > Lukas > > >> Best regards, >> >> Mete >> >> [1] Or allow bulk operations on /api/patch/ in general. >> >>> Stephen >>> >>> PS: I could have sworn I had asked this before, but I can't find any >>> mails about it so maybe I didn't. Please tell me to RTML (read the >>> mailing list) if so >>> >>>> --- >>>> Optimize db queries: >>>> I have spent quite a lot of time in optimizing the db queries for the >> REST API >>>> (thanks for the tip with the Django toolbar). Daniel stated that >>>> prefetch_related is possibly hitting the database for every relation >> when >>>> prefetching submissions but it turns out that we can tell Django to >> use a >>>> statement like: >>>> SELECT * >>>> FROM `patchwork_patch` >>>> INNER JOIN `patchwork_submission` >>>> ON (`patchwork_patch`.`submission_ptr_id` = >> `patchwork_submission`.`id`) >>>> WHERE `patchwork_patch`.`submission_ptr_id` IN >> (LIST_OF_ALL_SUBMISSION_IDS) >>>> >>>> We do the same for `patchwork_coverletter`. >>>> This means we only hit the db two times for casting _all_ >> submissions to a >>>> patch or cover-letter. >>>> >>>> Prefetching submissions__project eliminates similar and duplicate >> queries >>>> that are used to determine whether a logged in user is at least >> maintainer >>>> of one submission's project. >>>> >>>> docs/api/schemas/latest/patchwork.yaml | 273 +++++++++++++++++ >>>> docs/api/schemas/patchwork.j2 | 285 ++++++++++++++++++ >>>> docs/api/schemas/v1.2/patchwork.yaml | 273 +++++++++++++++++ >>>> patchwork/api/embedded.py | 39 +++ >>>> patchwork/api/index.py | 1 + >>>> patchwork/api/relation.py | 121 ++++++++ >>>> patchwork/models.py | 6 + >>>> patchwork/tests/api/test_relation.py | 181 +++++++++++ >>>> patchwork/tests/utils.py | 15 + >>>> patchwork/urls.py | 11 + >>>> ...submission-relations-c96bb6c567b416d8.yaml | 10 + >>>> 11 files changed, 1215 insertions(+) >>>> create mode 100644 patchwork/api/relation.py >>>> create mode 100644 patchwork/tests/api/test_relation.py >>>> create mode 100644 >> releasenotes/notes/add-submission-relations-c96bb6c567b416d8.yaml >>>> >>>> diff --git a/docs/api/schemas/latest/patchwork.yaml >> b/docs/api/schemas/latest/patchwork.yaml >>>> index a5e235be936d..7dd24fd700d5 100644 >>>> --- a/docs/api/schemas/latest/patchwork.yaml >>>> +++ b/docs/api/schemas/latest/patchwork.yaml >>>> @@ -1039,6 +1039,188 @@ paths: >>>> $ref: '#/components/schemas/Error' >>>> tags: >>>> - series >>>> + /api/relations/: >>>> + get: >>>> + description: List relations. >>>> + operationId: relations_list >>>> + parameters: >>>> + - $ref: '#/components/parameters/Page' >>>> + - $ref: '#/components/parameters/PageSize' >>>> + - $ref: '#/components/parameters/Order' >>>> + responses: >>>> + '200': >>>> + description: '' >>>> + headers: >>>> + Link: >>>> + $ref: '#/components/headers/Link' >>>> + content: >>>> + application/json: >>>> + schema: >>>> + type: array >>>> + items: >>>> + $ref: '#/components/schemas/Relation' >>>> + tags: >>>> + - relations >>>> + post: >>>> + description: Create a relation. >>>> + operationId: relations_create >>>> + security: >>>> + - basicAuth: [] >>>> + - apiKeyAuth: [] >>>> + requestBody: >>>> + $ref: '#/components/requestBodies/Relation' >>>> + responses: >>>> + '201': >>>> + description: '' >>>> + content: >>>> + application/json: >>>> + schema: >>>> + $ref: '#/components/schemas/Relation' >>>> + '400': >>>> + description: Invalid Request >>>> + content: >>>> + application/json: >>>> + schema: >>>> + $ref: '#/components/schemas/Error' >>>> + '403': >>>> + description: Forbidden >>>> + content: >>>> + application/json: >>>> + schema: >>>> + $ref: '#/components/schemas/Error' >>>> + '404': >>>> + description: Not found >>>> + content: >>>> + application/json: >>>> + schema: >>>> + $ref: '#/components/schemas/Error' >>>> + '409': >>>> + description: Conflict >>>> + content: >>>> + application/json: >>>> + schema: >>>> + $ref: '#/components/schemas/Error' >>>> + tags: >>>> + - checks >>>> + /api/relations/{id}/: >>>> + get: >>>> + description: Show a relation. >>>> + operationId: relation_read >>>> + parameters: >>>> + - in: path >>>> + name: id >>>> + description: A unique integer value identifying this >> relation. >>>> + required: true >>>> + schema: >>>> + title: ID >>>> + type: integer >>>> + responses: >>>> + '200': >>>> + description: '' >>>> + content: >>>> + application/json: >>>> + schema: >>>> + $ref: '#/components/schemas/Relation' >>>> + '404': >>>> + description: Not found >>>> + content: >>>> + application/json: >>>> + schema: >>>> + $ref: '#/components/schemas/Error' >>>> + '409': >>>> + description: Conflict >>>> + content: >>>> + application/json: >>>> + schema: >>>> + $ref: '#/components/schemas/Error' >>>> + tags: >>>> + - relations >>>> + patch: >>>> + description: Update a relation (partial). >>>> + operationId: relations_partial_update >>>> + security: >>>> + - basicAuth: [] >>>> + - apiKeyAuth: [] >>>> + parameters: >>>> + - in: path >>>> + name: id >>>> + description: A unique integer value identifying this >> relation. >>>> + required: true >>>> + schema: >>>> + title: ID >>>> + type: integer >>>> + requestBody: >>>> + $ref: '#/components/requestBodies/Relation' >>>> + responses: >>>> + '200': >>>> + description: '' >>>> + content: >>>> + application/json: >>>> + schema: >>>> + $ref: '#/components/schemas/Relation' >>>> + '400': >>>> + description: Bad request >>>> + content: >>>> + application/json: >>>> + schema: >>>> + $ref: '#/components/schemas/Error' >>>> + '403': >>>> + description: Forbidden >>>> + content: >>>> + application/json: >>>> + schema: >>>> + $ref: '#/components/schemas/Error' >>>> + '404': >>>> + description: Not found >>>> + content: >>>> + application/json: >>>> + schema: >>>> + $ref: '#/components/schemas/Error' >>>> + tags: >>>> + - relations >>>> + put: >>>> + description: Update a relation. >>>> + operationId: relations_update >>>> + security: >>>> + - basicAuth: [] >>>> + - apiKeyAuth: [] >>>> + parameters: >>>> + - in: path >>>> + name: id >>>> + description: A unique integer value identifying this >> relation. >>>> + required: true >>>> + schema: >>>> + title: ID >>>> + type: integer >>>> + requestBody: >>>> + $ref: '#/components/requestBodies/Relation' >>>> + responses: >>>> + '200': >>>> + description: '' >>>> + content: >>>> + application/json: >>>> + schema: >>>> + $ref: '#/components/schemas/Relation' >>>> + '400': >>>> + description: Bad request >>>> + content: >>>> + application/json: >>>> + schema: >>>> + $ref: '#/components/schemas/Error' >>>> + '403': >>>> + description: Forbidden >>>> + content: >>>> + application/json: >>>> + schema: >>>> + $ref: '#/components/schemas/Error' >>>> + '404': >>>> + description: Not found >>>> + content: >>>> + application/json: >>>> + schema: >>>> + $ref: '#/components/schemas/Error' >>>> + tags: >>>> + - relations >>>> /api/users/: >>>> get: >>>> description: List users. >>>> @@ -1314,6 +1496,18 @@ components: >>>> application/x-www-form-urlencoded: >>>> schema: >>>> $ref: '#/components/schemas/User' >>>> + Relation: >>>> + required: true >>>> + content: >>>> + application/json: >>>> + schema: >>>> + $ref: '#/components/schemas/RelationUpdate' >>>> + multipart/form-data: >>>> + schema: >>>> + $ref: '#/components/schemas/RelationUpdate' >>>> + application/x-www-form-urlencoded: >>>> + schema: >>>> + $ref: '#/components/schemas/RelationUpdate' >>>> schemas: >>>> Index: >>>> type: object >>>> @@ -1358,6 +1552,11 @@ components: >>>> type: string >>>> format: uri >>>> readOnly: true >>>> + relations: >>>> + title: Relations URL >>>> + type: string >>>> + format: uri >>>> + readOnly: true >>>> Bundle: >>>> required: >>>> - name >>>> @@ -1943,6 +2142,14 @@ components: >>>> title: Delegate >>>> type: integer >>>> nullable: true >>>> + RelationUpdate: >>>> + type: object >>>> + properties: >>>> + submissions: >>>> + title: Submission IDs >>>> + type: array >>>> + items: >>>> + type: integer >>>> Person: >>>> type: object >>>> properties: >>>> @@ -2133,6 +2340,30 @@ components: >>>> $ref: '#/components/schemas/PatchEmbedded' >>>> readOnly: true >>>> uniqueItems: true >>>> + Relation: >>>> + type: object >>>> + properties: >>>> + id: >>>> + title: ID >>>> + type: integer >>>> + url: >>>> + title: URL >>>> + type: string >>>> + format: uri >>>> + readOnly: true >>>> + by: >>>> + type: object >>>> + title: By >>>> + readOnly: true >>>> + allOf: >>>> + - $ref: '#/components/schemas/UserEmbedded' >>>> + submissions: >>>> + title: Submissions >>>> + type: array >>>> + items: >>>> + $ref: '#/components/schemas/SubmissionEmbedded' >>>> + readOnly: true >>>> + uniqueItems: true >>>> User: >>>> type: object >>>> properties: >>>> @@ -2211,6 +2442,48 @@ components: >>>> maxLength: 255 >>>> minLength: 1 >>>> readOnly: true >>>> + SubmissionEmbedded: >>>> + type: object >>>> + properties: >>>> + id: >>>> + title: ID >>>> + type: integer >>>> + readOnly: true >>>> + url: >>>> + title: URL >>>> + type: string >>>> + format: uri >>>> + readOnly: true >>>> + web_url: >>>> + title: Web URL >>>> + type: string >>>> + format: uri >>>> + readOnly: true >>>> + msgid: >>>> + title: Message ID >>>> + type: string >>>> + readOnly: true >>>> + minLength: 1 >>>> + list_archive_url: >>>> + title: List archive URL >>>> + type: string >>>> + readOnly: true >>>> + nullable: true >>>> + date: >>>> + title: Date >>>> + type: string >>>> + format: iso8601 >>>> + readOnly: true >>>> + name: >>>> + title: Name >>>> + type: string >>>> + readOnly: true >>>> + minLength: 1 >>>> + mbox: >>>> + title: Mbox >>>> + type: string >>>> + format: uri >>>> + readOnly: true >>>> CoverLetterEmbedded: >>>> type: object >>>> properties: >>>> diff --git a/docs/api/schemas/patchwork.j2 >> b/docs/api/schemas/patchwork.j2 >>>> index 196d78466b55..a034029accf9 100644 >>>> --- a/docs/api/schemas/patchwork.j2 >>>> +++ b/docs/api/schemas/patchwork.j2 >>>> @@ -1048,6 +1048,190 @@ paths: >>>> $ref: '#/components/schemas/Error' >>>> tags: >>>> - series >>>> +{% if version >= (1, 2) %} >>>> + /api/{{ version_url }}relations/: >>>> + get: >>>> + description: List relations. >>>> + operationId: relations_list >>>> + parameters: >>>> + - $ref: '#/components/parameters/Page' >>>> + - $ref: '#/components/parameters/PageSize' >>>> + - $ref: '#/components/parameters/Order' >>>> + responses: >>>> + '200': >>>> + description: '' >>>> + headers: >>>> + Link: >>>> + $ref: '#/components/headers/Link' >>>> + content: >>>> + application/json: >>>> + schema: >>>> + type: array >>>> + items: >>>> + $ref: '#/components/schemas/Relation' >>>> + tags: >>>> + - relations >>>> + post: >>>> + description: Create a relation. >>>> + operationId: relations_create >>>> + security: >>>> + - basicAuth: [] >>>> + - apiKeyAuth: [] >>>> + requestBody: >>>> + $ref: '#/components/requestBodies/Relation' >>>> + responses: >>>> + '201': >>>> + description: '' >>>> + content: >>>> + application/json: >>>> + schema: >>>> + $ref: '#/components/schemas/Relation' >>>> + '400': >>>> + description: Invalid Request >>>> + content: >>>> + application/json: >>>> + schema: >>>> + $ref: '#/components/schemas/Error' >>>> + '403': >>>> + description: Forbidden >>>> + content: >>>> + application/json: >>>> + schema: >>>> + $ref: '#/components/schemas/Error' >>>> + '404': >>>> + description: Not found >>>> + content: >>>> + application/json: >>>> + schema: >>>> + $ref: '#/components/schemas/Error' >>>> + '409': >>>> + description: Conflict >>>> + content: >>>> + application/json: >>>> + schema: >>>> + $ref: '#/components/schemas/Error' >>>> + tags: >>>> + - checks >>>> + /api/{{ version_url }}relations/{id}/: >>>> + get: >>>> + description: Show a relation. >>>> + operationId: relation_read >>>> + parameters: >>>> + - in: path >>>> + name: id >>>> + description: A unique integer value identifying this >> relation. >>>> + required: true >>>> + schema: >>>> + title: ID >>>> + type: integer >>>> + responses: >>>> + '200': >>>> + description: '' >>>> + content: >>>> + application/json: >>>> + schema: >>>> + $ref: '#/components/schemas/Relation' >>>> + '404': >>>> + description: Not found >>>> + content: >>>> + application/json: >>>> + schema: >>>> + $ref: '#/components/schemas/Error' >>>> + '409': >>>> + description: Conflict >>>> + content: >>>> + application/json: >>>> + schema: >>>> + $ref: '#/components/schemas/Error' >>>> + tags: >>>> + - relations >>>> + patch: >>>> + description: Update a relation (partial). >>>> + operationId: relations_partial_update >>>> + security: >>>> + - basicAuth: [] >>>> + - apiKeyAuth: [] >>>> + parameters: >>>> + - in: path >>>> + name: id >>>> + description: A unique integer value identifying this >> relation. >>>> + required: true >>>> + schema: >>>> + title: ID >>>> + type: integer >>>> + requestBody: >>>> + $ref: '#/components/requestBodies/Relation' >>>> + responses: >>>> + '200': >>>> + description: '' >>>> + content: >>>> + application/json: >>>> + schema: >>>> + $ref: '#/components/schemas/Relation' >>>> + '400': >>>> + description: Bad request >>>> + content: >>>> + application/json: >>>> + schema: >>>> + $ref: '#/components/schemas/Error' >>>> + '403': >>>> + description: Forbidden >>>> + content: >>>> + application/json: >>>> + schema: >>>> + $ref: '#/components/schemas/Error' >>>> + '404': >>>> + description: Not found >>>> + content: >>>> + application/json: >>>> + schema: >>>> + $ref: '#/components/schemas/Error' >>>> + tags: >>>> + - relations >>>> + put: >>>> + description: Update a relation. >>>> + operationId: relations_update >>>> + security: >>>> + - basicAuth: [] >>>> + - apiKeyAuth: [] >>>> + parameters: >>>> + - in: path >>>> + name: id >>>> + description: A unique integer value identifying this >> relation. >>>> + required: true >>>> + schema: >>>> + title: ID >>>> + type: integer >>>> + requestBody: >>>> + $ref: '#/components/requestBodies/Relation' >>>> + responses: >>>> + '200': >>>> + description: '' >>>> + content: >>>> + application/json: >>>> + schema: >>>> + $ref: '#/components/schemas/Relation' >>>> + '400': >>>> + description: Bad request >>>> + content: >>>> + application/json: >>>> + schema: >>>> + $ref: '#/components/schemas/Error' >>>> + '403': >>>> + description: Forbidden >>>> + content: >>>> + application/json: >>>> + schema: >>>> + $ref: '#/components/schemas/Error' >>>> + '404': >>>> + description: Not found >>>> + content: >>>> + application/json: >>>> + schema: >>>> + $ref: '#/components/schemas/Error' >>>> + tags: >>>> + - relations >>>> +{% endif %} >>>> /api/{{ version_url }}users/: >>>> get: >>>> description: List users. >>>> @@ -1325,6 +1509,20 @@ components: >>>> application/x-www-form-urlencoded: >>>> schema: >>>> $ref: '#/components/schemas/User' >>>> +{% if version >= (1, 2) %} >>>> + Relation: >>>> + required: true >>>> + content: >>>> + application/json: >>>> + schema: >>>> + $ref: '#/components/schemas/RelationUpdate' >>>> + multipart/form-data: >>>> + schema: >>>> + $ref: '#/components/schemas/RelationUpdate' >>>> + application/x-www-form-urlencoded: >>>> + schema: >>>> + $ref: '#/components/schemas/RelationUpdate' >>>> +{% endif %} >>>> schemas: >>>> Index: >>>> type: object >>>> @@ -1369,6 +1567,13 @@ components: >>>> type: string >>>> format: uri >>>> readOnly: true >>>> +{% if version >= (1, 2) %} >>>> + relations: >>>> + title: Relations URL >>>> + type: string >>>> + format: uri >>>> + readOnly: true >>>> +{% endif %} >>>> Bundle: >>>> required: >>>> - name >>>> @@ -1981,6 +2186,16 @@ components: >>>> title: Delegate >>>> type: integer >>>> nullable: true >>>> +{% if version >= (1, 2) %} >>>> + RelationUpdate: >>>> + type: object >>>> + properties: >>>> + submissions: >>>> + title: Submission IDs >>>> + type: array >>>> + items: >>>> + type: integer >>>> +{% endif %} >>>> Person: >>>> type: object >>>> properties: >>>> @@ -2177,6 +2392,32 @@ components: >>>> $ref: '#/components/schemas/PatchEmbedded' >>>> readOnly: true >>>> uniqueItems: true >>>> +{% if version >= (1, 2) %} >>>> + Relation: >>>> + type: object >>>> + properties: >>>> + id: >>>> + title: ID >>>> + type: integer >>>> + url: >>>> + title: URL >>>> + type: string >>>> + format: uri >>>> + readOnly: true >>>> + by: >>>> + type: object >>>> + title: By >>>> + readOnly: true >>>> + allOf: >>>> + - $ref: '#/components/schemas/UserEmbedded' >>>> + submissions: >>>> + title: Submissions >>>> + type: array >>>> + items: >>>> + $ref: '#/components/schemas/SubmissionEmbedded' >>>> + readOnly: true >>>> + uniqueItems: true >>>> +{% endif %} >>>> User: >>>> type: object >>>> properties: >>>> @@ -2255,6 +2496,50 @@ components: >>>> maxLength: 255 >>>> minLength: 1 >>>> readOnly: true >>>> +{% if version >= (1, 2) %} >>>> + SubmissionEmbedded: >>>> + type: object >>>> + properties: >>>> + id: >>>> + title: ID >>>> + type: integer >>>> + readOnly: true >>>> + url: >>>> + title: URL >>>> + type: string >>>> + format: uri >>>> + readOnly: true >>>> + web_url: >>>> + title: Web URL >>>> + type: string >>>> + format: uri >>>> + readOnly: true >>>> + msgid: >>>> + title: Message ID >>>> + type: string >>>> + readOnly: true >>>> + minLength: 1 >>>> + list_archive_url: >>>> + title: List archive URL >>>> + type: string >>>> + readOnly: true >>>> + nullable: true >>>> + date: >>>> + title: Date >>>> + type: string >>>> + format: iso8601 >>>> + readOnly: true >>>> + name: >>>> + title: Name >>>> + type: string >>>> + readOnly: true >>>> + minLength: 1 >>>> + mbox: >>>> + title: Mbox >>>> + type: string >>>> + format: uri >>>> + readOnly: true >>>> +{% endif %} >>>> CoverLetterEmbedded: >>>> type: object >>>> properties: >>>> diff --git a/docs/api/schemas/v1.2/patchwork.yaml >> b/docs/api/schemas/v1.2/patchwork.yaml >>>> index d7b4d2957cff..99425e968881 100644 >>>> --- a/docs/api/schemas/v1.2/patchwork.yaml >>>> +++ b/docs/api/schemas/v1.2/patchwork.yaml >>>> @@ -1039,6 +1039,188 @@ paths: >>>> $ref: '#/components/schemas/Error' >>>> tags: >>>> - series >>>> + /api/1.2/relations/: >>>> + get: >>>> + description: List relations. >>>> + operationId: relations_list >>>> + parameters: >>>> + - $ref: '#/components/parameters/Page' >>>> + - $ref: '#/components/parameters/PageSize' >>>> + - $ref: '#/components/parameters/Order' >>>> + responses: >>>> + '200': >>>> + description: '' >>>> + headers: >>>> + Link: >>>> + $ref: '#/components/headers/Link' >>>> + content: >>>> + application/json: >>>> + schema: >>>> + type: array >>>> + items: >>>> + $ref: '#/components/schemas/Relation' >>>> + tags: >>>> + - relations >>>> + post: >>>> + description: Create a relation. >>>> + operationId: relations_create >>>> + security: >>>> + - basicAuth: [] >>>> + - apiKeyAuth: [] >>>> + requestBody: >>>> + $ref: '#/components/requestBodies/Relation' >>>> + responses: >>>> + '201': >>>> + description: '' >>>> + content: >>>> + application/json: >>>> + schema: >>>> + $ref: '#/components/schemas/Relation' >>>> + '400': >>>> + description: Invalid Request >>>> + content: >>>> + application/json: >>>> + schema: >>>> + $ref: '#/components/schemas/Error' >>>> + '403': >>>> + description: Forbidden >>>> + content: >>>> + application/json: >>>> + schema: >>>> + $ref: '#/components/schemas/Error' >>>> + '404': >>>> + description: Not found >>>> + content: >>>> + application/json: >>>> + schema: >>>> + $ref: '#/components/schemas/Error' >>>> + '409': >>>> + description: Conflict >>>> + content: >>>> + application/json: >>>> + schema: >>>> + $ref: '#/components/schemas/Error' >>>> + tags: >>>> + - checks >>>> + /api/1.2/relations/{id}/: >>>> + get: >>>> + description: Show a relation. >>>> + operationId: relation_read >>>> + parameters: >>>> + - in: path >>>> + name: id >>>> + description: A unique integer value identifying this >> relation. >>>> + required: true >>>> + schema: >>>> + title: ID >>>> + type: integer >>>> + responses: >>>> + '200': >>>> + description: '' >>>> + content: >>>> + application/json: >>>> + schema: >>>> + $ref: '#/components/schemas/Relation' >>>> + '404': >>>> + description: Not found >>>> + content: >>>> + application/json: >>>> + schema: >>>> + $ref: '#/components/schemas/Error' >>>> + '409': >>>> + description: Conflict >>>> + content: >>>> + application/json: >>>> + schema: >>>> + $ref: '#/components/schemas/Error' >>>> + tags: >>>> + - relations >>>> + patch: >>>> + description: Update a relation (partial). >>>> + operationId: relations_partial_update >>>> + security: >>>> + - basicAuth: [] >>>> + - apiKeyAuth: [] >>>> + parameters: >>>> + - in: path >>>> + name: id >>>> + description: A unique integer value identifying this >> relation. >>>> + required: true >>>> + schema: >>>> + title: ID >>>> + type: integer >>>> + requestBody: >>>> + $ref: '#/components/requestBodies/Relation' >>>> + responses: >>>> + '200': >>>> + description: '' >>>> + content: >>>> + application/json: >>>> + schema: >>>> + $ref: '#/components/schemas/Relation' >>>> + '400': >>>> + description: Bad request >>>> + content: >>>> + application/json: >>>> + schema: >>>> + $ref: '#/components/schemas/Error' >>>> + '403': >>>> + description: Forbidden >>>> + content: >>>> + application/json: >>>> + schema: >>>> + $ref: '#/components/schemas/Error' >>>> + '404': >>>> + description: Not found >>>> + content: >>>> + application/json: >>>> + schema: >>>> + $ref: '#/components/schemas/Error' >>>> + tags: >>>> + - relations >>>> + put: >>>> + description: Update a relation. >>>> + operationId: relations_update >>>> + security: >>>> + - basicAuth: [] >>>> + - apiKeyAuth: [] >>>> + parameters: >>>> + - in: path >>>> + name: id >>>> + description: A unique integer value identifying this >> relation. >>>> + required: true >>>> + schema: >>>> + title: ID >>>> + type: integer >>>> + requestBody: >>>> + $ref: '#/components/requestBodies/Relation' >>>> + responses: >>>> + '200': >>>> + description: '' >>>> + content: >>>> + application/json: >>>> + schema: >>>> + $ref: '#/components/schemas/Relation' >>>> + '400': >>>> + description: Bad request >>>> + content: >>>> + application/json: >>>> + schema: >>>> + $ref: '#/components/schemas/Error' >>>> + '403': >>>> + description: Forbidden >>>> + content: >>>> + application/json: >>>> + schema: >>>> + $ref: '#/components/schemas/Error' >>>> + '404': >>>> + description: Not found >>>> + content: >>>> + application/json: >>>> + schema: >>>> + $ref: '#/components/schemas/Error' >>>> + tags: >>>> + - relations >>>> /api/1.2/users/: >>>> get: >>>> description: List users. >>>> @@ -1314,6 +1496,18 @@ components: >>>> application/x-www-form-urlencoded: >>>> schema: >>>> $ref: '#/components/schemas/User' >>>> + Relation: >>>> + required: true >>>> + content: >>>> + application/json: >>>> + schema: >>>> + $ref: '#/components/schemas/RelationUpdate' >>>> + multipart/form-data: >>>> + schema: >>>> + $ref: '#/components/schemas/RelationUpdate' >>>> + application/x-www-form-urlencoded: >>>> + schema: >>>> + $ref: '#/components/schemas/RelationUpdate' >>>> schemas: >>>> Index: >>>> type: object >>>> @@ -1358,6 +1552,11 @@ components: >>>> type: string >>>> format: uri >>>> readOnly: true >>>> + relations: >>>> + title: Relations URL >>>> + type: string >>>> + format: uri >>>> + readOnly: true >>>> Bundle: >>>> required: >>>> - name >>>> @@ -1943,6 +2142,14 @@ components: >>>> title: Delegate >>>> type: integer >>>> nullable: true >>>> + RelationUpdate: >>>> + type: object >>>> + properties: >>>> + submissions: >>>> + title: Submission IDs >>>> + type: array >>>> + items: >>>> + type: integer >>>> Person: >>>> type: object >>>> properties: >>>> @@ -2133,6 +2340,30 @@ components: >>>> $ref: '#/components/schemas/PatchEmbedded' >>>> readOnly: true >>>> uniqueItems: true >>>> + Relation: >>>> + type: object >>>> + properties: >>>> + id: >>>> + title: ID >>>> + type: integer >>>> + url: >>>> + title: URL >>>> + type: string >>>> + format: uri >>>> + readOnly: true >>>> + by: >>>> + type: object >>>> + title: By >>>> + readOnly: true >>>> + allOf: >>>> + - $ref: '#/components/schemas/UserEmbedded' >>>> + submissions: >>>> + title: Submissions >>>> + type: array >>>> + items: >>>> + $ref: '#/components/schemas/SubmissionEmbedded' >>>> + readOnly: true >>>> + uniqueItems: true >>>> User: >>>> type: object >>>> properties: >>>> @@ -2211,6 +2442,48 @@ components: >>>> maxLength: 255 >>>> minLength: 1 >>>> readOnly: true >>>> + SubmissionEmbedded: >>>> + type: object >>>> + properties: >>>> + id: >>>> + title: ID >>>> + type: integer >>>> + readOnly: true >>>> + url: >>>> + title: URL >>>> + type: string >>>> + format: uri >>>> + readOnly: true >>>> + web_url: >>>> + title: Web URL >>>> + type: string >>>> + format: uri >>>> + readOnly: true >>>> + msgid: >>>> + title: Message ID >>>> + type: string >>>> + readOnly: true >>>> + minLength: 1 >>>> + list_archive_url: >>>> + title: List archive URL >>>> + type: string >>>> + readOnly: true >>>> + nullable: true >>>> + date: >>>> + title: Date >>>> + type: string >>>> + format: iso8601 >>>> + readOnly: true >>>> + name: >>>> + title: Name >>>> + type: string >>>> + readOnly: true >>>> + minLength: 1 >>>> + mbox: >>>> + title: Mbox >>>> + type: string >>>> + format: uri >>>> + readOnly: true >>>> CoverLetterEmbedded: >>>> type: object >>>> properties: >>>> diff --git a/patchwork/api/embedded.py b/patchwork/api/embedded.py >>>> index de4f31165ee7..0fba291b62b8 100644 >>>> --- a/patchwork/api/embedded.py >>>> +++ b/patchwork/api/embedded.py >>>> @@ -102,6 +102,45 @@ class CheckSerializer(SerializedRelatedField): >>>> } >>>> >>>> >>>> +def _upgrade_instance(instance): >>>> + if hasattr(instance, 'patch'): >>>> + return instance.patch >>>> + else: >>>> + return instance.coverletter >>>> + >>>> + >>>> +class SubmissionSerializer(SerializedRelatedField): >>>> + >>>> + class _Serializer(BaseHyperlinkedModelSerializer): >>>> + """We need to 'upgrade' or specialise the submission to the >> relevant >>>> + subclass, so we can't use the mixins. This is gross but can go >> away >>>> + once we flatten the models.""" >>>> + url = SerializerMethodField() >>>> + web_url = SerializerMethodField() >>>> + mbox = SerializerMethodField() >>>> + >>>> + def get_url(self, instance): >>>> + instance = _upgrade_instance(instance) >>>> + request = self.context.get('request') >>>> + return >> request.build_absolute_uri(instance.get_absolute_api_url()) >>>> + >>>> + def get_web_url(self, instance): >>>> + instance = _upgrade_instance(instance) >>>> + request = self.context.get('request') >>>> + return >> request.build_absolute_uri(instance.get_absolute_url()) >>>> + >>>> + def get_mbox(self, instance): >>>> + instance = _upgrade_instance(instance) >>>> + request = self.context.get('request') >>>> + return request.build_absolute_uri(instance.get_mbox_url()) >>>> + >>>> + class Meta: >>>> + model = models.Submission >>>> + fields = ('id', 'url', 'web_url', 'msgid', >> 'list_archive_url', >>>> + 'date', 'name', 'mbox') >>>> + read_only_fields = fields >>>> + >>>> + >>>> class CoverLetterSerializer(SerializedRelatedField): >>>> >>>> class _Serializer(MboxMixin, WebURLMixin, >> BaseHyperlinkedModelSerializer): >>>> diff --git a/patchwork/api/index.py b/patchwork/api/index.py >>>> index 45485c9106f6..cf1845393835 100644 >>>> --- a/patchwork/api/index.py >>>> +++ b/patchwork/api/index.py >>>> @@ -21,4 +21,5 @@ class IndexView(APIView): >>>> 'series': reverse('api-series-list', request=request), >>>> 'events': reverse('api-event-list', request=request), >>>> 'bundles': reverse('api-bundle-list', request=request), >>>> + 'relations': reverse('api-relation-list', request=request), >>>> }) >>>> diff --git a/patchwork/api/relation.py b/patchwork/api/relation.py >>>> new file mode 100644 >>>> index 000000000000..37640d62e9cc >>>> --- /dev/null >>>> +++ b/patchwork/api/relation.py >>>> @@ -0,0 +1,121 @@ >>>> +# Patchwork - automated patch tracking system >>>> +# Copyright (C) 2019, Bayerische Motoren Werke Aktiengesellschaft (BMW >> AG) >>>> +# >>>> +# SPDX-License-Identifier: GPL-2.0-or-later >>>> + >>>> +from rest_framework import permissions >>>> +from rest_framework import status >>>> +from rest_framework.exceptions import PermissionDenied, APIException >>>> +from rest_framework.generics import GenericAPIView >>>> +from rest_framework.generics import ListCreateAPIView >>>> +from rest_framework.generics import RetrieveUpdateDestroyAPIView >>>> +from rest_framework.serializers import ModelSerializer >>>> + >>>> +from patchwork.api.base import PatchworkPermission >>>> +from patchwork.api.embedded import SubmissionSerializer >>>> +from patchwork.api.embedded import UserSerializer >>>> +from patchwork.models import SubmissionRelation >>>> + >>>> + >>>> +class MaintainerPermission(PatchworkPermission): >>>> + >>>> + def has_permission(self, request, view): >>>> + if request.method in permissions.SAFE_METHODS: >>>> + return True >>>> + >>>> + # Prevent showing an HTML POST form in the browseable API for >> logged in >>>> + # users who are not maintainers. >>>> + return len(request.user.maintains) > 0 >>>> + >>>> + def has_object_permission(self, request, view, relation): >>>> + if request.method in permissions.SAFE_METHODS: >>>> + return True >>>> + >>>> + maintains = request.user.maintains >>>> + submissions = relation.submissions.all() >>>> + # user has to be maintainer of every project a submission is >> part of >>>> + return self.check_user_maintains_all(maintains, submissions) >>>> + >>>> + @staticmethod >>>> + def check_user_maintains_all(maintains, submissions): >>>> + if any(s.project not in maintains for s in submissions): >>>> + detail = 'At least one submission is part of a project you >> are ' \ >>>> + 'not maintaining.' >>>> + raise PermissionDenied(detail=detail) >>>> + return True >>>> + >>>> + >>>> +class SubmissionConflict(APIException): >>>> + status_code = status.HTTP_409_CONFLICT >>>> + default_detail = 'At least one submission is already part of >> another ' \ >>>> + 'relation. You have to explicitly remove a >> submission ' \ >>>> + 'from its existing relation before moving it to >> this one.' >>>> + >>>> + >>>> +class SubmissionRelationSerializer(ModelSerializer): >>>> + by = UserSerializer(read_only=True) >>>> + submissions = SubmissionSerializer(many=True) >>>> + >>>> + def create(self, validated_data): >>>> + submissions = validated_data['submissions'] >>>> + if any(submission.related_id is not None >>>> + for submission in submissions): >>>> + raise SubmissionConflict() >>>> + return super(SubmissionRelationSerializer, >> self).create(validated_data) >>>> + >>>> + def update(self, instance, validated_data): >>>> + submissions = validated_data['submissions'] >>>> + if any(submission.related_id is not None and >>>> + submission.related_id != instance.id >>>> + for submission in submissions): >>>> + raise SubmissionConflict() >>>> + return super(SubmissionRelationSerializer, self) \ >>>> + .update(instance, validated_data) >>>> + >>>> + class Meta: >>>> + model = SubmissionRelation >>>> + fields = ('id', 'url', 'by', 'submissions',) >>>> + read_only_fields = ('url', 'by', ) >>>> + extra_kwargs = { >>>> + 'url': {'view_name': 'api-relation-detail'}, >>>> + } >>>> + >>>> + >>>> +class SubmissionRelationMixin(GenericAPIView): >>>> + serializer_class = SubmissionRelationSerializer >>>> + permission_classes = (MaintainerPermission,) >>>> + >>>> + def initial(self, request, *args, **kwargs): >>>> + user = request.user >>>> + if not hasattr(user, 'maintains'): >>>> + if user.is_authenticated: >>>> + user.maintains = user.profile.maintainer_projects.all() >>>> + else: >>>> + user.maintains = [] >>>> + super(SubmissionRelationMixin, self).initial(request, *args, >> **kwargs) >>>> + >>>> + def get_queryset(self): >>>> + return SubmissionRelation.objects.all() \ >>>> + .select_related('by') \ >>>> + .prefetch_related('submissions__patch', >>>> + 'submissions__coverletter', >>>> + 'submissions__project') >>>> + >>>> + >>>> +class SubmissionRelationList(SubmissionRelationMixin, >> ListCreateAPIView): >>>> + ordering = 'id' >>>> + ordering_fields = ['id'] >>>> + >>>> + def perform_create(self, serializer): >>>> + # has_object_permission() is not called when creating a new >> relation. >>>> + # Check whether user is maintainer of every project a >> submission is >>>> + # part of >>>> + maintains = self.request.user.maintains >>>> + submissions = serializer.validated_data['submissions'] >>>> + MaintainerPermission.check_user_maintains_all(maintains, >> submissions) >>>> + serializer.save(by=self.request.user) >>>> + >>>> + >>>> +class SubmissionRelationDetail(SubmissionRelationMixin, >>>> + RetrieveUpdateDestroyAPIView): >>>> + pass >>>> diff --git a/patchwork/models.py b/patchwork/models.py >>>> index a92203b24ff2..9ae3370e896b 100644 >>>> --- a/patchwork/models.py >>>> +++ b/patchwork/models.py >>>> @@ -415,6 +415,9 @@ class CoverLetter(Submission): >>>> kwargs={'project_id': self.project.linkname, >>>> 'msgid': self.url_msgid}) >>>> >>>> + def get_absolute_api_url(self): >>>> + return reverse('api-cover-detail', kwargs={'pk': self.id}) >>>> + >>>> def get_mbox_url(self): >>>> return reverse('cover-mbox', >>>> kwargs={'project_id': self.project.linkname, >>>> @@ -604,6 +607,9 @@ class Patch(Submission): >>>> kwargs={'project_id': self.project.linkname, >>>> 'msgid': self.url_msgid}) >>>> >>>> + def get_absolute_api_url(self): >>>> + return reverse('api-patch-detail', kwargs={'pk': self.id}) >>>> + >>>> def get_mbox_url(self): >>>> return reverse('patch-mbox', >>>> kwargs={'project_id': self.project.linkname, >>>> diff --git a/patchwork/tests/api/test_relation.py >> b/patchwork/tests/api/test_relation.py >>>> new file mode 100644 >>>> index 000000000000..5b1a04f13670 >>>> --- /dev/null >>>> +++ b/patchwork/tests/api/test_relation.py >>>> @@ -0,0 +1,181 @@ >>>> +# Patchwork - automated patch tracking system >>>> +# Copyright (C) 2019, Bayerische Motoren Werke Aktiengesellschaft (BMW >> AG) >>>> +# >>>> +# SPDX-License-Identifier: GPL-2.0-or-later >>>> + >>>> +import unittest >>>> + >>>> +import six >>>> +from django.conf import settings >>>> +from django.urls import reverse >>>> + >>>> +from patchwork.tests.api import utils >>>> +from patchwork.tests.utils import create_cover >>>> +from patchwork.tests.utils import create_maintainer >>>> +from patchwork.tests.utils import create_patches >>>> +from patchwork.tests.utils import create_project >>>> +from patchwork.tests.utils import create_relation >>>> +from patchwork.tests.utils import create_user >>>> + >>>> +if settings.ENABLE_REST_API: >>>> + from rest_framework import status >>>> + >>>> + >>>> +class UserType: >>>> + ANONYMOUS = 1 >>>> + NON_MAINTAINER = 2 >>>> + MAINTAINER = 3 >>>> + >>>> + >>>> +@unittest.skipUnless(settings.ENABLE_REST_API, 'requires >> ENABLE_REST_API') >>>> +class TestRelationAPI(utils.APITestCase): >>>> + fixtures = ['default_tags'] >>>> + >>>> + @staticmethod >>>> + def api_url(item=None): >>>> + kwargs = {} >>>> + if item is None: >>>> + return reverse('api-relation-list', kwargs=kwargs) >>>> + kwargs['pk'] = item >>>> + return reverse('api-relation-detail', kwargs=kwargs) >>>> + >>>> + def request_restricted(self, method, user_type): >>>> + """Assert post/delete/patch requests on the relation API.""" >>>> + assert method in ['post', 'delete', 'patch'] >>>> + >>>> + # setup >>>> + >>>> + project = create_project() >>>> + maintainer = create_maintainer(project) >>>> + >>>> + if user_type == UserType.ANONYMOUS: >>>> + expected_status = status.HTTP_403_FORBIDDEN >>>> + elif user_type == UserType.NON_MAINTAINER: >>>> + expected_status = status.HTTP_403_FORBIDDEN >>>> + self.client.force_authenticate(user=create_user()) >>>> + elif user_type == UserType.MAINTAINER: >>>> + if method == 'post': >>>> + expected_status = status.HTTP_201_CREATED >>>> + elif method == 'delete': >>>> + expected_status = status.HTTP_204_NO_CONTENT >>>> + else: >>>> + expected_status = status.HTTP_200_OK >>>> + self.client.force_authenticate(user=maintainer) >>>> + else: >>>> + raise ValueError >>>> + >>>> + resource_id = None >>>> + req = None >>>> + >>>> + if method == 'delete': >>>> + resource_id = create_relation(project=project, >> by=maintainer).id >>>> + elif method == 'post': >>>> + patch_ids = [p.id for p in create_patches(2, >> project=project)] >>>> + req = {'submissions': patch_ids} >>>> + elif method == 'patch': >>>> + resource_id = create_relation(project=project, >> by=maintainer).id >>>> + patch_ids = [p.id for p in create_patches(2, >> project=project)] >>>> + req = {'submissions': patch_ids} >>>> + else: >>>> + raise ValueError >>>> + >>>> + # request >>>> + >>>> + resp = getattr(self.client, method)(self.api_url(resource_id), >> req) >>>> + >>>> + # check >>>> + >>>> + self.assertEqual(expected_status, resp.status_code) >>>> + >>>> + if resp.status_code in range(status.HTTP_200_OK, >>>> + status.HTTP_204_NO_CONTENT): >>>> + self.assertRequest(req, resp.data) >>>> + >>>> + def assertRequest(self, request, resp): >>>> + if request.get('id'): >>>> + self.assertEqual(request['id'], resp['id']) >>>> + send_ids = request['submissions'] >>>> + resp_ids = [s['id'] for s in resp['submissions']] >>>> + six.assertCountEqual(self, resp_ids, send_ids) >>>> + >>>> + def assertSerialized(self, obj, resp): >>>> + self.assertEqual(obj.id, resp['id']) >>>> + exp_ids = [s.id for s in obj.submissions.all()] >>>> + act_ids = [s['id'] for s in resp['submissions']] >>>> + six.assertCountEqual(self, exp_ids, act_ids) >>>> + >>>> + def test_list_empty(self): >>>> + """List relation when none are present.""" >>>> + resp = self.client.get(self.api_url()) >>>> + self.assertEqual(status.HTTP_200_OK, resp.status_code) >>>> + self.assertEqual(0, len(resp.data)) >>>> + >>>> + @utils.store_samples('relation-list') >>>> + def test_list(self): >>>> + """List relations.""" >>>> + relation = create_relation() >>>> + >>>> + resp = self.client.get(self.api_url()) >>>> + self.assertEqual(status.HTTP_200_OK, resp.status_code) >>>> + self.assertEqual(1, len(resp.data)) >>>> + self.assertSerialized(relation, resp.data[0]) >>>> + >>>> + def test_detail(self): >>>> + """Show relation.""" >>>> + relation = create_relation() >>>> + >>>> + resp = self.client.get(self.api_url(relation.id)) >>>> + self.assertEqual(status.HTTP_200_OK, resp.status_code) >>>> + self.assertSerialized(relation, resp.data) >>>> + >>>> + @utils.store_samples('relation-create-error-forbidden') >>>> + def test_create_anonymous(self): >>>> + self.request_restricted('post', UserType.ANONYMOUS) >>>> + >>>> + def test_create_non_maintainer(self): >>>> + self.request_restricted('post', UserType.NON_MAINTAINER) >>>> + >>>> + @utils.store_samples('relation-create') >>>> + def test_create_maintainer(self): >>>> + self.request_restricted('post', UserType.MAINTAINER) >>>> + >>>> + @utils.store_samples('relation-update-error-forbidden') >>>> + def test_update_anonymous(self): >>>> + self.request_restricted('patch', UserType.ANONYMOUS) >>>> + >>>> + def test_update_non_maintainer(self): >>>> + self.request_restricted('patch', UserType.NON_MAINTAINER) >>>> + >>>> + @utils.store_samples('relation-update') >>>> + def test_update_maintainer(self): >>>> + self.request_restricted('patch', UserType.MAINTAINER) >>>> + >>>> + @utils.store_samples('relation-delete-error-forbidden') >>>> + def test_delete_anonymous(self): >>>> + self.request_restricted('delete', UserType.ANONYMOUS) >>>> + >>>> + def test_delete_non_maintainer(self): >>>> + self.request_restricted('delete', UserType.NON_MAINTAINER) >>>> + >>>> + @utils.store_samples('relation-update') >>>> + def test_delete_maintainer(self): >>>> + self.request_restricted('delete', UserType.MAINTAINER) >>>> + >>>> + def test_submission_conflict(self): >>>> + project = create_project() >>>> + maintainer = create_maintainer(project) >>>> + self.client.force_authenticate(user=maintainer) >>>> + relation = create_relation(by=maintainer, project=project) >>>> + submission_ids = [s.id for s in relation.submissions.all()] >>>> + >>>> + # try to create a new relation with a new submission (cover) >> and >>>> + # submissions already bound to another relation >>>> + cover = create_cover(project=project) >>>> + submission_ids.append(cover.id) >>>> + req = {'submissions': submission_ids} >>>> + resp = self.client.post(self.api_url(), req) >>>> + self.assertEqual(status.HTTP_409_CONFLICT, resp.status_code) >>>> + >>>> + # try to patch relation >>>> + resp = self.client.patch(self.api_url(relation.id), req) >>>> + self.assertEqual(status.HTTP_200_OK, resp.status_code) >>>> diff --git a/patchwork/tests/utils.py b/patchwork/tests/utils.py >>>> index 577183d0986c..ffe90976233e 100644 >>>> --- a/patchwork/tests/utils.py >>>> +++ b/patchwork/tests/utils.py >>>> @@ -16,6 +16,7 @@ from patchwork.models import Check >>>> from patchwork.models import Comment >>>> from patchwork.models import CoverLetter >>>> from patchwork.models import Patch >>>> +from patchwork.models import SubmissionRelation >>>> from patchwork.models import Person >>>> from patchwork.models import Project >>>> from patchwork.models import Series >>>> @@ -347,3 +348,17 @@ def create_covers(count=1, **kwargs): >>>> kwargs (dict): Overrides for various cover letter fields >>>> """ >>>> return _create_submissions(create_cover, count, **kwargs) >>>> + >>>> + >>>> +def create_relation(count_patches=2, by=None, **kwargs): >>>> + if not by: >>>> + project = create_project() >>>> + kwargs['project'] = project >>>> + by = create_maintainer(project) >>>> + relation = SubmissionRelation.objects.create(by=by) >>>> + values = { >>>> + 'related': relation >>>> + } >>>> + values.update(kwargs) >>>> + create_patches(count_patches, **values) >>>> + return relation >>>> diff --git a/patchwork/urls.py b/patchwork/urls.py >>>> index dcdcfb49e67e..92095f62c7b9 100644 >>>> --- a/patchwork/urls.py >>>> +++ b/patchwork/urls.py >>>> @@ -187,6 +187,7 @@ if settings.ENABLE_REST_API: >>>> from patchwork.api import patch as api_patch_views # noqa >>>> from patchwork.api import person as api_person_views # noqa >>>> from patchwork.api import project as api_project_views # noqa >>>> + from patchwork.api import relation as api_relation_views # noqa >>>> from patchwork.api import series as api_series_views # noqa >>>> from patchwork.api import user as api_user_views # noqa >>>> >>>> @@ -256,9 +257,19 @@ if settings.ENABLE_REST_API: >>>> name='api-cover-comment-list'), >>>> ] >>>> >>>> +
Stephen Finucane <stephen@that.guru> writes: > On Sat, 2019-12-07 at 17:46 +0100, Mete Polat wrote: >> View relations and add/update/delete them as a maintainer. Maintainers >> can only create relations of submissions (patches/cover letters) which >> are part of a project they maintain. >> >> New REST API urls: >> api/relations/ >> api/relations/<relation_id>/ >> >> Co-authored-by: Daniel Axtens <dja@axtens.net> >> Signed-off-by: Mete Polat <metepolat2000@gmail.com> > > Why did you choose to expose this as a separate API rather than as a > field on the '/patches' resource? While a 'Series' objet has enough > separate metadata to warrant a separate '/series' resource, a > 'SubmissionRelation' object is basically just a container. Including a > 'related_patches' field on the detailed patch view would seem like more > than enough detail for me, anyway, and unless there's a reason not to > do this, I'd like to see it done that way. Is it possible? > How would creating an relation work then? currently you POST to /api/relations/ with all the patch IDs you want to include in the relation. I agree that viewing relations through /api/patch makes sense, but I'm not sure how you create relations if that's the only endpoint you have? Regards, Daniel > Stephen > > PS: I could have sworn I had asked this before, but I can't find any > mails about it so maybe I didn't. Please tell me to RTML (read the > mailing list) if so > >> --- >> Optimize db queries: >> I have spent quite a lot of time in optimizing the db queries for the REST API >> (thanks for the tip with the Django toolbar). Daniel stated that >> prefetch_related is possibly hitting the database for every relation when >> prefetching submissions but it turns out that we can tell Django to use a >> statement like: >> SELECT * >> FROM `patchwork_patch` >> INNER JOIN `patchwork_submission` >> ON (`patchwork_patch`.`submission_ptr_id` = `patchwork_submission`.`id`) >> WHERE `patchwork_patch`.`submission_ptr_id` IN (LIST_OF_ALL_SUBMISSION_IDS) >> >> We do the same for `patchwork_coverletter`. >> This means we only hit the db two times for casting _all_ submissions to a >> patch or cover-letter. >> >> Prefetching submissions__project eliminates similar and duplicate queries >> that are used to determine whether a logged in user is at least maintainer >> of one submission's project. >> >> docs/api/schemas/latest/patchwork.yaml | 273 +++++++++++++++++ >> docs/api/schemas/patchwork.j2 | 285 ++++++++++++++++++ >> docs/api/schemas/v1.2/patchwork.yaml | 273 +++++++++++++++++ >> patchwork/api/embedded.py | 39 +++ >> patchwork/api/index.py | 1 + >> patchwork/api/relation.py | 121 ++++++++ >> patchwork/models.py | 6 + >> patchwork/tests/api/test_relation.py | 181 +++++++++++ >> patchwork/tests/utils.py | 15 + >> patchwork/urls.py | 11 + >> ...submission-relations-c96bb6c567b416d8.yaml | 10 + >> 11 files changed, 1215 insertions(+) >> create mode 100644 patchwork/api/relation.py >> create mode 100644 patchwork/tests/api/test_relation.py >> create mode 100644 releasenotes/notes/add-submission-relations-c96bb6c567b416d8.yaml >> >> diff --git a/docs/api/schemas/latest/patchwork.yaml b/docs/api/schemas/latest/patchwork.yaml >> index a5e235be936d..7dd24fd700d5 100644 >> --- a/docs/api/schemas/latest/patchwork.yaml >> +++ b/docs/api/schemas/latest/patchwork.yaml >> @@ -1039,6 +1039,188 @@ paths: >> $ref: '#/components/schemas/Error' >> tags: >> - series >> + /api/relations/: >> + get: >> + description: List relations. >> + operationId: relations_list >> + parameters: >> + - $ref: '#/components/parameters/Page' >> + - $ref: '#/components/parameters/PageSize' >> + - $ref: '#/components/parameters/Order' >> + responses: >> + '200': >> + description: '' >> + headers: >> + Link: >> + $ref: '#/components/headers/Link' >> + content: >> + application/json: >> + schema: >> + type: array >> + items: >> + $ref: '#/components/schemas/Relation' >> + tags: >> + - relations >> + post: >> + description: Create a relation. >> + operationId: relations_create >> + security: >> + - basicAuth: [] >> + - apiKeyAuth: [] >> + requestBody: >> + $ref: '#/components/requestBodies/Relation' >> + responses: >> + '201': >> + description: '' >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Relation' >> + '400': >> + description: Invalid Request >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Error' >> + '403': >> + description: Forbidden >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Error' >> + '404': >> + description: Not found >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Error' >> + '409': >> + description: Conflict >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Error' >> + tags: >> + - checks >> + /api/relations/{id}/: >> + get: >> + description: Show a relation. >> + operationId: relation_read >> + parameters: >> + - in: path >> + name: id >> + description: A unique integer value identifying this relation. >> + required: true >> + schema: >> + title: ID >> + type: integer >> + responses: >> + '200': >> + description: '' >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Relation' >> + '404': >> + description: Not found >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Error' >> + '409': >> + description: Conflict >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Error' >> + tags: >> + - relations >> + patch: >> + description: Update a relation (partial). >> + operationId: relations_partial_update >> + security: >> + - basicAuth: [] >> + - apiKeyAuth: [] >> + parameters: >> + - in: path >> + name: id >> + description: A unique integer value identifying this relation. >> + required: true >> + schema: >> + title: ID >> + type: integer >> + requestBody: >> + $ref: '#/components/requestBodies/Relation' >> + responses: >> + '200': >> + description: '' >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Relation' >> + '400': >> + description: Bad request >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Error' >> + '403': >> + description: Forbidden >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Error' >> + '404': >> + description: Not found >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Error' >> + tags: >> + - relations >> + put: >> + description: Update a relation. >> + operationId: relations_update >> + security: >> + - basicAuth: [] >> + - apiKeyAuth: [] >> + parameters: >> + - in: path >> + name: id >> + description: A unique integer value identifying this relation. >> + required: true >> + schema: >> + title: ID >> + type: integer >> + requestBody: >> + $ref: '#/components/requestBodies/Relation' >> + responses: >> + '200': >> + description: '' >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Relation' >> + '400': >> + description: Bad request >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Error' >> + '403': >> + description: Forbidden >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Error' >> + '404': >> + description: Not found >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Error' >> + tags: >> + - relations >> /api/users/: >> get: >> description: List users. >> @@ -1314,6 +1496,18 @@ components: >> application/x-www-form-urlencoded: >> schema: >> $ref: '#/components/schemas/User' >> + Relation: >> + required: true >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/RelationUpdate' >> + multipart/form-data: >> + schema: >> + $ref: '#/components/schemas/RelationUpdate' >> + application/x-www-form-urlencoded: >> + schema: >> + $ref: '#/components/schemas/RelationUpdate' >> schemas: >> Index: >> type: object >> @@ -1358,6 +1552,11 @@ components: >> type: string >> format: uri >> readOnly: true >> + relations: >> + title: Relations URL >> + type: string >> + format: uri >> + readOnly: true >> Bundle: >> required: >> - name >> @@ -1943,6 +2142,14 @@ components: >> title: Delegate >> type: integer >> nullable: true >> + RelationUpdate: >> + type: object >> + properties: >> + submissions: >> + title: Submission IDs >> + type: array >> + items: >> + type: integer >> Person: >> type: object >> properties: >> @@ -2133,6 +2340,30 @@ components: >> $ref: '#/components/schemas/PatchEmbedded' >> readOnly: true >> uniqueItems: true >> + Relation: >> + type: object >> + properties: >> + id: >> + title: ID >> + type: integer >> + url: >> + title: URL >> + type: string >> + format: uri >> + readOnly: true >> + by: >> + type: object >> + title: By >> + readOnly: true >> + allOf: >> + - $ref: '#/components/schemas/UserEmbedded' >> + submissions: >> + title: Submissions >> + type: array >> + items: >> + $ref: '#/components/schemas/SubmissionEmbedded' >> + readOnly: true >> + uniqueItems: true >> User: >> type: object >> properties: >> @@ -2211,6 +2442,48 @@ components: >> maxLength: 255 >> minLength: 1 >> readOnly: true >> + SubmissionEmbedded: >> + type: object >> + properties: >> + id: >> + title: ID >> + type: integer >> + readOnly: true >> + url: >> + title: URL >> + type: string >> + format: uri >> + readOnly: true >> + web_url: >> + title: Web URL >> + type: string >> + format: uri >> + readOnly: true >> + msgid: >> + title: Message ID >> + type: string >> + readOnly: true >> + minLength: 1 >> + list_archive_url: >> + title: List archive URL >> + type: string >> + readOnly: true >> + nullable: true >> + date: >> + title: Date >> + type: string >> + format: iso8601 >> + readOnly: true >> + name: >> + title: Name >> + type: string >> + readOnly: true >> + minLength: 1 >> + mbox: >> + title: Mbox >> + type: string >> + format: uri >> + readOnly: true >> CoverLetterEmbedded: >> type: object >> properties: >> diff --git a/docs/api/schemas/patchwork.j2 b/docs/api/schemas/patchwork.j2 >> index 196d78466b55..a034029accf9 100644 >> --- a/docs/api/schemas/patchwork.j2 >> +++ b/docs/api/schemas/patchwork.j2 >> @@ -1048,6 +1048,190 @@ paths: >> $ref: '#/components/schemas/Error' >> tags: >> - series >> +{% if version >= (1, 2) %} >> + /api/{{ version_url }}relations/: >> + get: >> + description: List relations. >> + operationId: relations_list >> + parameters: >> + - $ref: '#/components/parameters/Page' >> + - $ref: '#/components/parameters/PageSize' >> + - $ref: '#/components/parameters/Order' >> + responses: >> + '200': >> + description: '' >> + headers: >> + Link: >> + $ref: '#/components/headers/Link' >> + content: >> + application/json: >> + schema: >> + type: array >> + items: >> + $ref: '#/components/schemas/Relation' >> + tags: >> + - relations >> + post: >> + description: Create a relation. >> + operationId: relations_create >> + security: >> + - basicAuth: [] >> + - apiKeyAuth: [] >> + requestBody: >> + $ref: '#/components/requestBodies/Relation' >> + responses: >> + '201': >> + description: '' >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Relation' >> + '400': >> + description: Invalid Request >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Error' >> + '403': >> + description: Forbidden >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Error' >> + '404': >> + description: Not found >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Error' >> + '409': >> + description: Conflict >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Error' >> + tags: >> + - checks >> + /api/{{ version_url }}relations/{id}/: >> + get: >> + description: Show a relation. >> + operationId: relation_read >> + parameters: >> + - in: path >> + name: id >> + description: A unique integer value identifying this relation. >> + required: true >> + schema: >> + title: ID >> + type: integer >> + responses: >> + '200': >> + description: '' >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Relation' >> + '404': >> + description: Not found >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Error' >> + '409': >> + description: Conflict >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Error' >> + tags: >> + - relations >> + patch: >> + description: Update a relation (partial). >> + operationId: relations_partial_update >> + security: >> + - basicAuth: [] >> + - apiKeyAuth: [] >> + parameters: >> + - in: path >> + name: id >> + description: A unique integer value identifying this relation. >> + required: true >> + schema: >> + title: ID >> + type: integer >> + requestBody: >> + $ref: '#/components/requestBodies/Relation' >> + responses: >> + '200': >> + description: '' >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Relation' >> + '400': >> + description: Bad request >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Error' >> + '403': >> + description: Forbidden >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Error' >> + '404': >> + description: Not found >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Error' >> + tags: >> + - relations >> + put: >> + description: Update a relation. >> + operationId: relations_update >> + security: >> + - basicAuth: [] >> + - apiKeyAuth: [] >> + parameters: >> + - in: path >> + name: id >> + description: A unique integer value identifying this relation. >> + required: true >> + schema: >> + title: ID >> + type: integer >> + requestBody: >> + $ref: '#/components/requestBodies/Relation' >> + responses: >> + '200': >> + description: '' >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Relation' >> + '400': >> + description: Bad request >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Error' >> + '403': >> + description: Forbidden >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Error' >> + '404': >> + description: Not found >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Error' >> + tags: >> + - relations >> +{% endif %} >> /api/{{ version_url }}users/: >> get: >> description: List users. >> @@ -1325,6 +1509,20 @@ components: >> application/x-www-form-urlencoded: >> schema: >> $ref: '#/components/schemas/User' >> +{% if version >= (1, 2) %} >> + Relation: >> + required: true >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/RelationUpdate' >> + multipart/form-data: >> + schema: >> + $ref: '#/components/schemas/RelationUpdate' >> + application/x-www-form-urlencoded: >> + schema: >> + $ref: '#/components/schemas/RelationUpdate' >> +{% endif %} >> schemas: >> Index: >> type: object >> @@ -1369,6 +1567,13 @@ components: >> type: string >> format: uri >> readOnly: true >> +{% if version >= (1, 2) %} >> + relations: >> + title: Relations URL >> + type: string >> + format: uri >> + readOnly: true >> +{% endif %} >> Bundle: >> required: >> - name >> @@ -1981,6 +2186,16 @@ components: >> title: Delegate >> type: integer >> nullable: true >> +{% if version >= (1, 2) %} >> + RelationUpdate: >> + type: object >> + properties: >> + submissions: >> + title: Submission IDs >> + type: array >> + items: >> + type: integer >> +{% endif %} >> Person: >> type: object >> properties: >> @@ -2177,6 +2392,32 @@ components: >> $ref: '#/components/schemas/PatchEmbedded' >> readOnly: true >> uniqueItems: true >> +{% if version >= (1, 2) %} >> + Relation: >> + type: object >> + properties: >> + id: >> + title: ID >> + type: integer >> + url: >> + title: URL >> + type: string >> + format: uri >> + readOnly: true >> + by: >> + type: object >> + title: By >> + readOnly: true >> + allOf: >> + - $ref: '#/components/schemas/UserEmbedded' >> + submissions: >> + title: Submissions >> + type: array >> + items: >> + $ref: '#/components/schemas/SubmissionEmbedded' >> + readOnly: true >> + uniqueItems: true >> +{% endif %} >> User: >> type: object >> properties: >> @@ -2255,6 +2496,50 @@ components: >> maxLength: 255 >> minLength: 1 >> readOnly: true >> +{% if version >= (1, 2) %} >> + SubmissionEmbedded: >> + type: object >> + properties: >> + id: >> + title: ID >> + type: integer >> + readOnly: true >> + url: >> + title: URL >> + type: string >> + format: uri >> + readOnly: true >> + web_url: >> + title: Web URL >> + type: string >> + format: uri >> + readOnly: true >> + msgid: >> + title: Message ID >> + type: string >> + readOnly: true >> + minLength: 1 >> + list_archive_url: >> + title: List archive URL >> + type: string >> + readOnly: true >> + nullable: true >> + date: >> + title: Date >> + type: string >> + format: iso8601 >> + readOnly: true >> + name: >> + title: Name >> + type: string >> + readOnly: true >> + minLength: 1 >> + mbox: >> + title: Mbox >> + type: string >> + format: uri >> + readOnly: true >> +{% endif %} >> CoverLetterEmbedded: >> type: object >> properties: >> diff --git a/docs/api/schemas/v1.2/patchwork.yaml b/docs/api/schemas/v1.2/patchwork.yaml >> index d7b4d2957cff..99425e968881 100644 >> --- a/docs/api/schemas/v1.2/patchwork.yaml >> +++ b/docs/api/schemas/v1.2/patchwork.yaml >> @@ -1039,6 +1039,188 @@ paths: >> $ref: '#/components/schemas/Error' >> tags: >> - series >> + /api/1.2/relations/: >> + get: >> + description: List relations. >> + operationId: relations_list >> + parameters: >> + - $ref: '#/components/parameters/Page' >> + - $ref: '#/components/parameters/PageSize' >> + - $ref: '#/components/parameters/Order' >> + responses: >> + '200': >> + description: '' >> + headers: >> + Link: >> + $ref: '#/components/headers/Link' >> + content: >> + application/json: >> + schema: >> + type: array >> + items: >> + $ref: '#/components/schemas/Relation' >> + tags: >> + - relations >> + post: >> + description: Create a relation. >> + operationId: relations_create >> + security: >> + - basicAuth: [] >> + - apiKeyAuth: [] >> + requestBody: >> + $ref: '#/components/requestBodies/Relation' >> + responses: >> + '201': >> + description: '' >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Relation' >> + '400': >> + description: Invalid Request >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Error' >> + '403': >> + description: Forbidden >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Error' >> + '404': >> + description: Not found >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Error' >> + '409': >> + description: Conflict >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Error' >> + tags: >> + - checks >> + /api/1.2/relations/{id}/: >> + get: >> + description: Show a relation. >> + operationId: relation_read >> + parameters: >> + - in: path >> + name: id >> + description: A unique integer value identifying this relation. >> + required: true >> + schema: >> + title: ID >> + type: integer >> + responses: >> + '200': >> + description: '' >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Relation' >> + '404': >> + description: Not found >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Error' >> + '409': >> + description: Conflict >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Error' >> + tags: >> + - relations >> + patch: >> + description: Update a relation (partial). >> + operationId: relations_partial_update >> + security: >> + - basicAuth: [] >> + - apiKeyAuth: [] >> + parameters: >> + - in: path >> + name: id >> + description: A unique integer value identifying this relation. >> + required: true >> + schema: >> + title: ID >> + type: integer >> + requestBody: >> + $ref: '#/components/requestBodies/Relation' >> + responses: >> + '200': >> + description: '' >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Relation' >> + '400': >> + description: Bad request >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Error' >> + '403': >> + description: Forbidden >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Error' >> + '404': >> + description: Not found >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Error' >> + tags: >> + - relations >> + put: >> + description: Update a relation. >> + operationId: relations_update >> + security: >> + - basicAuth: [] >> + - apiKeyAuth: [] >> + parameters: >> + - in: path >> + name: id >> + description: A unique integer value identifying this relation. >> + required: true >> + schema: >> + title: ID >> + type: integer >> + requestBody: >> + $ref: '#/components/requestBodies/Relation' >> + responses: >> + '200': >> + description: '' >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Relation' >> + '400': >> + description: Bad request >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Error' >> + '403': >> + description: Forbidden >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Error' >> + '404': >> + description: Not found >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/Error' >> + tags: >> + - relations >> /api/1.2/users/: >> get: >> description: List users. >> @@ -1314,6 +1496,18 @@ components: >> application/x-www-form-urlencoded: >> schema: >> $ref: '#/components/schemas/User' >> + Relation: >> + required: true >> + content: >> + application/json: >> + schema: >> + $ref: '#/components/schemas/RelationUpdate' >> + multipart/form-data: >> + schema: >> + $ref: '#/components/schemas/RelationUpdate' >> + application/x-www-form-urlencoded: >> + schema: >> + $ref: '#/components/schemas/RelationUpdate' >> schemas: >> Index: >> type: object >> @@ -1358,6 +1552,11 @@ components: >> type: string >> format: uri >> readOnly: true >> + relations: >> + title: Relations URL >> + type: string >> + format: uri >> + readOnly: true >> Bundle: >> required: >> - name >> @@ -1943,6 +2142,14 @@ components: >> title: Delegate >> type: integer >> nullable: true >> + RelationUpdate: >> + type: object >> + properties: >> + submissions: >> + title: Submission IDs >> + type: array >> + items: >> + type: integer >> Person: >> type: object >> properties: >> @@ -2133,6 +2340,30 @@ components: >> $ref: '#/components/schemas/PatchEmbedded' >> readOnly: true >> uniqueItems: true >> + Relation: >> + type: object >> + properties: >> + id: >> + title: ID >> + type: integer >> + url: >> + title: URL >> + type: string >> + format: uri >> + readOnly: true >> + by: >> + type: object >> + title: By >> + readOnly: true >> + allOf: >> + - $ref: '#/components/schemas/UserEmbedded' >> + submissions: >> + title: Submissions >> + type: array >> + items: >> + $ref: '#/components/schemas/SubmissionEmbedded' >> + readOnly: true >> + uniqueItems: true >> User: >> type: object >> properties: >> @@ -2211,6 +2442,48 @@ components: >> maxLength: 255 >> minLength: 1 >> readOnly: true >> + SubmissionEmbedded: >> + type: object >> + properties: >> + id: >> + title: ID >> + type: integer >> + readOnly: true >> + url: >> + title: URL >> + type: string >> + format: uri >> + readOnly: true >> + web_url: >> + title: Web URL >> + type: string >> + format: uri >> + readOnly: true >> + msgid: >> + title: Message ID >> + type: string >> + readOnly: true >> + minLength: 1 >> + list_archive_url: >> + title: List archive URL >> + type: string >> + readOnly: true >> + nullable: true >> + date: >> + title: Date >> + type: string >> + format: iso8601 >> + readOnly: true >> + name: >> + title: Name >> + type: string >> + readOnly: true >> + minLength: 1 >> + mbox: >> + title: Mbox >> + type: string >> + format: uri >> + readOnly: true >> CoverLetterEmbedded: >> type: object >> properties: >> diff --git a/patchwork/api/embedded.py b/patchwork/api/embedded.py >> index de4f31165ee7..0fba291b62b8 100644 >> --- a/patchwork/api/embedded.py >> +++ b/patchwork/api/embedded.py >> @@ -102,6 +102,45 @@ class CheckSerializer(SerializedRelatedField): >> } >> >> >> +def _upgrade_instance(instance): >> + if hasattr(instance, 'patch'): >> + return instance.patch >> + else: >> + return instance.coverletter >> + >> + >> +class SubmissionSerializer(SerializedRelatedField): >> + >> + class _Serializer(BaseHyperlinkedModelSerializer): >> + """We need to 'upgrade' or specialise the submission to the relevant >> + subclass, so we can't use the mixins. This is gross but can go away >> + once we flatten the models.""" >> + url = SerializerMethodField() >> + web_url = SerializerMethodField() >> + mbox = SerializerMethodField() >> + >> + def get_url(self, instance): >> + instance = _upgrade_instance(instance) >> + request = self.context.get('request') >> + return request.build_absolute_uri(instance.get_absolute_api_url()) >> + >> + def get_web_url(self, instance): >> + instance = _upgrade_instance(instance) >> + request = self.context.get('request') >> + return request.build_absolute_uri(instance.get_absolute_url()) >> + >> + def get_mbox(self, instance): >> + instance = _upgrade_instance(instance) >> + request = self.context.get('request') >> + return request.build_absolute_uri(instance.get_mbox_url()) >> + >> + class Meta: >> + model = models.Submission >> + fields = ('id', 'url', 'web_url', 'msgid', 'list_archive_url', >> + 'date', 'name', 'mbox') >> + read_only_fields = fields >> + >> + >> class CoverLetterSerializer(SerializedRelatedField): >> >> class _Serializer(MboxMixin, WebURLMixin, BaseHyperlinkedModelSerializer): >> diff --git a/patchwork/api/index.py b/patchwork/api/index.py >> index 45485c9106f6..cf1845393835 100644 >> --- a/patchwork/api/index.py >> +++ b/patchwork/api/index.py >> @@ -21,4 +21,5 @@ class IndexView(APIView): >> 'series': reverse('api-series-list', request=request), >> 'events': reverse('api-event-list', request=request), >> 'bundles': reverse('api-bundle-list', request=request), >> + 'relations': reverse('api-relation-list', request=request), >> }) >> diff --git a/patchwork/api/relation.py b/patchwork/api/relation.py >> new file mode 100644 >> index 000000000000..37640d62e9cc >> --- /dev/null >> +++ b/patchwork/api/relation.py >> @@ -0,0 +1,121 @@ >> +# Patchwork - automated patch tracking system >> +# Copyright (C) 2019, Bayerische Motoren Werke Aktiengesellschaft (BMW AG) >> +# >> +# SPDX-License-Identifier: GPL-2.0-or-later >> + >> +from rest_framework import permissions >> +from rest_framework import status >> +from rest_framework.exceptions import PermissionDenied, APIException >> +from rest_framework.generics import GenericAPIView >> +from rest_framework.generics import ListCreateAPIView >> +from rest_framework.generics import RetrieveUpdateDestroyAPIView >> +from rest_framework.serializers import ModelSerializer >> + >> +from patchwork.api.base import PatchworkPermission >> +from patchwork.api.embedded import SubmissionSerializer >> +from patchwork.api.embedded import UserSerializer >> +from patchwork.models import SubmissionRelation >> + >> + >> +class MaintainerPermission(PatchworkPermission): >> + >> + def has_permission(self, request, view): >> + if request.method in permissions.SAFE_METHODS: >> + return True >> + >> + # Prevent showing an HTML POST form in the browseable API for logged in >> + # users who are not maintainers. >> + return len(request.user.maintains) > 0 >> + >> + def has_object_permission(self, request, view, relation): >> + if request.method in permissions.SAFE_METHODS: >> + return True >> + >> + maintains = request.user.maintains >> + submissions = relation.submissions.all() >> + # user has to be maintainer of every project a submission is part of >> + return self.check_user_maintains_all(maintains, submissions) >> + >> + @staticmethod >> + def check_user_maintains_all(maintains, submissions): >> + if any(s.project not in maintains for s in submissions): >> + detail = 'At least one submission is part of a project you are ' \ >> + 'not maintaining.' >> + raise PermissionDenied(detail=detail) >> + return True >> + >> + >> +class SubmissionConflict(APIException): >> + status_code = status.HTTP_409_CONFLICT >> + default_detail = 'At least one submission is already part of another ' \ >> + 'relation. You have to explicitly remove a submission ' \ >> + 'from its existing relation before moving it to this one.' >> + >> + >> +class SubmissionRelationSerializer(ModelSerializer): >> + by = UserSerializer(read_only=True) >> + submissions = SubmissionSerializer(many=True) >> + >> + def create(self, validated_data): >> + submissions = validated_data['submissions'] >> + if any(submission.related_id is not None >> + for submission in submissions): >> + raise SubmissionConflict() >> + return super(SubmissionRelationSerializer, self).create(validated_data) >> + >> + def update(self, instance, validated_data): >> + submissions = validated_data['submissions'] >> + if any(submission.related_id is not None and >> + submission.related_id != instance.id >> + for submission in submissions): >> + raise SubmissionConflict() >> + return super(SubmissionRelationSerializer, self) \ >> + .update(instance, validated_data) >> + >> + class Meta: >> + model = SubmissionRelation >> + fields = ('id', 'url', 'by', 'submissions',) >> + read_only_fields = ('url', 'by', ) >> + extra_kwargs = { >> + 'url': {'view_name': 'api-relation-detail'}, >> + } >> + >> + >> +class SubmissionRelationMixin(GenericAPIView): >> + serializer_class = SubmissionRelationSerializer >> + permission_classes = (MaintainerPermission,) >> + >> + def initial(self, request, *args, **kwargs): >> + user = request.user >> + if not hasattr(user, 'maintains'): >> + if user.is_authenticated: >> + user.maintains = user.profile.maintainer_projects.all() >> + else: >> + user.maintains = [] >> + super(SubmissionRelationMixin, self).initial(request, *args, **kwargs) >> + >> + def get_queryset(self): >> + return SubmissionRelation.objects.all() \ >> + .select_related('by') \ >> + .prefetch_related('submissions__patch', >> + 'submissions__coverletter', >> + 'submissions__project') >> + >> + >> +class SubmissionRelationList(SubmissionRelationMixin, ListCreateAPIView): >> + ordering = 'id' >> + ordering_fields = ['id'] >> + >> + def perform_create(self, serializer): >> + # has_object_permission() is not called when creating a new relation. >> + # Check whether user is maintainer of every project a submission is >> + # part of >> + maintains = self.request.user.maintains >> + submissions = serializer.validated_data['submissions'] >> + MaintainerPermission.check_user_maintains_all(maintains, submissions) >> + serializer.save(by=self.request.user) >> + >> + >> +class SubmissionRelationDetail(SubmissionRelationMixin, >> + RetrieveUpdateDestroyAPIView): >> + pass >> diff --git a/patchwork/models.py b/patchwork/models.py >> index a92203b24ff2..9ae3370e896b 100644 >> --- a/patchwork/models.py >> +++ b/patchwork/models.py >> @@ -415,6 +415,9 @@ class CoverLetter(Submission): >> kwargs={'project_id': self.project.linkname, >> 'msgid': self.url_msgid}) >> >> + def get_absolute_api_url(self): >> + return reverse('api-cover-detail', kwargs={'pk': self.id}) >> + >> def get_mbox_url(self): >> return reverse('cover-mbox', >> kwargs={'project_id': self.project.linkname, >> @@ -604,6 +607,9 @@ class Patch(Submission): >> kwargs={'project_id': self.project.linkname, >> 'msgid': self.url_msgid}) >> >> + def get_absolute_api_url(self): >> + return reverse('api-patch-detail', kwargs={'pk': self.id}) >> + >> def get_mbox_url(self): >> return reverse('patch-mbox', >> kwargs={'project_id': self.project.linkname, >> diff --git a/patchwork/tests/api/test_relation.py b/patchwork/tests/api/test_relation.py >> new file mode 100644 >> index 000000000000..5b1a04f13670 >> --- /dev/null >> +++ b/patchwork/tests/api/test_relation.py >> @@ -0,0 +1,181 @@ >> +# Patchwork - automated patch tracking system >> +# Copyright (C) 2019, Bayerische Motoren Werke Aktiengesellschaft (BMW AG) >> +# >> +# SPDX-License-Identifier: GPL-2.0-or-later >> + >> +import unittest >> + >> +import six >> +from django.conf import settings >> +from django.urls import reverse >> + >> +from patchwork.tests.api import utils >> +from patchwork.tests.utils import create_cover >> +from patchwork.tests.utils import create_maintainer >> +from patchwork.tests.utils import create_patches >> +from patchwork.tests.utils import create_project >> +from patchwork.tests.utils import create_relation >> +from patchwork.tests.utils import create_user >> + >> +if settings.ENABLE_REST_API: >> + from rest_framework import status >> + >> + >> +class UserType: >> + ANONYMOUS = 1 >> + NON_MAINTAINER = 2 >> + MAINTAINER = 3 >> + >> + >> +@unittest.skipUnless(settings.ENABLE_REST_API, 'requires ENABLE_REST_API') >> +class TestRelationAPI(utils.APITestCase): >> + fixtures = ['default_tags'] >> + >> + @staticmethod >> + def api_url(item=None): >> + kwargs = {} >> + if item is None: >> + return reverse('api-relation-list', kwargs=kwargs) >> + kwargs['pk'] = item >> + return reverse('api-relation-detail', kwargs=kwargs) >> + >> + def request_restricted(self, method, user_type): >> + """Assert post/delete/patch requests on the relation API.""" >> + assert method in ['post', 'delete', 'patch'] >> + >> + # setup >> + >> + project = create_project() >> + maintainer = create_maintainer(project) >> + >> + if user_type == UserType.ANONYMOUS: >> + expected_status = status.HTTP_403_FORBIDDEN >> + elif user_type == UserType.NON_MAINTAINER: >> + expected_status = status.HTTP_403_FORBIDDEN >> + self.client.force_authenticate(user=create_user()) >> + elif user_type == UserType.MAINTAINER: >> + if method == 'post': >> + expected_status = status.HTTP_201_CREATED >> + elif method == 'delete': >> + expected_status = status.HTTP_204_NO_CONTENT >> + else: >> + expected_status = status.HTTP_200_OK >> + self.client.force_authenticate(user=maintainer) >> + else: >> + raise ValueError >> + >> + resource_id = None >> + req = None >> + >> + if method == 'delete': >> + resource_id = create_relation(project=project, by=maintainer).id >> + elif method == 'post': >> + patch_ids = [p.id for p in create_patches(2, project=project)] >> + req = {'submissions': patch_ids} >> + elif method == 'patch': >> + resource_id = create_relation(project=project, by=maintainer).id >> + patch_ids = [p.id for p in create_patches(2, project=project)] >> + req = {'submissions': patch_ids} >> + else: >> + raise ValueError >> + >> + # request >> + >> + resp = getattr(self.client, method)(self.api_url(resource_id), req) >> + >> + # check >> + >> + self.assertEqual(expected_status, resp.status_code) >> + >> + if resp.status_code in range(status.HTTP_200_OK, >> + status.HTTP_204_NO_CONTENT): >> + self.assertRequest(req, resp.data) >> + >> + def assertRequest(self, request, resp): >> + if request.get('id'): >> + self.assertEqual(request['id'], resp['id']) >> + send_ids = request['submissions'] >> + resp_ids = [s['id'] for s in resp['submissions']] >> + six.assertCountEqual(self, resp_ids, send_ids) >> + >> + def assertSerialized(self, obj, resp): >> + self.assertEqual(obj.id, resp['id']) >> + exp_ids = [s.id for s in obj.submissions.all()] >> + act_ids = [s['id'] for s in resp['submissions']] >> + six.assertCountEqual(self, exp_ids, act_ids) >> + >> + def test_list_empty(self): >> + """List relation when none are present.""" >> + resp = self.client.get(self.api_url()) >> + self.assertEqual(status.HTTP_200_OK, resp.status_code) >> + self.assertEqual(0, len(resp.data)) >> + >> + @utils.store_samples('relation-list') >> + def test_list(self): >> + """List relations.""" >> + relation = create_relation() >> + >> + resp = self.client.get(self.api_url()) >> + self.assertEqual(status.HTTP_200_OK, resp.status_code) >> + self.assertEqual(1, len(resp.data)) >> + self.assertSerialized(relation, resp.data[0]) >> + >> + def test_detail(self): >> + """Show relation.""" >> + relation = create_relation() >> + >> + resp = self.client.get(self.api_url(relation.id)) >> + self.assertEqual(status.HTTP_200_OK, resp.status_code) >> + self.assertSerialized(relation, resp.data) >> + >> + @utils.store_samples('relation-create-error-forbidden') >> + def test_create_anonymous(self): >> + self.request_restricted('post', UserType.ANONYMOUS) >> + >> + def test_create_non_maintainer(self): >> + self.request_restricted('post', UserType.NON_MAINTAINER) >> + >> + @utils.store_samples('relation-create') >> + def test_create_maintainer(self): >> + self.request_restricted('post', UserType.MAINTAINER) >> + >> + @utils.store_samples('relation-update-error-forbidden') >> + def test_update_anonymous(self): >> + self.request_restricted('patch', UserType.ANONYMOUS) >> + >> + def test_update_non_maintainer(self): >> + self.request_restricted('patch', UserType.NON_MAINTAINER) >> + >> + @utils.store_samples('relation-update') >> + def test_update_maintainer(self): >> + self.request_restricted('patch', UserType.MAINTAINER) >> + >> + @utils.store_samples('relation-delete-error-forbidden') >> + def test_delete_anonymous(self): >> + self.request_restricted('delete', UserType.ANONYMOUS) >> + >> + def test_delete_non_maintainer(self): >> + self.request_restricted('delete', UserType.NON_MAINTAINER) >> + >> + @utils.store_samples('relation-update') >> + def test_delete_maintainer(self): >> + self.request_restricted('delete', UserType.MAINTAINER) >> + >> + def test_submission_conflict(self): >> + project = create_project() >> + maintainer = create_maintainer(project) >> + self.client.force_authenticate(user=maintainer) >> + relation = create_relation(by=maintainer, project=project) >> + submission_ids = [s.id for s in relation.submissions.all()] >> + >> + # try to create a new relation with a new submission (cover) and >> + # submissions already bound to another relation >> + cover = create_cover(project=project) >> + submission_ids.append(cover.id) >> + req = {'submissions': submission_ids} >> + resp = self.client.post(self.api_url(), req) >> + self.assertEqual(status.HTTP_409_CONFLICT, resp.status_code) >> + >> + # try to patch relation >> + resp = self.client.patch(self.api_url(relation.id), req) >> + self.assertEqual(status.HTTP_200_OK, resp.status_code) >> diff --git a/patchwork/tests/utils.py b/patchwork/tests/utils.py >> index 577183d0986c..ffe90976233e 100644 >> --- a/patchwork/tests/utils.py >> +++ b/patchwork/tests/utils.py >> @@ -16,6 +16,7 @@ from patchwork.models import Check >> from patchwork.models import Comment >> from patchwork.models import CoverLetter >> from patchwork.models import Patch >> +from patchwork.models import SubmissionRelation >> from patchwork.models import Person >> from patchwork.models import Project >> from patchwork.models import Series >> @@ -347,3 +348,17 @@ def create_covers(count=1, **kwargs): >> kwargs (dict): Overrides for various cover letter fields >> """ >> return _create_submissions(create_cover, count, **kwargs) >> + >> + >> +def create_relation(count_patches=2, by=None, **kwargs): >> + if not by: >> + project = create_project() >> + kwargs['project'] = project >> + by = create_maintainer(project) >> + relation = SubmissionRelation.objects.create(by=by) >> + values = { >> + 'related': relation >> + } >> + values.update(kwargs) >> + create_patches(count_patches, **values) >> + return relation >> diff --git a/patchwork/urls.py b/patchwork/urls.py >> index dcdcfb49e67e..92095f62c7b9 100644 >> --- a/patchwork/urls.py >> +++ b/patchwork/urls.py >> @@ -187,6 +187,7 @@ if settings.ENABLE_REST_API: >> from patchwork.api import patch as api_patch_views # noqa >> from patchwork.api import person as api_person_views # noqa >> from patchwork.api import project as api_project_views # noqa >> + from patchwork.api import relation as api_relation_views # noqa >> from patchwork.api import series as api_series_views # noqa >> from patchwork.api import user as api_user_views # noqa >> >> @@ -256,9 +257,19 @@ if settings.ENABLE_REST_API: >> name='api-cover-comment-list'), >> ] >> >> + api_1_2_patterns = [ >> + url(r'^relations/$', >> + api_relation_views.SubmissionRelationList.as_view(), >> + name='api-relation-list'), >> + url(r'^relations/(?P<pk>[^/]+)/$', >> + api_relation_views.SubmissionRelationDetail.as_view(), >> + name='api-relation-detail'), >> + ] >> + >> urlpatterns += [ >> url(r'^api/(?:(?P<version>(1.0|1.1|1.2))/)?', include(api_patterns)), >> url(r'^api/(?:(?P<version>(1.1|1.2))/)?', include(api_1_1_patterns)), >> + url(r'^api/(?:(?P<version>1.2)/)?', include(api_1_2_patterns)), >> >> # token change >> url(r'^user/generate-token/$', user_views.generate_token, >> diff --git a/releasenotes/notes/add-submission-relations-c96bb6c567b416d8.yaml b/releasenotes/notes/add-submission-relations-c96bb6c567b416d8.yaml >> new file mode 100644 >> index 000000000000..cb877991cd55 >> --- /dev/null >> +++ b/releasenotes/notes/add-submission-relations-c96bb6c567b416d8.yaml >> @@ -0,0 +1,10 @@ >> +--- >> +features: >> + - | >> + Submissions (cover letters or patches) can now be related to other ones >> + (e.g. revisions). Relations can be set via the REST API by maintainers >> + (currently only for submissions of projects they maintain) >> +api: >> + - | >> + Relations are available via ``/relations/`` and >> + ``/relations/{relationID}/`` endpoints. > > _______________________________________________ > Patchwork mailing list > Patchwork@lists.ozlabs.org > https://lists.ozlabs.org/listinfo/patchwork
On Sun, 2020-01-12 at 00:20 +1100, Daniel Axtens wrote: > Stephen Finucane <stephen@that.guru> writes: > > > On Sat, 2019-12-07 at 17:46 +0100, Mete Polat wrote: > > > View relations and add/update/delete them as a maintainer. Maintainers > > > can only create relations of submissions (patches/cover letters) which > > > are part of a project they maintain. > > > > > > New REST API urls: > > > api/relations/ > > > api/relations/<relation_id>/ > > > > > > Co-authored-by: Daniel Axtens <dja@axtens.net> > > > Signed-off-by: Mete Polat <metepolat2000@gmail.com> > > > > Why did you choose to expose this as a separate API rather than as a > > field on the '/patches' resource? While a 'Series' objet has enough > > separate metadata to warrant a separate '/series' resource, a > > 'SubmissionRelation' object is basically just a container. Including a > > 'related_patches' field on the detailed patch view would seem like more > > than enough detail for me, anyway, and unless there's a reason not to > > do this, I'd like to see it done that way. Is it possible? > > > > How would creating an relation work then? currently you POST to > /api/relations/ with all the patch IDs you want to include in the > relation. I agree that viewing relations through /api/patch makes sense, > but I'm not sure how you create relations if that's the only endpoint > you have? 'PATCH /api/patch/{patchID}' (or 'PUT'), surely? > Regards, > Daniel > > > Stephen > > > > PS: I could have sworn I had asked this before, but I can't find any > > mails about it so maybe I didn't. Please tell me to RTML (read the > > mailing list) if so > > > > > --- > > > Optimize db queries: > > > I have spent quite a lot of time in optimizing the db queries for the REST API > > > (thanks for the tip with the Django toolbar). Daniel stated that > > > prefetch_related is possibly hitting the database for every relation when > > > prefetching submissions but it turns out that we can tell Django to use a > > > statement like: > > > SELECT * > > > FROM `patchwork_patch` > > > INNER JOIN `patchwork_submission` > > > ON (`patchwork_patch`.`submission_ptr_id` = `patchwork_submission`.`id`) > > > WHERE `patchwork_patch`.`submission_ptr_id` IN (LIST_OF_ALL_SUBMISSION_IDS) > > > > > > We do the same for `patchwork_coverletter`. > > > This means we only hit the db two times for casting _all_ submissions to a > > > patch or cover-letter. > > > > > > Prefetching submissions__project eliminates similar and duplicate queries > > > that are used to determine whether a logged in user is at least maintainer > > > of one submission's project. > > > > > > docs/api/schemas/latest/patchwork.yaml | 273 +++++++++++++++++ > > > docs/api/schemas/patchwork.j2 | 285 ++++++++++++++++++ > > > docs/api/schemas/v1.2/patchwork.yaml | 273 +++++++++++++++++ > > > patchwork/api/embedded.py | 39 +++ > > > patchwork/api/index.py | 1 + > > > patchwork/api/relation.py | 121 ++++++++ > > > patchwork/models.py | 6 + > > > patchwork/tests/api/test_relation.py | 181 +++++++++++ > > > patchwork/tests/utils.py | 15 + > > > patchwork/urls.py | 11 + > > > ...submission-relations-c96bb6c567b416d8.yaml | 10 + > > > 11 files changed, 1215 insertions(+) > > > create mode 100644 patchwork/api/relation.py > > > create mode 100644 patchwork/tests/api/test_relation.py > > > create mode 100644 releasenotes/notes/add-submission-relations-c96bb6c567b416d8.yaml > > > > > > diff --git a/docs/api/schemas/latest/patchwork.yaml b/docs/api/schemas/latest/patchwork.yaml > > > index a5e235be936d..7dd24fd700d5 100644 > > > --- a/docs/api/schemas/latest/patchwork.yaml > > > +++ b/docs/api/schemas/latest/patchwork.yaml > > > @@ -1039,6 +1039,188 @@ paths: > > > $ref: '#/components/schemas/Error' > > > tags: > > > - series > > > + /api/relations/: > > > + get: > > > + description: List relations. > > > + operationId: relations_list > > > + parameters: > > > + - $ref: '#/components/parameters/Page' > > > + - $ref: '#/components/parameters/PageSize' > > > + - $ref: '#/components/parameters/Order' > > > + responses: > > > + '200': > > > + description: '' > > > + headers: > > > + Link: > > > + $ref: '#/components/headers/Link' > > > + content: > > > + application/json: > > > + schema: > > > + type: array > > > + items: > > > + $ref: '#/components/schemas/Relation' > > > + tags: > > > + - relations > > > + post: > > > + description: Create a relation. > > > + operationId: relations_create > > > + security: > > > + - basicAuth: [] > > > + - apiKeyAuth: [] > > > + requestBody: > > > + $ref: '#/components/requestBodies/Relation' > > > + responses: > > > + '201': > > > + description: '' > > > + content: > > > + application/json: > > > + schema: > > > + $ref: '#/components/schemas/Relation' > > > + '400': > > > + description: Invalid Request > > > + content: > > > + application/json: > > > + schema: > > > + $ref: '#/components/schemas/Error' > > > + '403': > > > + description: Forbidden > > > + content: > > > + application/json: > > > + schema: > > > + $ref: '#/components/schemas/Error' > > > + '404': > > > + description: Not found > > > + content: > > > + application/json: > > > + schema: > > > + $ref: '#/components/schemas/Error' > > > + '409': > > > + description: Conflict > > > + content: > > > + application/json: > > > + schema: > > > + $ref: '#/components/schemas/Error' > > > + tags: > > > + - checks > > > + /api/relations/{id}/: > > > + get: > > > + description: Show a relation. > > > + operationId: relation_read > > > + parameters: > > > + - in: path > > > + name: id > > > + description: A unique integer value identifying this relation. > > > + required: true > > > + schema: > > > + title: ID > > > + type: integer > > > + responses: > > > + '200': > > > + description: '' > > > + content: > > > + application/json: > > > + schema: > > > + $ref: '#/components/schemas/Relation' > > > + '404': > > > + description: Not found > > > + content: > > > + application/json: > > > + schema: > > > + $ref: '#/components/schemas/Error' > > > + '409': > > > + description: Conflict > > > + content: > > > + application/json: > > > + schema: > > > + $ref: '#/components/schemas/Error' > > > + tags: > > > + - relations > > > + patch: > > > + description: Update a relation (partial). > > > + operationId: relations_partial_update > > > + security: > > > + - basicAuth: [] > > > + - apiKeyAuth: [] > > > + parameters: > > > + - in: path > > > + name: id > > > + description: A unique integer value identifying this relation. > > > + required: true > > > + schema: > > > + title: ID > > > + type: integer > > > + requestBody: > > > + $ref: '#/components/requestBodies/Relation' > > > + responses: > > > + '200': > > > + description: '' > > > + content: > > > + application/json: > > > + schema: > > > + $ref: '#/components/schemas/Relation' > > > + '400': > > > + description: Bad request > > > + content: > > > + application/json: > > > + schema: > > > + $ref: '#/components/schemas/Error' > > > + '403': > > > + description: Forbidden > > > + content: > > > + application/json: > > > + schema: > > > + $ref: '#/components/schemas/Error' > > > + '404': > > > + description: Not found > > > + content: > > > + application/json: > > > + schema: > > > + $ref: '#/components/schemas/Error' > > > + tags: > > > + - relations > > > + put: > > > + description: Update a relation. > > > + operationId: relations_update > > > + security: > > > + - basicAuth: [] > > > + - apiKeyAuth: [] > > > + parameters: > > > + - in: path > > > + name: id > > > + description: A unique integer value identifying this relation. > > > + required: true > > > + schema: > > > + title: ID > > > + type: integer > > > + requestBody: > > > + $ref: '#/components/requestBodies/Relation' > > > + responses: > > > + '200': > > > + description: '' > > > + content: > > > + application/json: > > > + schema: > > > + $ref: '#/components/schemas/Relation' > > > + '400': > > > + description: Bad request > > > + content: > > > + application/json: > > > + schema: > > > + $ref: '#/components/schemas/Error' > > > + '403': > > > + description: Forbidden > > > + content: > > > + application/json: > > > + schema: > > > + $ref: '#/components/schemas/Error' > > > + '404': > > > + description: Not found > > > + content: > > > + application/json: > > > + schema: > > > + $ref: '#/components/schemas/Error' > > > + tags: > > > + - relations > > > /api/users/: > > > get: > > > description: List users. > > > @@ -1314,6 +1496,18 @@ components: > > > application/x-www-form-urlencoded: > > > schema: > > > $ref: '#/components/schemas/User' > > > + Relation: > > > + required: true > > > + content: > > > + application/json: > > > + schema: > > > + $ref: '#/components/schemas/RelationUpdate' > > > + multipart/form-data: > > > + schema: > > > + $ref: '#/components/schemas/RelationUpdate' > > > + application/x-www-form-urlencoded: > > > + schema: > > > + $ref: '#/components/schemas/RelationUpdate' > > > schemas: > > > Index: > > > type: object > > > @@ -1358,6 +1552,11 @@ components: > > > type: string > > > format: uri > > > readOnly: true > > > + relations: > > > + title: Relations URL > > > + type: string > > > + format: uri > > > + readOnly: true > > > Bundle: > > > required: > > > - name > > > @@ -1943,6 +2142,14 @@ components: > > > title: Delegate > > > type: integer > > > nullable: true > > > + RelationUpdate: > > > + type: object > > > + properties: > > > + submissions: > > > + title: Submission IDs > > > + type: array > > > + items: > > > + type: integer > > > Person: > > > type: object > > > properties: > > > @@ -2133,6 +2340,30 @@ components: > > > $ref: '#/components/schemas/PatchEmbedded' > > > readOnly: true > > > uniqueItems: true > > > + Relation: > > > + type: object > > > + properties: > > > + id: > > > + title: ID > > > + type: integer > > > + url: > > > + title: URL > > > + type: string > > > + format: uri > > > + readOnly: true > > > + by: > > > + type: object > > > + title: By > > > + readOnly: true > > > + allOf: > > > + - $ref: '#/components/schemas/UserEmbedded' > > > + submissions: > > > + title: Submissions > > > + type: array > > > + items: > > > + $ref: '#/components/schemas/SubmissionEmbedded' > > > + readOnly: true > > > + uniqueItems: true > > > User: > > > type: object > > > properties: > > > @@ -2211,6 +2442,48 @@ components: > > > maxLength: 255 > > > minLength: 1 > > > readOnly: true > > > + SubmissionEmbedded: > > > + type: object > > > + properties: > > > + id: > > > + title: ID > > > + type: integer > > > + readOnly: true > > > + url: > > > + title: URL > > > + type: string > > > + format: uri > > > + readOnly: true > > > + web_url: > > > + title: Web URL > > > + type: string > > > + format: uri > > > + readOnly: true > > > + msgid: > > > + title: Message ID > > > + type: string > > > + readOnly: true > > > + minLength: 1 > > > + list_archive_url: > > > + title: List archive URL > > > + type: string > > > + readOnly: true > > > + nullable: true > > > + date: > > > + title: Date > > > + type: string > > > + format: iso8601 > > > + readOnly: true > > > + name: > > > + title: Name > > > + type: string > > > + readOnly: true > > > + minLength: 1 > > > + mbox: > > > + title: Mbox > > > + type: string > > > + format: uri > > > + readOnly: true > > > CoverLetterEmbedded: > > > type: object > > > properties: > > > diff --git a/docs/api/schemas/patchwork.j2 b/docs/api/schemas/patchwork.j2 > > > index 196d78466b55..a034029accf9 100644 > > > --- a/docs/api/schemas/patchwork.j2 > > > +++ b/docs/api/schemas/patchwork.j2 > > > @@ -1048,6 +1048,190 @@ paths: > > > $ref: '#/components/schemas/Error' > > > tags: > > > - series > > > +{% if version >= (1, 2) %} > > > + /api/{{ version_url }}relations/: > > > + get: > > > + description: List relations. > > > + operationId: relations_list > > > + parameters: > > > + - $ref: '#/components/parameters/Page' > > > + - $ref: '#/components/parameters/PageSize' > > > + - $ref: '#/components/parameters/Order' > > > + responses: > > > + '200': > > > + description: '' > > > + headers: > > > + Link: > > > + $ref: '#/components/headers/Link' > > > + content: > > > + application/json: > > > + schema: > > > + type: array > > > + items: > > > + $ref: '#/components/schemas/Relation' > > > + tags: > > > + - relations > > > + post: > > > + description: Create a relation. > > > + operationId: relations_create > > > + security: > > > + - basicAuth: [] > > > + - apiKeyAuth: [] > > > + requestBody: > > > + $ref: '#/components/requestBodies/Relation' > > > + responses: > > > + '201': > > > + description: '' > > > + content: > > > + application/json: > > > + schema: > > > + $ref: '#/components/schemas/Relation' > > > + '400': > > > + description: Invalid Request > > > + content: > > > + application/json: > > > + schema: > > > + $ref: '#/components/schemas/Error' > > > + '403': > > > + description: Forbidden > > > + content: > > > + application/json: > > > + schema: > > > + $ref: '#/components/schemas/Error' > > > + '404': > > > + description: Not found > > > + content: > > > + application/json: > > > + schema: > > > + $ref: '#/components/schemas/Error' > > > + '409': > > > + description: Conflict > > > + content: > > > + application/json: > > > + schema: > > > + $ref: '#/components/schemas/Error' > > > + tags: > > > + - checks > > > + /api/{{ version_url }}relations/{id}/: > > > + get: > > > + description: Show a relation. > > > + operationId: relation_read > > > + parameters: > > > + - in: path > > > + name: id > > > + description: A unique integer value identifying this relation. > > > + required: true > > > + schema: > > > + title: ID > > > + type: integer > > > + responses: > > > + '200': > > > + description: '' > > > + content: > > > + application/json: > > > + schema: > > > + $ref: '#/components/schemas/Relation' > > > + '404': > > > + description: Not found > > > + content: > > > + application/json: > > > + schema: > > > + $ref: '#/components/schemas/Error' > > > + '409': > > > + description: Conflict > > > + content: > > > + application/json: > > > + schema: > > > + $ref: '#/components/schemas/Error' > > > + tags: > > > + - relations > > > + patch: > > > + description: Update a relation (partial). > > > + operationId: relations_partial_update > > > + security: > > > + - basicAuth: [] > > > + - apiKeyAuth: [] > > > + parameters: > > > + - in: path > > > + name: id > > > + description: A unique integer value identifying this relation. > > > + required: true > > > + schema: > > > + title: ID > > > + type: integer > > > + requestBody: > > > + $ref: '#/components/requestBodies/Relation' > > > + responses: > > > + '200': > > > + description: '' > > > + content: > > > + application/json: > > > + schema: > > > + $ref: '#/components/schemas/Relation' > > > + '400': > > > + description: Bad request > > > + content: > > > + application/json: > > > + schema: > > > + $ref: '#/components/schemas/Error' > > > + '403': > > > + description: Forbidden > > > + content: > > > + application/json: > > > + schema: > > > + $ref: '#/components/schemas/Error' > > > + '404': > > > + description: Not found > > > + content: > > > + application/json: > > > + schema: > > > + $ref: '#/components/schemas/Error' > > > + tags: > > > + - relations > > > + put: > > > + description: Update a relation. > > > + operationId: relations_update > > > + security: > > > + - basicAuth: [] > > > + - apiKeyAuth: [] > > > + parameters: > > > + - in: path > > > + name: id > > > + description: A unique integer value identifying this relation. > > > + required: true > > > + schema: > > > + title: ID > > > + type: integer > > > + requestBody: > > > + $ref: '#/components/requestBodies/Relation' > > > + responses: > > > + '200': > > > + description: '' > > > + content: > > > + application/json: > > > + schema: > > > + $ref: '#/components/schemas/Relation' > > > + '400': > > > + description: Bad request > > > + content: > > > + application/json: > > > + schema: > > > + $ref: '#/components/schemas/Error' > > > + '403': > > > + description: Forbidden > > > + content: > > > + application/json: > > > + schema: > > > + $ref: '#/components/schemas/Error' > > > + '404': > > > + description: Not found > > > + content: > > > + application/json: > > > + schema: > > > + $ref: '#/components/schemas/Error' > > > + tags: > > > + - relations > > > +{% endif %} > > > /api/{{ version_url }}users/: > > > get: > > > description: List users. > > > @@ -1325,6 +1509,20 @@ components: > > > application/x-www-form-urlencoded: > > > schema: > > > $ref: '#/components/schemas/User' > > > +{% if version >= (1, 2) %} > > > + Relation: > > > + required: true > > > + content: > > > + application/json: > > > + schema: > > > + $ref: '#/components/schemas/RelationUpdate' > > > + multipart/form-data: > > > + schema: > > > + $ref: '#/components/schemas/RelationUpdate' > > > + application/x-www-form-urlencoded: > > > + schema: > > > + $ref: '#/components/schemas/RelationUpdate' > > > +{% endif %} > > > schemas: > > > Index: > > > type: object > > > @@ -1369,6 +1567,13 @@ components: > > > type: string > > > format: uri > > > readOnly: true > > > +{% if version >= (1, 2) %} > > > + relations: > > > + title: Relations URL > > > + type: string > > > + format: uri > > > + readOnly: true > > > +{% endif %} > > > Bundle: > > > required: > > > - name > > > @@ -1981,6 +2186,16 @@ components: > > > title: Delegate > > > type: integer > > > nullable: true > > > +{% if version >= (1, 2) %} > > > + RelationUpdate: > > > + type: object > > > + properties: > > > + submissions: > > > + title: Submission IDs > > > + type: array > > > + items: > > > + type: integer > > > +{% endif %} > > > Person: > > > type: object > > > properties: > > > @@ -2177,6 +2392,32 @@ components: > > > $ref: '#/components/schemas/PatchEmbedded' > > > readOnly: true > > > uniqueItems: true > > > +{% if version >= (1, 2) %} > > > + Relation: > > > + type: object > > > + properties: > > > + id: > > > + title: ID > > > + type: integer > > > + url: > > > + title: URL > > > + type: string > > > + format: uri > > > + readOnly: true > > > + by: > > > + type: object > > > + title: By > > > + readOnly: true > > > + allOf: > > > + - $ref: '#/components/schemas/UserEmbedded' > > > + submissions: > > > + title: Submissions > > > + type: array > > > + items: > > > + $ref: '#/components/schemas/SubmissionEmbedded' > > > + readOnly: true > > > + uniqueItems: true > > > +{% endif %} > > > User: > > > type: object > > > properties: > > > @@ -2255,6 +2496,50 @@ components: > > > maxLength: 255 > > > minLength: 1 > > > readOnly: true > > > +{% if version >= (1, 2) %} > > > + SubmissionEmbedded: > > > + type: object > > > + properties: > > > + id: > > > + title: ID > > > + type: integer > > > + readOnly: true > > > + url: > > > + title: URL > > > + type: string > > > + format: uri > > > + readOnly: true > > > + web_url: > > > + title: Web URL > > > + type: string > > > + format: uri > > > + readOnly: true > > > + msgid: > > > + title: Message ID > > > + type: string > > > + readOnly: true > > > + minLength: 1 > > > + list_archive_url: > > > + title: List archive URL > > > + type: string > > > + readOnly: true > > > + nullable: true > > > + date: > > > + title: Date > > > + type: string > > > + format: iso8601 > > > + readOnly: true > > > + name: > > > + title: Name > > > + type: string > > > + readOnly: true > > > + minLength: 1 > > > + mbox: > > > + title: Mbox > > > + type: string > > > + format: uri > > > + readOnly: true > > > +{% endif %} > > > CoverLetterEmbedded: > > > type: object > > > properties: > > > diff --git a/docs/api/schemas/v1.2/patchwork.yaml b/docs/api/schemas/v1.2/patchwork.yaml > > > index d7b4d2957cff..99425e968881 100644 > > > --- a/docs/api/schemas/v1.2/patchwork.yaml > > > +++ b/docs/api/schemas/v1.2/patchwork.yaml > > > @@ -1039,6 +1039,188 @@ paths: > > > $ref: '#/components/schemas/Error' > > > tags: > > > - series > > > + /api/1.2/relations/: > > > + get: > > > + description: List relations. > > > + operationId: relations_list > > > + parameters: > > > + - $ref: '#/components/parameters/Page' > > > + - $ref: '#/components/parameters/PageSize' > > > + - $ref: '#/components/parameters/Order' > > > + responses: > > > + '200': > > > + description: '' > > > + headers: > > > + Link: > > > + $ref: '#/components/headers/Link' > > > + content: > > > + application/json: > > > + schema: > > > + type: array > > > + items: > > > + $ref: '#/components/schemas/Relation' > > > + tags: > > > + - relations > > > + post: > > > + description: Create a relation. > > > + operationId: relations_create > > > + security: > > > + - basicAuth: [] > > > + - apiKeyAuth: [] > > > + requestBody: > > > + $ref: '#/components/requestBodies/Relation' > > > + responses: > > > + '201': > > > + description: '' > > > + content: > > > + application/json: > > > + schema: > > > + $ref: '#/components/schemas/Relation' > > > + '400': > > > + description: Invalid Request > > > + content: > > > + application/json: > > > + schema: > > > + $ref: '#/components/schemas/Error' > > > + '403': > > > + description: Forbidden > > > + content: > > > + application/json: > > > + schema: > > > + $ref: '#/components/schemas/Error' > > > + '404': > > > + description: Not found > > > + content: > > > + application/json: > > > + schema: > > > + $ref: '#/components/schemas/Error' > > > + '409': > > > + description: Conflict > > > + content: > > > + application/json: > > > + schema: > > > + $ref: '#/components/schemas/Error' > > > + tags: > > > + - checks > > > + /api/1.2/relations/{id}/: > > > + get: > > > + description: Show a relation. > > > + operationId: relation_read > > > + parameters: > > > + - in: path > > > + name: id > > > + description: A unique integer value identifying this relation. > > > + required: true > > > + schema: > > > + title: ID > > > + type: integer > > > + responses: > > > + '200': > > > + description: '' > > > + content: > > > + application/json: > > > + schema: > > > + $ref: '#/components/schemas/Relation' > > > + '404': > > > + description: Not found > > > + content: > > > + application/json: > > > + schema: > > > + $ref: '#/components/schemas/Error' > > > + '409': > > > + description: Conflict > > > + content: > > > + application/json: > > > + schema: > > > + $ref: '#/components/schemas/Error' > > > + tags: > > > + - relations > > > + patch: > > > + description: Update a relation (partial). > > > + operationId: relations_partial_update > > > + security: > > > + - basicAuth: [] > > > + - apiKeyAuth: [] > > > + parameters: > > > + - in: path > > > + name: id > > > + description: A unique integer value identifying this relation. > > > + required: true > > > + schema: > > > + title: ID > > > + type: integer > > > + requestBody: > > > + $ref: '#/components/requestBodies/Relation' > > > + responses: > > > + '200': > > > + description: '' > > > + content: > > > + application/json: > > > + schema: > > > + $ref: '#/components/schemas/Relation' > > > + '400': > > > + description: Bad request > > > + content: > > > + application/json: > > > + schema: > > > + $ref: '#/components/schemas/Error' > > > + '403': > > > + description: Forbidden > > > + content: > > > + application/json: > > > + schema: > > > + $ref: '#/components/schemas/Error' > > > + '404': > > > + description: Not found > > > + content: > > > + application/json: > > > + schema: > > > + $ref: '#/components/schemas/Error' > > > + tags: > > > + - relations > > > + put: > > > + description: Update a relation. > > > + operationId: relations_update > > > + security: > > > + - basicAuth: [] > > > + - apiKeyAuth: [] > > > + parameters: > > > + - in: path > > > + name: id > > > + description: A unique integer value identifying this relation. > > > + required: true > > > + schema: > > > + title: ID > > > + type: integer > > > + requestBody: > > > + $ref: '#/components/requestBodies/Relation' > > > + responses: > > > + '200': > > > + description: '' > > > + content: > > > + application/json: > > > + schema: > > > + $ref: '#/components/schemas/Relation' > > > + '400': > > > + description: Bad request > > > + content: > > > + application/json: > > > + schema: > > > + $ref: '#/components/schemas/Error' > > > + '403': > > > + description: Forbidden > > > + content: > > > + application/json: > > > + schema: > > > + $ref: '#/components/schemas/Error' > > > + '404': > > > + description: Not found > > > + content: > > > + application/json: > > > + schema: > > > + $ref: '#/components/schemas/Error' > > > + tags: > > > + - relations > > > /api/1.2/users/: > > > get: > > > description: List users. > > > @@ -1314,6 +1496,18 @@ components: > > > application/x-www-form-urlencoded: > > > schema: > > > $ref: '#/components/schemas/User' > > > + Relation: > > > + required: true > > > + content: > > > + application/json: > > > + schema: > > > + $ref: '#/components/schemas/RelationUpdate' > > > + multipart/form-data: > > > + schema: > > > + $ref: '#/components/schemas/RelationUpdate' > > > + application/x-www-form-urlencoded: > > > + schema: > > > + $ref: '#/components/schemas/RelationUpdate' > > > schemas: > > > Index: > > > type: object > > > @@ -1358,6 +1552,11 @@ components: > > > type: string > > > format: uri > > > readOnly: true > > > + relations: > > > + title: Relations URL > > > + type: string > > > + format: uri > > > + readOnly: true > > > Bundle: > > > required: > > > - name > > > @@ -1943,6 +2142,14 @@ components: > > > title: Delegate > > > type: integer > > > nullable: true > > > + RelationUpdate: > > > + type: object > > > + properties: > > > + submissions: > > > + title: Submission IDs > > > + type: array > > > + items: > > > + type: integer > > > Person: > > > type: object > > > properties: > > > @@ -2133,6 +2340,30 @@ components: > > > $ref: '#/components/schemas/PatchEmbedded' > > > readOnly: true > > > uniqueItems: true > > > + Relation: > > > + type: object > > > + properties: > > > + id: > > > + title: ID > > > + type: integer > > > + url: > > > + title: URL > > > + type: string > > > + format: uri > > > + readOnly: true > > > + by: > > > + type: object > > > + title: By > > > + readOnly: true > > > + allOf: > > > + - $ref: '#/components/schemas/UserEmbedded' > > > + submissions: > > > + title: Submissions > > > + type: array > > > + items: > > > + $ref: '#/components/schemas/SubmissionEmbedded' > > > + readOnly: true > > > + uniqueItems: true > > > User: > > > type: object > > > properties: > > > @@ -2211,6 +2442,48 @@ components: > > > maxLength: 255 > > > minLength: 1 > > > readOnly: true > > > + SubmissionEmbedded: > > > + type: object > > > + properties: > > > + id: > > > + title: ID > > > + type: integer > > > + readOnly: true > > > + url: > > > + title: URL > > > + type: string > > > + format: uri > > > + readOnly: true > > > + web_url: > > > + title: Web URL > > > + type: string > > > + format: uri > > > + readOnly: true > > > + msgid: > > > + title: Message ID > > > + type: string > > > + readOnly: true > > > + minLength: 1 > > > + list_archive_url: > > > + title: List archive URL > > > + type: string > > > + readOnly: true > > > + nullable: true > > > + date: > > > + title: Date > > > + type: string > > > + format: iso8601 > > > + readOnly: true > > > + name: > > > + title: Name > > > + type: string > > > + readOnly: true > > > + minLength: 1 > > > + mbox: > > > + title: Mbox > > > + type: string > > > + format: uri > > > + readOnly: true > > > CoverLetterEmbedded: > > > type: object > > > properties: > > > diff --git a/patchwork/api/embedded.py b/patchwork/api/embedded.py > > > index de4f31165ee7..0fba291b62b8 100644 > > > --- a/patchwork/api/embedded.py > > > +++ b/patchwork/api/embedded.py > > > @@ -102,6 +102,45 @@ class CheckSerializer(SerializedRelatedField): > > > } > > > > > > > > > +def _upgrade_instance(instance): > > > + if hasattr(instance, 'patch'): > > > + return instance.patch > > > + else: > > > + return instance.coverletter > > > + > > > + > > > +class SubmissionSerializer(SerializedRelatedField): > > > + > > > + class _Serializer(BaseHyperlinkedModelSerializer): > > > + """We need to 'upgrade' or specialise the submission to the relevant > > > + subclass, so we can't use the mixins. This is gross but can go away > > > + once we flatten the models.""" > > > + url = SerializerMethodField() > > > + web_url = SerializerMethodField() > > > + mbox = SerializerMethodField() > > > + > > > + def get_url(self, instance): > > > + instance = _upgrade_instance(instance) > > > + request = self.context.get('request') > > > + return request.build_absolute_uri(instance.get_absolute_api_url()) > > > + > > > + def get_web_url(self, instance): > > > + instance = _upgrade_instance(instance) > > > + request = self.context.get('request') > > > + return request.build_absolute_uri(instance.get_absolute_url()) > > > + > > > + def get_mbox(self, instance): > > > + instance = _upgrade_instance(instance) > > > + request = self.context.get('request') > > > + return request.build_absolute_uri(instance.get_mbox_url()) > > > + > > > + class Meta: > > > + model = models.Submission > > > + fields = ('id', 'url', 'web_url', 'msgid', 'list_archive_url', > > > + 'date', 'name', 'mbox') > > > + read_only_fields = fields > > > + > > > + > > > class CoverLetterSerializer(SerializedRelatedField): > > > > > > class _Serializer(MboxMixin, WebURLMixin, BaseHyperlinkedModelSerializer): > > > diff --git a/patchwork/api/index.py b/patchwork/api/index.py > > > index 45485c9106f6..cf1845393835 100644 > > > --- a/patchwork/api/index.py > > > +++ b/patchwork/api/index.py > > > @@ -21,4 +21,5 @@ class IndexView(APIView): > > > 'series': reverse('api-series-list', request=request), > > > 'events': reverse('api-event-list', request=request), > > > 'bundles': reverse('api-bundle-list', request=request), > > > + 'relations': reverse('api-relation-list', request=request), > > > }) > > > diff --git a/patchwork/api/relation.py b/patchwork/api/relation.py > > > new file mode 100644 > > > index 000000000000..37640d62e9cc > > > --- /dev/null > > > +++ b/patchwork/api/relation.py > > > @@ -0,0 +1,121 @@ > > > +# Patchwork - automated patch tracking system > > > +# Copyright (C) 2019, Bayerische Motoren Werke Aktiengesellschaft (BMW AG) > > > +# > > > +# SPDX-License-Identifier: GPL-2.0-or-later > > > + > > > +from rest_framework import permissions > > > +from rest_framework import status > > > +from rest_framework.exceptions import PermissionDenied, APIException > > > +from rest_framework.generics import GenericAPIView > > > +from rest_framework.generics import ListCreateAPIView > > > +from rest_framework.generics import RetrieveUpdateDestroyAPIView > > > +from rest_framework.serializers import ModelSerializer > > > + > > > +from patchwork.api.base import PatchworkPermission > > > +from patchwork.api.embedded import SubmissionSerializer > > > +from patchwork.api.embedded import UserSerializer > > > +from patchwork.models import SubmissionRelation > > > + > > > + > > > +class MaintainerPermission(PatchworkPermission): > > > + > > > + def has_permission(self, request, view): > > > + if request.method in permissions.SAFE_METHODS: > > > + return True > > > + > > > + # Prevent showing an HTML POST form in the browseable API for logged in > > > + # users who are not maintainers. > > > + return len(request.user.maintains) > 0 > > > + > > > + def has_object_permission(self, request, view, relation): > > > + if request.method in permissions.SAFE_METHODS: > > > + return True > > > + > > > + maintains = request.user.maintains > > > + submissions = relation.submissions.all() > > > + # user has to be maintainer of every project a submission is part of > > > + return self.check_user_maintains_all(maintains, submissions) > > > + > > > + @staticmethod > > > + def check_user_maintains_all(maintains, submissions): > > > + if any(s.project not in maintains for s in submissions): > > > + detail = 'At least one submission is part of a project you are ' \ > > > + 'not maintaining.' > > > + raise PermissionDenied(detail=detail) > > > + return True > > > + > > > + > > > +class SubmissionConflict(APIException): > > > + status_code = status.HTTP_409_CONFLICT > > > + default_detail = 'At least one submission is already part of another ' \ > > > + 'relation. You have to explicitly remove a submission ' \ > > > + 'from its existing relation before moving it to this one.' > > > + > > > + > > > +class SubmissionRelationSerializer(ModelSerializer): > > > + by = UserSerializer(read_only=True) > > > + submissions = SubmissionSerializer(many=True) > > > + > > > + def create(self, validated_data): > > > + submissions = validated_data['submissions'] > > > + if any(submission.related_id is not None > > > + for submission in submissions): > > > + raise SubmissionConflict() > > > + return super(SubmissionRelationSerializer, self).create(validated_data) > > > + > > > + def update(self, instance, validated_data): > > > + submissions = validated_data['submissions'] > > > + if any(submission.related_id is not None and > > > + submission.related_id != instance.id > > > + for submission in submissions): > > > + raise SubmissionConflict() > > > + return super(SubmissionRelationSerializer, self) \ > > > + .update(instance, validated_data) > > > + > > > + class Meta: > > > + model = SubmissionRelation > > > + fields = ('id', 'url', 'by', 'submissions',) > > > + read_only_fields = ('url', 'by', ) > > > + extra_kwargs = { > > > + 'url': {'view_name': 'api-relation-detail'}, > > > + } > > > + > > > + > > > +class SubmissionRelationMixin(GenericAPIView): > > > + serializer_class = SubmissionRelationSerializer > > > + permission_classes = (MaintainerPermission,) > > > + > > > + def initial(self, request, *args, **kwargs): > > > + user = request.user > > > + if not hasattr(user, 'maintains'): > > > + if user.is_authenticated: > > > + user.maintains = user.profile.maintainer_projects.all() > > > + else: > > > + user.maintains = [] > > > + super(SubmissionRelationMixin, self).initial(request, *args, **kwargs) > > > + > > > + def get_queryset(self): > > > + return SubmissionRelation.objects.all() \ > > > + .select_related('by') \ > > > + .prefetch_related('submissions__patch', > > > + 'submissions__coverletter', > > > + 'submissions__project') > > > + > > > + > > > +class SubmissionRelationList(SubmissionRelationMixin, ListCreateAPIView): > > > + ordering = 'id' > > > + ordering_fields = ['id'] > > > + > > > + def perform_create(self, serializer): > > > + # has_object_permission() is not called when creating a new relation. > > > + # Check whether user is maintainer of every project a submission is > > > + # part of > > > + maintains = self.request.user.maintains > > > + submissions = serializer.validated_data['submissions'] > > > + MaintainerPermission.check_user_maintains_all(maintains, submissions) > > > + serializer.save(by=self.request.user) > > > + > > > + > > > +class SubmissionRelationDetail(SubmissionRelationMixin, > > > + RetrieveUpdateDestroyAPIView): > > > + pass > > > diff --git a/patchwork/models.py b/patchwork/models.py > > > index a92203b24ff2..9ae3370e896b 100644 > > > --- a/patchwork/models.py > > > +++ b/patchwork/models.py > > > @@ -415,6 +415,9 @@ class CoverLetter(Submission): > > > kwargs={'project_id': self.project.linkname, > > > 'msgid': self.url_msgid}) > > > > > > + def get_absolute_api_url(self): > > > + return reverse('api-cover-detail', kwargs={'pk': self.id}) > > > + > > > def get_mbox_url(self): > > > return reverse('cover-mbox', > > > kwargs={'project_id': self.project.linkname, > > > @@ -604,6 +607,9 @@ class Patch(Submission): > > > kwargs={'project_id': self.project.linkname, > > > 'msgid': self.url_msgid}) > > > > > > + def get_absolute_api_url(self): > > > + return reverse('api-patch-detail', kwargs={'pk': self.id}) > > > + > > > def get_mbox_url(self): > > > return reverse('patch-mbox', > > > kwargs={'project_id': self.project.linkname, > > > diff --git a/patchwork/tests/api/test_relation.py b/patchwork/tests/api/test_relation.py > > > new file mode 100644 > > > index 000000000000..5b1a04f13670 > > > --- /dev/null > > > +++ b/patchwork/tests/api/test_relation.py > > > @@ -0,0 +1,181 @@ > > > +# Patchwork - automated patch tracking system > > > +# Copyright (C) 2019, Bayerische Motoren Werke Aktiengesellschaft (BMW AG) > > > +# > > > +# SPDX-License-Identifier: GPL-2.0-or-later > > > + > > > +import unittest > > > + > > > +import six > > > +from django.conf import settings > > > +from django.urls import reverse > > > + > > > +from patchwork.tests.api import utils > > > +from patchwork.tests.utils import create_cover > > > +from patchwork.tests.utils import create_maintainer > > > +from patchwork.tests.utils import create_patches > > > +from patchwork.tests.utils import create_project > > > +from patchwork.tests.utils import create_relation > > > +from patchwork.tests.utils import create_user > > > + > > > +if settings.ENABLE_REST_API: > > > + from rest_framework import status > > > + > > > + > > > +class UserType: > > > + ANONYMOUS = 1 > > > + NON_MAINTAINER = 2 > > > + MAINTAINER = 3 > > > + > > > + > > > +@unittest.skipUnless(settings.ENABLE_REST_API, 'requires ENABLE_REST_API') > > > +class TestRelationAPI(utils.APITestCase): > > > + fixtures = ['default_tags'] > > > + > > > + @staticmethod > > > + def api_url(item=None): > > > + kwargs = {} > > > + if item is None: > > > + return reverse('api-relation-list', kwargs=kwargs) > > > + kwargs['pk'] = item > > > + return reverse('api-relation-detail', kwargs=kwargs) > > > + > > > + def request_restricted(self, method, user_type): > > > + """Assert post/delete/patch requests on the relation API.""" > > > + assert method in ['post', 'delete', 'patch'] > > > + > > > + # setup > > > + > > > + project = create_project() > > > + maintainer = create_maintainer(project) > > > + > > > + if user_type == UserType.ANONYMOUS: > > > + expected_status = status.HTTP_403_FORBIDDEN > > > + elif user_type == UserType.NON_MAINTAINER: > > > + expected_status = status.HTTP_403_FORBIDDEN > > > + self.client.force_authenticate(user=create_user()) > > > + elif user_type == UserType.MAINTAINER: > > > + if method == 'post': > > > + expected_status = status.HTTP_201_CREATED > > > + elif method == 'delete': > > > + expected_status = status.HTTP_204_NO_CONTENT > > > + else: > > > + expected_status = status.HTTP_200_OK > > > + self.client.force_authenticate(user=maintainer) > > > + else: > > > + raise ValueError > > > + > > > + resource_id = None > > > + req = None > > > + > > > + if method == 'delete': > > > + resource_id = create_relation(project=project, by=maintainer).id > > > + elif method == 'post': > > > + patch_ids = [p.id for p in create_patches(2, project=project)] > > > + req = {'submissions': patch_ids} > > > + elif method == 'patch': > > > + resource_id = create_relation(project=project, by=maintainer).id > > > + patch_ids = [p.id for p in create_patches(2, project=project)] > > > + req = {'submissions': patch_ids} > > > + else: > > > + raise ValueError > > > + > > > + # request > > > + > > > + resp = getattr(self.client, method)(self.api_url(resource_id), req) > > > + > > > + # check > > > + > > > + self.assertEqual(expected_status, resp.status_code) > > > + > > > + if resp.status_code in range(status.HTTP_200_OK, > > > + status.HTTP_204_NO_CONTENT): > > > + self.assertRequest(req, resp.data) > > > + > > > + def assertRequest(self, request, resp): > > > + if request.get('id'): > > > + self.assertEqual(request['id'], resp['id']) > > > + send_ids = request['submissions'] > > > + resp_ids = [s['id'] for s in resp['submissions']] > > > + six.assertCountEqual(self, resp_ids, send_ids) > > > + > > > + def assertSerialized(self, obj, resp): > > > + self.assertEqual(obj.id, resp['id']) > > > + exp_ids = [s.id for s in obj.submissions.all()] > > > + act_ids = [s['id'] for s in resp['submissions']] > > > + six.assertCountEqual(self, exp_ids, act_ids) > > > + > > > + def test_list_empty(self): > > > + """List relation when none are present.""" > > > + resp = self.client.get(self.api_url()) > > > + self.assertEqual(status.HTTP_200_OK, resp.status_code) > > > + self.assertEqual(0, len(resp.data)) > > > + > > > + @utils.store_samples('relation-list') > > > + def test_list(self): > > > + """List relations.""" > > > + relation = create_relation() > > > + > > > + resp = self.client.get(self.api_url()) > > > + self.assertEqual(status.HTTP_200_OK, resp.status_code) > > > + self.assertEqual(1, len(resp.data)) > > > + self.assertSerialized(relation, resp.data[0]) > > > + > > > + def test_detail(self): > > > + """Show relation.""" > > > + relation = create_relation() > > > + > > > + resp = self.client.get(self.api_url(relation.id)) > > > + self.assertEqual(status.HTTP_200_OK, resp.status_code) > > > + self.assertSerialized(relation, resp.data) > > > + > > > + @utils.store_samples('relation-create-error-forbidden') > > > + def test_create_anonymous(self): > > > + self.request_restricted('post', UserType.ANONYMOUS) > > > + > > > + def test_create_non_maintainer(self): > > > + self.request_restricted('post', UserType.NON_MAINTAINER) > > > + > > > + @utils.store_samples('relation-create') > > > + def test_create_maintainer(self): > > > + self.request_restricted('post', UserType.MAINTAINER) > > > + > > > + @utils.store_samples('relation-update-error-forbidden') > > > + def test_update_anonymous(self): > > > + self.request_restricted('patch', UserType.ANONYMOUS) > > > + > > > + def test_update_non_maintainer(self): > > > + self.request_restricted('patch', UserType.NON_MAINTAINER) > > > + > > > + @utils.store_samples('relation-update') > > > + def test_update_maintainer(self): > > > + self.request_restricted('patch', UserType.MAINTAINER) > > > + > > > + @utils.store_samples('relation-delete-error-forbidden') > > > + def test_delete_anonymous(self): > > > + self.request_restricted('delete', UserType.ANONYMOUS) > > > + > > > + def test_delete_non_maintainer(self): > > > + self.request_restricted('delete', UserType.NON_MAINTAINER) > > > + > > > + @utils.store_samples('relation-update') > > > + def test_delete_maintainer(self): > > > + self.request_restricted('delete', UserType.MAINTAINER) > > > + > > > + def test_submission_conflict(self): > > > + project = create_project() > > > + maintainer = create_maintainer(project) > > > + self.client.force_authenticate(user=maintainer) > > > + relation = create_relation(by=maintainer, project=project) > > > + submission_ids = [s.id for s in relation.submissions.all()] > > > + > > > + # try to create a new relation with a new submission (cover) and > > > + # submissions already bound to another relation > > > + cover = create_cover(project=project) > > > + submission_ids.append(cover.id) > > > + req = {'submissions': submission_ids} > > > + resp = self.client.post(self.api_url(), req) > > > + self.assertEqual(status.HTTP_409_CONFLICT, resp.status_code) > > > + > > > + # try to patch relation > > > + resp = self.client.patch(self.api_url(relation.id), req) > > > + self.assertEqual(status.HTTP_200_OK, resp.status_code) > > > diff --git a/patchwork/tests/utils.py b/patchwork/tests/utils.py > > > index 577183d0986c..ffe90976233e 100644 > > > --- a/patchwork/tests/utils.py > > > +++ b/patchwork/tests/utils.py > > > @@ -16,6 +16,7 @@ from patchwork.models import Check > > > from patchwork.models import Comment > > > from patchwork.models import CoverLetter > > > from patchwork.models import Patch > > > +from patchwork.models import SubmissionRelation > > > from patchwork.models import Person > > > from patchwork.models import Project > > > from patchwork.models import Series > > > @@ -347,3 +348,17 @@ def create_covers(count=1, **kwargs): > > > kwargs (dict): Overrides for various cover letter fields > > > """ > > > return _create_submissions(create_cover, count, **kwargs) > > > + > > > + > > > +def create_relation(count_patches=2, by=None, **kwargs): > > > + if not by: > > > + project = create_project() > > > + kwargs['project'] = project > > > + by = create_maintainer(project) > > > + relation = SubmissionRelation.objects.create(by=by) > > > + values = { > > > + 'related': relation > > > + } > > > + values.update(kwargs) > > > + create_patches(count_patches, **values) > > > + return relation > > > diff --git a/patchwork/urls.py b/patchwork/urls.py > > > index dcdcfb49e67e..92095f62c7b9 100644 > > > --- a/patchwork/urls.py > > > +++ b/patchwork/urls.py > > > @@ -187,6 +187,7 @@ if settings.ENABLE_REST_API: > > > from patchwork.api import patch as api_patch_views # noqa > > > from patchwork.api import person as api_person_views # noqa > > > from patchwork.api import project as api_project_views # noqa > > > + from patchwork.api import relation as api_relation_views # noqa > > > from patchwork.api import series as api_series_views # noqa > > > from patchwork.api import user as api_user_views # noqa > > > > > > @@ -256,9 +257,19 @@ if settings.ENABLE_REST_API: > > > name='api-cover-comment-list'), > > > ] > > > > > > + api_1_2_patterns = [ > > > + url(r'^relations/$', > > > + api_relation_views.SubmissionRelationList.as_view(), > > > + name='api-relation-list'), > > > + url(r'^relations/(?P<pk>[^/]+)/$', > > > + api_relation_views.SubmissionRelationDetail.as_view(), > > > + name='api-relation-detail'), > > > + ] > > > + > > > urlpatterns += [ > > > url(r'^api/(?:(?P<version>(1.0|1.1|1.2))/)?', include(api_patterns)), > > > url(r'^api/(?:(?P<version>(1.1|1.2))/)?', include(api_1_1_patterns)), > > > + url(r'^api/(?:(?P<version>1.2)/)?', include(api_1_2_patterns)), > > > > > > # token change > > > url(r'^user/generate-token/$', user_views.generate_token, > > > diff --git a/releasenotes/notes/add-submission-relations-c96bb6c567b416d8.yaml b/releasenotes/notes/add-submission-relations-c96bb6c567b416d8.yaml > > > new file mode 100644 > > > index 000000000000..cb877991cd55 > > > --- /dev/null > > > +++ b/releasenotes/notes/add-submission-relations-c96bb6c567b416d8.yaml > > > @@ -0,0 +1,10 @@ > > > +--- > > > +features: > > > + - | > > > + Submissions (cover letters or patches) can now be related to other ones > > > + (e.g. revisions). Relations can be set via the REST API by maintainers > > > + (currently only for submissions of projects they maintain) > > > +api: > > > + - | > > > + Relations are available via ``/relations/`` and > > > + ``/relations/{relationID}/`` endpoints. > > > > _______________________________________________ > > Patchwork mailing list > > Patchwork@lists.ozlabs.org > > https://lists.ozlabs.org/listinfo/patchwork
Stephen Finucane <stephen@that.guru> writes: > On Sun, 2020-01-12 at 00:20 +1100, Daniel Axtens wrote: >> Stephen Finucane <stephen@that.guru> writes: >> >> > On Sat, 2019-12-07 at 17:46 +0100, Mete Polat wrote: >> > > View relations and add/update/delete them as a maintainer. Maintainers >> > > can only create relations of submissions (patches/cover letters) which >> > > are part of a project they maintain. >> > > >> > > New REST API urls: >> > > api/relations/ >> > > api/relations/<relation_id>/ >> > > >> > > Co-authored-by: Daniel Axtens <dja@axtens.net> >> > > Signed-off-by: Mete Polat <metepolat2000@gmail.com> >> > >> > Why did you choose to expose this as a separate API rather than as a >> > field on the '/patches' resource? While a 'Series' objet has enough >> > separate metadata to warrant a separate '/series' resource, a >> > 'SubmissionRelation' object is basically just a container. Including a >> > 'related_patches' field on the detailed patch view would seem like more >> > than enough detail for me, anyway, and unless there's a reason not to >> > do this, I'd like to see it done that way. Is it possible? >> > >> >> How would creating an relation work then? currently you POST to >> /api/relations/ with all the patch IDs you want to include in the >> relation. I agree that viewing relations through /api/patch makes sense, >> but I'm not sure how you create relations if that's the only endpoint >> you have? > > 'PATCH /api/patch/{patchID}' (or 'PUT'), surely? Sorry, that was bashed out too quickly. There are a few cases I'm thinking about. On reflection you're right that we can do it without a separate relations endpoint, if we're careful, but I think it can be a bit unintuitive. ** relating patches for the first time If you want to relate say patches 7, 21 and 42, I can see PATCH /api/patch/7 related=[21, 42] or PATCH /api/patch/21 related=[7, 42] etc working I would have gone with POST /api/relations patches=[7, 21, 42] returning an ID of a relation (say 1). ** adding a patch to a relation Say we want to add patch 9 to the relation, I guess we'd do: PATCH /api/patch/9 related=[7] (or 21, or 42, or a combination) We probably don't want to be trying to do that by patching 7 or 21 or 42, you'd need a read-modify-write cycle so you risk wiping out a change that came through in the mean time... I would have gone with PATCH /api/patch/9 related=1 (We don't want to PATCH /api/relation/1 because of the same RMW issue) ** removal of a patch What happens when you want to remove patch 21 from the relation? I guess we could do PATCH /api/patch/21 related=[] Again we don't want to do this by patching 7 or 42 or 9 as we'd need a RMW loop that's even more non-atomic than usual I would have gone with PATCH /api/patch/21 related=null again, not wanting to PATCH /api/relation/1 for RMW reasons So yeah, I guess not having an API view for relations would work. I think it's a bit trickier to get right from an implementation point of view, but I'm not going to go to the mat over it. Regards, Daniel > >> Regards, >> Daniel >> >> > Stephen >> > >> > PS: I could have sworn I had asked this before, but I can't find any >> > mails about it so maybe I didn't. Please tell me to RTML (read the >> > mailing list) if so >> > >> > > --- >> > > Optimize db queries: >> > > I have spent quite a lot of time in optimizing the db queries for the REST API >> > > (thanks for the tip with the Django toolbar). Daniel stated that >> > > prefetch_related is possibly hitting the database for every relation when >> > > prefetching submissions but it turns out that we can tell Django to use a >> > > statement like: >> > > SELECT * >> > > FROM `patchwork_patch` >> > > INNER JOIN `patchwork_submission` >> > > ON (`patchwork_patch`.`submission_ptr_id` = `patchwork_submission`.`id`) >> > > WHERE `patchwork_patch`.`submission_ptr_id` IN (LIST_OF_ALL_SUBMISSION_IDS) >> > > >> > > We do the same for `patchwork_coverletter`. >> > > This means we only hit the db two times for casting _all_ submissions to a >> > > patch or cover-letter. >> > > >> > > Prefetching submissions__project eliminates similar and duplicate queries >> > > that are used to determine whether a logged in user is at least maintainer >> > > of one submission's project. >> > > >> > > docs/api/schemas/latest/patchwork.yaml | 273 +++++++++++++++++ >> > > docs/api/schemas/patchwork.j2 | 285 ++++++++++++++++++ >> > > docs/api/schemas/v1.2/patchwork.yaml | 273 +++++++++++++++++ >> > > patchwork/api/embedded.py | 39 +++ >> > > patchwork/api/index.py | 1 + >> > > patchwork/api/relation.py | 121 ++++++++ >> > > patchwork/models.py | 6 + >> > > patchwork/tests/api/test_relation.py | 181 +++++++++++ >> > > patchwork/tests/utils.py | 15 + >> > > patchwork/urls.py | 11 + >> > > ...submission-relations-c96bb6c567b416d8.yaml | 10 + >> > > 11 files changed, 1215 insertions(+) >> > > create mode 100644 patchwork/api/relation.py >> > > create mode 100644 patchwork/tests/api/test_relation.py >> > > create mode 100644 releasenotes/notes/add-submission-relations-c96bb6c567b416d8.yaml >> > > >> > > diff --git a/docs/api/schemas/latest/patchwork.yaml b/docs/api/schemas/latest/patchwork.yaml >> > > index a5e235be936d..7dd24fd700d5 100644 >> > > --- a/docs/api/schemas/latest/patchwork.yaml >> > > +++ b/docs/api/schemas/latest/patchwork.yaml >> > > @@ -1039,6 +1039,188 @@ paths: >> > > $ref: '#/components/schemas/Error' >> > > tags: >> > > - series >> > > + /api/relations/: >> > > + get: >> > > + description: List relations. >> > > + operationId: relations_list >> > > + parameters: >> > > + - $ref: '#/components/parameters/Page' >> > > + - $ref: '#/components/parameters/PageSize' >> > > + - $ref: '#/components/parameters/Order' >> > > + responses: >> > > + '200': >> > > + description: '' >> > > + headers: >> > > + Link: >> > > + $ref: '#/components/headers/Link' >> > > + content: >> > > + application/json: >> > > + schema: >> > > + type: array >> > > + items: >> > > + $ref: '#/components/schemas/Relation' >> > > + tags: >> > > + - relations >> > > + post: >> > > + description: Create a relation. >> > > + operationId: relations_create >> > > + security: >> > > + - basicAuth: [] >> > > + - apiKeyAuth: [] >> > > + requestBody: >> > > + $ref: '#/components/requestBodies/Relation' >> > > + responses: >> > > + '201': >> > > + description: '' >> > > + content: >> > > + application/json: >> > > + schema: >> > > + $ref: '#/components/schemas/Relation' >> > > + '400': >> > > + description: Invalid Request >> > > + content: >> > > + application/json: >> > > + schema: >> > > + $ref: '#/components/schemas/Error' >> > > + '403': >> > > + description: Forbidden >> > > + content: >> > > + application/json: >> > > + schema: >> > > + $ref: '#/components/schemas/Error' >> > > + '404': >> > > + description: Not found >> > > + content: >> > > + application/json: >> > > + schema: >> > > + $ref: '#/components/schemas/Error' >> > > + '409': >> > > + description: Conflict >> > > + content: >> > > + application/json: >> > > + schema: >> > > + $ref: '#/components/schemas/Error' >> > > + tags: >> > > + - checks >> > > + /api/relations/{id}/: >> > > + get: >> > > + description: Show a relation. >> > > + operationId: relation_read >> > > + parameters: >> > > + - in: path >> > > + name: id >> > > + description: A unique integer value identifying this relation. >> > > + required: true >> > > + schema: >> > > + title: ID >> > > + type: integer >> > > + responses: >> > > + '200': >> > > + description: '' >> > > + content: >> > > + application/json: >> > > + schema: >> > > + $ref: '#/components/schemas/Relation' >> > > + '404': >> > > + description: Not found >> > > + content: >> > > + application/json: >> > > + schema: >> > > + $ref: '#/components/schemas/Error' >> > > + '409': >> > > + description: Conflict >> > > + content: >> > > + application/json: >> > > + schema: >> > > + $ref: '#/components/schemas/Error' >> > > + tags: >> > > + - relations >> > > + patch: >> > > + description: Update a relation (partial). >> > > + operationId: relations_partial_update >> > > + security: >> > > + - basicAuth: [] >> > > + - apiKeyAuth: [] >> > > + parameters: >> > > + - in: path >> > > + name: id >> > > + description: A unique integer value identifying this relation. >> > > + required: true >> > > + schema: >> > > + title: ID >> > > + type: integer >> > > + requestBody: >> > > + $ref: '#/components/requestBodies/Relation' >> > > + responses: >> > > + '200': >> > > + description: '' >> > > + content: >> > > + application/json: >> > > + schema: >> > > + $ref: '#/components/schemas/Relation' >> > > + '400': >> > > + description: Bad request >> > > + content: >> > > + application/json: >> > > + schema: >> > > + $ref: '#/components/schemas/Error' >> > > + '403': >> > > + description: Forbidden >> > > + content: >> > > + application/json: >> > > + schema: >> > > + $ref: '#/components/schemas/Error' >> > > + '404': >> > > + description: Not found >> > > + content: >> > > + application/json: >> > > + schema: >> > > + $ref: '#/components/schemas/Error' >> > > + tags: >> > > + - relations >> > > + put: >> > > + description: Update a relation. >> > > + operationId: relations_update >> > > + security: >> > > + - basicAuth: [] >> > > + - apiKeyAuth: [] >> > > + parameters: >> > > + - in: path >> > > + name: id >> > > + description: A unique integer value identifying this relation. >> > > + required: true >> > > + schema: >> > > + title: ID >> > > + type: integer >> > > + requestBody: >> > > + $ref: '#/components/requestBodies/Relation' >> > > + responses: >> > > + '200': >> > > + description: '' >> > > + content: >> > > + application/json: >> > > + schema: >> > > + $ref: '#/components/schemas/Relation' >> > > + '400': >> > > + description: Bad request >> > > + content: >> > > + application/json: >> > > + schema: >> > > + $ref: '#/components/schemas/Error' >> > > + '403': >> > > + description: Forbidden >> > > + content: >> > > + application/json: >> > > + schema: >> > > + $ref: '#/components/schemas/Error' >> > > + '404': >> > > + description: Not found >> > > + content: >> > > + application/json: >> > > + schema: >> > > + $ref: '#/components/schemas/Error' >> > > + tags: >> > > + - relations >> > > /api/users/: >> > > get: >> > > description: List users. >> > > @@ -1314,6 +1496,18 @@ components: >> > > application/x-www-form-urlencoded: >> > > schema: >> > > $ref: '#/components/schemas/User' >> > > + Relation: >> > > + required: true >> > > + content: >> > > + application/json: >> > > + schema: >> > > + $ref: '#/components/schemas/RelationUpdate' >> > > + multipart/form-data: >> > > + schema: >> > > + $ref: '#/components/schemas/RelationUpdate' >> > > + application/x-www-form-urlencoded: >> > > + schema: >> > > + $ref: '#/components/schemas/RelationUpdate' >> > > schemas: >> > > Index: >> > > type: object >> > > @@ -1358,6 +1552,11 @@ components: >> > > type: string >> > > format: uri >> > > readOnly: true >> > > + relations: >> > > + title: Relations URL >> > > + type: string >> > > + format: uri >> > > + readOnly: true >> > > Bundle: >> > > required: >> > > - name >> > > @@ -1943,6 +2142,14 @@ components: >> > > title: Delegate >> > > type: integer >> > > nullable: true >> > > + RelationUpdate: >> > > + type: object >> > > + properties: >> > > + submissions: >> > > + title: Submission IDs >> > > + type: array >> > > + items: >> > > + type: integer >> > > Person: >> > > type: object >> > > properties: >> > > @@ -2133,6 +2340,30 @@ components: >> > > $ref: '#/components/schemas/PatchEmbedded' >> > > readOnly: true >> > > uniqueItems: true >> > > + Relation: >> > > + type: object >> > > + properties: >> > > + id: >> > > + title: ID >> > > + type: integer >> > > + url: >> > > + title: URL >> > > + type: string >> > > + format: uri >> > > + readOnly: true >> > > + by: >> > > + type: object >> > > + title: By >> > > + readOnly: true >> > > + allOf: >> > > + - $ref: '#/components/schemas/UserEmbedded' >> > > + submissions: >> > > + title: Submissions >> > > + type: array >> > > + items: >> > > + $ref: '#/components/schemas/SubmissionEmbedded' >> > > + readOnly: true >> > > + uniqueItems: true >> > > User: >> > > type: object >> > > properties: >> > > @@ -2211,6 +2442,48 @@ components: >> > > maxLength: 255 >> > > minLength: 1 >> > > readOnly: true >> > > + SubmissionEmbedded: >> > > + type: object >> > > + properties: >> > > + id: >> > > + title: ID >> > > + type: integer >> > > + readOnly: true >> > > + url: >> > > + title: URL >> > > + type: string >> > > + format: uri >> > > + readOnly: true >> > > + web_url: >> > > + title: Web URL >> > > + type: string >> > > + format: uri >> > > + readOnly: true >> > > + msgid: >> > > + title: Message ID >> > > + type: string >> > > + readOnly: true >> > > + minLength: 1 >> > > + list_archive_url: >> > > + title: List archive URL >> > > + type: string >> > > + readOnly: true >> > > + nullable: true >> > > + date: >> > > + title: Date >> > > + type: string >> > > + format: iso8601 >> > > + readOnly: true >> > > + name: >> > > + title: Name >> > > + type: string >> > > + readOnly: true >> > > + minLength: 1 >> > > + mbox: >> > > + title: Mbox >> > > + type: string >> > > + format: uri >> > > + readOnly: true >> > > CoverLetterEmbedded: >> > > type: object >> > > properties: >> > > diff --git a/docs/api/schemas/patchwork.j2 b/docs/api/schemas/patchwork.j2 >> > > index 196d78466b55..a034029accf9 100644 >> > > --- a/docs/api/schemas/patchwork.j2 >> > > +++ b/docs/api/schemas/patchwork.j2 >> > > @@ -1048,6 +1048,190 @@ paths: >> > > $ref: '#/components/schemas/Error' >> > > tags: >> > > - series >> > > +{% if version >= (1, 2) %} >> > > + /api/{{ version_url }}relations/: >> > > + get: >> > > + description: List relations. >> > > + operationId: relations_list >> > > + parameters: >> > > + - $ref: '#/components/parameters/Page' >> > > + - $ref: '#/components/parameters/PageSize' >> > > + - $ref: '#/components/parameters/Order' >> > > + responses: >> > > + '200': >> > > + description: '' >> > > + headers: >> > > + Link: >> > > + $ref: '#/components/headers/Link' >> > > + content: >> > > + application/json: >> > > + schema: >> > > + type: array >> > > + items: >> > > + $ref: '#/components/schemas/Relation' >> > > + tags: >> > > + - relations >> > > + post: >> > > + description: Create a relation. >> > > + operationId: relations_create >> > > + security: >> > > + - basicAuth: [] >> > > + - apiKeyAuth: [] >> > > + requestBody: >> > > + $ref: '#/components/requestBodies/Relation' >> > > + responses: >> > > + '201': >> > > + description: '' >> > > + content: >> > > + application/json: >> > > + schema: >> > > + $ref: '#/components/schemas/Relation' >> > > + '400': >> > > + description: Invalid Request >> > > + content: >> > > + application/json: >> > > + schema: >> > > + $ref: '#/components/schemas/Error' >> > > + '403': >> > > + description: Forbidden >> > > + content: >> > > + application/json: >> > > + schema: >> > > + $ref: '#/components/schemas/Error' >> > > + '404': >> > > + description: Not found >> > > + content: >> > > + application/json: >> > > + schema: >> > > + $ref: '#/components/schemas/Error' >> > > + '409': >> > > + description: Conflict >> > > + content: >> > > + application/json: >> > > + schema: >> > > + $ref: '#/components/schemas/Error' >> > > + tags: >> > > + - checks >> > > + /api/{{ version_url }}relations/{id}/: >> > > + get: >> > > + description: Show a relation. >> > > + operationId: relation_read >> > > + parameters: >> > > + - in: path >> > > + name: id >> > > + description: A unique integer value identifying this relation. >> > > + required: true >> > > + schema: >> > > + title: ID >> > > + type: integer >> > > + responses: >> > > + '200': >> > > + description: '' >> > > + content: >> > > + application/json: >> > > + schema: >> > > + $ref: '#/components/schemas/Relation' >> > > + '404': >> > > + description: Not found >> > > + content: >> > > + application/json: >> > > + schema: >> > > + $ref: '#/components/schemas/Error' >> > > + '409': >> > > + description: Conflict >> > > + content: >> > > + application/json: >> > > + schema: >> > > + $ref: '#/components/schemas/Error' >> > > + tags: >> > > + - relations >> > > + patch: >> > > + description: Update a relation (partial). >> > > + operationId: relations_partial_update >> > > + security: >> > > + - basicAuth: [] >> > > + - apiKeyAuth: [] >> > > + parameters: >> > > + - in: path >> > > + name: id >> > > + description: A unique integer value identifying this relation. >> > > + required: true >> > > + schema: >> > > + title: ID >> > > + type: integer >> > > + requestBody: >> > > + $ref: '#/components/requestBodies/Relation' >> > > + responses: >> > > + '200': >> > > + description: '' >> > > + content: >> > > + application/json: >> > > + schema: >> > > + $ref: '#/components/schemas/Relation' >> > > + '400': >> > > + description: Bad request >> > > + content: >> > > + application/json: >> > > + schema: >> > > + $ref: '#/components/schemas/Error' >> > > + '403': >> > > + description: Forbidden >> > > + content: >> > > + application/json: >> > > + schema: >> > > + $ref: '#/components/schemas/Error' >> > > + '404': >> > > + description: Not found >> > > + content: >> > > + application/json: >> > > + schema: >> > > + $ref: '#/components/schemas/Error' >> > > + tags: >> > > + - relations >> > > + put: >> > > + description: Update a relation. >> > > + operationId: relations_update >> > > + security: >> > > + - basicAuth: [] >> > > + - apiKeyAuth: [] >> > > + parameters: >> > > + - in: path >> > > + name: id >> > > + description: A unique integer value identifying this relation. >> > > + required: true >> > > + schema: >> > > + title: ID >> > > + type: integer >> > > + requestBody: >> > > + $ref: '#/components/requestBodies/Relation' >> > > + responses: >> > > + '200': >> > > + description: '' >> > > + content: >> > > + application/json: >> > > + schema: >> > > + $ref: '#/components/schemas/Relation' >> > > + '400': >> > > + description: Bad request >> > > + content: >> > > + application/json: >> > > + schema: >> > > + $ref: '#/components/schemas/Error' >> > > + '403': >> > > + description: Forbidden >> > > + content: >> > > + application/json: >> > > + schema: >> > > + $ref: '#/components/schemas/Error' >> > > + '404': >> > > + description: Not found >> > > + content: >> > > + application/json: >> > > + schema: >> > > + $ref: '#/components/schemas/Error' >> > > + tags: >> > > + - relations >> > > +{% endif %} >> > > /api/{{ version_url }}users/: >> > > get: >> > > description: List users. >> > > @@ -1325,6 +1509,20 @@ components: >> > > application/x-www-form-urlencoded: >> > > schema: >> > > $ref: '#/components/schemas/User' >> > > +{% if version >= (1, 2) %} >> > > + Relation: >> > > + required: true >> > > + content: >> > > + application/json: >> > > + schema: >> > > + $ref: '#/components/schemas/RelationUpdate' >> > > + multipart/form-data: >> > > + schema: >> > > + $ref: '#/components/schemas/RelationUpdate' >> > > + application/x-www-form-urlencoded: >> > > + schema: >> > > + $ref: '#/components/schemas/RelationUpdate' >> > > +{% endif %} >> > > schemas: >> > > Index: >> > > type: object >> > > @@ -1369,6 +1567,13 @@ components: >> > > type: string >> > > format: uri >> > > readOnly: true >> > > +{% if version >= (1, 2) %} >> > > + relations: >> > > + title: Relations URL >> > > + type: string >> > > + format: uri >> > > + readOnly: true >> > > +{% endif %} >> > > Bundle: >> > > required: >> > > - name >> > > @@ -1981,6 +2186,16 @@ components: >> > > title: Delegate >> > > type: integer >> > > nullable: true >> > > +{% if version >= (1, 2) %} >> > > + RelationUpdate: >> > > + type: object >> > > + properties: >> > > + submissions: >> > > + title: Submission IDs >> > > + type: array >> > > + items: >> > > + type: integer >> > > +{% endif %} >> > > Person: >> > > type: object >> > > properties: >> > > @@ -2177,6 +2392,32 @@ components: >> > > $ref: '#/components/schemas/PatchEmbedded' >> > > readOnly: true >> > > uniqueItems: true >> > > +{% if version >= (1, 2) %} >> > > + Relation: >> > > + type: object >> > > + properties: >> > > + id: >> > > + title: ID >> > > + type: integer >> > > + url: >> > > + title: URL >> > > + type: string >> > > + format: uri >> > > + readOnly: true >> > > + by: >> > > + type: object >> > > + title: By >> > > + readOnly: true >> > > + allOf: >> > > + - $ref: '#/components/schemas/UserEmbedded' >> > > + submissions: >> > > + title: Submissions >> > > + type: array >> > > + items: >> > > + $ref: '#/components/schemas/SubmissionEmbedded' >> > > + readOnly: true >> > > + uniqueItems: true >> > > +{% endif %} >> > > User: >> > > type: object >> > > properties: >> > > @@ -2255,6 +2496,50 @@ components: >> > > maxLength: 255 >> > > minLength: 1 >> > > readOnly: true >> > > +{% if version >= (1, 2) %} >> > > + SubmissionEmbedded: >> > > + type: object >> > > + properties: >> > > + id: >> > > + title: ID >> > > + type: integer >> > > + readOnly: true >> > > + url: >> > > + title: URL >> > > + type: string >> > > + format: uri >> > > + readOnly: true >> > > + web_url: >> > > + title: Web URL >> > > + type: string >> > > + format: uri >> > > + readOnly: true >> > > + msgid: >> > > + title: Message ID >> > > + type: string >> > > + readOnly: true >> > > + minLength: 1 >> > > + list_archive_url: >> > > + title: List archive URL >> > > + type: string >> > > + readOnly: true >> > > + nullable: true >> > > + date: >> > > + title: Date >> > > + type: string >> > > + format: iso8601 >> > > + readOnly: true >> > > + name: >> > > + title: Name >> > > + type: string >> > > + readOnly: true >> > > + minLength: 1 >> > > + mbox: >> > > + title: Mbox >> > > + type: string >> > > + format: uri >> > > + readOnly: true >> > > +{% endif %} >> > > CoverLetterEmbedded: >> > > type: object >> > > properties: >> > > diff --git a/docs/api/schemas/v1.2/patchwork.yaml b/docs/api/schemas/v1.2/patchwork.yaml >> > > index d7b4d2957cff..99425e968881 100644 >> > > --- a/docs/api/schemas/v1.2/patchwork.yaml >> > > +++ b/docs/api/schemas/v1.2/patchwork.yaml >> > > @@ -1039,6 +1039,188 @@ paths: >> > > $ref: '#/components/schemas/Error' >> > > tags: >> > > - series >> > > + /api/1.2/relations/: >> > > + get: >> > > + description: List relations. >> > > + operationId: relations_list >> > > + parameters: >> > > + - $ref: '#/components/parameters/Page' >> > > + - $ref: '#/components/parameters/PageSize' >> > > + - $ref: '#/components/parameters/Order' >> > > + responses: >> > > + '200': >> > > + description: '' >> > > + headers: >> > > + Link: >> > > + $ref: '#/components/headers/Link' >> > > + content: >> > > + application/json: >> > > + schema: >> > > + type: array >> > > + items: >> > > + $ref: '#/components/schemas/Relation' >> > > + tags: >> > > + - relations >> > > + post: >> > > + description: Create a relation. >> > > + operationId: relations_create >> > > + security: >> > > + - basicAuth: [] >> > > + - apiKeyAuth: [] >> > > + requestBody: >> > > + $ref: '#/components/requestBodies/Relation' >> > > + responses: >> > > + '201': >> > > + description: '' >> > > + content: >> > > + application/json: >> > > + schema: >> > > + $ref: '#/components/schemas/Relation' >> > > + '400': >> > > + description: Invalid Request >> > > + content: >> > > + application/json: >> > > + schema: >> > > + $ref: '#/components/schemas/Error' >> > > + '403': >> > > + description: Forbidden >> > > + content: >> > > + application/json: >> > > + schema: >> > > + $ref: '#/components/schemas/Error' >> > > + '404': >> > > + description: Not found >> > > + content: >> > > + application/json: >> > > + schema: >> > > + $ref: '#/components/schemas/Error' >> > > + '409': >> > > + description: Conflict >> > > + content: >> > > + application/json: >> > > + schema: >> > > + $ref: '#/components/schemas/Error' >> > > + tags: >> > > + - checks >> > > + /api/1.2/relations/{id}/: >> > > + get: >> > > + description: Show a relation. >> > > + operationId: relation_read >> > > + parameters: >> > > + - in: path >> > > + name: id >> > > + description: A unique integer value identifying this relation. >> > > + required: true >> > > + schema: >> > > + title: ID >> > > + type: integer >> > > + responses: >> > > + '200': >> > > + description: '' >> > > + content: >> > > + application/json: >> > > + schema: >> > > + $ref: '#/components/schemas/Relation' >> > > + '404': >> > > + description: Not found >> > > + content: >> > > + application/json: >> > > + schema: >> > > + $ref: '#/components/schemas/Error' >> > > + '409': >> > > + description: Conflict >> > > + content: >> > > + application/json: >> > > + schema: >> > > + $ref: '#/components/schemas/Error' >> > > + tags: >> > > + - relations >> > > + patch: >> > > + description: Update a relation (partial). >> > > + operationId: relations_partial_update >> > > + security: >> > > + - basicAuth: [] >> > > + - apiKeyAuth: [] >> > > + parameters: >> > > + - in: path >> > > + name: id >> > > + description: A unique integer value identifying this relation. >> > > + required: true >> > > + schema: >> > > + title: ID >> > > + type: integer >> > > + requestBody: >> > > + $ref: '#/components/requestBodies/Relation' >> > > + responses: >> > > + '200': >> > > + description: '' >> > > + content: >> > > + application/json: >> > > + schema: >> > > + $ref: '#/components/schemas/Relation' >> > > + '400': >> > > + description: Bad request >> > > + content: >> > > + application/json: >> > > + schema: >> > > + $ref: '#/components/schemas/Error' >> > > + '403': >> > > + description: Forbidden >> > > + content: >> > > + application/json: >> > > + schema: >> > > + $ref: '#/components/schemas/Error' >> > > + '404': >> > > + description: Not found >> > > + content: >> > > + application/json: >> > > + schema: >> > > + $ref: '#/components/schemas/Error' >> > > + tags: >> > > + - relations >> > > + put: >> > > + description: Update a relation. >> > > + operationId: relations_update >> > > + security: >> > > + - basicAuth: [] >> > > + - apiKeyAuth: [] >> > > + parameters: >> > > + - in: path >> > > + name: id >> > > + description: A unique integer value identifying this relation. >> > > + required: true >> > > + schema: >> > > + title: ID >> > > + type: integer >> > > + requestBody: >> > > + $ref: '#/components/requestBodies/Relation' >> > > + responses: >> > > + '200': >> > > + description: '' >> > > + content: >> > > + application/json: >> > > + schema: >> > > + $ref: '#/components/schemas/Relation' >> > > + '400': >> > > + description: Bad request >> > > + content: >> > > + application/json: >> > > + schema: >> > > + $ref: '#/components/schemas/Error' >> > > + '403': >> > > + description: Forbidden >> > > + content: >> > > + application/json: >> > > + schema: >> > > + $ref: '#/components/schemas/Error' >> > > + '404': >> > > + description: Not found >> > > + content: >> > > + application/json: >> > > + schema: >> > > + $ref: '#/components/schemas/Error' >> > > + tags: >> > > + - relations >> > > /api/1.2/users/: >> > > get: >> > > description: List users. >> > > @@ -1314,6 +1496,18 @@ components: >> > > application/x-www-form-urlencoded: >> > > schema: >> > > $ref: '#/components/schemas/User' >> > > + Relation: >> > > + required: true >> > > + content: >> > > + application/json: >> > > + schema: >> > > + $ref: '#/components/schemas/RelationUpdate' >> > > + multipart/form-data: >> > > + schema: >> > > + $ref: '#/components/schemas/RelationUpdate' >> > > + application/x-www-form-urlencoded: >> > > + schema: >> > > + $ref: '#/components/schemas/RelationUpdate' >> > > schemas: >> > > Index: >> > > type: object >> > > @@ -1358,6 +1552,11 @@ components: >> > > type: string >> > > format: uri >> > > readOnly: true >> > > + relations: >> > > + title: Relations URL >> > > + type: string >> > > + format: uri >> > > + readOnly: true >> > > Bundle: >> > > required: >> > > - name >> > > @@ -1943,6 +2142,14 @@ components: >> > > title: Delegate >> > > type: integer >> > > nullable: true >> > > + RelationUpdate: >> > > + type: object >> > > + properties: >> > > + submissions: >> > > + title: Submission IDs >> > > + type: array >> > > + items: >> > > + type: integer >> > > Person: >> > > type: object >> > > properties: >> > > @@ -2133,6 +2340,30 @@ components: >> > > $ref: '#/components/schemas/PatchEmbedded' >> > > readOnly: true >> > > uniqueItems: true >> > > + Relation: >> > > + type: object >> > > + properties: >> > > + id: >> > > + title: ID >> > > + type: integer >> > > + url: >> > > + title: URL >> > > + type: string >> > > + format: uri >> > > + readOnly: true >> > > + by: >> > > + type: object >> > > + title: By >> > > + readOnly: true >> > > + allOf: >> > > + - $ref: '#/components/schemas/UserEmbedded' >> > > + submissions: >> > > + title: Submissions >> > > + type: array >> > > + items: >> > > + $ref: '#/components/schemas/SubmissionEmbedded' >> > > + readOnly: true >> > > + uniqueItems: true >> > > User: >> > > type: object >> > > properties: >> > > @@ -2211,6 +2442,48 @@ components: >> > > maxLength: 255 >> > > minLength: 1 >> > > readOnly: true >> > > + SubmissionEmbedded: >> > > + type: object >> > > + properties: >> > > + id: >> > > + title: ID >> > > + type: integer >> > > + readOnly: true >> > > + url: >> > > + title: URL >> > > + type: string >> > > + format: uri >> > > + readOnly: true >> > > + web_url: >> > > + title: Web URL >> > > + type: string >> > > + format: uri >> > > + readOnly: true >> > > + msgid: >> > > + title: Message ID >> > > + type: string >> > > + readOnly: true >> > > + minLength: 1 >> > > + list_archive_url: >> > > + title: List archive URL >> > > + type: string >> > > + readOnly: true >> > > + nullable: true >> > > + date: >> > > + title: Date >> > > + type: string >> > > + format: iso8601 >> > > + readOnly: true >> > > + name: >> > > + title: Name >> > > + type: string >> > > + readOnly: true >> > > + minLength: 1 >> > > + mbox: >> > > + title: Mbox >> > > + type: string >> > > + format: uri >> > > + readOnly: true >> > > CoverLetterEmbedded: >> > > type: object >> > > properties: >> > > diff --git a/patchwork/api/embedded.py b/patchwork/api/embedded.py >> > > index de4f31165ee7..0fba291b62b8 100644 >> > > --- a/patchwork/api/embedded.py >> > > +++ b/patchwork/api/embedded.py >> > > @@ -102,6 +102,45 @@ class CheckSerializer(SerializedRelatedField): >> > > } >> > > >> > > >> > > +def _upgrade_instance(instance): >> > > + if hasattr(instance, 'patch'): >> > > + return instance.patch >> > > + else: >> > > + return instance.coverletter >> > > + >> > > + >> > > +class SubmissionSerializer(SerializedRelatedField): >> > > + >> > > + class _Serializer(BaseHyperlinkedModelSerializer): >> > > + """We need to 'upgrade' or specialise the submission to the relevant >> > > + subclass, so we can't use the mixins. This is gross but can go away >> > > + once we flatten the models.""" >> > > + url = SerializerMethodField() >> > > + web_url = SerializerMethodField() >> > > + mbox = SerializerMethodField() >> > > + >> > > + def get_url(self, instance): >> > > + instance = _upgrade_instance(instance) >> > > + request = self.context.get('request') >> > > + return request.build_absolute_uri(instance.get_absolute_api_url()) >> > > + >> > > + def get_web_url(self, instance): >> > > + instance = _upgrade_instance(instance) >> > > + request = self.context.get('request') >> > > + return request.build_absolute_uri(instance.get_absolute_url()) >> > > + >> > > + def get_mbox(self, instance): >> > > + instance = _upgrade_instance(instance) >> > > + request = self.context.get('request') >> > > + return request.build_absolute_uri(instance.get_mbox_url()) >> > > + >> > > + class Meta: >> > > + model = models.Submission >> > > + fields = ('id', 'url', 'web_url', 'msgid', 'list_archive_url', >> > > + 'date', 'name', 'mbox') >> > > + read_only_fields = fields >> > > + >> > > + >> > > class CoverLetterSerializer(SerializedRelatedField): >> > > >> > > class _Serializer(MboxMixin, WebURLMixin, BaseHyperlinkedModelSerializer): >> > > diff --git a/patchwork/api/index.py b/patchwork/api/index.py >> > > index 45485c9106f6..cf1845393835 100644 >> > > --- a/patchwork/api/index.py >> > > +++ b/patchwork/api/index.py >> > > @@ -21,4 +21,5 @@ class IndexView(APIView): >> > > 'series': reverse('api-series-list', request=request), >> > > 'events': reverse('api-event-list', request=request), >> > > 'bundles': reverse('api-bundle-list', request=request), >> > > + 'relations': reverse('api-relation-list', request=request), >> > > }) >> > > diff --git a/patchwork/api/relation.py b/patchwork/api/relation.py >> > > new file mode 100644 >> > > index 000000000000..37640d62e9cc >> > > --- /dev/null >> > > +++ b/patchwork/api/relation.py >> > > @@ -0,0 +1,121 @@ >> > > +# Patchwork - automated patch tracking system >> > > +# Copyright (C) 2019, Bayerische Motoren Werke Aktiengesellschaft (BMW AG) >> > > +# >> > > +# SPDX-License-Identifier: GPL-2.0-or-later >> > > + >> > > +from rest_framework import permissions >> > > +from rest_framework import status >> > > +from rest_framework.exceptions import PermissionDenied, APIException >> > > +from rest_framework.generics import GenericAPIView >> > > +from rest_framework.generics import ListCreateAPIView >> > > +from rest_framework.generics import RetrieveUpdateDestroyAPIView >> > > +from rest_framework.serializers import ModelSerializer >> > > + >> > > +from patchwork.api.base import PatchworkPermission >> > > +from patchwork.api.embedded import SubmissionSerializer >> > > +from patchwork.api.embedded import UserSerializer >> > > +from patchwork.models import SubmissionRelation >> > > + >> > > + >> > > +class MaintainerPermission(PatchworkPermission): >> > > + >> > > + def has_permission(self, request, view): >> > > + if request.method in permissions.SAFE_METHODS: >> > > + return True >> > > + >> > > + # Prevent showing an HTML POST form in the browseable API for logged in >> > > + # users who are not maintainers. >> > > + return len(request.user.maintains) > 0 >> > > + >> > > + def has_object_permission(self, request, view, relation): >> > > + if request.method in permissions.SAFE_METHODS: >> > > + return True >> > > + >> > > + maintains = request.user.maintains >> > > + submissions = relation.submissions.all() >> > > + # user has to be maintainer of every project a submission is part of >> > > + return self.check_user_maintains_all(maintains, submissions) >> > > + >> > > + @staticmethod >> > > + def check_user_maintains_all(maintains, submissions): >> > > + if any(s.project not in maintains for s in submissions): >> > > + detail = 'At least one submission is part of a project you are ' \ >> > > + 'not maintaining.' >> > > + raise PermissionDenied(detail=detail) >> > > + return True >> > > + >> > > + >> > > +class SubmissionConflict(APIException): >> > > + status_code = status.HTTP_409_CONFLICT >> > > + default_detail = 'At least one submission is already part of another ' \ >> > > + 'relation. You have to explicitly remove a submission ' \ >> > > + 'from its existing relation before moving it to this one.' >> > > + >> > > + >> > > +class SubmissionRelationSerializer(ModelSerializer): >> > > + by = UserSerializer(read_only=True) >> > > + submissions = SubmissionSerializer(many=True) >> > > + >> > > + def create(self, validated_data): >> > > + submissions = validated_data['submissions'] >> > > + if any(submission.related_id is not None >> > > + for submission in submissions): >> > > + raise SubmissionConflict() >> > > + return super(SubmissionRelationSerializer, self).create(validated_data) >> > > + >> > > + def update(self, instance, validated_data): >> > > + submissions = validated_data['submissions'] >> > > + if any(submission.related_id is not None and >> > > + submission.related_id != instance.id >> > > + for submission in submissions): >> > > + raise SubmissionConflict() >> > > + return super(SubmissionRelationSerializer, self) \ >> > > + .update(instance, validated_data) >> > > + >> > > + class Meta: >> > > + model = SubmissionRelation >> > > + fields = ('id', 'url', 'by', 'submissions',) >> > > + read_only_fields = ('url', 'by', ) >> > > + extra_kwargs = { >> > > + 'url': {'view_name': 'api-relation-detail'}, >> > > + } >> > > + >> > > + >> > > +class SubmissionRelationMixin(GenericAPIView): >> > > + serializer_class = SubmissionRelationSerializer >> > > + permission_classes = (MaintainerPermission,) >> > > + >> > > + def initial(self, request, *args, **kwargs): >> > > + user = request.user >> > > + if not hasattr(user, 'maintains'): >> > > + if user.is_authenticated: >> > > + user.maintains = user.profile.maintainer_projects.all() >> > > + else: >> > > + user.maintains = [] >> > > + super(SubmissionRelationMixin, self).initial(request, *args, **kwargs) >> > > + >> > > + def get_queryset(self): >> > > + return SubmissionRelation.objects.all() \ >> > > + .select_related('by') \ >> > > + .prefetch_related('submissions__patch', >> > > + 'submissions__coverletter', >> > > + 'submissions__project') >> > > + >> > > + >> > > +class SubmissionRelationList(SubmissionRelationMixin, ListCreateAPIView): >> > > + ordering = 'id' >> > > + ordering_fields = ['id'] >> > > + >> > > + def perform_create(self, serializer): >> > > + # has_object_permission() is not called when creating a new relation. >> > > + # Check whether user is maintainer of every project a submission is >> > > + # part of >> > > + maintains = self.request.user.maintains >> > > + submissions = serializer.validated_data['submissions'] >> > > + MaintainerPermission.check_user_maintains_all(maintains, submissions) >> > > + serializer.save(by=self.request.user) >> > > + >> > > + >> > > +class SubmissionRelationDetail(SubmissionRelationMixin, >> > > + RetrieveUpdateDestroyAPIView): >> > > + pass >> > > diff --git a/patchwork/models.py b/patchwork/models.py >> > > index a92203b24ff2..9ae3370e896b 100644 >> > > --- a/patchwork/models.py >> > > +++ b/patchwork/models.py >> > > @@ -415,6 +415,9 @@ class CoverLetter(Submission): >> > > kwargs={'project_id': self.project.linkname, >> > > 'msgid': self.url_msgid}) >> > > >> > > + def get_absolute_api_url(self): >> > > + return reverse('api-cover-detail', kwargs={'pk': self.id}) >> > > + >> > > def get_mbox_url(self): >> > > return reverse('cover-mbox', >> > > kwargs={'project_id': self.project.linkname, >> > > @@ -604,6 +607,9 @@ class Patch(Submission): >> > > kwargs={'project_id': self.project.linkname, >> > > 'msgid': self.url_msgid}) >> > > >> > > + def get_absolute_api_url(self): >> > > + return reverse('api-patch-detail', kwargs={'pk': self.id}) >> > > + >> > > def get_mbox_url(self): >> > > return reverse('patch-mbox', >> > > kwargs={'project_id': self.project.linkname, >> > > diff --git a/patchwork/tests/api/test_relation.py b/patchwork/tests/api/test_relation.py >> > > new file mode 100644 >> > > index 000000000000..5b1a04f13670 >> > > --- /dev/null >> > > +++ b/patchwork/tests/api/test_relation.py >> > > @@ -0,0 +1,181 @@ >> > > +# Patchwork - automated patch tracking system >> > > +# Copyright (C) 2019, Bayerische Motoren Werke Aktiengesellschaft (BMW AG) >> > > +# >> > > +# SPDX-License-Identifier: GPL-2.0-or-later >> > > + >> > > +import unittest >> > > + >> > > +import six >> > > +from django.conf import settings >> > > +from django.urls import reverse >> > > + >> > > +from patchwork.tests.api import utils >> > > +from patchwork.tests.utils import create_cover >> > > +from patchwork.tests.utils import create_maintainer >> > > +from patchwork.tests.utils import create_patches >> > > +from patchwork.tests.utils import create_project >> > > +from patchwork.tests.utils import create_relation >> > > +from patchwork.tests.utils import create_user >> > > + >> > > +if settings.ENABLE_REST_API: >> > > + from rest_framework import status >> > > + >> > > + >> > > +class UserType: >> > > + ANONYMOUS = 1 >> > > + NON_MAINTAINER = 2 >> > > + MAINTAINER = 3 >> > > + >> > > + >> > > +@unittest.skipUnless(settings.ENABLE_REST_API, 'requires ENABLE_REST_API') >> > > +class TestRelationAPI(utils.APITestCase): >> > > + fixtures = ['default_tags'] >> > > + >> > > + @staticmethod >> > > + def api_url(item=None): >> > > + kwargs = {} >> > > + if item is None: >> > > + return reverse('api-relation-list', kwargs=kwargs) >> > > + kwargs['pk'] = item >> > > + return reverse('api-relation-detail', kwargs=kwargs) >> > > + >> > > + def request_restricted(self, method, user_type): >> > > + """Assert post/delete/patch requests on the relation API.""" >> > > + assert method in ['post', 'delete', 'patch'] >> > > + >> > > + # setup >> > > + >> > > + project = create_project() >> > > + maintainer = create_maintainer(project) >> > > + >> > > + if user_type == UserType.ANONYMOUS: >> > > + expected_status = status.HTTP_403_FORBIDDEN >> > > + elif user_type == UserType.NON_MAINTAINER: >> > > + expected_status = status.HTTP_403_FORBIDDEN >> > > + self.client.force_authenticate(user=create_user()) >> > > + elif user_type == UserType.MAINTAINER: >> > > + if method == 'post': >> > > + expected_status = status.HTTP_201_CREATED >> > > + elif method == 'delete': >> > > + expected_status = status.HTTP_204_NO_CONTENT >> > > + else: >> > > + expected_status = status.HTTP_200_OK >> > > + self.client.force_authenticate(user=maintainer) >> > > + else: >> > > + raise ValueError >> > > + >> > > + resource_id = None >> > > + req = None >> > > + >> > > + if method == 'delete': >> > > + resource_id = create_relation(project=project, by=maintainer).id >> > > + elif method == 'post': >> > > + patch_ids = [p.id for p in create_patches(2, project=project)] >> > > + req = {'submissions': patch_ids} >> > > + elif method == 'patch': >> > > + resource_id = create_relation(project=project, by=maintainer).id >> > > + patch_ids = [p.id for p in create_patches(2, project=project)] >> > > + req = {'submissions': patch_ids} >> > > + else: >> > > + raise ValueError >> > > + >> > > + # request >> > > + >> > > + resp = getattr(self.client, method)(self.api_url(resource_id), req) >> > > + >> > > + # check >> > > + >> > > + self.assertEqual(expected_status, resp.status_code) >> > > + >> > > + if resp.status_code in range(status.HTTP_200_OK, >> > > + status.HTTP_204_NO_CONTENT): >> > > + self.assertRequest(req, resp.data) >> > > + >> > > + def assertRequest(self, request, resp): >> > > + if request.get('id'): >> > > + self.assertEqual(request['id'], resp['id']) >> > > + send_ids = request['submissions'] >> > > + resp_ids = [s['id'] for s in resp['submissions']] >> > > + six.assertCountEqual(self, resp_ids, send_ids) >> > > + >> > > + def assertSerialized(self, obj, resp): >> > > + self.assertEqual(obj.id, resp['id']) >> > > + exp_ids = [s.id for s in obj.submissions.all()] >> > > + act_ids = [s['id'] for s in resp['submissions']] >> > > + six.assertCountEqual(self, exp_ids, act_ids) >> > > + >> > > + def test_list_empty(self): >> > > + """List relation when none are present.""" >> > > + resp = self.client.get(self.api_url()) >> > > + self.assertEqual(status.HTTP_200_OK, resp.status_code) >> > > + self.assertEqual(0, len(resp.data)) >> > > + >> > > + @utils.store_samples('relation-list') >> > > + def test_list(self): >> > > + """List relations.""" >> > > + relation = create_relation() >> > > + >> > > + resp = self.client.get(self.api_url()) >> > > + self.assertEqual(status.HTTP_200_OK, resp.status_code) >> > > + self.assertEqual(1, len(resp.data)) >> > > + self.assertSerialized(relation, resp.data[0]) >> > > + >> > > + def test_detail(self): >> > > + """Show relation.""" >> > > + relation = create_relation() >> > > + >> > > + resp = self.client.get(self.api_url(relation.id)) >> > > + self.assertEqual(status.HTTP_200_OK, resp.status_code) >> > > + self.assertSerialized(relation, resp.data) >> > > + >> > > + @utils.store_samples('relation-create-error-forbidden') >> > > + def test_create_anonymous(self): >> > > + self.request_restricted('post', UserType.ANONYMOUS) >> > > + >> > > + def test_create_non_maintainer(self): >> > > + self.request_restricted('post', UserType.NON_MAINTAINER) >> > > + >> > > + @utils.store_samples('relation-create') >> > > + def test_create_maintainer(self): >> > > + self.request_restricted('post', UserType.MAINTAINER) >> > > + >> > > + @utils.store_samples('relation-update-error-forbidden') >> > > + def test_update_anonymous(self): >> > > + self.request_restricted('patch', UserType.ANONYMOUS) >> > > + >> > > + def test_update_non_maintainer(self): >> > > + self.request_restricted('patch', UserType.NON_MAINTAINER) >> > > + >> > > + @utils.store_samples('relation-update') >> > > + def test_update_maintainer(self): >> > > + self.request_restricted('patch', UserType.MAINTAINER) >> > > + >> > > + @utils.store_samples('relation-delete-error-forbidden') >> > > + def test_delete_anonymous(self): >> > > + self.request_restricted('delete', UserType.ANONYMOUS) >> > > + >> > > + def test_delete_non_maintainer(self): >> > > + self.request_restricted('delete', UserType.NON_MAINTAINER) >> > > + >> > > + @utils.store_samples('relation-update') >> > > + def test_delete_maintainer(self): >> > > + self.request_restricted('delete', UserType.MAINTAINER) >> > > + >> > > + def test_submission_conflict(self): >> > > + project = create_project() >> > > + maintainer = create_maintainer(project) >> > > + self.client.force_authenticate(user=maintainer) >> > > + relation = create_relation(by=maintainer, project=project) >> > > + submission_ids = [s.id for s in relation.submissions.all()] >> > > + >> > > + # try to create a new relation with a new submission (cover) and >> > > + # submissions already bound to another relation >> > > + cover = create_cover(project=project) >> > > + submission_ids.append(cover.id) >> > > + req = {'submissions': submission_ids} >> > > + resp = self.client.post(self.api_url(), req) >> > > + self.assertEqual(status.HTTP_409_CONFLICT, resp.status_code) >> > > + >> > > + # try to patch relation >> > > + resp = self.client.patch(self.api_url(relation.id), req) >> > > + self.assertEqual(status.HTTP_200_OK, resp.status_code) >> > > diff --git a/patchwork/tests/utils.py b/patchwork/tests/utils.py >> > > index 577183d0986c..ffe90976233e 100644 >> > > --- a/patchwork/tests/utils.py >> > > +++ b/patchwork/tests/utils.py >> > > @@ -16,6 +16,7 @@ from patchwork.models import Check >> > > from patchwork.models import Comment >> > > from patchwork.models import CoverLetter >> > > from patchwork.models import Patch >> > > +from patchwork.models import SubmissionRelation >> > > from patchwork.models import Person >> > > from patchwork.models import Project >> > > from patchwork.models import Series >> > > @@ -347,3 +348,17 @@ def create_covers(count=1, **kwargs): >> > > kwargs (dict): Overrides for various cover letter fields >> > > """ >> > > return _create_submissions(create_cover, count, **kwargs) >> > > + >> > > + >> > > +def create_relation(count_patches=2, by=None, **kwargs): >> > > + if not by: >> > > + project = create_project() >> > > + kwargs['project'] = project >> > > + by = create_maintainer(project) >> > > + relation = SubmissionRelation.objects.create(by=by) >> > > + values = { >> > > + 'related': relation >> > > + } >> > > + values.update(kwargs) >> > > + create_patches(count_patches, **values) >> > > + return relation >> > > diff --git a/patchwork/urls.py b/patchwork/urls.py >> > > index dcdcfb49e67e..92095f62c7b9 100644 >> > > --- a/patchwork/urls.py >> > > +++ b/patchwork/urls.py >> > > @@ -187,6 +187,7 @@ if settings.ENABLE_REST_API: >> > > from patchwork.api import patch as api_patch_views # noqa >> > > from patchwork.api import person as api_person_views # noqa >> > > from patchwork.api import project as api_project_views # noqa >> > > + from patchwork.api import relation as api_relation_views # noqa >> > > from patchwork.api import series as api_series_views # noqa >> > > from patchwork.api import user as api_user_views # noqa >> > > >> > > @@ -256,9 +257,19 @@ if settings.ENABLE_REST_API: >> > > name='api-cover-comment-list'), >> > > ] >> > > >> > > + api_1_2_patterns = [ >> > > + url(r'^relations/$', >> > > + api_relation_views.SubmissionRelationList.as_view(), >> > > + name='api-relation-list'), >> > > + url(r'^relations/(?P<pk>[^/]+)/$', >> > > + api_relation_views.SubmissionRelationDetail.as_view(), >> > > + name='api-relation-detail'), >> > > + ] >> > > + >> > > urlpatterns += [ >> > > url(r'^api/(?:(?P<version>(1.0|1.1|1.2))/)?', include(api_patterns)), >> > > url(r'^api/(?:(?P<version>(1.1|1.2))/)?', include(api_1_1_patterns)), >> > > + url(r'^api/(?:(?P<version>1.2)/)?', include(api_1_2_patterns)), >> > > >> > > # token change >> > > url(r'^user/generate-token/$', user_views.generate_token, >> > > diff --git a/releasenotes/notes/add-submission-relations-c96bb6c567b416d8.yaml b/releasenotes/notes/add-submission-relations-c96bb6c567b416d8.yaml >> > > new file mode 100644 >> > > index 000000000000..cb877991cd55 >> > > --- /dev/null >> > > +++ b/releasenotes/notes/add-submission-relations-c96bb6c567b416d8.yaml >> > > @@ -0,0 +1,10 @@ >> > > +--- >> > > +features: >> > > + - | >> > > + Submissions (cover letters or patches) can now be related to other ones >> > > + (e.g. revisions). Relations can be set via the REST API by maintainers >> > > + (currently only for submissions of projects they maintain) >> > > +api: >> > > + - | >> > > + Relations are available via ``/relations/`` and >> > > + ``/relations/{relationID}/`` endpoints. >> > >> > _______________________________________________ >> > Patchwork mailing list >> > Patchwork@lists.ozlabs.org >> > https://lists.ozlabs.org/listinfo/patchwork
I've spent some time tonight bashing this out, based on Stephen's API design. I have got display to work, just working on writing now. Stephen, would you be able to write the API spec if I can send an otherwise working implementation? I think the ramp-up time for me on that might be a big unnecessary delay... Kind regards, Daniel
On Sat, 2020-01-25 at 00:20 +1100, Daniel Axtens wrote: > I've spent some time tonight bashing this out, based on Stephen's API > design. I have got display to work, just working on writing now. > > Stephen, would you be able to write the API spec if I can send an > otherwise working implementation? I think the ramp-up time for me on > that might be a big unnecessary delay... Sure thing. Stephen > Kind regards, > Daniel
I've made good progress with this, things work etc, I'm just wrestling with SQL queries. One thing I noticed is that the current /api/patches/ list seems to go out to the database for the project linkname for every patch: ... /opt/pyenv/versions/3.8.1/lib/python3.8/site-packages/rest_framework/serializers.py in to_representation(674) return [ /opt/pyenv/versions/3.8.1/lib/python3.8/site-packages/rest_framework/serializers.py in <listcomp>(675) self.child.to_representation(item) for item in iterable /home/patchwork/patchwork/patchwork/api/patch.py in to_representation(110) data = super(PatchListSerializer, self).to_representation(instance) /home/patchwork/patchwork/patchwork/api/base.py in to_representation(90) data = super(BaseHyperlinkedModelSerializer, self).to_representation( /opt/pyenv/versions/3.8.1/lib/python3.8/site-packages/rest_framework/serializers.py in to_representation(526) ret[field.field_name] = field.to_representation(attribute) /home/patchwork/patchwork/patchwork/api/embedded.py in to_representation(57) return self._Serializer(context=self.context).to_representation(data) /home/patchwork/patchwork/patchwork/api/base.py in to_representation(90) data = super(BaseHyperlinkedModelSerializer, self).to_representation( /opt/pyenv/versions/3.8.1/lib/python3.8/site-packages/rest_framework/serializers.py in to_representation(526) ret[field.field_name] = field.to_representation(attribute) /opt/pyenv/versions/3.8.1/lib/python3.8/site-packages/rest_framework/fields.py in to_representation(1873) return method(value) /home/patchwork/patchwork/patchwork/api/embedded.py in get_web_url(81) return request.build_absolute_uri(instance.get_absolute_url()) /home/patchwork/patchwork/patchwork/models.py in get_absolute_url(767) kwargs={'project_id': self.project.linkname}) + ( I see about 22 of these, perfectly identical, on the existing code - and the number goes up when I include the new code which displays more embedded patches. Thoughts? Regards, Daniel Stephen Finucane <stephen@that.guru> writes: > On Sat, 2020-01-25 at 00:20 +1100, Daniel Axtens wrote: >> I've spent some time tonight bashing this out, based on Stephen's API >> design. I have got display to work, just working on writing now. >> >> Stephen, would you be able to write the API spec if I can send an >> otherwise working implementation? I think the ramp-up time for me on >> that might be a big unnecessary delay... > > Sure thing. > > Stephen > >> Kind regards, >> Daniel
diff --git a/docs/api/schemas/latest/patchwork.yaml b/docs/api/schemas/latest/patchwork.yaml index a5e235be936d..7dd24fd700d5 100644 --- a/docs/api/schemas/latest/patchwork.yaml +++ b/docs/api/schemas/latest/patchwork.yaml @@ -1039,6 +1039,188 @@ paths: $ref: '#/components/schemas/Error' tags: - series + /api/relations/: + get: + description: List relations. + operationId: relations_list + parameters: + - $ref: '#/components/parameters/Page' + - $ref: '#/components/parameters/PageSize' + - $ref: '#/components/parameters/Order' + responses: + '200': + description: '' + headers: + Link: + $ref: '#/components/headers/Link' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Relation' + tags: + - relations + post: + description: Create a relation. + operationId: relations_create + security: + - basicAuth: [] + - apiKeyAuth: [] + requestBody: + $ref: '#/components/requestBodies/Relation' + responses: + '201': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/Relation' + '400': + description: Invalid Request + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '409': + description: Conflict + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + tags: + - checks + /api/relations/{id}/: + get: + description: Show a relation. + operationId: relation_read + parameters: + - in: path + name: id + description: A unique integer value identifying this relation. + required: true + schema: + title: ID + type: integer + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/Relation' + '404': + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '409': + description: Conflict + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + tags: + - relations + patch: + description: Update a relation (partial). + operationId: relations_partial_update + security: + - basicAuth: [] + - apiKeyAuth: [] + parameters: + - in: path + name: id + description: A unique integer value identifying this relation. + required: true + schema: + title: ID + type: integer + requestBody: + $ref: '#/components/requestBodies/Relation' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/Relation' + '400': + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + tags: + - relations + put: + description: Update a relation. + operationId: relations_update + security: + - basicAuth: [] + - apiKeyAuth: [] + parameters: + - in: path + name: id + description: A unique integer value identifying this relation. + required: true + schema: + title: ID + type: integer + requestBody: + $ref: '#/components/requestBodies/Relation' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/Relation' + '400': + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + tags: + - relations /api/users/: get: description: List users. @@ -1314,6 +1496,18 @@ components: application/x-www-form-urlencoded: schema: $ref: '#/components/schemas/User' + Relation: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RelationUpdate' + multipart/form-data: + schema: + $ref: '#/components/schemas/RelationUpdate' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/RelationUpdate' schemas: Index: type: object @@ -1358,6 +1552,11 @@ components: type: string format: uri readOnly: true + relations: + title: Relations URL + type: string + format: uri + readOnly: true Bundle: required: - name @@ -1943,6 +2142,14 @@ components: title: Delegate type: integer nullable: true + RelationUpdate: + type: object + properties: + submissions: + title: Submission IDs + type: array + items: + type: integer Person: type: object properties: @@ -2133,6 +2340,30 @@ components: $ref: '#/components/schemas/PatchEmbedded' readOnly: true uniqueItems: true + Relation: + type: object + properties: + id: + title: ID + type: integer + url: + title: URL + type: string + format: uri + readOnly: true + by: + type: object + title: By + readOnly: true + allOf: + - $ref: '#/components/schemas/UserEmbedded' + submissions: + title: Submissions + type: array + items: + $ref: '#/components/schemas/SubmissionEmbedded' + readOnly: true + uniqueItems: true User: type: object properties: @@ -2211,6 +2442,48 @@ components: maxLength: 255 minLength: 1 readOnly: true + SubmissionEmbedded: + type: object + properties: + id: + title: ID + type: integer + readOnly: true + url: + title: URL + type: string + format: uri + readOnly: true + web_url: + title: Web URL + type: string + format: uri + readOnly: true + msgid: + title: Message ID + type: string + readOnly: true + minLength: 1 + list_archive_url: + title: List archive URL + type: string + readOnly: true + nullable: true + date: + title: Date + type: string + format: iso8601 + readOnly: true + name: + title: Name + type: string + readOnly: true + minLength: 1 + mbox: + title: Mbox + type: string + format: uri + readOnly: true CoverLetterEmbedded: type: object properties: diff --git a/docs/api/schemas/patchwork.j2 b/docs/api/schemas/patchwork.j2 index 196d78466b55..a034029accf9 100644 --- a/docs/api/schemas/patchwork.j2 +++ b/docs/api/schemas/patchwork.j2 @@ -1048,6 +1048,190 @@ paths: $ref: '#/components/schemas/Error' tags: - series +{% if version >= (1, 2) %} + /api/{{ version_url }}relations/: + get: + description: List relations. + operationId: relations_list + parameters: + - $ref: '#/components/parameters/Page' + - $ref: '#/components/parameters/PageSize' + - $ref: '#/components/parameters/Order' + responses: + '200': + description: '' + headers: + Link: + $ref: '#/components/headers/Link' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Relation' + tags: + - relations + post: + description: Create a relation. + operationId: relations_create + security: + - basicAuth: [] + - apiKeyAuth: [] + requestBody: + $ref: '#/components/requestBodies/Relation' + responses: + '201': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/Relation' + '400': + description: Invalid Request + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '409': + description: Conflict + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + tags: + - checks + /api/{{ version_url }}relations/{id}/: + get: + description: Show a relation. + operationId: relation_read + parameters: + - in: path + name: id + description: A unique integer value identifying this relation. + required: true + schema: + title: ID + type: integer + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/Relation' + '404': + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '409': + description: Conflict + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + tags: + - relations + patch: + description: Update a relation (partial). + operationId: relations_partial_update + security: + - basicAuth: [] + - apiKeyAuth: [] + parameters: + - in: path + name: id + description: A unique integer value identifying this relation. + required: true + schema: + title: ID + type: integer + requestBody: + $ref: '#/components/requestBodies/Relation' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/Relation' + '400': + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + tags: + - relations + put: + description: Update a relation. + operationId: relations_update + security: + - basicAuth: [] + - apiKeyAuth: [] + parameters: + - in: path + name: id + description: A unique integer value identifying this relation. + required: true + schema: + title: ID + type: integer + requestBody: + $ref: '#/components/requestBodies/Relation' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/Relation' + '400': + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + tags: + - relations +{% endif %} /api/{{ version_url }}users/: get: description: List users. @@ -1325,6 +1509,20 @@ components: application/x-www-form-urlencoded: schema: $ref: '#/components/schemas/User' +{% if version >= (1, 2) %} + Relation: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RelationUpdate' + multipart/form-data: + schema: + $ref: '#/components/schemas/RelationUpdate' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/RelationUpdate' +{% endif %} schemas: Index: type: object @@ -1369,6 +1567,13 @@ components: type: string format: uri readOnly: true +{% if version >= (1, 2) %} + relations: + title: Relations URL + type: string + format: uri + readOnly: true +{% endif %} Bundle: required: - name @@ -1981,6 +2186,16 @@ components: title: Delegate type: integer nullable: true +{% if version >= (1, 2) %} + RelationUpdate: + type: object + properties: + submissions: + title: Submission IDs + type: array + items: + type: integer +{% endif %} Person: type: object properties: @@ -2177,6 +2392,32 @@ components: $ref: '#/components/schemas/PatchEmbedded' readOnly: true uniqueItems: true +{% if version >= (1, 2) %} + Relation: + type: object + properties: + id: + title: ID + type: integer + url: + title: URL + type: string + format: uri + readOnly: true + by: + type: object + title: By + readOnly: true + allOf: + - $ref: '#/components/schemas/UserEmbedded' + submissions: + title: Submissions + type: array + items: + $ref: '#/components/schemas/SubmissionEmbedded' + readOnly: true + uniqueItems: true +{% endif %} User: type: object properties: @@ -2255,6 +2496,50 @@ components: maxLength: 255 minLength: 1 readOnly: true +{% if version >= (1, 2) %} + SubmissionEmbedded: + type: object + properties: + id: + title: ID + type: integer + readOnly: true + url: + title: URL + type: string + format: uri + readOnly: true + web_url: + title: Web URL + type: string + format: uri + readOnly: true + msgid: + title: Message ID + type: string + readOnly: true + minLength: 1 + list_archive_url: + title: List archive URL + type: string + readOnly: true + nullable: true + date: + title: Date + type: string + format: iso8601 + readOnly: true + name: + title: Name + type: string + readOnly: true + minLength: 1 + mbox: + title: Mbox + type: string + format: uri + readOnly: true +{% endif %} CoverLetterEmbedded: type: object properties: diff --git a/docs/api/schemas/v1.2/patchwork.yaml b/docs/api/schemas/v1.2/patchwork.yaml index d7b4d2957cff..99425e968881 100644 --- a/docs/api/schemas/v1.2/patchwork.yaml +++ b/docs/api/schemas/v1.2/patchwork.yaml @@ -1039,6 +1039,188 @@ paths: $ref: '#/components/schemas/Error' tags: - series + /api/1.2/relations/: + get: + description: List relations. + operationId: relations_list + parameters: + - $ref: '#/components/parameters/Page' + - $ref: '#/components/parameters/PageSize' + - $ref: '#/components/parameters/Order' + responses: + '200': + description: '' + headers: + Link: + $ref: '#/components/headers/Link' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Relation' + tags: + - relations + post: + description: Create a relation. + operationId: relations_create + security: + - basicAuth: [] + - apiKeyAuth: [] + requestBody: + $ref: '#/components/requestBodies/Relation' + responses: + '201': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/Relation' + '400': + description: Invalid Request + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '409': + description: Conflict + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + tags: + - checks + /api/1.2/relations/{id}/: + get: + description: Show a relation. + operationId: relation_read + parameters: + - in: path + name: id + description: A unique integer value identifying this relation. + required: true + schema: + title: ID + type: integer + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/Relation' + '404': + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '409': + description: Conflict + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + tags: + - relations + patch: + description: Update a relation (partial). + operationId: relations_partial_update + security: + - basicAuth: [] + - apiKeyAuth: [] + parameters: + - in: path + name: id + description: A unique integer value identifying this relation. + required: true + schema: + title: ID + type: integer + requestBody: + $ref: '#/components/requestBodies/Relation' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/Relation' + '400': + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + tags: + - relations + put: + description: Update a relation. + operationId: relations_update + security: + - basicAuth: [] + - apiKeyAuth: [] + parameters: + - in: path + name: id + description: A unique integer value identifying this relation. + required: true + schema: + title: ID + type: integer + requestBody: + $ref: '#/components/requestBodies/Relation' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/Relation' + '400': + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + tags: + - relations /api/1.2/users/: get: description: List users. @@ -1314,6 +1496,18 @@ components: application/x-www-form-urlencoded: schema: $ref: '#/components/schemas/User' + Relation: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RelationUpdate' + multipart/form-data: + schema: + $ref: '#/components/schemas/RelationUpdate' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/RelationUpdate' schemas: Index: type: object @@ -1358,6 +1552,11 @@ components: type: string format: uri readOnly: true + relations: + title: Relations URL + type: string + format: uri + readOnly: true Bundle: required: - name @@ -1943,6 +2142,14 @@ components: title: Delegate type: integer nullable: true + RelationUpdate: + type: object + properties: + submissions: + title: Submission IDs + type: array + items: + type: integer Person: type: object properties: @@ -2133,6 +2340,30 @@ components: $ref: '#/components/schemas/PatchEmbedded' readOnly: true uniqueItems: true + Relation: + type: object + properties: + id: + title: ID + type: integer + url: + title: URL + type: string + format: uri + readOnly: true + by: + type: object + title: By + readOnly: true + allOf: + - $ref: '#/components/schemas/UserEmbedded' + submissions: + title: Submissions + type: array + items: + $ref: '#/components/schemas/SubmissionEmbedded' + readOnly: true + uniqueItems: true User: type: object properties: @@ -2211,6 +2442,48 @@ components: maxLength: 255 minLength: 1 readOnly: true + SubmissionEmbedded: + type: object + properties: + id: + title: ID + type: integer + readOnly: true + url: + title: URL + type: string + format: uri + readOnly: true + web_url: + title: Web URL + type: string + format: uri + readOnly: true + msgid: + title: Message ID + type: string + readOnly: true + minLength: 1 + list_archive_url: + title: List archive URL + type: string + readOnly: true + nullable: true + date: + title: Date + type: string + format: iso8601 + readOnly: true + name: + title: Name + type: string + readOnly: true + minLength: 1 + mbox: + title: Mbox + type: string + format: uri + readOnly: true CoverLetterEmbedded: type: object properties: diff --git a/patchwork/api/embedded.py b/patchwork/api/embedded.py index de4f31165ee7..0fba291b62b8 100644 --- a/patchwork/api/embedded.py +++ b/patchwork/api/embedded.py @@ -102,6 +102,45 @@ class CheckSerializer(SerializedRelatedField): } +def _upgrade_instance(instance): + if hasattr(instance, 'patch'): + return instance.patch + else: + return instance.coverletter + + +class SubmissionSerializer(SerializedRelatedField): + + class _Serializer(BaseHyperlinkedModelSerializer): + """We need to 'upgrade' or specialise the submission to the relevant + subclass, so we can't use the mixins. This is gross but can go away + once we flatten the models.""" + url = SerializerMethodField() + web_url = SerializerMethodField() + mbox = SerializerMethodField() + + def get_url(self, instance): + instance = _upgrade_instance(instance) + request = self.context.get('request') + return request.build_absolute_uri(instance.get_absolute_api_url()) + + def get_web_url(self, instance): + instance = _upgrade_instance(instance) + request = self.context.get('request') + return request.build_absolute_uri(instance.get_absolute_url()) + + def get_mbox(self, instance): + instance = _upgrade_instance(instance) + request = self.context.get('request') + return request.build_absolute_uri(instance.get_mbox_url()) + + class Meta: + model = models.Submission + fields = ('id', 'url', 'web_url', 'msgid', 'list_archive_url', + 'date', 'name', 'mbox') + read_only_fields = fields + + class CoverLetterSerializer(SerializedRelatedField): class _Serializer(MboxMixin, WebURLMixin, BaseHyperlinkedModelSerializer): diff --git a/patchwork/api/index.py b/patchwork/api/index.py index 45485c9106f6..cf1845393835 100644 --- a/patchwork/api/index.py +++ b/patchwork/api/index.py @@ -21,4 +21,5 @@ class IndexView(APIView): 'series': reverse('api-series-list', request=request), 'events': reverse('api-event-list', request=request), 'bundles': reverse('api-bundle-list', request=request), + 'relations': reverse('api-relation-list', request=request), }) diff --git a/patchwork/api/relation.py b/patchwork/api/relation.py new file mode 100644 index 000000000000..37640d62e9cc --- /dev/null +++ b/patchwork/api/relation.py @@ -0,0 +1,121 @@ +# Patchwork - automated patch tracking system +# Copyright (C) 2019, Bayerische Motoren Werke Aktiengesellschaft (BMW AG) +# +# SPDX-License-Identifier: GPL-2.0-or-later + +from rest_framework import permissions +from rest_framework import status +from rest_framework.exceptions import PermissionDenied, APIException +from rest_framework.generics import GenericAPIView +from rest_framework.generics import ListCreateAPIView +from rest_framework.generics import RetrieveUpdateDestroyAPIView +from rest_framework.serializers import ModelSerializer + +from patchwork.api.base import PatchworkPermission +from patchwork.api.embedded import SubmissionSerializer +from patchwork.api.embedded import UserSerializer +from patchwork.models import SubmissionRelation + + +class MaintainerPermission(PatchworkPermission): + + def has_permission(self, request, view): + if request.method in permissions.SAFE_METHODS: + return True + + # Prevent showing an HTML POST form in the browseable API for logged in + # users who are not maintainers. + return len(request.user.maintains) > 0 + + def has_object_permission(self, request, view, relation): + if request.method in permissions.SAFE_METHODS: + return True + + maintains = request.user.maintains + submissions = relation.submissions.all() + # user has to be maintainer of every project a submission is part of + return self.check_user_maintains_all(maintains, submissions) + + @staticmethod + def check_user_maintains_all(maintains, submissions): + if any(s.project not in maintains for s in submissions): + detail = 'At least one submission is part of a project you are ' \ + 'not maintaining.' + raise PermissionDenied(detail=detail) + return True + + +class SubmissionConflict(APIException): + status_code = status.HTTP_409_CONFLICT + default_detail = 'At least one submission is already part of another ' \ + 'relation. You have to explicitly remove a submission ' \ + 'from its existing relation before moving it to this one.' + + +class SubmissionRelationSerializer(ModelSerializer): + by = UserSerializer(read_only=True) + submissions = SubmissionSerializer(many=True) + + def create(self, validated_data): + submissions = validated_data['submissions'] + if any(submission.related_id is not None + for submission in submissions): + raise SubmissionConflict() + return super(SubmissionRelationSerializer, self).create(validated_data) + + def update(self, instance, validated_data): + submissions = validated_data['submissions'] + if any(submission.related_id is not None and + submission.related_id != instance.id + for submission in submissions): + raise SubmissionConflict() + return super(SubmissionRelationSerializer, self) \ + .update(instance, validated_data) + + class Meta: + model = SubmissionRelation + fields = ('id', 'url', 'by', 'submissions',) + read_only_fields = ('url', 'by', ) + extra_kwargs = { + 'url': {'view_name': 'api-relation-detail'}, + } + + +class SubmissionRelationMixin(GenericAPIView): + serializer_class = SubmissionRelationSerializer + permission_classes = (MaintainerPermission,) + + def initial(self, request, *args, **kwargs): + user = request.user + if not hasattr(user, 'maintains'): + if user.is_authenticated: + user.maintains = user.profile.maintainer_projects.all() + else: + user.maintains = [] + super(SubmissionRelationMixin, self).initial(request, *args, **kwargs) + + def get_queryset(self): + return SubmissionRelation.objects.all() \ + .select_related('by') \ + .prefetch_related('submissions__patch', + 'submissions__coverletter', + 'submissions__project') + + +class SubmissionRelationList(SubmissionRelationMixin, ListCreateAPIView): + ordering = 'id' + ordering_fields = ['id'] + + def perform_create(self, serializer): + # has_object_permission() is not called when creating a new relation. + # Check whether user is maintainer of every project a submission is + # part of + maintains = self.request.user.maintains + submissions = serializer.validated_data['submissions'] + MaintainerPermission.check_user_maintains_all(maintains, submissions) + serializer.save(by=self.request.user) + + +class SubmissionRelationDetail(SubmissionRelationMixin, + RetrieveUpdateDestroyAPIView): + pass diff --git a/patchwork/models.py b/patchwork/models.py index a92203b24ff2..9ae3370e896b 100644 --- a/patchwork/models.py +++ b/patchwork/models.py @@ -415,6 +415,9 @@ class CoverLetter(Submission): kwargs={'project_id': self.project.linkname, 'msgid': self.url_msgid}) + def get_absolute_api_url(self): + return reverse('api-cover-detail', kwargs={'pk': self.id}) + def get_mbox_url(self): return reverse('cover-mbox', kwargs={'project_id': self.project.linkname, @@ -604,6 +607,9 @@ class Patch(Submission): kwargs={'project_id': self.project.linkname, 'msgid': self.url_msgid}) + def get_absolute_api_url(self): + return reverse('api-patch-detail', kwargs={'pk': self.id}) + def get_mbox_url(self): return reverse('patch-mbox', kwargs={'project_id': self.project.linkname, diff --git a/patchwork/tests/api/test_relation.py b/patchwork/tests/api/test_relation.py new file mode 100644 index 000000000000..5b1a04f13670 --- /dev/null +++ b/patchwork/tests/api/test_relation.py @@ -0,0 +1,181 @@ +# Patchwork - automated patch tracking system +# Copyright (C) 2019, Bayerische Motoren Werke Aktiengesellschaft (BMW AG) +# +# SPDX-License-Identifier: GPL-2.0-or-later + +import unittest + +import six +from django.conf import settings +from django.urls import reverse + +from patchwork.tests.api import utils +from patchwork.tests.utils import create_cover +from patchwork.tests.utils import create_maintainer +from patchwork.tests.utils import create_patches +from patchwork.tests.utils import create_project +from patchwork.tests.utils import create_relation +from patchwork.tests.utils import create_user + +if settings.ENABLE_REST_API: + from rest_framework import status + + +class UserType: + ANONYMOUS = 1 + NON_MAINTAINER = 2 + MAINTAINER = 3 + + +@unittest.skipUnless(settings.ENABLE_REST_API, 'requires ENABLE_REST_API') +class TestRelationAPI(utils.APITestCase): + fixtures = ['default_tags'] + + @staticmethod + def api_url(item=None): + kwargs = {} + if item is None: + return reverse('api-relation-list', kwargs=kwargs) + kwargs['pk'] = item + return reverse('api-relation-detail', kwargs=kwargs) + + def request_restricted(self, method, user_type): + """Assert post/delete/patch requests on the relation API.""" + assert method in ['post', 'delete', 'patch'] + + # setup + + project = create_project() + maintainer = create_maintainer(project) + + if user_type == UserType.ANONYMOUS: + expected_status = status.HTTP_403_FORBIDDEN + elif user_type == UserType.NON_MAINTAINER: + expected_status = status.HTTP_403_FORBIDDEN + self.client.force_authenticate(user=create_user()) + elif user_type == UserType.MAINTAINER: + if method == 'post': + expected_status = status.HTTP_201_CREATED + elif method == 'delete': + expected_status = status.HTTP_204_NO_CONTENT + else: + expected_status = status.HTTP_200_OK + self.client.force_authenticate(user=maintainer) + else: + raise ValueError + + resource_id = None + req = None + + if method == 'delete': + resource_id = create_relation(project=project, by=maintainer).id + elif method == 'post': + patch_ids = [p.id for p in create_patches(2, project=project)] + req = {'submissions': patch_ids} + elif method == 'patch': + resource_id = create_relation(project=project, by=maintainer).id + patch_ids = [p.id for p in create_patches(2, project=project)] + req = {'submissions': patch_ids} + else: + raise ValueError + + # request + + resp = getattr(self.client, method)(self.api_url(resource_id), req) + + # check + + self.assertEqual(expected_status, resp.status_code) + + if resp.status_code in range(status.HTTP_200_OK, + status.HTTP_204_NO_CONTENT): + self.assertRequest(req, resp.data) + + def assertRequest(self, request, resp): + if request.get('id'): + self.assertEqual(request['id'], resp['id']) + send_ids = request['submissions'] + resp_ids = [s['id'] for s in resp['submissions']] + six.assertCountEqual(self, resp_ids, send_ids) + + def assertSerialized(self, obj, resp): + self.assertEqual(obj.id, resp['id']) + exp_ids = [s.id for s in obj.submissions.all()] + act_ids = [s['id'] for s in resp['submissions']] + six.assertCountEqual(self, exp_ids, act_ids) + + def test_list_empty(self): + """List relation when none are present.""" + resp = self.client.get(self.api_url()) + self.assertEqual(status.HTTP_200_OK, resp.status_code) + self.assertEqual(0, len(resp.data)) + + @utils.store_samples('relation-list') + def test_list(self): + """List relations.""" + relation = create_relation() + + resp = self.client.get(self.api_url()) + self.assertEqual(status.HTTP_200_OK, resp.status_code) + self.assertEqual(1, len(resp.data)) + self.assertSerialized(relation, resp.data[0]) + + def test_detail(self): + """Show relation.""" + relation = create_relation() + + resp = self.client.get(self.api_url(relation.id)) + self.assertEqual(status.HTTP_200_OK, resp.status_code) + self.assertSerialized(relation, resp.data) + + @utils.store_samples('relation-create-error-forbidden') + def test_create_anonymous(self): + self.request_restricted('post', UserType.ANONYMOUS) + + def test_create_non_maintainer(self): + self.request_restricted('post', UserType.NON_MAINTAINER) + + @utils.store_samples('relation-create') + def test_create_maintainer(self): + self.request_restricted('post', UserType.MAINTAINER) + + @utils.store_samples('relation-update-error-forbidden') + def test_update_anonymous(self): + self.request_restricted('patch', UserType.ANONYMOUS) + + def test_update_non_maintainer(self): + self.request_restricted('patch', UserType.NON_MAINTAINER) + + @utils.store_samples('relation-update') + def test_update_maintainer(self): + self.request_restricted('patch', UserType.MAINTAINER) + + @utils.store_samples('relation-delete-error-forbidden') + def test_delete_anonymous(self): + self.request_restricted('delete', UserType.ANONYMOUS) + + def test_delete_non_maintainer(self): + self.request_restricted('delete', UserType.NON_MAINTAINER) + + @utils.store_samples('relation-update') + def test_delete_maintainer(self): + self.request_restricted('delete', UserType.MAINTAINER) + + def test_submission_conflict(self): + project = create_project() + maintainer = create_maintainer(project) + self.client.force_authenticate(user=maintainer) + relation = create_relation(by=maintainer, project=project) + submission_ids = [s.id for s in relation.submissions.all()] + + # try to create a new relation with a new submission (cover) and + # submissions already bound to another relation + cover = create_cover(project=project) + submission_ids.append(cover.id) + req = {'submissions': submission_ids} + resp = self.client.post(self.api_url(), req) + self.assertEqual(status.HTTP_409_CONFLICT, resp.status_code) + + # try to patch relation + resp = self.client.patch(self.api_url(relation.id), req) + self.assertEqual(status.HTTP_200_OK, resp.status_code) diff --git a/patchwork/tests/utils.py b/patchwork/tests/utils.py index 577183d0986c..ffe90976233e 100644 --- a/patchwork/tests/utils.py +++ b/patchwork/tests/utils.py @@ -16,6 +16,7 @@ from patchwork.models import Check from patchwork.models import Comment from patchwork.models import CoverLetter from patchwork.models import Patch +from patchwork.models import SubmissionRelation from patchwork.models import Person from patchwork.models import Project from patchwork.models import Series @@ -347,3 +348,17 @@ def create_covers(count=1, **kwargs): kwargs (dict): Overrides for various cover letter fields """ return _create_submissions(create_cover, count, **kwargs) + + +def create_relation(count_patches=2, by=None, **kwargs): + if not by: + project = create_project() + kwargs['project'] = project + by = create_maintainer(project) + relation = SubmissionRelation.objects.create(by=by) + values = { + 'related': relation + } + values.update(kwargs) + create_patches(count_patches, **values) + return relation diff --git a/patchwork/urls.py b/patchwork/urls.py index dcdcfb49e67e..92095f62c7b9 100644 --- a/patchwork/urls.py +++ b/patchwork/urls.py @@ -187,6 +187,7 @@ if settings.ENABLE_REST_API: from patchwork.api import patch as api_patch_views # noqa from patchwork.api import person as api_person_views # noqa from patchwork.api import project as api_project_views # noqa + from patchwork.api import relation as api_relation_views # noqa from patchwork.api import series as api_series_views # noqa from patchwork.api import user as api_user_views # noqa @@ -256,9 +257,19 @@ if settings.ENABLE_REST_API: name='api-cover-comment-list'), ] + api_1_2_patterns = [ + url(r'^relations/$', + api_relation_views.SubmissionRelationList.as_view(), + name='api-relation-list'), + url(r'^relations/(?P<pk>[^/]+)/$', + api_relation_views.SubmissionRelationDetail.as_view(), + name='api-relation-detail'), + ] + urlpatterns += [ url(r'^api/(?:(?P<version>(1.0|1.1|1.2))/)?', include(api_patterns)), url(r'^api/(?:(?P<version>(1.1|1.2))/)?', include(api_1_1_patterns)), + url(r'^api/(?:(?P<version>1.2)/)?', include(api_1_2_patterns)), # token change url(r'^user/generate-token/$', user_views.generate_token, diff --git a/releasenotes/notes/add-submission-relations-c96bb6c567b416d8.yaml b/releasenotes/notes/add-submission-relations-c96bb6c567b416d8.yaml new file mode 100644 index 000000000000..cb877991cd55 --- /dev/null +++ b/releasenotes/notes/add-submission-relations-c96bb6c567b416d8.yaml @@ -0,0 +1,10 @@ +--- +features: + - | + Submissions (cover letters or patches) can now be related to other ones + (e.g. revisions). Relations can be set via the REST API by maintainers + (currently only for submissions of projects they maintain) +api: + - | + Relations are available via ``/relations/`` and + ``/relations/{relationID}/`` endpoints.
View relations and add/update/delete them as a maintainer. Maintainers can only create relations of submissions (patches/cover letters) which are part of a project they maintain. New REST API urls: api/relations/ api/relations/<relation_id>/ Co-authored-by: Daniel Axtens <dja@axtens.net> Signed-off-by: Mete Polat <metepolat2000@gmail.com> --- Optimize db queries: I have spent quite a lot of time in optimizing the db queries for the REST API (thanks for the tip with the Django toolbar). Daniel stated that prefetch_related is possibly hitting the database for every relation when prefetching submissions but it turns out that we can tell Django to use a statement like: SELECT * FROM `patchwork_patch` INNER JOIN `patchwork_submission` ON (`patchwork_patch`.`submission_ptr_id` = `patchwork_submission`.`id`) WHERE `patchwork_patch`.`submission_ptr_id` IN (LIST_OF_ALL_SUBMISSION_IDS) We do the same for `patchwork_coverletter`. This means we only hit the db two times for casting _all_ submissions to a patch or cover-letter. Prefetching submissions__project eliminates similar and duplicate queries that are used to determine whether a logged in user is at least maintainer of one submission's project. docs/api/schemas/latest/patchwork.yaml | 273 +++++++++++++++++ docs/api/schemas/patchwork.j2 | 285 ++++++++++++++++++ docs/api/schemas/v1.2/patchwork.yaml | 273 +++++++++++++++++ patchwork/api/embedded.py | 39 +++ patchwork/api/index.py | 1 + patchwork/api/relation.py | 121 ++++++++ patchwork/models.py | 6 + patchwork/tests/api/test_relation.py | 181 +++++++++++ patchwork/tests/utils.py | 15 + patchwork/urls.py | 11 + ...submission-relations-c96bb6c567b416d8.yaml | 10 + 11 files changed, 1215 insertions(+) create mode 100644 patchwork/api/relation.py create mode 100644 patchwork/tests/api/test_relation.py create mode 100644 releasenotes/notes/add-submission-relations-c96bb6c567b416d8.yaml