Stephen Finucane <step...@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 <d...@axtens.net> >> Signed-off-by: Mete Polat <metepolat2...@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 _______________________________________________ Patchwork mailing list Patchwork@lists.ozlabs.org https://lists.ozlabs.org/listinfo/patchwork