diff --git a/docs/.DS_Store b/docs/.DS_Store new file mode 100644 index 00000000..70c33f05 Binary files /dev/null and b/docs/.DS_Store differ diff --git a/docs/api/rest/index.rst b/docs/api/rest/index.rst index cdf91044..c0133b94 100644 --- a/docs/api/rest/index.rst +++ b/docs/api/rest/index.rst @@ -43,6 +43,11 @@ If all you want is reference guides, skip straight to :ref:`rest-api-schemas`. The API version was bumped to v1.3 in Patchwork v3.1. The older APIs are still supported. For more information, refer to :ref:`rest-api-versions`. +.. versionchanged:: 3.2 + + The API version was bumped to v1.4 in Patchwork v3.2. The older APIs are + still supported. For more information, refer to :ref:`rest-api-versions`. + Getting Started --------------- @@ -79,7 +84,7 @@ well-supported. To repeat the above example using `requests`:, run $ python >>> import json >>> import requests - >>> r = requests.get('https://patchwork.example.com/api/1.3/') + >>> r = requests.get('https://patchwork.example.com/api/1.4/') >>> print(json.dumps(r.json(), indent=2)) { "bundles": "https://patchwork.example.com/api/1.4/bundles/", diff --git a/docs/api/schemas/latest/patchwork.yaml b/docs/api/schemas/latest/patchwork.yaml index b2bb220f..e58266e4 100644 --- a/docs/api/schemas/latest/patchwork.yaml +++ b/docs/api/schemas/latest/patchwork.yaml @@ -1203,7 +1203,7 @@ paths: title: ID type: integer get: - summary: Show a series. + summary: Return the details of a series. description: | Retrieve a series by its ID. A series is a collection of patches with an optional cover letter. @@ -1223,6 +1223,128 @@ paths: $ref: '#/components/schemas/Error' tags: - series + patch: + summary: Partially update a series. + description: | + Only updates the 'supersedes' field of a series. Replaces the whole set + of superseded series. + + :: + + Instance: + instance.supersedes = [ + 'http://example.com/api/series/1/', + 'http://example.com/api/series/2/', + 'http://example.com/api/series/5/' + ] + + Request: + PATCH { + "supersedes": [ + 'http://example.com/api/series/1/', + 'http://example.com/api/series/8/' + ] + } + + Result: + instance.supersedes = [ + 'http://example.com/api/series/1/', + 'http://example.com/api/series/8/' + ] + security: + - basicAuth: [] + - apiKeyAuth: [] + requestBody: + $ref: '#/components/requestBodies/SeriesUpdate' + operationId: series_partial_update + responses: + '200': + description: 'Updated series' + content: + application/json: + schema: + $ref: '#/components/schemas/Series' + '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: + - series + put: + summary: Update a series. + description: | + Only updates the 'supersedes' field of a series. Replaces the whole set + of superseded series. + + :: + + Instance: + instance.supersedes = [ + 'http://example.com/api/series/1/', + 'http://example.com/api/series/2/', + 'http://example.com/api/series/5/' + ] + + Request: + PUT { + "supersedes": [ + 'http://example.com/api/series/1/', + 'http://example.com/api/series/8/' + ] + } + + Result: + instance.supersedes = [ + 'http://example.com/api/series/1/', + 'http://example.com/api/series/8/' + ] + security: + - basicAuth: [] + - apiKeyAuth: [] + requestBody: + $ref: '#/components/requestBodies/SeriesUpdate' + operationId: series_update + responses: + '200': + description: 'Updated series' + content: + application/json: + schema: + $ref: '#/components/schemas/Series' + '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: + - series /api/users: get: summary: List users. @@ -1506,6 +1628,14 @@ components: application/x-www-form-urlencoded: schema: $ref: '#/components/schemas/Project' + SeriesUpdate: + required: true + description: | + A series. + content: + application/json: + schema: + $ref: '#/components/schemas/SeriesUpdate' User: required: true description: | @@ -2528,6 +2658,24 @@ components: show_dependencies: title: Whether the parse dependencies feature is enabled. type: boolean + show_series_versions: + title: Whether series versioning is enabled. + type: boolean + SeriesUpdate: + type: object + title: SeriesUpdate + description: | + Updatable fields af a series. + properties: + supersedes: + type: array + items: + oneOf: + - type: string + format: uri + - type: string + format: uri-reference + description: Series deprecated by the current series Series: type: object title: Series @@ -2613,7 +2761,7 @@ components: type: array items: type: string - format: url + format: uri readOnly: true uniqueItems: true dependents: @@ -2621,7 +2769,22 @@ components: type: array items: type: string - format: url + format: uri + readOnly: true + uniqueItems: true + supersedes: + title: Series deprecated by this series + type: array + items: + type: string + format: uri + uniqueItems: true + superseded: + title: Series that superseded this series + type: array + items: + type: string + format: uri readOnly: true uniqueItems: true User: diff --git a/docs/api/schemas/patchwork.j2 b/docs/api/schemas/patchwork.j2 index f37d3213..5a7072cc 100644 --- a/docs/api/schemas/patchwork.j2 +++ b/docs/api/schemas/patchwork.j2 @@ -1228,7 +1228,7 @@ paths: title: ID type: integer get: - summary: Show a series. + summary: Return the details of a series. description: | Retrieve a series by its ID. A series is a collection of patches with an optional cover letter. @@ -1248,6 +1248,130 @@ paths: $ref: '#/components/schemas/Error' tags: - series +{% if version >= (1, 4) %} + patch: + summary: Partially update a series. + description: | + Only updates the 'supersedes' field of a series. Replaces the whole set + of superseded series. + + :: + + Instance: + instance.supersedes = [ + 'http://example.com/api/series/1/', + 'http://example.com/api/series/2/', + 'http://example.com/api/series/5/' + ] + + Request: + PATCH { + "supersedes": [ + 'http://example.com/api/series/1/', + 'http://example.com/api/series/8/' + ] + } + + Result: + instance.supersedes = [ + 'http://example.com/api/series/1/', + 'http://example.com/api/series/8/' + ] + security: + - basicAuth: [] + - apiKeyAuth: [] + requestBody: + $ref: '#/components/requestBodies/SeriesUpdate' + operationId: series_partial_update + responses: + '200': + description: 'Updated series' + content: + application/json: + schema: + $ref: '#/components/schemas/Series' + '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: + - series + put: + summary: Update a series. + description: | + Only updates the 'supersedes' field of a series. Replaces the whole set + of superseded series. + + :: + + Instance: + instance.supersedes = [ + 'http://example.com/api/series/1/', + 'http://example.com/api/series/2/', + 'http://example.com/api/series/5/' + ] + + Request: + PUT { + "supersedes": [ + 'http://example.com/api/series/1/', + 'http://example.com/api/series/8/' + ] + } + + Result: + instance.supersedes = [ + 'http://example.com/api/series/1/', + 'http://example.com/api/series/8/' + ] + security: + - basicAuth: [] + - apiKeyAuth: [] + requestBody: + $ref: '#/components/requestBodies/SeriesUpdate' + operationId: series_update + responses: + '200': + description: 'Updated series' + content: + application/json: + schema: + $ref: '#/components/schemas/Series' + '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: + - series +{% endif %} /api/{{ version_url }}users: get: summary: List users. @@ -1547,6 +1671,16 @@ components: application/x-www-form-urlencoded: schema: $ref: '#/components/schemas/Project' +{% if version >= (1, 4) %} + SeriesUpdate: + required: true + description: | + A series. + content: + application/json: + schema: + $ref: '#/components/schemas/SeriesUpdate' +{% endif %} User: required: true description: | @@ -2621,6 +2755,26 @@ components: show_dependencies: title: Whether the parse dependencies feature is enabled. type: boolean + show_series_versions: + title: Whether series versioning is enabled. + type: boolean +{% endif %} +{% if version >= (1, 4) %} + SeriesUpdate: + type: object + title: SeriesUpdate + description: | + Updatable fields af a series. + properties: + supersedes: + type: array + items: + oneOf: + - type: string + format: uri + - type: string + format: uri-reference + description: Series deprecated by the current series {% endif %} Series: type: object @@ -2710,7 +2864,7 @@ components: type: array items: type: string - format: url + format: uri readOnly: true uniqueItems: true dependents: @@ -2718,7 +2872,22 @@ components: type: array items: type: string - format: url + format: uri + readOnly: true + uniqueItems: true + supersedes: + title: Series deprecated by this series + type: array + items: + type: string + format: uri + uniqueItems: true + superseded: + title: Series that superseded this series + type: array + items: + type: string + format: uri readOnly: true uniqueItems: true {% endif %} diff --git a/docs/api/schemas/v1.0/patchwork.yaml b/docs/api/schemas/v1.0/patchwork.yaml index d317f53f..93ee6fe2 100644 --- a/docs/api/schemas/v1.0/patchwork.yaml +++ b/docs/api/schemas/v1.0/patchwork.yaml @@ -916,7 +916,7 @@ paths: title: ID type: integer get: - summary: Show a series. + summary: Return the details of a series. description: | Retrieve a series by its ID. A series is a collection of patches with an optional cover letter. diff --git a/docs/api/schemas/v1.1/patchwork.yaml b/docs/api/schemas/v1.1/patchwork.yaml index ce17d2f2..f2649b43 100644 --- a/docs/api/schemas/v1.1/patchwork.yaml +++ b/docs/api/schemas/v1.1/patchwork.yaml @@ -916,7 +916,7 @@ paths: title: ID type: integer get: - summary: Show a series. + summary: Return the details of a series. description: | Retrieve a series by its ID. A series is a collection of patches with an optional cover letter. diff --git a/docs/api/schemas/v1.2/patchwork.yaml b/docs/api/schemas/v1.2/patchwork.yaml index dddaafe5..488352d6 100644 --- a/docs/api/schemas/v1.2/patchwork.yaml +++ b/docs/api/schemas/v1.2/patchwork.yaml @@ -1059,7 +1059,7 @@ paths: title: ID type: integer get: - summary: Show a series. + summary: Return the details of a series. description: | Retrieve a series by its ID. A series is a collection of patches with an optional cover letter. diff --git a/docs/api/schemas/v1.3/patchwork.yaml b/docs/api/schemas/v1.3/patchwork.yaml index 41b44832..77e65f12 100644 --- a/docs/api/schemas/v1.3/patchwork.yaml +++ b/docs/api/schemas/v1.3/patchwork.yaml @@ -1203,7 +1203,7 @@ paths: title: ID type: integer get: - summary: Show a series. + summary: Return the details of a series. description: | Retrieve a series by its ID. A series is a collection of patches with an optional cover letter. diff --git a/docs/api/schemas/v1.4/patchwork.yaml b/docs/api/schemas/v1.4/patchwork.yaml index 036fe15f..9e5abf3f 100644 --- a/docs/api/schemas/v1.4/patchwork.yaml +++ b/docs/api/schemas/v1.4/patchwork.yaml @@ -1203,7 +1203,7 @@ paths: title: ID type: integer get: - summary: Show a series. + summary: Return the details of a series. description: | Retrieve a series by its ID. A series is a collection of patches with an optional cover letter. @@ -1223,6 +1223,128 @@ paths: $ref: '#/components/schemas/Error' tags: - series + patch: + summary: Partially update a series. + description: | + Only updates the 'supersedes' field of a series. Replaces the whole set + of superseded series. + + :: + + Instance: + instance.supersedes = [ + 'http://example.com/api/series/1/', + 'http://example.com/api/series/2/', + 'http://example.com/api/series/5/' + ] + + Request: + PATCH { + "supersedes": [ + 'http://example.com/api/series/1/', + 'http://example.com/api/series/8/' + ] + } + + Result: + instance.supersedes = [ + 'http://example.com/api/series/1/', + 'http://example.com/api/series/8/' + ] + security: + - basicAuth: [] + - apiKeyAuth: [] + requestBody: + $ref: '#/components/requestBodies/SeriesUpdate' + operationId: series_partial_update + responses: + '200': + description: 'Updated series' + content: + application/json: + schema: + $ref: '#/components/schemas/Series' + '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: + - series + put: + summary: Update a series. + description: | + Only updates the 'supersedes' field of a series. Replaces the whole set + of superseded series. + + :: + + Instance: + instance.supersedes = [ + 'http://example.com/api/series/1/', + 'http://example.com/api/series/2/', + 'http://example.com/api/series/5/' + ] + + Request: + PUT { + "supersedes": [ + 'http://example.com/api/series/1/', + 'http://example.com/api/series/8/' + ] + } + + Result: + instance.supersedes = [ + 'http://example.com/api/series/1/', + 'http://example.com/api/series/8/' + ] + security: + - basicAuth: [] + - apiKeyAuth: [] + requestBody: + $ref: '#/components/requestBodies/SeriesUpdate' + operationId: series_update + responses: + '200': + description: 'Updated series' + content: + application/json: + schema: + $ref: '#/components/schemas/Series' + '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: + - series /api/1.4/users: get: summary: List users. @@ -1506,6 +1628,14 @@ components: application/x-www-form-urlencoded: schema: $ref: '#/components/schemas/Project' + SeriesUpdate: + required: true + description: | + A series. + content: + application/json: + schema: + $ref: '#/components/schemas/SeriesUpdate' User: required: true description: | @@ -2528,6 +2658,24 @@ components: show_dependencies: title: Whether the parse dependencies feature is enabled. type: boolean + show_series_versions: + title: Whether series versioning is enabled. + type: boolean + SeriesUpdate: + type: object + title: SeriesUpdate + description: | + Updatable fields af a series. + properties: + supersedes: + type: array + items: + oneOf: + - type: string + format: uri + - type: string + format: uri-reference + description: Series deprecated by the current series Series: type: object title: Series @@ -2613,7 +2761,7 @@ components: type: array items: type: string - format: url + format: uri readOnly: true uniqueItems: true dependents: @@ -2621,7 +2769,22 @@ components: type: array items: type: string - format: url + format: uri + readOnly: true + uniqueItems: true + supersedes: + title: Series deprecated by this series + type: array + items: + type: string + format: uri + uniqueItems: true + superseded: + title: Series that superseded this series + type: array + items: + type: string + format: uri readOnly: true uniqueItems: true User: diff --git a/patchwork/admin.py b/patchwork/admin.py index d1c389a1..26132790 100644 --- a/patchwork/admin.py +++ b/patchwork/admin.py @@ -151,7 +151,7 @@ class SeriesAdmin(admin.ModelAdmin): readonly_fields = ('received_total', 'received_all') search_fields = ('submitter__name', 'submitter__email') exclude = ('patches',) - filter_horizontal = ('dependencies',) + filter_horizontal = ('dependencies', 'supersedes') inlines = (PatchInline,) def received_all(self, series): diff --git a/patchwork/api/base.py b/patchwork/api/base.py index 16e5cb8d..cdcfd9f6 100644 --- a/patchwork/api/base.py +++ b/patchwork/api/base.py @@ -96,7 +96,7 @@ def get_paginated_response(self, data): class PatchworkPermission(permissions.BasePermission): """ - This permission works for Project, Patch, PatchComment + This permission works for Project, Patch, Series, PatchComment and CoverComment model objects """ diff --git a/patchwork/api/project.py b/patchwork/api/project.py index 6307dda0..5d21637c 100644 --- a/patchwork/api/project.py +++ b/patchwork/api/project.py @@ -40,6 +40,7 @@ class Meta: 'list_archive_url_format', 'commit_url_format', 'show_dependencies', + 'show_series_versions', ) read_only_fields = ( 'name', @@ -56,7 +57,7 @@ class Meta: 'list_archive_url_format', 'commit_url_format', ), - '1.4': ('show_dependencies',), + '1.4': ('show_dependencies', 'show_series_versions'), } extra_kwargs = { 'url': {'view_name': 'api-project-detail'}, diff --git a/patchwork/api/series.py b/patchwork/api/series.py index c9f5f54b..f06376c1 100644 --- a/patchwork/api/series.py +++ b/patchwork/api/series.py @@ -4,11 +4,10 @@ # SPDX-License-Identifier: GPL-2.0-or-later from rest_framework.generics import ListAPIView -from rest_framework.generics import RetrieveAPIView -from rest_framework.serializers import ( - SerializerMethodField, - HyperlinkedRelatedField, -) +from rest_framework.generics import RetrieveUpdateAPIView +from rest_framework.serializers import SerializerMethodField +from rest_framework.serializers import HyperlinkedRelatedField +from rest_framework.serializers import ValidationError from patchwork.api.base import BaseHyperlinkedModelSerializer from patchwork.api.base import PatchworkPermission @@ -33,6 +32,55 @@ class SeriesSerializer(BaseHyperlinkedModelSerializer): dependents = HyperlinkedRelatedField( read_only=True, view_name='api-series-detail', many=True ) + supersedes = HyperlinkedRelatedField( + view_name='api-series-detail', + queryset=Series.objects.select_related('project').all(), + required=False, + many=True, + ) + superseded = HyperlinkedRelatedField( + read_only=True, + view_name='api-series-detail', + many=True, + ) + + def update(self, instance, validated_data, *args, **kwargs): + allowed_fields = {'supersedes'} + incoming_fields = set(validated_data.keys()) + + if not incoming_fields.issubset(allowed_fields): + invalid_fields = incoming_fields - allowed_fields + raise ValidationError( + { + 'detail': 'Cannot update fields: ' + f"{', '.join(invalid_fields)}. Only 'supersedes' can be " + 'updated.' + } + ) + + if 'supersedes' in validated_data: + supersedes = validated_data.pop('supersedes', []) + + if instance in supersedes: + raise ValidationError( + {'detail': 'A series cannot be linked to itself.'} + ) + + if any( + series.project != instance.project for series in supersedes + ): + raise ValidationError( + {'detail': 'Series must belong to the same project.'} + ) + + try: + instance.supersedes.set(supersedes) + except Series.DoesNotExist: + raise ValidationError( + {'detail': 'Unable to find one of the referenced series'} + ) + + return instance def get_web_url(self, instance): request = self.context.get('request') @@ -48,6 +96,11 @@ def to_representation(self, instance): if field in self.fields: del self.fields[field] + if not instance.project.show_series_versions: + for field in ('supersedes', 'superseded'): + if field in self.fields: + del self.fields[field] + data = super().to_representation(instance) return data @@ -71,6 +124,8 @@ class Meta: 'patches', 'dependencies', 'dependents', + 'supersedes', + 'superseded', ) read_only_fields = ( 'date', @@ -83,10 +138,11 @@ class Meta: 'patches', 'dependencies', 'dependents', + 'superseded', ) versioned_fields = { '1.1': ('web_url',), - '1.4': ('dependencies', 'dependents'), + '1.4': ('dependencies', 'dependents', 'supersedes', 'superseded'), } extra_kwargs = { 'url': {'view_name': 'api-series-detail'}, @@ -105,6 +161,8 @@ def get_queryset(self): 'cover_letter__project', 'dependencies', 'dependents', + 'supersedes', + 'superseded', ) .select_related('submitter', 'project') ) @@ -119,7 +177,40 @@ class SeriesList(SeriesMixin, ListAPIView): ordering = 'id' -class SeriesDetail(SeriesMixin, RetrieveAPIView): - """Show a series.""" +class SeriesDetail(SeriesMixin, RetrieveUpdateAPIView): + """Show a series. + + retrieve: + Return the details of a series. + + update: + Only updates the 'supersedes' field of a series. Replaces the whole set + of superseded series. + + :: + + Instance: + instance.supersedes = [ + 'http://example.com/api/series/1/', + 'http://example.com/api/series/2/', + 'http://example.com/api/series/5/' + ] + + Request: + PUT/PATCH { + "supersedes": [ + 'http://example.com/api/series/1/', + 'http://example.com/api/series/8/' + ] + } + + Result: + instance.supersedes = [ + 'http://example.com/api/series/1/', + 'http://example.com/api/series/8/' + ] + """ - pass + # PUT operation will behave as a partial update + def put(self, request, *args, **kwargs): + return self.partial_update(request, *args, **kwargs) diff --git a/patchwork/migrations/0049_series_sequences.py b/patchwork/migrations/0049_series_sequences.py new file mode 100644 index 00000000..c45a1def --- /dev/null +++ b/patchwork/migrations/0049_series_sequences.py @@ -0,0 +1,31 @@ +# Generated by Django 5.1.4 on 2025-03-20 01:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('patchwork', '0048_series_dependencies'), + ] + + operations = [ + migrations.AddField( + model_name='series', + name='supersedes', + field=models.ManyToManyField( + blank=True, + help_text='Previous versions of this patch.', + related_name='superseded', + to='patchwork.series', + ), + ), + migrations.AddField( + model_name='project', + name='show_series_versions', + field=models.BooleanField( + default=False, + help_text='Enable parsing series previous versions on patches ' + 'and cover letters.', + ), + ), + ] diff --git a/patchwork/models.py b/patchwork/models.py index ae2f4a6d..f0ea5171 100644 --- a/patchwork/models.py +++ b/patchwork/models.py @@ -104,6 +104,11 @@ class Project(models.Model): default=False, help_text='Enable dependency tracking for patches and cover letters.', ) + show_series_versions = models.BooleanField( + default=False, + help_text='Enable parsing series previous versions on patches and ' + 'cover letters.', + ) use_tags = models.BooleanField(default=True) def is_editable(self, user): @@ -858,6 +863,15 @@ class Series(FilenameMixin, models.Model): related_query_name='dependent', ) + # versioning + supersedes = models.ManyToManyField( + 'self', + symmetrical=False, + blank=True, + help_text='Previous versions of this patch.', + related_name='superseded', + ) + # metadata name = models.CharField( max_length=255, @@ -889,6 +903,15 @@ def _format_name(obj): return match.group(2) return obj.name.strip() + def is_editable(self, user): + if not user.is_authenticated: + return False + + if user.is_superuser or user == self.submitter.user: + return True + + return self.project.is_editable(user) + @property def received_total(self): return self.patches.count() diff --git a/patchwork/tests/api/test_series.py b/patchwork/tests/api/test_series.py index 24d7d9a6..f223b42b 100644 --- a/patchwork/tests/api/test_series.py +++ b/patchwork/tests/api/test_series.py @@ -44,6 +44,7 @@ def assertSerialized(self, series_obj, series_json): self.assertIn(series_obj.get_mbox_url(), series_json['mbox']) self.assertIn(series_obj.get_absolute_url(), series_json['web_url']) + # dependencies for dep, item in zip( series_obj.dependencies.all(), series_json['dependencies'] ): @@ -58,6 +59,21 @@ def assertSerialized(self, series_obj, series_json): reverse('api-series-detail', kwargs={'pk': dep.id}), item ) + # versioning + for ver, item in zip( + series_obj.supersedes.all(), series_json['supersedes'] + ): + self.assertIn( + reverse('api-series-detail', kwargs={'pk': ver.id}), item + ) + + for ver, item in zip( + series_obj.superseded.all(), series_json['superseded'] + ): + self.assertIn( + reverse('api-series-detail', kwargs={'pk': ver.id}), item + ) + # nested fields self.assertEqual(series_obj.project.id, series_json['project']['id']) @@ -79,7 +95,9 @@ def test_list_empty(self): def _create_series(self): project_obj = create_project( - linkname='myproject', show_dependencies=True + linkname='myproject', + show_dependencies=True, + show_series_versions=True, ) person_obj = create_person(email='test@example.com') series_obj = create_series(project=project_obj, submitter=person_obj) @@ -197,7 +215,7 @@ def test_list_bug_335(self): create_cover(series=series_obj) create_patch(series=series_obj) - with self.assertNumQueries(8): + with self.assertNumQueries(10): self.client.get(self.api_url()) @utils.store_samples('series-detail') @@ -225,6 +243,8 @@ def test_detail_version_1_3(self): self.assertIn('web_url', resp.data['patches'][0]) self.assertNotIn('dependents', resp.data) self.assertNotIn('dependencies', resp.data) + self.assertNotIn('superseded', resp.data) + self.assertNotIn('supersedes', resp.data) @utils.store_samples('series-detail-1-0') def test_detail_version_1_0(self): @@ -251,8 +271,8 @@ def test_detail_invalid(self): with self.assertRaises(NoReverseMatch): self.client.get(self.api_url('foo')) - def test_create_update_delete(self): - """Ensure creates, updates and deletes aren't allowed""" + def test_create_delete(self): + """Ensure creates and deletes aren't allowed""" user = create_maintainer() user.is_superuser = True user.save() @@ -263,8 +283,306 @@ def test_create_update_delete(self): series = create_series() - resp = self.client.patch(self.api_url(series.id), {'name': 'Test'}) - self.assertEqual(status.HTTP_405_METHOD_NOT_ALLOWED, resp.status_code) - resp = self.client.delete(self.api_url(series.id)) self.assertEqual(status.HTTP_405_METHOD_NOT_ALLOWED, resp.status_code) + + def test_series_versioning(self): + """Test toggling versioning on and off.""" + project = create_project( + show_dependencies=True, + show_series_versions=True, + ) + submitter = create_person(email='test@example.com') + series_a = create_series(project=project, submitter=submitter) + create_cover(series=series_a) + create_patch(series=series_a) + series_b = create_series(project=project, submitter=submitter) + create_cover(series=series_b) + create_patch(series=series_b) + series_a.supersedes.set([series_b]) + + resp = self.client.get(self.api_url()) + self.assertEqual(2, len(resp.data)) + for series_data in resp.data: + self.assertIn('supersedes', series_data) + self.assertIn('superseded', series_data) + + project.show_series_versions = False + project.save() + + resp = self.client.get(self.api_url()) + self.assertEqual(2, len(resp.data)) + for series_data in resp.data: + self.assertNotIn('supersedes', series_data) + self.assertNotIn('superseded', series_data) + + +@override_settings(PATCHWORK_API_ENABLED=True) +class TestSeriesDetailUpdate(utils.APITestCase): + @staticmethod + def api_url(item, version=None): + kwargs = {} + if version: + kwargs['version'] = version + + kwargs['pk'] = item + return reverse('api-series-detail', kwargs=kwargs) + + def _get_series_url(self, series, request=None): + url = reverse( + 'api-series-detail', + kwargs={'pk': series.id}, + ) + # Build absolute uri for spec validation + if request is not None: + return request.build_absolute_uri(url) + + return url + + def _assert_contains_series_url(self, response, key, series): + self.assertEqual(response.status_code, status.HTTP_200_OK) + container = response.json().get(key) + for item in container: + if item.endswith( + self._get_series_url(series, response.wsgi_request) + ): + return True + raise AssertionError( + f'No item in {container} ends with {self._get_series_url(series)}' + ) + + def setUp(self): + super().setUp() + self.client.defaults.update( + {'HTTP_HOST': 'example.com', 'SERVER_NAME': 'example.com'} + ) + self.project = create_project( + linkname='myproject', + show_dependencies=True, + show_series_versions=True, + ) + user = create_user() + self.submitter = create_person(email='test@example.com', user=user) + + self.superseded = create_series( + project=self.project, submitter=self.submitter + ) + create_cover(series=self.superseded) + create_patch(series=self.superseded) + + self.series = create_series( + project=self.project, submitter=self.submitter + ) + create_cover(series=self.series) + create_patch(series=self.series) + + self.url = self._get_series_url(self.series) + + def authenticate_as_submitter(self): + self.client.authenticate(user=self.submitter.user) + + def authenticate_as_maintainer(self): + user = create_maintainer(self.project) + self.client.authenticate(user=user) + + def authenticate_as_superuser(self): + user = create_user() + user.is_superuser = True + user.save() + self.client.authenticate(user=user) + + def authenticate_as_unrelated_user(self): + user = create_user() + self.client.authenticate(user=user) + + # PATCH tests + def test_patch_series_as_submitter(self): + series_b = create_series( + project=self.project, submitter=self.submitter + ) + self.authenticate_as_submitter() + response = self.client.patch( + self.url, + {'supersedes': [self._get_series_url(self.superseded)]}, + format='json', + ) + self._assert_contains_series_url( + response, 'supersedes', self.superseded + ) + + # Add series_b and remove superseded + response = self.client.patch( + self.url, + {'supersedes': [self._get_series_url(series_b)]}, + format='json', + ) + self._assert_contains_series_url(response, 'supersedes', series_b) + with self.assertRaises(AssertionError): + self._assert_contains_series_url( + response, 'supersedes', self.superseded + ) + + def test_patch_series_as_maintainer(self): + self.authenticate_as_maintainer() + response = self.client.patch( + self.url, + {'supersedes': [self._get_series_url(self.superseded)]}, + format='json', + ) + self._assert_contains_series_url( + response, 'supersedes', self.superseded + ) + + def test_patch_series_as_superuser(self): + self.authenticate_as_superuser() + response = self.client.patch( + self.url, + {'supersedes': [self._get_series_url(self.superseded)]}, + format='json', + ) + self._assert_contains_series_url( + response, 'supersedes', self.superseded + ) + + def test_patch_series_as_unrelated_user_forbidden(self): + self.authenticate_as_unrelated_user() + response = self.client.patch( + self.url, + {'supersedes': [self._get_series_url(self.superseded)]}, + format='json', + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_patch_series_unauthenticated_forbidden(self): + response = self.client.patch( + self.url, + {'supersedes': [self._get_series_url(self.superseded)]}, + format='json', + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + # PUT tests + def test_put_series_as_submitter(self): + series_b = create_series( + project=self.project, submitter=self.submitter + ) + self.authenticate_as_submitter() + response = self.client.put( + self.url, + {'supersedes': [self._get_series_url(self.superseded)]}, + format='json', + ) + self._assert_contains_series_url( + response, 'supersedes', self.superseded + ) + + # Rewrite the whole supersedes attribute + response = self.client.put( + self.url, + {'supersedes': [self._get_series_url(series_b)]}, + format='json', + ) + self._assert_contains_series_url(response, 'supersedes', series_b) + with self.assertRaises(AssertionError): + self._assert_contains_series_url( + response, 'supersedes', self.superseded + ) + + def test_put_series_as_maintainer(self): + self.authenticate_as_maintainer() + response = self.client.put( + self.url, + {'supersedes': [self._get_series_url(self.superseded)]}, + format='json', + ) + self._assert_contains_series_url( + response, 'supersedes', self.superseded + ) + + def test_put_series_as_superuser(self): + self.authenticate_as_superuser() + response = self.client.put( + self.url, + {'supersedes': [self._get_series_url(self.superseded)]}, + format='json', + ) + self._assert_contains_series_url( + response, 'supersedes', self.superseded + ) + + def test_put_series_as_unrelated_user_forbidden(self): + self.authenticate_as_unrelated_user() + response = self.client.put( + self.url, + {'supersedes': [self._get_series_url(self.superseded)]}, + format='json', + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_put_series_unauthenticated_forbidden(self): + response = self.client.put( + self.url, + {'supersedes': [self._get_series_url(self.superseded)]}, + format='json', + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + # Invalid input tests + def test_patch_invalid_input(self): + self.authenticate_as_maintainer() + response = self.client.patch(self.url, {'name': 'name'}, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_put_invalid_input(self): + self.authenticate_as_maintainer() + response = self.client.put(self.url, {'name': 'name'}, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_patch_invalid_series_id(self): + self.authenticate_as_maintainer() + response = self.client.patch( + self.url, + {'supersedes': ['/api/series/99999999/']}, + format='json', + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_put_invalid_series_id(self): + self.authenticate_as_maintainer() + response = self.client.put( + self.url, + {'supersedes': ['/api/series/99999999/']}, + format='json', + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_self_link_validation(self): + self.authenticate_as_submitter() + + response = self.client.put( + self.url, + {'supersedes': [self._get_series_url(self.series)]}, + format='json', + ) + + self.assertContains( + response, + 'A series cannot be linked to itself.', + status_code=status.HTTP_400_BAD_REQUEST, + ) + + def test_cross_project_validation(self): + self.authenticate_as_submitter() + series_x = create_series() + + response = self.client.put( + self.url, + {'supersedes': [self._get_series_url(series_x)]}, + format='json', + ) + + self.assertContains( + response, + 'Series must belong to the same project.', + status_code=status.HTTP_400_BAD_REQUEST, + ) diff --git a/patchwork/tests/test_series.py b/patchwork/tests/test_series.py index e5f60e3a..66c61c7b 100644 --- a/patchwork/tests/test_series.py +++ b/patchwork/tests/test_series.py @@ -1081,3 +1081,27 @@ def test_dependency_multi_2(self): self.assertEqual(series2.dependencies.count(), 1) self.assertEqual(series3.dependencies.count(), 2) self.assertEqual(series3.dependents.count(), 0) + + +class SeriesVersioningTestCase(TestCase): + def setUp(self): + self.series_a = utils.create_series() # main series + self.project = self.series_a.project # main project + self.submitter = self.series_a.submitter # main user + + # same project series + self.series_b = utils.create_series(project=self.project) + self.series_c = utils.create_series(project=self.project) + # different project series + self.series_x = utils.create_series() + + def test_add_superseded_series(self): + self.series_c.supersedes.set([self.series_b]) + + self.assertIn(self.series_b, self.series_c.supersedes.all()) + self.assertIn(self.series_c, self.series_b.superseded.all()) + + self.series_b.supersedes.set([self.series_a]) + + self.assertIn(self.series_a, self.series_b.supersedes.all()) + self.assertIn(self.series_b, self.series_a.superseded.all())