From 2e7dbe08bbf542c384ee5fe798a17774887f52d2 Mon Sep 17 00:00:00 2001 From: Victor Accarini Date: Wed, 19 Mar 2025 10:13:16 -0300 Subject: [PATCH 1/7] models: Add fields for series sequences * Add ManyToMany field to represent a new series that will supersed another, or is superseded by another. * Add show_series_versions to enable this feature in a project Signed-off-by: Victor Accarini --- patchwork/migrations/0049_series_sequences.py | 31 +++++++++++++++++++ patchwork/models.py | 23 ++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 patchwork/migrations/0049_series_sequences.py 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() From 958410ba687f2bdca2ddd7ac28c8932991278733 Mon Sep 17 00:00:00 2001 From: Victor Accarini Date: Wed, 19 Mar 2025 12:06:18 -0300 Subject: [PATCH 2/7] admin: Add Series.supersedes field Signed-off-by: Victor Accarini --- patchwork/admin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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): From 6e7a961c29815bb1fb1417201a6c28c6345d900c Mon Sep 17 00:00:00 2001 From: Victor Accarini Date: Wed, 19 Mar 2025 13:17:05 -0300 Subject: [PATCH 3/7] api: add series versioning fields For projects with the series versioning enabled, we will show all series that are superseded or supersedes a series. Signed-off-by: Victor Accarini --- patchwork/api/project.py | 3 ++- patchwork/api/series.py | 23 ++++++++++++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) 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..688a929c 100644 --- a/patchwork/api/series.py +++ b/patchwork/api/series.py @@ -33,6 +33,16 @@ class SeriesSerializer(BaseHyperlinkedModelSerializer): dependents = HyperlinkedRelatedField( read_only=True, view_name='api-series-detail', many=True ) + supersedes = HyperlinkedRelatedField( + read_only=True, + view_name='api-series-detail', + many=True, + ) + superseded = HyperlinkedRelatedField( + read_only=True, + view_name='api-series-detail', + many=True, + ) def get_web_url(self, instance): request = self.context.get('request') @@ -48,6 +58,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 +86,8 @@ class Meta: 'patches', 'dependencies', 'dependents', + 'supersedes', + 'superseded', ) read_only_fields = ( 'date', @@ -83,10 +100,12 @@ class Meta: 'patches', 'dependencies', 'dependents', + 'supersedes', + '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 +124,8 @@ def get_queryset(self): 'cover_letter__project', 'dependencies', 'dependents', + 'supersedes', + 'superseded', ) .select_related('submitter', 'project') ) From 2240516e2abd2e8efefc5538b6409be9b94002fb Mon Sep 17 00:00:00 2001 From: Victor Accarini Date: Tue, 29 Apr 2025 19:02:11 -0300 Subject: [PATCH 4/7] api: add series versioning updates Add PUT and PATCH methods to the series detail view, both are handled as partial updates and the only accepted field for now is 'supersedes'. To update which series are superseded by the current one the user must provide the complete list. Signed-off-by: Victor Accarini --- patchwork/api/base.py | 2 +- patchwork/api/series.py | 78 +++++++++++++++++++++++++++++++++++------ 2 files changed, 69 insertions(+), 11 deletions(-) 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/series.py b/patchwork/api/series.py index 688a929c..401fa2bd 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 @@ -34,8 +33,9 @@ class SeriesSerializer(BaseHyperlinkedModelSerializer): read_only=True, view_name='api-series-detail', many=True ) supersedes = HyperlinkedRelatedField( - read_only=True, view_name='api-series-detail', + queryset=Series.objects.all(), + required=False, many=True, ) superseded = HyperlinkedRelatedField( @@ -44,6 +44,32 @@ class SeriesSerializer(BaseHyperlinkedModelSerializer): 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', []) + + 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') return request.build_absolute_uri(instance.get_absolute_url()) @@ -100,7 +126,6 @@ class Meta: 'patches', 'dependencies', 'dependents', - 'supersedes', 'superseded', ) versioned_fields = { @@ -140,7 +165,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) From 9b17e4b3086e6706738e9dc39c6866b503c80954 Mon Sep 17 00:00:00 2001 From: Victor Accarini Date: Tue, 29 Apr 2025 19:05:06 -0300 Subject: [PATCH 5/7] tests: add tests for series versioning Signed-off-by: Victor Accarini --- patchwork/api/series.py | 12 ++ patchwork/tests/api/test_series.py | 332 ++++++++++++++++++++++++++++- patchwork/tests/test_series.py | 24 +++ 3 files changed, 361 insertions(+), 7 deletions(-) diff --git a/patchwork/api/series.py b/patchwork/api/series.py index 401fa2bd..08faa067 100644 --- a/patchwork/api/series.py +++ b/patchwork/api/series.py @@ -61,6 +61,18 @@ def update(self, instance, validated_data, *args, **kwargs): 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: 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()) From 2859427a45e04a1257df1f5da6c18c27682a44cd Mon Sep 17 00:00:00 2001 From: Victor Accarini Date: Tue, 29 Apr 2025 19:05:26 -0300 Subject: [PATCH 6/7] docs: update documentation with series versioning Signed-off-by: Victor Accarini --- docs/.DS_Store | Bin 0 -> 8196 bytes docs/api/rest/index.rst | 7 +- docs/api/schemas/latest/patchwork.yaml | 169 +++++++++++++++++++++++- docs/api/schemas/patchwork.j2 | 175 ++++++++++++++++++++++++- docs/api/schemas/v1.0/patchwork.yaml | 2 +- docs/api/schemas/v1.1/patchwork.yaml | 2 +- docs/api/schemas/v1.2/patchwork.yaml | 2 +- docs/api/schemas/v1.3/patchwork.yaml | 2 +- docs/api/schemas/v1.4/patchwork.yaml | 169 +++++++++++++++++++++++- 9 files changed, 514 insertions(+), 14 deletions(-) create mode 100644 docs/.DS_Store diff --git a/docs/.DS_Store b/docs/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..70c33f05e6fe80ffab09b6ae12e3dd309658b3df GIT binary patch literal 8196 zcmeHM&yUhT6n+EjQY0DtPH3h;>{@}EY@kryT2TC_P}6fg=H1&jhl0i(d*pa7oP(yR^7eKl&lQNSqhUn(H) z4>p=aSL0G4-#Xy91OQfWm<@f@0nD*Ax*C@XnTqdJ)q~Jfp-T**=oq(UIMCI&RH*1A z6rF^gS?CHy@a*8(98SVjXuMItD6p=8tlf8MNFGfoEY$DvQhJ2e{>jGVl~`|Ud8)eh9H=4YZpAM5G^3MAs?|B%^hK-w#lQdRFkY~r~UNPq? z9Z^Cty`?sCKU>XR+%DvPjeH6a7lCs|C+NqN6*yAKyk2$Yd_=5djW@7<6j(Lv!kC$- zSlNWq;yk6}0>eU-oFl+lOySejb@2}VlH0*+@mq368?3_zzK*3L`Yb_{Hf3~L)RFgh zUY80T(gF49U{ycf3;DPm%#cFsV+sn9atCaP+!BqEpfRJsmMBnGc{FAH|77#~|1HVZ zq+t{=3S6dusPqT@9!Ac8Bo~8Zt!<;dL6auqmI|2y$K^PX%W>evABO1L2$dXH<5D4x Vz<&FSfU8srykg^w0{^}OzW{tTc&z{c literal 0 HcmV?d00001 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: From 1a2f2c072125dad75b18a386afffb38fbea44d58 Mon Sep 17 00:00:00 2001 From: DATVenancio <˜danielvenancioif@gmail.com> Date: Wed, 4 Jun 2025 10:31:31 -0300 Subject: [PATCH 7/7] api: optimize prefetching related projects Signed-off-by: DATVenancio Signed-off-by: Victor Accarini --- patchwork/api/series.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/patchwork/api/series.py b/patchwork/api/series.py index 08faa067..f06376c1 100644 --- a/patchwork/api/series.py +++ b/patchwork/api/series.py @@ -34,7 +34,7 @@ class SeriesSerializer(BaseHyperlinkedModelSerializer): ) supersedes = HyperlinkedRelatedField( view_name='api-series-detail', - queryset=Series.objects.all(), + queryset=Series.objects.select_related('project').all(), required=False, many=True, )