Add a SeriesMetadata model to store arbitrary key-value metadata on series for external integrations (e.g. linking to a GitHub PR or a CI job). Each entry is a separate row with a unique key per series rather than a JSON blob, making it easier to query and index individual keys.
Both key and value columns are indexed. The API supports filtering series by metadata via ?metadata_key=foo&metadata_value=bar query parameters, either independently or combined. The REST API presents metadata as a flat dict for convenience. Maintainers can update metadata via PATCH with a JSON object. Setting a key to null deletes it. The API contract is part of version 1.4. Show the series metadata key/value pairs on the patch detail page when metadata is present. This allows seeing external integration data (e.g. forge PR references) directly in the web UI. Signed-off-by: Robin Jarry <[email protected]> --- docs/api/schemas/latest/patchwork.yaml | 130 +++++++++++++++++ docs/api/schemas/patchwork.j2 | 134 ++++++++++++++++++ docs/api/schemas/v1.4/patchwork.yaml | 130 +++++++++++++++++ patchwork/admin.py | 7 + patchwork/api/filters.py | 8 ++ patchwork/api/series.py | 49 ++++++- patchwork/migrations/0050_series_metadata.py | 44 ++++++ patchwork/models.py | 27 ++++ patchwork/templates/patchwork/submission.html | 10 ++ patchwork/templatetags/utils.py | 9 ++ patchwork/tests/unit/api/test_series.py | 40 +++++- .../series-metadata-b2c3d4e5f6g7h8i9.yaml | 15 ++ 12 files changed, 591 insertions(+), 12 deletions(-) create mode 100644 patchwork/migrations/0050_series_metadata.py create mode 100644 releasenotes/notes/series-metadata-b2c3d4e5f6g7h8i9.yaml diff --git a/docs/api/schemas/latest/patchwork.yaml b/docs/api/schemas/latest/patchwork.yaml index eb7dcd382fc8..bd4e4cea4b6c 100644 --- a/docs/api/schemas/latest/patchwork.yaml +++ b/docs/api/schemas/latest/patchwork.yaml @@ -1401,6 +1401,18 @@ paths: schema: title: '' type: string + - in: query + name: metadata_key + description: A metadata key to filter series by. + schema: + title: '' + type: string + - in: query + name: metadata_value + description: A metadata value to filter series by. + schema: + title: '' + type: string responses: '200': description: 'List of series' @@ -1445,6 +1457,82 @@ paths: $ref: '#/components/schemas/Error' tags: - series + patch: + summary: Update a series (partial). + description: + Partially update an existing series. + You must be a maintainer of the project that the series belongs to. + operationId: series_partial_update + security: + - basicAuth: [] + - apiKeyAuth: [] + requestBody: + $ref: '#/components/requestBodies/Series' + responses: + '200': + description: 'Updated series' + content: + application/json: + schema: + $ref: '#/components/schemas/Series' + '400': + description: 'Invalid request' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorSeriesUpdate' + '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: + Update an existing series. + You must be a maintainer of the project that the series belongs to. + operationId: series_update + security: + - basicAuth: [] + - apiKeyAuth: [] + requestBody: + $ref: '#/components/requestBodies/Series' + responses: + '200': + description: 'Updated series' + content: + application/json: + schema: + $ref: '#/components/schemas/Series' + '400': + description: 'Invalid request' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorSeriesUpdate' + '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. @@ -1700,6 +1788,14 @@ components: application/json: schema: $ref: '#/components/schemas/CommentUpdate' + Series: + required: true + description: | + A series. + content: + application/json: + schema: + $ref: '#/components/schemas/SeriesUpdate' Webhook: required: true description: | @@ -2854,6 +2950,28 @@ components: format: url readOnly: true uniqueItems: true + metadata: + title: Metadata + description: | + Arbitrary key-value metadata for external integrations. + type: object + additionalProperties: + type: string + SeriesUpdate: + type: object + title: Series update + description: | + The fields to set on an existing series. + properties: + metadata: + title: Metadata + description: | + Arbitrary key-value metadata. Set a key to null to delete it. + type: object + additionalProperties: + type: + - 'null' + - 'string' User: type: object title: User @@ -3449,6 +3567,18 @@ components: type: array items: type: string + ErrorSeriesUpdate: + type: object + title: A series update error. + description: | + A mapping of field names to validation failures. + properties: + metadata: + title: Metadata + type: array + items: + type: string + readOnly: true ErrorWebhookCreateUpdate: type: object title: A webhook creation or update error. diff --git a/docs/api/schemas/patchwork.j2 b/docs/api/schemas/patchwork.j2 index 56c72158e0b2..e05c1ec2be30 100644 --- a/docs/api/schemas/patchwork.j2 +++ b/docs/api/schemas/patchwork.j2 @@ -1428,6 +1428,20 @@ paths: schema: title: '' type: string +{% if version >= (1, 4) %} + - in: query + name: metadata_key + description: A metadata key to filter series by. + schema: + title: '' + type: string + - in: query + name: metadata_value + description: A metadata value to filter series by. + schema: + title: '' + type: string +{% endif %} responses: '200': description: 'List of series' @@ -1472,6 +1486,84 @@ paths: $ref: '#/components/schemas/Error' tags: - series +{% if version >= (1, 4) %} + patch: + summary: Update a series (partial). + description: + Partially update an existing series. + You must be a maintainer of the project that the series belongs to. + operationId: series_partial_update + security: + - basicAuth: [] + - apiKeyAuth: [] + requestBody: + $ref: '#/components/requestBodies/Series' + responses: + '200': + description: 'Updated series' + content: + application/json: + schema: + $ref: '#/components/schemas/Series' + '400': + description: 'Invalid request' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorSeriesUpdate' + '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: + Update an existing series. + You must be a maintainer of the project that the series belongs to. + operationId: series_update + security: + - basicAuth: [] + - apiKeyAuth: [] + requestBody: + $ref: '#/components/requestBodies/Series' + responses: + '200': + description: 'Updated series' + content: + application/json: + schema: + $ref: '#/components/schemas/Series' + '400': + description: 'Invalid request' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorSeriesUpdate' + '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. @@ -1744,6 +1836,14 @@ components: $ref: '#/components/schemas/CommentUpdate' {% endif %} {% if version >= (1, 4) %} + Series: + required: true + description: | + A series. + content: + application/json: + schema: + $ref: '#/components/schemas/SeriesUpdate' Webhook: required: true description: | @@ -2955,6 +3055,28 @@ components: format: url readOnly: true uniqueItems: true + metadata: + title: Metadata + description: | + Arbitrary key-value metadata for external integrations. + type: object + additionalProperties: + type: string + SeriesUpdate: + type: object + title: Series update + description: | + The fields to set on an existing series. + properties: + metadata: + title: Metadata + description: | + Arbitrary key-value metadata. Set a key to null to delete it. + type: object + additionalProperties: + type: + - 'null' + - 'string' {% endif %} User: type: object @@ -3578,6 +3700,18 @@ components: type: string {% endif %} {% if version >= (1, 4) %} + ErrorSeriesUpdate: + type: object + title: A series update error. + description: | + A mapping of field names to validation failures. + properties: + metadata: + title: Metadata + type: array + items: + type: string + readOnly: true ErrorWebhookCreateUpdate: type: object title: A webhook creation or update error. diff --git a/docs/api/schemas/v1.4/patchwork.yaml b/docs/api/schemas/v1.4/patchwork.yaml index d15bf24e000b..7d94e5bd88f9 100644 --- a/docs/api/schemas/v1.4/patchwork.yaml +++ b/docs/api/schemas/v1.4/patchwork.yaml @@ -1401,6 +1401,18 @@ paths: schema: title: '' type: string + - in: query + name: metadata_key + description: A metadata key to filter series by. + schema: + title: '' + type: string + - in: query + name: metadata_value + description: A metadata value to filter series by. + schema: + title: '' + type: string responses: '200': description: 'List of series' @@ -1445,6 +1457,82 @@ paths: $ref: '#/components/schemas/Error' tags: - series + patch: + summary: Update a series (partial). + description: + Partially update an existing series. + You must be a maintainer of the project that the series belongs to. + operationId: series_partial_update + security: + - basicAuth: [] + - apiKeyAuth: [] + requestBody: + $ref: '#/components/requestBodies/Series' + responses: + '200': + description: 'Updated series' + content: + application/json: + schema: + $ref: '#/components/schemas/Series' + '400': + description: 'Invalid request' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorSeriesUpdate' + '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: + Update an existing series. + You must be a maintainer of the project that the series belongs to. + operationId: series_update + security: + - basicAuth: [] + - apiKeyAuth: [] + requestBody: + $ref: '#/components/requestBodies/Series' + responses: + '200': + description: 'Updated series' + content: + application/json: + schema: + $ref: '#/components/schemas/Series' + '400': + description: 'Invalid request' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorSeriesUpdate' + '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. @@ -1700,6 +1788,14 @@ components: application/json: schema: $ref: '#/components/schemas/CommentUpdate' + Series: + required: true + description: | + A series. + content: + application/json: + schema: + $ref: '#/components/schemas/SeriesUpdate' Webhook: required: true description: | @@ -2854,6 +2950,28 @@ components: format: url readOnly: true uniqueItems: true + metadata: + title: Metadata + description: | + Arbitrary key-value metadata for external integrations. + type: object + additionalProperties: + type: string + SeriesUpdate: + type: object + title: Series update + description: | + The fields to set on an existing series. + properties: + metadata: + title: Metadata + description: | + Arbitrary key-value metadata. Set a key to null to delete it. + type: object + additionalProperties: + type: + - 'null' + - 'string' User: type: object title: User @@ -3449,6 +3567,18 @@ components: type: array items: type: string + ErrorSeriesUpdate: + type: object + title: A series update error. + description: | + A mapping of field names to validation failures. + properties: + metadata: + title: Metadata + type: array + items: + type: string + readOnly: true ErrorWebhookCreateUpdate: type: object title: A webhook creation or update error. diff --git a/patchwork/admin.py b/patchwork/admin.py index 26de8baefb36..4d9ee7aad4aa 100644 --- a/patchwork/admin.py +++ b/patchwork/admin.py @@ -21,6 +21,7 @@ from patchwork.models import PatchRelation from patchwork.models import Person from patchwork.models import Project from patchwork.models import Series +from patchwork.models import SeriesMetadata from patchwork.models import SeriesReference from patchwork.models import State from patchwork.models import Tag @@ -167,6 +168,12 @@ class SeriesReferenceAdmin(admin.ModelAdmin): model = SeriesReference [email protected](SeriesMetadata) +class SeriesMetadataAdmin(admin.ModelAdmin): + model = SeriesMetadata + list_display = ('series', 'key', 'value') + + @admin.register(Check) class CheckAdmin(admin.ModelAdmin): list_display = ( diff --git a/patchwork/api/filters.py b/patchwork/api/filters.py index e332c5316c2e..7a59073f23c2 100644 --- a/patchwork/api/filters.py +++ b/patchwork/api/filters.py @@ -165,6 +165,14 @@ class TimestampMixin(BaseFilterSet): class SeriesFilterSet(TimestampMixin, BaseFilterSet): submitter = PersonFilter(queryset=Person.objects.all(), distinct=False) project = ProjectFilter(queryset=Project.objects.all(), distinct=False) + metadata_key = CharFilter( + field_name='metadata_entry__key', + label='Metadata key', + ) + metadata_value = CharFilter( + field_name='metadata_entry__value', + label='Metadata value', + ) class Meta: model = Series diff --git a/patchwork/api/series.py b/patchwork/api/series.py index c9f5f54bdbfd..c8acb12f578f 100644 --- a/patchwork/api/series.py +++ b/patchwork/api/series.py @@ -4,7 +4,7 @@ # SPDX-License-Identifier: GPL-2.0-or-later from rest_framework.generics import ListAPIView -from rest_framework.generics import RetrieveAPIView +from rest_framework.generics import RetrieveUpdateAPIView from rest_framework.serializers import ( SerializerMethodField, HyperlinkedRelatedField, @@ -18,6 +18,7 @@ from patchwork.api.embedded import PatchSerializer from patchwork.api.embedded import PersonSerializer from patchwork.api.embedded import ProjectSerializer from patchwork.models import Series +from patchwork.models import SeriesMetadata class SeriesSerializer(BaseHyperlinkedModelSerializer): @@ -33,6 +34,7 @@ class SeriesSerializer(BaseHyperlinkedModelSerializer): dependents = HyperlinkedRelatedField( read_only=True, view_name='api-series-detail', many=True ) + metadata = SerializerMethodField() def get_web_url(self, instance): request = self.context.get('request') @@ -42,15 +44,39 @@ class SeriesSerializer(BaseHyperlinkedModelSerializer): request = self.context.get('request') return request.build_absolute_uri(instance.get_mbox_url()) + def get_metadata(self, instance): + return {m.key: m.value for m in instance.metadata.all()} + def to_representation(self, instance): if not instance.project.show_dependencies: for field in ('dependencies', 'dependents'): if field in self.fields: del self.fields[field] - data = super().to_representation(instance) + return super().to_representation(instance) - return data + def to_internal_value(self, data): + ret = super().to_internal_value(data) + if 'metadata' in data: + ret['metadata'] = data['metadata'] + return ret + + def update(self, instance, validated_data): + metadata = validated_data.pop('metadata', None) + instance = super().update(instance, validated_data) + if metadata is not None: + for key, value in metadata.items(): + if value is None: + SeriesMetadata.objects.filter( + series=instance, key=key + ).delete() + else: + SeriesMetadata.objects.update_or_create( + series=instance, + key=key, + defaults={'value': str(value)}, + ) + return instance class Meta: model = Series @@ -71,6 +97,7 @@ class SeriesSerializer(BaseHyperlinkedModelSerializer): 'patches', 'dependencies', 'dependents', + 'metadata', ) read_only_fields = ( 'date', @@ -86,7 +113,7 @@ class SeriesSerializer(BaseHyperlinkedModelSerializer): ) versioned_fields = { '1.1': ('web_url',), - '1.4': ('dependencies', 'dependents'), + '1.4': ('dependencies', 'dependents', 'metadata'), } extra_kwargs = { 'url': {'view_name': 'api-series-detail'}, @@ -105,6 +132,7 @@ class SeriesMixin(object): 'cover_letter__project', 'dependencies', 'dependents', + 'metadata', ) .select_related('submitter', 'project') ) @@ -119,7 +147,16 @@ class SeriesList(SeriesMixin, ListAPIView): ordering = 'id' -class SeriesDetail(SeriesMixin, RetrieveAPIView): - """Show a series.""" +class SeriesDetail(SeriesMixin, RetrieveUpdateAPIView): + """ + get: + Show a series. + + patch: + Update a series. + + put: + Update a series. + """ pass diff --git a/patchwork/migrations/0050_series_metadata.py b/patchwork/migrations/0050_series_metadata.py new file mode 100644 index 000000000000..f3fccf10a02f --- /dev/null +++ b/patchwork/migrations/0050_series_metadata.py @@ -0,0 +1,44 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ('patchwork', '0049_webhook'), + ] + + operations = [ + migrations.AlterModelOptions( + name='webhook', + options={'ordering': ['id']}, + ), + migrations.CreateModel( + name='SeriesMetadata', + fields=[ + ( + 'id', + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name='ID', + ), + ), + ('key', models.CharField(db_index=True, max_length=255)), + ('value', models.TextField(db_index=True, max_length=255)), + ( + 'series', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name='metadata', + related_query_name='metadata_entry', + to='patchwork.series', + ), + ), + ], + options={ + 'unique_together': {('series', 'key')}, + 'ordering': ['key'], + }, + ), + ] diff --git a/patchwork/models.py b/patchwork/models.py index ed89aa7e26c7..f3f982465cf9 100644 --- a/patchwork/models.py +++ b/patchwork/models.py @@ -972,6 +972,13 @@ class Series(FilenameMixin, models.Model): return patch + def is_editable(self, user): + if not user.is_authenticated: + return False + if self.project.is_editable(user): + return True + return False + def get_absolute_url(self): # TODO(stephenfin): We really need a proper series view return reverse( @@ -1013,6 +1020,26 @@ class SeriesReference(models.Model): unique_together = [('project', 'msgid')] +class SeriesMetadata(models.Model): + """A single key-value metadata entry for a series.""" + + series = models.ForeignKey( + Series, + related_name='metadata', + related_query_name='metadata_entry', + on_delete=models.CASCADE, + ) + key = models.CharField(max_length=255, db_index=True) + value = models.TextField(max_length=255, db_index=True) + + def __str__(self): + return f'{self.key}={self.value}' + + class Meta: + unique_together = [('series', 'key')] + ordering = ['key'] + + class Bundle(models.Model): owner = models.ForeignKey( User, diff --git a/patchwork/templates/patchwork/submission.html b/patchwork/templates/patchwork/submission.html index cd74491c0e92..7be7f1966d80 100644 --- a/patchwork/templates/patchwork/submission.html +++ b/patchwork/templates/patchwork/submission.html @@ -97,6 +97,16 @@ </td> </tr> {% endif %} +{% if submission.series.metadata.all %} + <tr> + <th>Metadata</th> + <td> +{% for entry in submission.series.metadata.all %} + <strong>{{ entry.key }}:</strong> {{ entry.value|metadata_value }}{% if not forloop.last %} | {% endif %} +{% endfor %} + </td> + </tr> +{% endif %} {% if submission.related %} <tr> <th>Related</th> diff --git a/patchwork/templatetags/utils.py b/patchwork/templatetags/utils.py index 78c0aac80fc8..198fa0468e7c 100644 --- a/patchwork/templatetags/utils.py +++ b/patchwork/templatetags/utils.py @@ -4,6 +4,7 @@ # SPDX-License-Identifier: GPL-2.0-or-later from django import template +from django.utils.html import format_html register = template.Library() @@ -16,3 +17,11 @@ def verbose_name_plural(obj): @register.simple_tag def is_editable(obj, user): return obj.is_editable(user) + + [email protected] +def metadata_value(value): + s = str(value) + if s.startswith(('http://', 'https://')): + return format_html('<a href="{}">{}</a>', s, s) + return format_html('<code>{}</code>', s) diff --git a/patchwork/tests/unit/api/test_series.py b/patchwork/tests/unit/api/test_series.py index 80887d48d3b5..3b5ce6aedf3e 100644 --- a/patchwork/tests/unit/api/test_series.py +++ b/patchwork/tests/unit/api/test_series.py @@ -197,7 +197,7 @@ class TestSeriesAPI(utils.APITestCase): create_cover(series=series_obj) create_patch(series=series_obj) - with self.assertNumQueries(8): + with self.assertNumQueries(9): self.client.get(self.api_url()) @utils.store_samples('series-detail') @@ -251,8 +251,8 @@ class TestSeriesAPI(utils.APITestCase): 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 +263,36 @@ class TestSeriesAPI(utils.APITestCase): 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_update(self): + """Ensure maintainers can update series metadata.""" + project = create_project() + user = create_maintainer(project=project) + self.client.authenticate(user=user) + + series = create_series(project=project) + + resp = self.client.patch( + self.api_url(series.id), + {'metadata': {'github': 'owner/repo#42'}}, + validate_request=False, + validate_response=False, + ) + self.assertEqual(status.HTTP_200_OK, resp.status_code) + self.assertEqual({'github': 'owner/repo#42'}, resp.data['metadata']) + + def test_update_non_maintainer(self): + """Ensure non-maintainers cannot update series.""" + series = create_series() + user = create_user() + self.client.authenticate(user=user) + + resp = self.client.patch( + self.api_url(series.id), + {'metadata': {'test': 'value'}}, + validate_request=False, + validate_response=False, + ) + self.assertEqual(status.HTTP_403_FORBIDDEN, resp.status_code) diff --git a/releasenotes/notes/series-metadata-b2c3d4e5f6g7h8i9.yaml b/releasenotes/notes/series-metadata-b2c3d4e5f6g7h8i9.yaml new file mode 100644 index 000000000000..f1e759fc0ba1 --- /dev/null +++ b/releasenotes/notes/series-metadata-b2c3d4e5f6g7h8i9.yaml @@ -0,0 +1,15 @@ +--- +features: + - | + Series can now have arbitrary key-value metadata attached for external + integrations. Each metadata entry is stored as a separate row allowing + efficient querying and indexing. The patch detail page displays metadata + inline with URL values rendered as clickable links. +api: + - | + Add ``metadata`` field to the series endpoint (v1.4). Metadata is presented + as a JSON object. Maintainers can update metadata via PATCH, setting a key + to null deletes it. + - | + Add ``metadata_key`` and ``metadata_value`` query filters to the series + list endpoint for filtering by metadata entries. -- 2.54.0 _______________________________________________ Patchwork mailing list [email protected] https://lists.ozlabs.org/listinfo/patchwork
