From patchwork Fri May 29 14:43:55 2026 Message-ID: In-Reply-To: References: From: Robin Jarry Date: Fri, 29 May 2026 16:43:55 +0200 Subject: [PATCH patchwork v4 04/15] series: add metadata key-value store Sender: pw@patches.jarry.cc Reply-To: pw@patches.jarry.cc List-ID: X-Patchwork-Hint: ignore To: pw@patches.jarry.cc Cc: Robin Jarry , Robin Jarry X-Patchwork-Submitter: Robin Jarry X-Patchwork-Id: 117 Content-Type: text/plain; charset=utf-8 Content-Transfer-Encoding: 8bit Add a SeriesMetadata model for attaching arbitrary key-value pairs to patch series. This will be used by forge backends to store references such as PR numbers and branch names (e.g. github_pr=42, github_branch=pwforge/1a2b/fix). The key field is indexed for fast lookups by metadata type. The value field is also indexed to support reverse lookups (e.g. finding which series is linked to a given PR number). Signed-off-by: Robin Jarry --- Notes: https://github.com/rjarry/patchwork/pull/3/commits/e1be2e22d2c9ec79330672c5f03a435b04a0ef44 patchwork/admin.py | 9 ++++ patchwork/migrations/0050_series_metadata.py | 41 +++++++++++++++++++ patchwork/models.py | 17 ++++++++ patchwork/templates/patchwork/submission.html | 10 +++++ patchwork/templatetags/utils.py | 9 ++++ patchwork/tests/test_series.py | 39 ++++++++++++++++++ 6 files changed, 125 insertions(+) create mode 100644 patchwork/migrations/0050_series_metadata.py diff --git a/patchwork/admin.py b/patchwork/admin.py index d1c389a..a264878 100644 --- a/patchwork/admin.py +++ b/patchwork/admin.py @@ -19,6 +19,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 @@ -181,6 +182,14 @@ class SeriesReferenceAdmin(admin.ModelAdmin): admin.site.register(SeriesReference, SeriesReferenceAdmin) +class SeriesMetadataAdmin(admin.ModelAdmin): + model = SeriesMetadata + list_display = ('series', 'key', 'value') + + +admin.site.register(SeriesMetadata, SeriesMetadataAdmin) + + class CheckAdmin(admin.ModelAdmin): list_display = ( 'patch', diff --git a/patchwork/migrations/0050_series_metadata.py b/patchwork/migrations/0050_series_metadata.py new file mode 100644 index 0000000..5f6b292 --- /dev/null +++ b/patchwork/migrations/0050_series_metadata.py @@ -0,0 +1,41 @@ +# Generated by Django 5.1.15 on 2026-05-30 00:43 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('patchwork', '0049_series_respin_tracking'), + ] + + operations = [ + 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', + to='patchwork.series', + ), + ), + ], + options={ + 'ordering': ['key'], + 'unique_together': {('series', 'key')}, + }, + ), + ] diff --git a/patchwork/models.py b/patchwork/models.py index 387490a..0b23b3b 100644 --- a/patchwork/models.py +++ b/patchwork/models.py @@ -1042,6 +1042,23 @@ class SeriesReference(models.Model): unique_together = [('project', 'msgid')] +class SeriesMetadata(models.Model): + series = models.ForeignKey( + Series, + related_name='metadata', + 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 '%s=%s' % (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 b02555e..4cec979 100644 --- a/patchwork/templates/patchwork/submission.html +++ b/patchwork/templates/patchwork/submission.html @@ -112,6 +112,16 @@ {% endif %} +{% if submission.series.metadata.all %} + + Metadata + +{% for entry in submission.series.metadata.all %} + {{ entry.key }}: {{ entry.value|metadata_value }}{% if not forloop.last %} | {% endif %} +{% endfor %} + + +{% endif %} {% if submission.related %} Related diff --git a/patchwork/templatetags/utils.py b/patchwork/templatetags/utils.py index 78c0aac..198fa04 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) + + +@register.filter +def metadata_value(value): + s = str(value) + if s.startswith(('http://', 'https://')): + return format_html('{}', s, s) + return format_html('{}', s) diff --git a/patchwork/tests/test_series.py b/patchwork/tests/test_series.py index e5f60e3..dadf34e 100644 --- a/patchwork/tests/test_series.py +++ b/patchwork/tests/test_series.py @@ -1081,3 +1081,42 @@ class SeriesDependencyTestCase(SeriesDependencyBase): self.assertEqual(series2.dependencies.count(), 1) self.assertEqual(series3.dependencies.count(), 2) self.assertEqual(series3.dependents.count(), 0) + + +class SeriesMetadataTest(TestCase): + def test_create_metadata(self): + series = utils.create_series() + meta = models.SeriesMetadata.objects.create( + series=series, key='github_pr', value='42' + ) + self.assertEqual(str(meta), 'github_pr=42') + + def test_unique_key_per_series(self): + from django.db import IntegrityError + + series = utils.create_series() + models.SeriesMetadata.objects.create( + series=series, key='github_pr', value='42' + ) + with self.assertRaises(IntegrityError): + models.SeriesMetadata.objects.create( + series=series, key='github_pr', value='99' + ) + + def test_lookup_by_key_and_value(self): + series = utils.create_series() + models.SeriesMetadata.objects.create( + series=series, key='github_pr', value='42' + ) + found = models.SeriesMetadata.objects.get(key='github_pr', value='42') + self.assertEqual(found.series, series) + + def test_multiple_keys(self): + series = utils.create_series() + models.SeriesMetadata.objects.create( + series=series, key='github_pr', value='42' + ) + models.SeriesMetadata.objects.create( + series=series, key='github_branch', value='pwforge/1a2b/fix' + ) + self.assertEqual(series.metadata.count(), 2)