From afeb3578cc511142e4e0df55cd3591b3dafed5cd Mon Sep 17 00:00:00 2001 From: Stephen Finucane Date: Sat, 11 Apr 2026 16:39:04 +0200 Subject: [PATCH 01/18] fields: Add ColorField This will allow us to store color codes cleanly in the database. --- patchwork/fields.py | 24 ++++++++++++++++++++++++ patchwork/forms.py | 29 ++++++++++++++++++++++++++++- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/patchwork/fields.py b/patchwork/fields.py index 9a9633df6..8958ea2a0 100644 --- a/patchwork/fields.py +++ b/patchwork/fields.py @@ -27,3 +27,27 @@ def from_db_value(self, value, *args, **kwargs): def db_type(self, connection=None): return 'char(%d)' % self.n_bytes + + +class ColorField(models.Field): + description = 'Hex color code' + + def get_internal_type(self): + return 'PositiveIntegerField' + + def to_python(self, value): + if isinstance(value, str) or value is None: + return value + return '#%06x' % value + + def from_db_value(self, value, *args, **kwargs): + return self.to_python(value) + + def get_prep_value(self, value): + return int(value.lstrip('#'), 16) + + def formfield(self, *args, **kwargs): + from patchwork import forms # noqa + + kwargs['form_class'] = forms.ColorField + return super(ColorField, self).formfield(*args, **kwargs) diff --git a/patchwork/forms.py b/patchwork/forms.py index efedfa976..1e62368a9 100644 --- a/patchwork/forms.py +++ b/patchwork/forms.py @@ -3,9 +3,11 @@ # # SPDX-License-Identifier: GPL-2.0-or-later +import re + from django.contrib.auth.models import User from django import forms -from django.forms import renderers +from django.forms import widgets, renderers from django.db.models import Q from django.db.utils import ProgrammingError from django.template.backends import django as django_template_backend @@ -253,3 +255,28 @@ def save(self, instance, commit=True): if commit: instance.save() return instance + + +class ColorInput(widgets.Input): + input_type = 'color' + + +class ColorField(forms.CharField): + widget = ColorInput + default_error_messages = { + 'invalid': 'Enter a valid colour value: e.g. "#ff0022"', + } + + def clean(self, value): + if not re.match('^#?([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$', value): + raise forms.ValidationError(self.error_messages['invalid']) + + value = int(value.lstrip('#'), 16) + super(ColorField, self).clean(value) + + return value + + def widget_attrs(self, widget): + attrs = super().widget_attrs(widget) + attrs['maxlength'] = 7 + return attrs From 9809b33d85855d1fe65093901e9069be2cb7d1dc Mon Sep 17 00:00:00 2001 From: Stephen Finucane Date: Sat, 11 Apr 2026 16:42:38 +0200 Subject: [PATCH 02/18] models: Add submission labels Labels are arbitrary bits of metadata attached to a submission. They can be used to signify priority, category, or other similar information. They can also be used to filter patches and identify the ones most interesting to a given user. Labels can be associated with a project to ensure that one project can use a totally different set of labels to another and to, in the future, allow a project administrator to use their own labels. However, they can also be global, which is useful for things that would be common across multiple projects such as "RFC". --- patchwork/fixtures/default_labels.xml | 9 + ...rlabel_cover_labels_patchlabel_and_more.py | 157 ++++++++++++++++++ patchwork/models.py | 81 ++++++++- .../notes/labels-6d0096c7d8505627.yaml | 11 ++ 4 files changed, 257 insertions(+), 1 deletion(-) create mode 100644 patchwork/fixtures/default_labels.xml create mode 100644 patchwork/migrations/0054_label_coverlabel_cover_labels_patchlabel_and_more.py create mode 100644 releasenotes/notes/labels-6d0096c7d8505627.yaml diff --git a/patchwork/fixtures/default_labels.xml b/patchwork/fixtures/default_labels.xml new file mode 100644 index 000000000..1e1aa705b --- /dev/null +++ b/patchwork/fixtures/default_labels.xml @@ -0,0 +1,9 @@ + + + + + RFC + "RFC" stands for "Request for Comment", which tells maintainers that they should review your patch thoroughly and provide feedback. RFC is typically used when sending feature patches for the first time, or anytime the patch is more than just a simple bug fix. + #EDEDED + + diff --git a/patchwork/migrations/0054_label_coverlabel_cover_labels_patchlabel_and_more.py b/patchwork/migrations/0054_label_coverlabel_cover_labels_patchlabel_and_more.py new file mode 100644 index 000000000..e53512798 --- /dev/null +++ b/patchwork/migrations/0054_label_coverlabel_cover_labels_patchlabel_and_more.py @@ -0,0 +1,157 @@ +# Generated by Django 6.0.7 on 2026-11-01 11:48 + +import django.db.models.deletion +import django.db.models.functions.text +import patchwork.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('patchwork', '0053_series_ordering_indexes'), + ] + + operations = [ + migrations.CreateModel( + name='Label', + fields=[ + ( + 'id', + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name='ID', + ), + ), + ( + 'name', + models.CharField( + help_text='The label value.', max_length=25 + ), + ), + ( + 'description', + models.TextField( + blank=True, + help_text='A description of what the label is ' + 'and when it should be applied.', + max_length=1000, + null=True, + ), + ), + ( + 'color', + patchwork.fields.ColorField( + help_text='The color code to use in the UI.' + ), + ), + ( + 'project', + models.ForeignKey( + blank=True, + help_text='The project this label is associated with. ' + 'If unset, this label is global to the instance', + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name='labels', + related_query_name='label', + to='patchwork.project', + ), + ), + ], + options={ + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='CoverLabel', + fields=[ + ( + 'pk', + models.CompositePrimaryKey( + 'cover', + 'label', + blank=True, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + 'cover', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to='patchwork.cover', + ), + ), + ( + 'label', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to='patchwork.label', + ), + ), + ], + ), + migrations.AddField( + model_name='cover', + name='labels', + field=models.ManyToManyField( + through='patchwork.CoverLabel', to='patchwork.label' + ), + ), + migrations.CreateModel( + name='PatchLabel', + fields=[ + ( + 'pk', + models.CompositePrimaryKey( + 'patch', + 'label', + blank=True, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + 'label', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to='patchwork.label', + ), + ), + ( + 'patch', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to='patchwork.patch', + ), + ), + ], + ), + migrations.AddField( + model_name='patch', + name='labels', + field=models.ManyToManyField( + through='patchwork.PatchLabel', to='patchwork.label' + ), + ), + migrations.AddConstraint( + model_name='label', + constraint=models.UniqueConstraint( + models.F('project'), + django.db.models.functions.text.Lower('name'), + name='uniq_labels_project_id_name', + ), + ), + migrations.AlterUniqueTogether( + name='coverlabel', + unique_together={('cover', 'label')}, + ), + migrations.AlterUniqueTogether( + name='patchlabel', + unique_together={('patch', 'label')}, + ), + ] diff --git a/patchwork/models.py b/patchwork/models.py index e9e54307f..e88757df1 100644 --- a/patchwork/models.py +++ b/patchwork/models.py @@ -15,11 +15,14 @@ from django.core.validators import validate_unicode_slug from django.core.validators import MaxValueValidator from django.db import models +from django.db.models.constraints import UniqueConstraint from django.urls import reverse from django.utils.functional import cached_property from django.utils import timezone as tz_utils +from django.db.models.functions import Lower +from django.db.models import OuterRef, Exists, Q -from patchwork.fields import HashField +from patchwork.fields import HashField, ColorField from patchwork.hasher import hash_diff if settings.ENABLE_REST_API: @@ -261,6 +264,61 @@ class Meta: ordering = ['ordering'] +class Label(models.Model): + """Labels for patches. + + Labels are arbitrary bits of metadata attached to a patch. They can + be used to signify priority, category, or other similar information. + They can also be used to filter patches and identify the ones most + interesting to a given user. + """ + + project = models.ForeignKey( + Project, + related_name='labels', + related_query_name='label', + null=True, + blank=True, + on_delete=models.CASCADE, + help_text='The project this label is associated with. If unset, this ' + 'label is global to the instance', + ) + + name = models.CharField(max_length=25, help_text='The label value.') + description = models.TextField( + null=True, + blank=True, + max_length=1000, + help_text='A description of what the label is and when it should be ' + 'applied.', + ) + color = ColorField(help_text='The color code to use in the UI.') + + def __str__(self): + return self.name + + @staticmethod + def get_project_labels(project): + project_label_names = Label.objects.filter( + project=project + ).values_list('name') + return Label.objects.filter( + models.Q(project=project) + | ( + models.Q(project__isnull=True) + & ~models.Q(name__in=project_label_names) + ) + ) + + class Meta: + ordering = ['name'] + constraints = [ + UniqueConstraint( + 'project', Lower('name'), name='uniq_labels_project_id_name' + ) + ] + + class Tag(models.Model): name = models.CharField(max_length=20) pattern = models.CharField( @@ -466,7 +524,18 @@ class Meta: abstract = True +class CoverLabel(models.Model): + cover = models.ForeignKey('Cover', on_delete=models.CASCADE) + label = models.ForeignKey('Label', on_delete=models.CASCADE) + pk = models.CompositePrimaryKey("cover", "label") + + class Meta: + unique_together = [('cover', 'label')] + + class Cover(SubmissionMixin): + labels = models.ManyToManyField(Label, through=CoverLabel) + def get_absolute_url(self): return reverse( 'cover-detail', @@ -500,11 +569,21 @@ class Meta: ] +class PatchLabel(models.Model): + patch = models.ForeignKey('Patch', on_delete=models.CASCADE) + label = models.ForeignKey('Label', on_delete=models.CASCADE) + pk = models.CompositePrimaryKey("patch", "label") + + class Meta: + unique_together = [('patch', 'label')] + + class Patch(SubmissionMixin): diff = models.TextField(null=True, blank=True) commit_ref = models.CharField(max_length=255, null=True, blank=True) pull_url = models.CharField(max_length=255, null=True, blank=True) tags = models.ManyToManyField(Tag, through=PatchTag) + labels = models.ManyToManyField(Label, through=PatchLabel) # patchwork metadata diff --git a/releasenotes/notes/labels-6d0096c7d8505627.yaml b/releasenotes/notes/labels-6d0096c7d8505627.yaml new file mode 100644 index 000000000..fdebd6b7a --- /dev/null +++ b/releasenotes/notes/labels-6d0096c7d8505627.yaml @@ -0,0 +1,11 @@ +--- +features: + - | + Add support for patch labels. Labels are a way to organize and prioritize + submissions. You can apply labels to patches and cover letters to signify + priority, category, or any other marker you wish. + + Labels can be either global or tied to the project they are created in. + Labels can have an optional description attached, which will provide a + little insight into the purpose of the label. Labels are completely + customizable and the labels available will vary by instance. From 94cf3a800aecca08422587c91036df5ec2ac640c Mon Sep 17 00:00:00 2001 From: Stephen Finucane Date: Sat, 11 Apr 2026 16:48:28 +0200 Subject: [PATCH 03/18] parser: Extract and save labels --- patchwork/parser.py | 29 ++++++- patchwork/tests/unit/test_parser.py | 123 ++++++++++++++++++++++++---- patchwork/tests/utils.py | 14 ++++ 3 files changed, 145 insertions(+), 21 deletions(-) diff --git a/patchwork/parser.py b/patchwork/parser.py index e421b316e..953e7dbcb 100644 --- a/patchwork/parser.py +++ b/patchwork/parser.py @@ -26,6 +26,7 @@ from patchwork.models import CoverComment from patchwork.models import DelegationRule from patchwork.models import get_default_initial_patch_state +from patchwork.models import Label from patchwork.models import Patch from patchwork.models import PatchComment from patchwork.models import Person @@ -246,7 +247,7 @@ def _find_series_by_references(project, mail): The matching ``Series`` instance, if any """ subject = mail.get('Subject') - name, prefixes = clean_subject(subject, [project.linkname]) + name, prefixes, _ = clean_subject(subject, [project.linkname]) version = parse_version(name, prefixes) msg_id = find_message_id(mail) @@ -300,7 +301,7 @@ def _find_series_by_markers(project, mail, author): """ subject = mail.get('Subject') - name, prefixes = clean_subject(subject, [project.linkname]) + name, prefixes, _ = clean_subject(subject, [project.linkname]) _, total = parse_series_marker(prefixes) version = parse_version(name, prefixes) @@ -837,6 +838,7 @@ def clean_subject(subject, drop_prefixes=None): subject = normalise_space(subject) prefixes = [] + cleaned_prefixes = [] match = prefix_re.match(subject) @@ -848,6 +850,10 @@ def clean_subject(subject, drop_prefixes=None): if p.lower() not in drop_prefixes ] + cleaned_prefixes += [ + p for p in split_prefixes(prefix_str) if p.lower() in drop_prefixes + ] + subject = match.group(2) match = prefix_re.match(subject) @@ -855,7 +861,7 @@ def clean_subject(subject, drop_prefixes=None): if prefixes: subject = '[%s] %s' % (','.join(prefixes), subject) - return (subject, prefixes) + return (subject, prefixes, cleaned_prefixes) def subject_check(subject): @@ -1236,7 +1242,19 @@ def parse_mail(mail, list_id=None): msgid = find_message_id(mail) subject = mail.get('Subject') - name, prefixes = clean_subject(subject, [project.linkname]) + + project_labels = Label.get_project_labels(project) + project_labels_names = [label.name.lower() for label in project_labels] + name, prefixes, cleaned_prefixes = clean_subject( + subject, [project.linkname, *project_labels_names] + ) + cleaned_prefixes = [prefix.lower() for prefix in cleaned_prefixes] + labels = [ + label + for label in project_labels + if label.name.lower() in cleaned_prefixes + ] + is_comment = subject_check(subject) x, n = parse_series_marker(prefixes) version = parse_version(name, prefixes) @@ -1284,6 +1302,9 @@ def parse_mail(mail, list_id=None): delegate=delegate, state=find_state(mail), ) + if labels: + patch.labels.set(labels) + logger.debug('Patch saved') for attempt in range(1, 11): # arbitrary retry count diff --git a/patchwork/tests/unit/test_parser.py b/patchwork/tests/unit/test_parser.py index 1411ef0bc..f3b1f2226 100644 --- a/patchwork/tests/unit/test_parser.py +++ b/patchwork/tests/unit/test_parser.py @@ -41,6 +41,7 @@ from patchwork.tests.utils import create_cover_comment from patchwork.tests.utils import create_patch from patchwork.tests.utils import create_patch_comment +from patchwork.tests.utils import create_label from patchwork.tests.utils import create_project from patchwork.tests.utils import create_series from patchwork.tests.utils import create_series_reference @@ -542,7 +543,7 @@ def _create_email(subject): def _test_encoding(self, subject_header, parsed_subject): email = self._create_email(subject_header) - name, _ = clean_subject(email.get('Subject')) + name, _, _ = clean_subject(email.get('Subject')) self.assertEqual(name, parsed_subject) def test_subject_ascii_encoding(self): @@ -1106,52 +1107,61 @@ def test_tags(self): class SubjectTest(TestCase): def test_clean_subject(self): - self.assertEqual(clean_subject('meep'), ('meep', [])) - self.assertEqual(clean_subject('Re: meep'), ('meep', [])) - self.assertEqual(clean_subject('[PATCH] meep'), ('meep', [])) + self.assertEqual(clean_subject('meep'), ('meep', [], [])) + self.assertEqual(clean_subject('Re: meep'), ('meep', [], [])) self.assertEqual( - clean_subject('[PATCH] meep \n meep'), ('meep meep', []) + clean_subject('[PATCH] meep'), ('meep', [], ['PATCH']) ) self.assertEqual( - clean_subject('[PATCH] meep,\n meep'), ('meep, meep', []) + clean_subject('[PATCH] meep \n meep'), ('meep meep', [], ['PATCH']) ) self.assertEqual( - clean_subject('[PATCH RFC] meep'), ('[RFC] meep', ['RFC']) + clean_subject('[PATCH] meep,\n meep'), + ('meep, meep', [], ['PATCH']), ) self.assertEqual( - clean_subject('[PATCH,RFC] meep'), ('[RFC] meep', ['RFC']) + clean_subject('[PATCH RFC] meep'), + ('[RFC] meep', ['RFC'], ['PATCH']), ) self.assertEqual( - clean_subject('[PATCH,1/2] meep'), ('[1/2] meep', ['1/2']) + clean_subject('[PATCH,RFC] meep'), + ('[RFC] meep', ['RFC'], ['PATCH']), + ) + self.assertEqual( + clean_subject('[PATCH,1/2] meep'), + ('[1/2] meep', ['1/2'], ['PATCH']), ) self.assertEqual( clean_subject('[PATCH RFC 1/2] meep'), - ('[RFC,1/2] meep', ['RFC', '1/2']), + ('[RFC,1/2] meep', ['RFC', '1/2'], ['PATCH']), ) self.assertEqual( - clean_subject('[PATCH] [RFC] meep'), ('[RFC] meep', ['RFC']) + clean_subject('[PATCH] [RFC] meep'), + ('[RFC] meep', ['RFC'], ['PATCH']), ) self.assertEqual( clean_subject('[PATCH] [RFC,1/2] meep'), - ('[RFC,1/2] meep', ['RFC', '1/2']), + ('[RFC,1/2] meep', ['RFC', '1/2'], ['PATCH']), ) self.assertEqual( clean_subject('[PATCH] [RFC] [1/2] meep'), - ('[RFC,1/2] meep', ['RFC', '1/2']), + ('[RFC,1/2] meep', ['RFC', '1/2'], ['PATCH']), ) self.assertEqual( clean_subject('[PATCH] rewrite [a-z] regexes'), - ('rewrite [a-z] regexes', []), + ('rewrite [a-z] regexes', [], ['PATCH']), ) self.assertEqual( clean_subject('[PATCH] [RFC] rewrite [a-z] regexes'), - ('[RFC] rewrite [a-z] regexes', ['RFC']), + ('[RFC] rewrite [a-z] regexes', ['RFC'], ['PATCH']), ) self.assertEqual( - clean_subject('[foo] [bar] meep', ['foo']), ('[bar] meep', ['bar']) + clean_subject('[foo] [bar] meep', ['foo']), + ('[bar] meep', ['bar'], ['foo']), ) self.assertEqual( - clean_subject('[FOO] [bar] meep', ['foo']), ('[bar] meep', ['bar']) + clean_subject('[FOO] [bar] meep', ['foo']), + ('[bar] meep', ['bar'], ['FOO']), ) def test_subject_check(self): @@ -1570,3 +1580,82 @@ def test_parse_depends_on_url(self): result = parser.parse_depends_on('\n'.join([content2, content3])) self.assertIn(self.series1, result) self.assertIn(self.series2, result) + + +class TestParseLabels(TestCase): + orig_diff = read_patch('0001-add-line.patch') + + def setUp(self): + create_state() + self.project = create_project() + self.project2 = create_project() + + self.label = create_label(name='label', project=None) + self.label2 = create_label(name='label2', project=None) + self.label_rfc_global = create_label(name='RFC', project=None) + self.label_rfc = create_label(name='RFC', project=self.project) + self.label_p2 = create_label(name='label_p2', project=self.project2) + + def create_email( + self, subject, project, contents=None, in_reply_to=None, msgid=None + ): + msgid = make_msgid() if msgid is None else msgid + email = create_email( + self.orig_diff if contents is None else contents, + subject=subject, + listid=project.listid, + msgid=msgid, + in_reply_to=in_reply_to, + ) + _parse_mail(email) + patch = Patch.objects.get(msgid=msgid) + return patch + + def cmp_labels(self, patch, target_labels): + patch_labels = list(patch.labels.values_list('id', flat=True)) + target_labels = [label.id for label in target_labels] + self.assertEqual(len(patch_labels), len(target_labels)) + + for label in target_labels: + self.assertIn(label, patch_labels) + + def test_single_label(self): + patch = self.create_email('[PATCH,label] test subject', self.project) + self.cmp_labels(patch, [self.label]) + self.assertEqual(patch.name, 'test subject') + + def test_many_labels(self): + patch = self.create_email( + '[PATCH label v2 RFC 3/8] test subject', self.project + ) + self.cmp_labels(patch, [self.label, self.label_rfc]) + self.assertEqual(patch.name, '[v2,3/8] test subject') + + def test_unknown_label(self): + patch = self.create_email( + '[PATCH,unknown-label] test subject', self.project + ) + self.cmp_labels(patch, []) + self.assertEqual(patch.name, '[unknown-label] test subject') + + def test_label_from_different_project(self): + patch = self.create_email( + '[PATCH,label_p2] test subject', self.project + ) + self.cmp_labels(patch, []) + self.assertEqual(patch.name, '[label_p2] test subject') + + patch = self.create_email( + '[PATCH,label_p2] test subject', self.project2 + ) + self.cmp_labels(patch, [self.label_p2]) + self.assertEqual(patch.name, 'test subject') + + def test_local_global_labels(self): + patch = self.create_email('[PATCH,RFC] test subject', self.project) + self.cmp_labels(patch, [self.label_rfc]) + self.assertEqual(patch.name, 'test subject') + + patch = self.create_email('[PATCH,RFC] test subject', self.project2) + self.cmp_labels(patch, [self.label_rfc_global]) + self.assertEqual(patch.name, 'test subject') diff --git a/patchwork/tests/utils.py b/patchwork/tests/utils.py index 0fed9a801..a6d3bd2e4 100644 --- a/patchwork/tests/utils.py +++ b/patchwork/tests/utils.py @@ -14,6 +14,7 @@ from patchwork.models import Check from patchwork.models import Cover from patchwork.models import CoverComment +from patchwork.models import Label from patchwork.models import Patch from patchwork.models import PatchComment from patchwork.models import PatchRelation @@ -313,6 +314,19 @@ def create_relation(**kwargs): return PatchRelation.objects.create(**kwargs) +def create_label(**kwargs): + """Create a 'Label' object.""" + num = Label.objects.count() + + values = { + 'name': 'label%d' % num if 'name' not in kwargs else kwargs['name'], + 'color': '#fff', + } + values.update(**kwargs) + + return Label.objects.create(**values) + + def _create_submissions(create_func, count=1, **kwargs): """Create 'count' SubmissionMixin-based objects. From 851585768d18d82008fbb90b6a6fde5088a4befb Mon Sep 17 00:00:00 2001 From: Stephen Finucane Date: Sat, 11 Apr 2026 16:53:55 +0200 Subject: [PATCH 04/18] admin: Add label views There are two added: a general labels view that includes both project and non-project labels, and an inline labels view that's part of the project. --- patchwork/admin.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/patchwork/admin.py b/patchwork/admin.py index d3bdae1bb..6eda7e2e9 100644 --- a/patchwork/admin.py +++ b/patchwork/admin.py @@ -13,6 +13,7 @@ from patchwork.models import Cover from patchwork.models import CoverComment from patchwork.models import DelegationRule +from patchwork.models import Label from patchwork.models import Patch from patchwork.models import PatchComment from patchwork.models import PatchRelation @@ -44,11 +45,18 @@ class DelegationRuleInline(admin.TabularInline): fields = ('path', 'user', 'priority') +class LabelInline(admin.TabularInline): + model = Label + fields = ('name', 'description', 'color') + extra = 0 + + @admin.register(Project) class ProjectAdmin(admin.ModelAdmin): list_display = ('name', 'linkname', 'listid', 'listemail') inlines = [ DelegationRuleInline, + LabelInline, ] @@ -91,6 +99,7 @@ class PatchAdmin(admin.ModelAdmin): 'is_pull_request', ) list_filter = ('project', 'submitter', 'state', 'archived') + readonly_fields = ('labels',) list_select_related = ('submitter', 'project', 'state') search_fields = ('name', 'submitter__name', 'submitter__email') date_hierarchy = 'date' @@ -194,3 +203,9 @@ class TagAdmin(admin.ModelAdmin): @admin.register(PatchRelation) class PatchRelationAdmin(admin.ModelAdmin): model = PatchRelation + + +@admin.register(Label) +class LabelAdmin(admin.ModelAdmin): + list_display = ('name', 'color', 'project') + list_filter = ('project',) From 64f7619880635e0733109656feab188b2a957f58 Mon Sep 17 00:00:00 2001 From: Stephen Finucane Date: Sat, 11 Apr 2026 16:54:38 +0200 Subject: [PATCH 05/18] views: Populate 'project' attribute of PatchForm This is required to ensure we can filter delegates by project. If we don't do this, our ' % (self.param, value) + ) + + FILTERS = [ SeriesFilter, SubmitterFilter, @@ -526,6 +583,7 @@ def set_status(self, *args, **kwargs): SearchFilter, ArchiveFilter, DelegateFilter, + LabelsFilter, ] @@ -555,14 +613,22 @@ def available_filters(self): def apply(self, queryset): kwargs = collections.OrderedDict() + q_filters = [] for f in self._filters: if f.applied: - kwargs.update(f.kwargs) + if f.kwargs is not None: + kwargs.update(f.kwargs) + else: + q_filters.append(f) - if not kwargs: + if not kwargs and not q_filters: return queryset - return queryset.filter(**kwargs) + queryset = queryset.filter(**kwargs) + for f in q_filters: + queryset = f.q(queryset) + + return queryset def querystring(self, remove=None): params = self.params diff --git a/patchwork/models.py b/patchwork/models.py index e88757df1..0d501bfbe 100644 --- a/patchwork/models.py +++ b/patchwork/models.py @@ -310,6 +310,22 @@ def get_project_labels(project): ) ) + @staticmethod + def filter_by_names(labels): + labels = [label.lower() for label in labels] + result = {} + label_q = Label.objects.annotate(lname=Lower('name')).filter( + lname__in=labels + ) + + for label in label_q: + if label.name not in result: + result[label.name] = [label.id] + else: + result[label.name].append(label.id) + + return result + class Meta: ordering = ['name'] constraints = [ @@ -400,6 +416,37 @@ def with_tag_counts(self, project=None): return qs + def exclude_labels(self, labels): + lids = Label.filter_by_names(labels) + for _, ids_with_name in lids.items(): + for lid in ids_with_name: + label_q = Patch.labels.through.objects.filter( + patch_id=OuterRef('pk'), label_id=lid + ) + self = self.exclude(Exists(label_q)) + + return self + + def include_labels(self, labels): + if len(labels) == 0: + return self + + lids = Label.filter_by_names(labels) + if len(lids) > 0: + for _, ids_with_name in lids.items(): + q = Q() + for lid in ids_with_name: + q |= Exists( + Patch.labels.through.objects.filter( + patch_id=OuterRef('pk'), label_id=lid + ) + ) + self = self.filter(q) + + return self + else: + return Patch.objects.none() + class PatchManager(models.Manager): def get_queryset(self): diff --git a/patchwork/tests/unit/views/test_patch.py b/patchwork/tests/unit/views/test_patch.py index 133bae366..6bdee67b9 100644 --- a/patchwork/tests/unit/views/test_patch.py +++ b/patchwork/tests/unit/views/test_patch.py @@ -25,6 +25,7 @@ from patchwork.tests.utils import create_project from patchwork.tests.utils import create_state from patchwork.tests.utils import create_user +from patchwork.tests.utils import create_label from patchwork.tests.utils import read_patch @@ -203,6 +204,147 @@ def test_utf8_handling(self): self.assertEqual(response.status_code, 200) +class PatchListLabelFilteringTest(TestCase): + def setUp(self): + self.project = create_project() + self.label1 = create_label(project=self.project) + self.label2 = create_label(project=self.project) + self.label_RFC = create_label(project=self.project, name='RFC') + self.label_RFC_global = create_label(project=None, name='RFC') + self.global_label = create_label(project=None) + + self.person = create_person(name='test', email='test@example.com') + + self.p_no_labels = self.create_patch([]) + self.p_label1 = self.create_patch([self.label1]) + self.p_label1_2 = self.create_patch([self.label1, self.label2]) + self.p_label1_global = self.create_patch( + [self.label1, self.global_label] + ) + self.p_label_rfc = self.create_patch([self.label_RFC]) + self.p_label_rfc_global = self.create_patch([self.label_RFC_global]) + + project2 = create_project() + self.p2_no_labels = create_patch( + submitter=self.person, project=project2 + ) + self.p2_global_label = create_patch( + submitter=self.person, + project=project2, + labels=[self.global_label], + ) + + # NOTE: does not include project2 patches on purpose + self.patches = [ + self.p_no_labels, + self.p_label1, + self.p_label1_2, + self.p_label1_global, + self.p_label_rfc, + self.p_label_rfc_global, + ] + + def _extract_patch_ids(self, response): + """Extract patch identifiers from patch list HTML.""" + id_re = re.compile(r' Date: Fri, 31 Jul 2026 14:57:56 +0200 Subject: [PATCH 09/18] REST: Add filtering by labels to API Patches in the API can be filtered by labels using the 'labels' query parameter. --- docs/api/schemas/latest/patchwork.yaml | 9 +++ docs/api/schemas/patchwork.j2 | 11 ++++ docs/api/schemas/v1.4/patchwork.yaml | 9 +++ patchwork/api/filters.py | 17 +++++ patchwork/tests/unit/api/test_patch.py | 88 ++++++++++++++++++++++++++ 5 files changed, 134 insertions(+) diff --git a/docs/api/schemas/latest/patchwork.yaml b/docs/api/schemas/latest/patchwork.yaml index 598875651..f428ccad1 100644 --- a/docs/api/schemas/latest/patchwork.yaml +++ b/docs/api/schemas/latest/patchwork.yaml @@ -578,6 +578,15 @@ paths: schema: title: '' type: string + - in: query + name: labels + description: | + List of labels assigned to queried patches, separated by ','. + If a label name begins with a '-', patches with that label won't + be included. + schema: + title: '' + type: string responses: '200': description: 'List of patches' diff --git a/docs/api/schemas/patchwork.j2 b/docs/api/schemas/patchwork.j2 index fed185445..1fa9eb155 100644 --- a/docs/api/schemas/patchwork.j2 +++ b/docs/api/schemas/patchwork.j2 @@ -596,6 +596,17 @@ paths: schema: title: '' type: string +{% endif %} +{% if version >= (1, 4) %} + - in: query + name: labels + description: | + List of labels assigned to queried patches, separated by ','. + If a label name begins with a '-', patches with that label won't + be included. + schema: + title: '' + type: string {% endif %} responses: '200': diff --git a/docs/api/schemas/v1.4/patchwork.yaml b/docs/api/schemas/v1.4/patchwork.yaml index aef4d80f3..f2c625bc8 100644 --- a/docs/api/schemas/v1.4/patchwork.yaml +++ b/docs/api/schemas/v1.4/patchwork.yaml @@ -578,6 +578,15 @@ paths: schema: title: '' type: string + - in: query + name: labels + description: | + List of labels assigned to queried patches, separated by ','. + If a label name begins with a '-', patches with that label won't + be included. + schema: + title: '' + type: string responses: '200': description: 'List of patches' diff --git a/patchwork/api/filters.py b/patchwork/api/filters.py index e332c5316..aeecacb2d 100644 --- a/patchwork/api/filters.py +++ b/patchwork/api/filters.py @@ -175,6 +175,21 @@ def msgid_filter(queryset, name, value): return queryset.filter(**{name: '<' + value + '>'}) +def labels_filter(queryset, _, value): + label_names = value.split(',') + + labels_pos, labels_neg = [], [] + for label in label_names: + if not label.startswith('-'): + labels_pos.append(label) + else: + labels_neg.append(label[1:]) + + queryset = queryset.exclude_labels(labels_neg) + queryset = queryset.include_labels(labels_pos) + return queryset + + class CoverFilterSet(TimestampMixin, BaseFilterSet): project = ProjectFilter(queryset=Project.objects.all(), distinct=False) # NOTE(stephenfin): We disable the select-based HTML widgets for these @@ -206,6 +221,7 @@ class PatchFilterSet(TimestampMixin, BaseFilterSet): state = StateFilter(queryset=State.objects.all(), distinct=False) hash = CharFilter(lookup_expr='iexact') msgid = CharFilter(method=msgid_filter) + labels = CharFilter(method=labels_filter) class Meta: model = Patch @@ -225,6 +241,7 @@ class Meta: ) versioned_fields = { '1.2': ('hash', 'msgid'), + '1.4': ('labels'), } diff --git a/patchwork/tests/unit/api/test_patch.py b/patchwork/tests/unit/api/test_patch.py index a93350b36..6caaeefe7 100644 --- a/patchwork/tests/unit/api/test_patch.py +++ b/patchwork/tests/unit/api/test_patch.py @@ -253,6 +253,94 @@ def test_list_filter_msgid(self): resp = self.client.get(self.api_url(), {'msgid': 'fishfish@fish.fish'}) self.assertEqual(0, len(resp.data)) + def test_list_filter_labels(self): + """Filter patches by labels.""" + label1 = create_label() + label2 = create_label() + label3 = create_label() + + person_obj = create_person(email='test@example.com') + project_obj = create_project(linkname='myproject') + state_obj = create_state(name='Under Review') + patch_kwargs = { + 'state': state_obj, + 'project': project_obj, + 'submitter': person_obj, + } + patch_no_labels = create_patch(**patch_kwargs) + patch1 = create_patch(**patch_kwargs, labels=[label1]) + patch2 = create_patch(**patch_kwargs, labels=[label1, label2]) + + resp = self.client.get(self.api_url(), {'labels': label1.name}) + self.assertEqual([patch1.id, patch2.id], [x['id'] for x in resp.data]) + + resp = self.client.get(self.api_url(), {'labels': label3.name}) + self.assertEqual(0, len(resp.data)) + + resp = self.client.get(self.api_url(), {'labels': '-' + label2.name}) + self.assertEqual( + [patch_no_labels.id, patch1.id], [x['id'] for x in resp.data] + ) + + resp = self.client.get( + self.api_url(), {'labels': label1.name + ',' + label2.name} + ) + self.assertEqual([patch2.id], [x['id'] for x in resp.data]) + + def test_list_filter_labels_local_global(self): + """Test filtering for patches with different labels, one local and + one global (without a project) that have the same name. + """ + project_obj = create_project(linkname='myproject') + project_obj2 = create_project(linkname='myproject2') + + label_local = create_label(name='label', project=project_obj) + label_local2 = create_label(name='label', project=project_obj2) + label_global = create_label(name='label', project=None) + + person_obj = create_person(email='test@example.com') + state_obj = create_state(name='Under Review') + patch_kwargs = { + 'state': state_obj, + 'project': project_obj, + 'submitter': person_obj, + } + patch_no_labels = create_patch(**patch_kwargs) + patch1 = create_patch(**patch_kwargs, labels=[label_local]) + patch2 = create_patch(**patch_kwargs, labels=[label_global]) + patch3 = create_patch( + **{'project': project_obj2, **patch_kwargs}, labels=[label_local2] + ) + + resp = self.client.get(self.api_url(), {'labels': 'label'}) + self.assertEqual( + [patch1.id, patch2.id, patch3.id], [x['id'] for x in resp.data] + ) + + resp = self.client.get(self.api_url(), {'labels': '-label'}) + self.assertEqual([patch_no_labels.id], [x['id'] for x in resp.data]) + + def test_list_filter_labels_case_insensitive(self): + """Test if filtering by label is case-insensitive.""" + label = create_label(name='RFC') + + person_obj = create_person(email='test@example.com') + project_obj = create_project(linkname='myproject') + state_obj = create_state(name='Under Review') + patch_kwargs = { + 'state': state_obj, + 'project': project_obj, + 'submitter': person_obj, + } + create_patch(**patch_kwargs) + patch1 = create_patch(**patch_kwargs, labels=[label]) + + resp = self.client.get(self.api_url(), {'labels': 'RFC'}) + self.assertEqual([patch1.id], [x['id'] for x in resp.data]) + + resp = self.client.get(self.api_url(), {'labels': 'rfc'}) + self.assertEqual([patch1.id], [x['id'] for x in resp.data]) + @utils.store_samples('patch-list-1-0') def test_list_version_1_0(self): """List patches using API v1.0.""" From f1688c91a9b3dce6e4b1ccdd37f648d7a00e47b2 Mon Sep 17 00:00:00 2001 From: Franciszek Stachura Date: Sun, 12 Apr 2026 16:38:31 +0200 Subject: [PATCH 10/18] htdocs: Update selectize to 0.15.2 a741c957 upgraded selectize to 0.13.5. However, it looks like a non-standalone version was uploaded by accident. On page load, the following error can be seen in debug console: "Selectize: Dependency MicroPlugin is missing". Because of that, the submitter autocomplete no longer works. Version 0.13.5 was not released on Github, so I have decided to upgrade the library to the latest available version. Add updated files to .gitattributes to skip them in patches. --- .gitattributes | 2 ++ htdocs/README.rst | 2 +- htdocs/css/selectize.bootstrap3.css | 26 +++++++++++++++++----- htdocs/js/selectize.min.js | 34 +++++++++++++++++++++++++++-- 4 files changed, 55 insertions(+), 9 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..e36065822 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +htdocs/js/*.min.js binary +htdocs/css/selectize.bootstrap3.css binary diff --git a/htdocs/README.rst b/htdocs/README.rst index 9a5211233..7f7eff94e 100644 --- a/htdocs/README.rst +++ b/htdocs/README.rst @@ -140,4 +140,4 @@ js :Website: https://selectize.github.io/selectize.js/ :GitHub: https://github.com/selectize/selectize.js - :Version: 0.13.5 + :Version: 0.15.2 diff --git a/htdocs/css/selectize.bootstrap3.css b/htdocs/css/selectize.bootstrap3.css index 3165727e7..ca53d550b 100644 --- a/htdocs/css/selectize.bootstrap3.css +++ b/htdocs/css/selectize.bootstrap3.css @@ -1,6 +1,20 @@ -.selectize-control.plugin-drag_drop.multi>.selectize-input.dragging{overflow:visible}.selectize-control.plugin-drag_drop.multi>.selectize-input>div.ui-sortable-placeholder{visibility:visible !important;background:#f2f2f2 !important;background:rgba(0,0,0,.06) !important;border:0 none !important;box-shadow:inset 0 0 12px 4px #fff}.selectize-control.plugin-drag_drop .ui-sortable-placeholder::after{content:"!";visibility:hidden}.selectize-control.plugin-drag_drop .ui-sortable-helper{box-shadow:0 2px 5px rgba(0,0,0,.2)}.selectize-control .dropdown-header{position:relative;padding:6px 12px;border-bottom:1px solid #d0d0d0;background:#f8f8f8;border-radius:4px 4px 0 0}.selectize-control .dropdown-header-close{position:absolute;right:12px;top:50%;color:#333;opacity:.4;margin-top:-12px;line-height:20px;font-size:20px !important}.selectize-control .dropdown-header-close:hover{color:#000}.selectize-dropdown.plugin-optgroup_columns .selectize-dropdown-content{display:flex}.selectize-dropdown.plugin-optgroup_columns .optgroup{border-right:1px solid #f2f2f2;border-top:0 none;flex-grow:1;flex-basis:0;min-width:0}.selectize-dropdown.plugin-optgroup_columns .optgroup:last-child{border-right:0 none}.selectize-dropdown.plugin-optgroup_columns .optgroup:before{display:none}.selectize-dropdown.plugin-optgroup_columns .optgroup-header{border-top:0 none}.selectize-control.plugin-remove_button .item{display:inline-flex;align-items:center;padding-right:0 !important}.selectize-control.plugin-remove_button .item .remove{color:inherit;text-decoration:none;vertical-align:middle;display:inline-block;padding:1px 5px;border-left:1px solid rgba(0,0,0,0);border-radius:0 2px 2px 0;box-sizing:border-box;margin-left:5px}.selectize-control.plugin-remove_button .item .remove:hover{background:rgba(0,0,0,.05)}.selectize-control.plugin-remove_button .item.active .remove{border-left-color:rgba(0,0,0,0)}.selectize-control.plugin-remove_button .disabled .item .remove:hover{background:none}.selectize-control.plugin-remove_button .disabled .item .remove{border-left-color:rgba(77,77,77,0)}.selectize-control.plugin-remove_button .remove-single{position:absolute;right:0;top:0;font-size:23px}.selectize-control{position:relative}.selectize-dropdown,.selectize-input,.selectize-input input{color:#333;font-family:inherit;font-size:inherit;line-height:20px;font-smoothing:inherit}.selectize-input,.selectize-control.single .selectize-input.input-active{background:#fff;cursor:text;display:inline-block}.selectize-input{border:1px solid #ccc;padding:6px 12px;display:inline-block;width:100%;position:relative;z-index:1;box-sizing:border-box;box-shadow:none;border-radius:4px}.selectize-control.multi .selectize-input.has-items{padding:calc( - 6px - 1px - 0 - ) 12px calc( - 6px - 1px - 3px - - 0 - )}.selectize-input.full{background-color:#fff}.selectize-input.disabled,.selectize-input.disabled *{cursor:default !important}.selectize-input.focus{box-shadow:inset 0 1px 2px rgba(0,0,0,.15)}.selectize-input.dropdown-active{border-radius:4px 4px 0 0}.selectize-input>*{vertical-align:baseline;display:inline-block;zoom:1}.selectize-control.multi .selectize-input>div{cursor:pointer;margin:0 3px 3px 0;padding:1px 5px;background:#efefef;color:#333;border:0 solid rgba(0,0,0,0)}.selectize-control.multi .selectize-input>div.active{background:#337ab7;color:#fff;border:0 solid rgba(0,0,0,0)}.selectize-control.multi .selectize-input.disabled>div,.selectize-control.multi .selectize-input.disabled>div.active{color:gray;background:#fff;border:0 solid rgba(77,77,77,0)}.selectize-input>input{display:inline-block !important;padding:0 !important;min-height:0 !important;max-height:none !important;max-width:100% !important;margin:0 !important;text-indent:0 !important;border:0 none !important;background:none !important;line-height:inherit !important;user-select:auto !important;box-shadow:none !important}.selectize-input>input::-ms-clear{display:none}.selectize-input>input:focus{outline:none !important}.selectize-input>input[placeholder]{box-sizing:initial}.selectize-input.has-items>input{margin:0 0px !important}.selectize-input::after{content:" ";display:block;clear:left}.selectize-input.dropdown-active::before{content:" ";display:block;position:absolute;background:#fff;height:1px;bottom:0;left:0;right:0}.selectize-dropdown{position:absolute;top:100%;left:0;width:100%;z-index:10;border:1px solid #d0d0d0;background:#fff;margin:-1px 0 0 0;border-top:0 none;box-sizing:border-box;box-shadow:0 1px 3px rgba(0,0,0,.1);border-radius:0 0 4px 4px}.selectize-dropdown [data-selectable]{cursor:pointer;overflow:hidden}.selectize-dropdown [data-selectable] .highlight{background:rgba(255,237,40,.4);border-radius:1px}.selectize-dropdown .option,.selectize-dropdown .optgroup-header,.selectize-dropdown .no-results,.selectize-dropdown .create{padding:3px 12px}.selectize-dropdown .option,.selectize-dropdown [data-disabled],.selectize-dropdown [data-disabled] [data-selectable].option{cursor:inherit;opacity:.5}.selectize-dropdown [data-selectable].option{opacity:1;cursor:pointer}.selectize-dropdown .optgroup:first-child .optgroup-header{border-top:0 none}.selectize-dropdown .optgroup-header{color:#777;background:#fff;cursor:default}.selectize-dropdown .active{background-color:#f5f5f5;color:#262626}.selectize-dropdown .active.create{color:#262626}.selectize-dropdown .create{color:rgba(51,51,51,.5)}.selectize-dropdown-content{overflow-y:auto;overflow-x:hidden;max-height:200px;overflow-scrolling:touch}.selectize-dropdown-emptyoptionlabel{text-align:center}.selectize-dropdown .spinner{display:inline-block;width:30px;height:30px;margin:3px 12px}.selectize-dropdown .spinner:after{content:" ";display:block;width:24px;height:24px;margin:3px;border-radius:50%;border:5px solid #d0d0d0;border-color:#d0d0d0 rgba(0,0,0,0) #d0d0d0 rgba(0,0,0,0);animation:lds-dual-ring 1.2s linear infinite}@keyframes lds-dual-ring{0%{transform:rotate(0deg)}100%{transform:rotate(360deg)}}.selectize-control.single .selectize-input,.selectize-control.single .selectize-input input{cursor:pointer}.selectize-control.single .selectize-input.input-active,.selectize-control.single .selectize-input.input-active input{cursor:text}.selectize-control.single .selectize-input:after{content:" ";display:block;position:absolute;top:50%;right:17px;margin-top:-3px;width:0;height:0;border-style:solid;border-width:5px 5px 0 5px;border-color:#333 rgba(0,0,0,0) rgba(0,0,0,0) rgba(0,0,0,0)}.selectize-control.single .selectize-input.dropdown-active:after{margin-top:-4px;border-width:0 5px 5px 5px;border-color:rgba(0,0,0,0) rgba(0,0,0,0) #333 rgba(0,0,0,0)}.selectize-control.rtl{text-align:right}.selectize-control.rtl.single .selectize-input:after{left:17px;right:auto}.selectize-control.rtl .selectize-input>input{margin:0 4px 0 -2px !important}.selectize-control .selectize-input.disabled{opacity:.5;background-color:#fff}.selectize-dropdown,.selectize-dropdown.form-control{height:auto;padding:0;margin:2px 0 0 0;z-index:1000;background:#fff;border:1px solid #ccc;border:1px solid rgba(0,0,0,.15);border-radius:4px;box-shadow:0 6px 12px rgba(0,0,0,.175)}.selectize-dropdown .optgroup-header{font-size:12px;line-height:1.428571429}.selectize-dropdown .optgroup:first-child:before{display:none}.selectize-dropdown .optgroup:before{content:" ";display:block;height:1px;margin:9px 0;overflow:hidden;background-color:#e5e5e5;margin-left:-12px;margin-right:-12px}.selectize-dropdown-content{padding:5px 0}.selectize-dropdown-emptyoptionlabel{text-align:center}.selectize-input{min-height:34px}.selectize-input.dropdown-active{border-radius:4px}.selectize-input.dropdown-active::before{display:none}.selectize-input.focus{border-color:#66afe9;outline:0;box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 8px rgba(102,175,233,.6)}.has-error .selectize-input{border-color:#a94442;box-shadow:inset 0 1px 1px rgba(0,0,0,.075)}.has-error .selectize-input:focus{border-color:#843534;box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #ce8483}.selectize-control.multi .selectize-input.has-items{padding-left:7px;padding-right:7px}.selectize-control.multi .selectize-input>div{border-radius:3px}.form-control.selectize-control{padding:0;height:auto;border:none;background:none;box-shadow:none;border-radius:0} \ No newline at end of file +/** + * Selectize (v0.15.2) + * https://selectize.dev + * + * Copyright (c) 2013-2015 Brian Reavis & contributors + * Copyright (c) 2020-2022 Selectize Team & contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this + * file except in compliance with the License. You may obtain a copy of the License at: + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF + * ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + * + * @author Brian Reavis + * @author Ris Adams + */ +.selectize-control.plugin-drag_drop.multi>.selectize-input.dragging{overflow:visible}.selectize-control.plugin-drag_drop.multi>.selectize-input>div.ui-sortable-placeholder{visibility:visible !important;background:#f2f2f2 !important;background:rgba(0,0,0,0.06) !important;border:0 none !important;box-shadow:inset 0 0 12px 4px #fff}.selectize-control.plugin-drag_drop .ui-sortable-placeholder::after{content:"!";visibility:hidden}.selectize-control.plugin-drag_drop .ui-sortable-helper{box-shadow:0 2px 5px rgba(0,0,0,0.2)}.selectize-dropdown.plugin-dropdown_header .selectize-dropdown-header{position:relative;padding:6px 12px;border-bottom:1px solid #d0d0d0;background:#f8f8f8;border-radius:4px 4px 0 0}.selectize-dropdown.plugin-dropdown_header .selectize-dropdown-header-close{position:absolute;right:12px;top:50%;color:#333;opacity:.4;margin-top:-12px;line-height:20px;font-size:20px !important;text-decoration:none}.selectize-dropdown.plugin-dropdown_header .selectize-dropdown-header-close:hover{color:black}.selectize-dropdown.plugin-optgroup_columns .selectize-dropdown-content{display:flex}.selectize-dropdown.plugin-optgroup_columns .optgroup{border-right:1px solid #f2f2f2;border-top:0 none;flex-grow:1;flex-basis:0;min-width:0}.selectize-dropdown.plugin-optgroup_columns .optgroup:last-child{border-right:0 none}.selectize-dropdown.plugin-optgroup_columns .optgroup:before{display:none}.selectize-dropdown.plugin-optgroup_columns .optgroup-header{border-top:0 none}.selectize-control.plugin-remove_button .item{display:inline-flex;align-items:center;padding-right:0 !important}.selectize-control.plugin-remove_button .item .remove{color:inherit;text-decoration:none;vertical-align:middle;display:inline-block;padding:1px 5px;border-left:1px solid rgba(0,0,0,0);border-radius:0 2px 2px 0;box-sizing:border-box;margin-left:5px}.selectize-control.plugin-remove_button .item .remove:hover{background:rgba(0,0,0,0.05)}.selectize-control.plugin-remove_button .item.active .remove{border-left-color:rgba(0,0,0,0)}.selectize-control.plugin-remove_button .disabled .item .remove:hover{background:0}.selectize-control.plugin-remove_button .disabled .item .remove{border-left-color:rgba(77,77,77,0)}.selectize-control.plugin-clear_button .clear{text-decoration:none;display:flex;position:absolute;height:100%;width:25px;top:0;right:calc(12px - 5px);color:#000;opacity:.4;font-weight:bold;border:0;cursor:pointer;z-index:1;font-size:21px;justify-content:center;align-items:center}.selectize-control.plugin-clear_button .clear:hover{opacity:1}.selectize-control.plugin-clear_button.single .clear{right:calc(12px - 5px + 1.5rem)}.selectize-dropdown.plugin-auto_position.selectize-position-top{border-top:1px solid #d0d0d0;border-bottom:0 none;border-radius:3px 3px 0 0;box-shadow:0 -6px 12px rgba(0,0,0,0.18)}.selectize-control.plugin-auto_position .selectize-input.selectize-position-top.dropdown-active{border-radius:0 0 3px 3px;border-top:0 none}.selectize-control.plugin-auto_position .selectize-input.selectize-position-top.dropdown-active::before{top:0;bottom:unset}.selectize-control{position:relative}.selectize-dropdown,.selectize-input,.selectize-input input{color:#333;font-family:inherit;font-size:inherit;line-height:20px;font-smoothing:inherit}.selectize-input,.selectize-control.single .selectize-input.input-active{background:#fff;cursor:text;display:inline-block}.selectize-input{border:1px solid #ccc;padding:6px 12px;display:inline-block;width:100%;position:relative;z-index:1;box-sizing:border-box;box-shadow:none;border-radius:4px}.selectize-control.multi .selectize-input.has-items{padding:calc(6px - 1px - 0px) 12px calc(6px - 1px - 3px - 0px)}.selectize-input.full{background-color:#fff}.selectize-input.disabled,.selectize-input.disabled *{cursor:default !important}.selectize-input.focus{box-shadow:inset 0 1px 2px rgba(0,0,0,0.15)}.selectize-input.dropdown-active{border-radius:4px 4px 0 0}.selectize-input>*{vertical-align:baseline;display:inline-block;zoom:1}.selectize-control.multi .selectize-input>div{cursor:pointer;margin:0 3px 3px 0;padding:1px 5px;background:#efefef;color:#333;border:0 solid rgba(0,0,0,0)}.selectize-control.multi .selectize-input>div.active{background:#337ab7;color:#fff;border:0 solid rgba(0,0,0,0)}.selectize-control.multi .selectize-input.disabled>div,.selectize-control.multi .selectize-input.disabled>div.active{color:gray;background:white;border:0 solid rgba(77,77,77,0)}.selectize-input>input{display:inline-block !important;padding:0 !important;min-height:0 !important;max-height:none !important;max-width:100% !important;margin:0 !important;text-indent:0 !important;border:0 none !important;background:none !important;line-height:inherit !important;user-select:auto !important;box-shadow:none !important}.selectize-input>input::-ms-clear{display:none}.selectize-input>input:focus{outline:none !important}.selectize-input>input[placeholder]{box-sizing:initial}.selectize-input.has-items>input{margin:0 0 !important}.selectize-input::after{content:" ";display:block;clear:left}.selectize-input.dropdown-active::before{content:" ";display:block;position:absolute;background:#d6d6d6;height:1px;bottom:0;left:0;right:0}.selectize-dropdown{position:absolute;top:100%;left:0;width:100%;z-index:10;border:1px solid #d0d0d0;background:#fff;margin:-1px 0 0 0;border-top:0 none;box-sizing:border-box;box-shadow:0 1px 3px rgba(0,0,0,0.1);border-radius:0 0 4px 4px}.selectize-dropdown [data-selectable]{cursor:pointer;overflow:hidden}.selectize-dropdown [data-selectable] .highlight{background:rgba(255,237,40,0.4);border-radius:1px}.selectize-dropdown .option,.selectize-dropdown .optgroup-header,.selectize-dropdown .no-results,.selectize-dropdown .create{padding:3px 12px}.selectize-dropdown .option,.selectize-dropdown [data-disabled],.selectize-dropdown [data-disabled] [data-selectable].option{cursor:inherit;opacity:.5}.selectize-dropdown [data-selectable].option{opacity:1;cursor:pointer}.selectize-dropdown .optgroup:first-child .optgroup-header{border-top:0 none}.selectize-dropdown .optgroup-header{color:#777;background:#fff;cursor:default}.selectize-dropdown .active{background-color:#337ab7;color:#fff}.selectize-dropdown .active.create{color:#262626}.selectize-dropdown .selected{background-color:#337ab7;color:#fff}.selectize-dropdown .create{color:rgba(51,51,51,0.5)}.selectize-dropdown .active:not(.selected){background:#f5f5f5;color:#262626}.selectize-dropdown-content{overflow-y:auto;overflow-x:hidden;max-height:200px;overflow-scrolling:touch}.selectize-dropdown-emptyoptionlabel{text-align:center}.selectize-dropdown .spinner{display:inline-block;width:30px;height:30px;margin:3px 12px}.selectize-dropdown .spinner:after{content:" ";display:block;width:24px;height:24px;margin:3px;border-radius:50%;border:5px solid #d0d0d0;border-color:#d0d0d0 transparent #d0d0d0 transparent;animation:lds-dual-ring 1.2s linear infinite}@keyframes lds-dual-ring{0%{transform:rotate(0)}100%{transform:rotate(360deg)}}.selectize-control.single .selectize-input,.selectize-control.single .selectize-input input{cursor:pointer}.selectize-control.single .selectize-input.input-active,.selectize-control.single .selectize-input.input-active input:not(:read-only){cursor:text}.selectize-control.single .selectize-input:after{content:" ";display:block;position:absolute;top:50%;right:17px;margin-top:-3px;width:0;height:0;border-style:solid;border-width:5px 5px 0 5px;border-color:#333 transparent transparent transparent}.selectize-control.single .selectize-input.dropdown-active:after{margin-top:-4px;border-width:0 5px 5px 5px;border-color:transparent transparent #333 transparent}.selectize-control.rtl{text-align:right}.selectize-control.rtl.single .selectize-input:after{left:17px;right:auto}.selectize-control.rtl .selectize-input>input{margin:0 4px 0 -2px !important}.selectize-control .selectize-input.disabled{opacity:.5;background-color:#fff}.selectize-dropdown,.selectize-dropdown.form-control{height:auto;padding:0;margin:2px 0 0 0;z-index:1000;background:#fff;border:1px solid #ccc;border:1px solid rgba(0,0,0,0.15);border-radius:4px;box-shadow:0 6px 12px rgba(0,0,0,0.175)}.selectize-dropdown .optgroup-header{font-size:12px;line-height:1.428571429}.selectize-dropdown .optgroup:first-child:before{display:none}.selectize-dropdown .optgroup:before{content:" ";display:block;height:1px;margin:9px 0;overflow:hidden;background-color:#e5e5e5;margin-left:-12px;margin-right:-12px}.selectize-dropdown-content{padding:5px 0}.selectize-dropdown-emptyoptionlabel{text-align:center}.selectize-input{min-height:34px}.selectize-input.dropdown-active{border-radius:4px}.selectize-input.dropdown-active::before{display:none}.selectize-input.focus{border-color:#66afe9;outline:0;box-shadow:inset 0 1px 1px rgba(0,0,0,0.075),0 0 8px rgba(102,175,233,0.6)}.has-error .selectize-input{border-color:#a94442;box-shadow:inset 0 1px 1px rgba(0,0,0,0.075)}.has-error .selectize-input:focus{border-color:#843534;box-shadow:inset 0 1px 1px rgba(0,0,0,0.075),0 0 6px #ce8483}.selectize-control.multi .selectize-input.has-items{padding-left:7px;padding-right:7px}.selectize-control.multi .selectize-input>div{border-radius:3px}.form-control.selectize-control{padding:0;height:auto;border:0;background:0;box-shadow:none;border-radius:0}.selectize-dropdown.plugin-auto_position.selectize-position-top{border-top:1px solid #d0d0d0;border-bottom:0 none;border-radius:3px 3px 0 0;box-shadow:0 -6px 12px rgba(0,0,0,0.18)}.selectize-control.plugin-auto_position .selectize-input.selectize-position-top.dropdown-active{border-radius:0 0 3px 3px;border-top:0 none}.selectize-control.plugin-auto_position .selectize-input.selectize-position-top.dropdown-active::before{top:0;bottom:unset}.selectize-control.plugin-clear_button .clear{text-decoration:none;display:flex;position:absolute;height:100%;width:25px;top:0;right:calc(12px - 5px);color:#000;opacity:.4;font-weight:bold;border:0;cursor:pointer;z-index:1;font-size:21px;justify-content:center;align-items:center}.selectize-control.plugin-clear_button .clear:hover{opacity:1}.selectize-control.plugin-clear_button.single .clear{right:calc(12px - 5px + 1.5rem)}.selectize-control.plugin-drag_drop.multi>.selectize-input.dragging{overflow:visible}.selectize-control.plugin-drag_drop.multi>.selectize-input>div.ui-sortable-placeholder{visibility:visible !important;background:#f2f2f2 !important;background:rgba(0,0,0,0.06) !important;border:0 none !important;box-shadow:inset 0 0 12px 4px #fff}.selectize-control.plugin-drag_drop .ui-sortable-placeholder::after{content:"!";visibility:hidden}.selectize-control.plugin-drag_drop .ui-sortable-helper{box-shadow:0 2px 5px rgba(0,0,0,0.2)}.selectize-dropdown.plugin-dropdown_header .selectize-dropdown-header{position:relative;padding:6px 12px;border-bottom:1px solid #d0d0d0;background:#f8f8f8;border-radius:4px 4px 0 0}.selectize-dropdown.plugin-dropdown_header .selectize-dropdown-header-close{position:absolute;right:12px;top:50%;color:#333;opacity:.4;margin-top:-12px;line-height:20px;font-size:20px !important;text-decoration:none}.selectize-dropdown.plugin-dropdown_header .selectize-dropdown-header-close:hover{color:black}.selectize-dropdown.plugin-optgroup_columns .selectize-dropdown-content{display:flex}.selectize-dropdown.plugin-optgroup_columns .optgroup{border-right:1px solid #f2f2f2;border-top:0 none;flex-grow:1;flex-basis:0;min-width:0}.selectize-dropdown.plugin-optgroup_columns .optgroup:last-child{border-right:0 none}.selectize-dropdown.plugin-optgroup_columns .optgroup:before{display:none}.selectize-dropdown.plugin-optgroup_columns .optgroup-header{border-top:0 none}.selectize-control.plugin-remove_button .item{display:inline-flex;align-items:center;padding-right:0 !important}.selectize-control.plugin-remove_button .item .remove{color:inherit;text-decoration:none;vertical-align:middle;display:inline-block;padding:1px 5px;border-left:1px solid rgba(0,0,0,0);border-radius:0 2px 2px 0;box-sizing:border-box;margin-left:5px}.selectize-control.plugin-remove_button .item .remove:hover{background:rgba(0,0,0,0.05)}.selectize-control.plugin-remove_button .item.active .remove{border-left-color:rgba(0,0,0,0)}.selectize-control.plugin-remove_button .disabled .item .remove:hover{background:0}.selectize-control.plugin-remove_button .disabled .item .remove{border-left-color:rgba(77,77,77,0)} \ No newline at end of file diff --git a/htdocs/js/selectize.min.js b/htdocs/js/selectize.min.js index 0a5384be6..4fb14c096 100644 --- a/htdocs/js/selectize.min.js +++ b/htdocs/js/selectize.min.js @@ -1,2 +1,32 @@ -/*! selectize.js - v0.13.5 | https://github.com/selectize/selectize.js | Apache License (v2) */ -var highlight=function($element,pattern){var regex,highlight;if("string"!=typeof pattern||pattern.length)return regex="string"==typeof pattern?new RegExp(pattern,"i"):pattern,highlight=function(node){var skip=0;if(3===node.nodeType){var spannode,match,pos=node.data.search(regex);0<=pos&&0/g,">").replace(/"/g,""")},escape_replace=function(str){return(str+"").replace(/\$/g,"$$$$")},hook={before:function(self,method,fn){var original=self[method];self[method]=function(){return fn.apply(self,arguments),original.apply(self,arguments)}},after:function(self,method,fn){var original=self[method];self[method]=function(){var result=original.apply(self,arguments);return fn.apply(self,arguments),result}}},once=function(fn){var called=!1;return function(){called||(called=!0,fn.apply(this,arguments))}},debounce=function(fn,delay){var timeout;return function(){var self=this,args=arguments;window.clearTimeout(timeout),timeout=window.setTimeout(function(){fn.apply(self,args)},delay)}},debounce_events=function(self,types,fn){var type,trigger=self.trigger,event_args={};for(type in self.trigger=function(){var type=arguments[0];if(-1===types.indexOf(type))return trigger.apply(self,arguments);event_args[type]=arguments},fn.apply(self,[]),self.trigger=trigger,event_args)event_args.hasOwnProperty(type)&&trigger.apply(self,event_args[type])},watchChildEvent=function($parent,event,selector,fn){$parent.on(event,selector,function(e){for(var child=e.target;child&&child.parentNode!==$parent[0];)child=child.parentNode;return e.currentTarget=child,fn.apply(this,[e])})},getSelection=function(input){var sel,selLen,result={};return void 0===input?console.warn("WARN getSelection cannot locate input control"):"selectionStart"in input?(result.start=input.selectionStart,result.length=input.selectionEnd-result.start):document.selection&&(input.focus(),sel=document.selection.createRange(),selLen=document.selection.createRange().text.length,sel.moveStart("character",-input.value.length),result.start=sel.text.length-selLen,result.length=selLen),result},transferStyles=function($from,$to,properties){var i,n,styles={};if(properties)for(i=0,n=properties.length;i").css({position:"absolute",width:"auto",padding:0,whiteSpace:"pre"}),$("
").css({position:"absolute",width:0,height:0,overflow:"hidden"}).append(Selectize.$testInput).appendTo("body")),Selectize.$testInput.text(str),transferStyles($parent,Selectize.$testInput,["letterSpacing","fontSize","fontFamily","fontWeight","textTransform"]),Selectize.$testInput.width()):0},autoGrow=function($input){function update(e,options){var printable,keyCode,selection;options=options||{},(e=e||window.event||{}).metaKey||e.altKey||!options.force&&!1===$input.data("grow")||(options=$input.val(),e.type&&"keydown"===e.type.toLowerCase()&&(printable=48<=(keyCode=e.keyCode)&&keyCode<=57||65<=keyCode&&keyCode<=90||96<=keyCode&&keyCode<=111||186<=keyCode&&keyCode<=222||32===keyCode,keyCode===KEY_DELETE||keyCode===KEY_BACKSPACE?(selection=getSelection($input[0])).length?options=options.substring(0,selection.start)+options.substring(selection.start+selection.length):keyCode===KEY_BACKSPACE&&selection.start?options=options.substring(0,selection.start-1)+options.substring(selection.start+1):keyCode===KEY_DELETE&&void 0!==selection.start&&(options=options.substring(0,selection.start)+options.substring(selection.start+1)):printable&&(keyCode=e.shiftKey,selection=String.fromCharCode(e.keyCode),options+=selection=keyCode?selection.toUpperCase():selection.toLowerCase())),e=(printable=$input.attr("placeholder"))?measureString(printable,$input)+4:0,(keyCode=Math.max(measureString(options,$input),e)+4)!==currentWidth&&(currentWidth=keyCode,$input.width(keyCode),$input.triggerHandler("resize")))}var currentWidth=null;$input.on("keydown keyup update blur",update),update()},domToString=function(d){var tmp=document.createElement("div");return tmp.appendChild(d.cloneNode(!0)),tmp.innerHTML},logError=function(message,options){options=options||{};console.error("Selectize: "+message),options.explanation&&(console.group&&console.group(),console.error(options.explanation),console.group&&console.groupEnd())},Selectize=function($input,settings){var i,n,input=$input[0],computedStyle=(input.selectize=this,window.getComputedStyle&&window.getComputedStyle(input,null));if(computedStyle=(computedStyle?computedStyle.getPropertyValue("direction"):input.currentStyle&&input.currentStyle.direction)||$input.parents("[dir]:first").attr("dir")||"",$.extend(this,{order:0,settings:settings,$input:$input,tabIndex:$input.attr("tabindex")||"",tagType:"select"===input.tagName.toLowerCase()?TAG_SELECT:TAG_INPUT,rtl:/rtl/i.test(computedStyle),eventNS:".selectize"+ ++Selectize.count,highlightedValue:null,isBlurring:!1,isOpen:!1,isDisabled:!1,isRequired:$input.is("[required]"),isInvalid:!1,isLocked:!1,isFocused:!1,isInputHidden:!1,isSetup:!1,isShiftDown:!1,isCmdDown:!1,isCtrlDown:!1,ignoreFocus:!1,ignoreBlur:!1,ignoreHover:!1,hasOptions:!1,currentResults:null,lastValue:"",lastValidValue:"",caretPos:0,loading:0,loadedSearches:{},$activeOption:null,$activeItems:[],optgroups:{},options:{},userOptions:{},items:[],renderCache:{},onSearchChange:null===settings.loadThrottle?this.onSearchChange:debounce(this.onSearchChange,settings.loadThrottle)}),this.sifter=new Sifter(this.options,{diacritics:settings.diacritics}),this.settings.options){for(i=0,n=this.settings.options.length;i").addClass(settings.wrapperClass).addClass(classes).addClass(inputMode),$control=$("
").addClass(settings.inputClass).addClass("items").appendTo($wrapper),$control_input=$('').appendTo($control).attr("tabindex",$input.is(":disabled")?"-1":self.tabIndex),$dropdown_parent=$(settings.dropdownParent||$wrapper),inputMode=$("
").addClass(settings.dropdownClass).addClass(inputMode).hide().appendTo($dropdown_parent),$dropdown_parent=$("
").addClass(settings.dropdownContentClass).attr("tabindex","-1").appendTo(inputMode);(inputId=$input.attr("id"))&&($control_input.attr("id",inputId+"-selectized"),$("label[for='"+inputId+"']").attr("for",inputId+"-selectized")),self.settings.copyClassesToDropdown&&inputMode.addClass(classes),$wrapper.css({width:$input[0].style.width}),self.plugins.names.length&&(inputId="plugin-"+self.plugins.names.join(" plugin-"),$wrapper.addClass(inputId),inputMode.addClass(inputId)),(null===settings.maxItems||1[data-selectable]",function(e){e.stopImmediatePropagation()}),inputMode.on("mouseenter","[data-selectable]",function(){return self.onOptionHover.apply(self,arguments)}),inputMode.on("mousedown click","[data-selectable]",function(){return self.onOptionSelect.apply(self,arguments)}),watchChildEvent($control,"mousedown","*:not(input)",function(){return self.onItemSelect.apply(self,arguments)}),autoGrow($control_input),$control.on({mousedown:function(){return self.onMouseDown.apply(self,arguments)},click:function(){return self.onClick.apply(self,arguments)}}),$control_input.on({mousedown:function(e){e.stopPropagation()},keydown:function(){return self.onKeyDown.apply(self,arguments)},keypress:function(){return self.onKeyPress.apply(self,arguments)},input:function(){return self.onInput.apply(self,arguments)},resize:function(){self.positionDropdown.apply(self,[])},blur:function(){return self.onBlur.apply(self,arguments)},focus:function(){return self.ignoreBlur=!1,self.onFocus.apply(self,arguments)},paste:function(){return self.onPaste.apply(self,arguments)}}),$document.on("keydown"+eventNS,function(e){self.isCmdDown=e[IS_MAC?"metaKey":"ctrlKey"],self.isCtrlDown=e[IS_MAC?"altKey":"ctrlKey"],self.isShiftDown=e.shiftKey}),$document.on("keyup"+eventNS,function(e){e.keyCode===KEY_CTRL&&(self.isCtrlDown=!1),e.keyCode===KEY_SHIFT&&(self.isShiftDown=!1),e.keyCode===KEY_CMD&&(self.isCmdDown=!1)}),$document.on("mousedown"+eventNS,function(e){if(self.isFocused){if(e.target===self.$dropdown[0]||e.target.parentNode===self.$dropdown[0])return!1;self.$control.has(e.target).length||e.target===self.$control[0]||self.blur(e.target)}}),$window.on(["scroll"+eventNS,"resize"+eventNS].join(" "),function(){self.isOpen&&self.positionDropdown.apply(self,arguments)}),$window.on("mousemove"+eventNS,function(){self.ignoreHover=!1}),this.revertSettings={$children:$input.children().detach(),tabindex:$input.attr("tabindex")},$input.attr("tabindex",-1).hide().after(self.$wrapper),Array.isArray(settings.items)&&(self.lastValidValue=settings.items,self.setValue(settings.items),delete settings.items),SUPPORTS_VALIDITY_API&&$input.on("invalid"+eventNS,function(e){e.preventDefault(),self.isInvalid=!0,self.refreshState()}),self.updateOriginalInput(),self.refreshItems(),self.refreshState(),self.updatePlaceholder(),self.isSetup=!0,$input.is(":disabled")&&self.disable(),self.on("change",this.onChange),$input.data("selectize",self),$input.addClass("selectized"),self.trigger("initialize"),!0===settings.preload&&self.onSearchChange("")},setupTemplates:function(){var field_label=this.settings.labelField,field_value=this.settings.valueField,field_optgroup=this.settings.optgroupLabelField;this.settings.render=$.extend({},{optgroup:function(data){return'
'+data.html+"
"},optgroup_header:function(data,escape){return'
'+escape(data[field_optgroup])+"
"},option:function(data,escape){return'
'+escape(data[field_label])+"
"},item:function(data,escape){return'
'+escape(data[field_label])+"
"},option_create:function(data,escape){return'
Add '+escape(data.input)+"…
"}},this.settings.render)},setupCallbacks:function(){var key,fn,callbacks={initialize:"onInitialize",change:"onChange",item_add:"onItemAdd",item_remove:"onItemRemove",clear:"onClear",option_add:"onOptionAdd",option_remove:"onOptionRemove",option_clear:"onOptionClear",optgroup_add:"onOptionGroupAdd",optgroup_remove:"onOptionGroupRemove",optgroup_clear:"onOptionGroupClear",dropdown_open:"onDropdownOpen",dropdown_close:"onDropdownClose",type:"onType",load:"onLoad",focus:"onFocus",blur:"onBlur",dropdown_item_activate:"onDropdownItemActivate",dropdown_item_deactivate:"onDropdownItemDeactivate"};for(key in callbacks)callbacks.hasOwnProperty(key)&&(fn=this.settings[callbacks[key]])&&this.on(key,fn)},onClick:function(e){this.isFocused&&this.isOpen||(this.focus(),e.preventDefault())},onMouseDown:function(e){var self=this,defaultPrevented=e.isDefaultPrevented();$(e.target);if(self.isFocused){if(e.target!==self.$control_input[0])return"single"===self.settings.mode?self.isOpen?self.close():self.open():defaultPrevented||self.setActiveItem(null),!1}else defaultPrevented||window.setTimeout(function(){self.focus()},0)},onChange:function(){""!==this.getValue()&&(this.lastValidValue=this.getValue()),this.$input.trigger("input"),this.$input.trigger("change")},onPaste:function(e){var self=this;self.isFull()||self.isInputHidden||self.isLocked?e.preventDefault():self.settings.splitOn&&setTimeout(function(){var pastedText=self.$control_input.val();if(pastedText.match(self.settings.splitOn))for(var splitInput=pastedText.trim().split(self.settings.splitOn),i=0,n=splitInput.length;i=this.settings.maxItems},updateOriginalInput:function(opts){var i,n,options,label;if(opts=opts||{},this.tagType===TAG_SELECT){for(options=[],i=0,n=this.items.length;i'+escape_html(label)+"");options.length||this.$input.attr("multiple")||options.push(''),this.$input.html(options.join(""))}else this.$input.val(this.getValue()),this.$input.attr("value",this.$input.val());this.isSetup&&!opts.silent&&this.trigger("change",this.$input.val())},updatePlaceholder:function(){var $input;this.settings.placeholder&&($input=this.$control_input,this.items.length?$input.removeAttr("placeholder"):$input.attr("placeholder",this.settings.placeholder),$input.triggerHandler("update",{force:!0}))},open:function(){this.isLocked||this.isOpen||"multi"===this.settings.mode&&this.isFull()||(this.focus(),this.isOpen=!0,this.refreshState(),this.$dropdown.css({visibility:"hidden",display:"block"}),this.positionDropdown(),this.$dropdown.css({visibility:"visible"}),this.trigger("dropdown_open",this.$dropdown))},close:function(){var trigger=this.isOpen;"single"===this.settings.mode&&this.items.length&&(this.hideInput(),this.isBlurring&&this.$control_input.blur()),this.isOpen=!1,this.$dropdown.hide(),this.setActiveOption(null),this.refreshState(),trigger&&this.trigger("dropdown_close",this.$dropdown)},positionDropdown:function(){var $control=this.$control,offset="body"===this.settings.dropdownParent?$control.offset():$control.position();offset.top+=$control.outerHeight(!0),this.$dropdown.css({width:$control[0].getBoundingClientRect().width,top:offset.top,left:offset.left})},clear:function(silent){this.items.length&&(this.$control.children(":not(input)").remove(),this.items=[],this.lastQuery=null,this.setCaret(0),this.setActiveItem(null),this.updatePlaceholder(),this.updateOriginalInput({silent:silent}),this.refreshState(),this.showInput(),this.trigger("clear"))},insertAtCaret:function($el){var caret=Math.min(this.caretPos,this.items.length),$el=$el[0],target=this.buffer||this.$control[0];0===caret?target.insertBefore($el,target.firstChild):target.insertBefore($el,target.childNodes[caret]),this.setCaret(caret+1)},deleteSelection:function(e){var i,n,values,option_select,$tail,direction=e&&e.keyCode===KEY_BACKSPACE?-1:1,selection=getSelection(this.$control_input[0]);if(this.$activeOption&&!this.settings.hideSelected&&(option_select=("string"==typeof this.settings.deselectBehavior&&"top"===this.settings.deselectBehavior?this.getFirstOption():this.getAdjacentOption(this.$activeOption,-1)).attr("data-value")),values=[],this.$activeItems.length){for($tail=this.$control.children(".active:"+(0'+settings.emptyOptionLabel+""+input_html)),("select"===tag_name?init_select:function($input,settings_element){var i,n,values,option,data_raw=$input.attr(attr_data);if(data_raw)for(settings_element.options=JSON.parse(data_raw),i=0,n=settings_element.options.length;iwindow.innerHeight?POSITION.top:POSITION.bottom,styles={width:$control.outerWidth(),left:offset.left};dropdownHeight===POSITION.top?(Object.assign(styles,{bottom:offset.top,top:"unset",margin:"0 0 5px 0"}),this.$dropdown.addClass("selectize-position-top")):(Object.assign(styles,{top:offset.top,bottom:"unset",margin:"5px 0 0 0"}),this.$dropdown.removeClass("selectize-position-top")),this.$dropdown.css(styles)}}),Selectize.define("auto_select_on_type",function(options){var originalBlur,self=this;self.onBlur=(originalBlur=self.onBlur,function(e){var $matchedItem=self.getFirstItemMatchedByTextContent(self.lastValue,!0);return void 0!==$matchedItem.attr("data-value")&&self.getValue()!==$matchedItem.attr("data-value")&&self.setValue($matchedItem.attr("data-value")),originalBlur.apply(this,arguments)})}),Selectize.define("autofill_disable",function(options){var original,self=this;self.setup=(original=self.setup,function(){original.apply(self,arguments),self.$control_input.attr({autocomplete:"new-password",autofill:"no"})})}),Selectize.define("drag_drop",function(options){if(!$.fn.sortable)throw new Error('The "drag_drop" plugin requires jQuery UI "sortable".');var self,original;"multi"===this.settings.mode&&((self=this).lock=(original=self.lock,function(){var sortable=self.$control.data("sortable");return sortable&&sortable.disable(),original.apply(self,arguments)}),self.unlock=function(){var original=self.unlock;return function(){var sortable=self.$control.data("sortable");return sortable&&sortable.enable(),original.apply(self,arguments)}}(),self.setup=function(){var original=self.setup;return function(){original.apply(this,arguments);var $control=self.$control.sortable({items:"[data-value]",forcePlaceholderSize:!0,disabled:self.isLocked,start:function(e,ui){ui.placeholder.css("width",ui.helper.css("width")),$control.addClass("dragging")},stop:function(){$control.removeClass("dragging");var active=self.$activeItems?self.$activeItems.slice():null,values=[];$control.children("[data-value]").each(function(){values.push($(this).attr("data-value"))}),self.isFocused=!1,self.setValue(values),self.isFocused=!0,self.setActiveItem(active),self.positionDropdown()}})}}())}),Selectize.define("dropdown_header",function(options){var original,self=this;options=$.extend({title:"Untitled",headerClass:"selectize-dropdown-header",titleRowClass:"selectize-dropdown-header-title",labelClass:"selectize-dropdown-header-label",closeClass:"selectize-dropdown-header-close",html:function(data){return'
'+data.title+'×
'}},options),self.setup=(original=self.setup,function(){original.apply(self,arguments),self.$dropdown_header=$(options.html(options)),self.$dropdown.prepend(self.$dropdown_header)})}),Selectize.define("optgroup_columns",function(options){function equalizeSizes(){var i,height_max,width,width_parent,$optgroups=$("[data-group]",self.$dropdown_content),n=$optgroups.length;if(n&&self.$dropdown_content.width()){if(options.equalizeHeight){for(i=height_max=0;i
',div=div.firstChild,doc.body.appendChild(div),width=getScrollbarWidth.width=div.offsetWidth-div.clientWidth,doc.body.removeChild(div)),width});(options.equalizeHeight||options.equalizeWidth)&&(hook.after(this,"positionDropdown",equalizeSizes),hook.after(this,"refreshOptions",equalizeSizes))}),Selectize.define("remove_button",function(options){options=$.extend({label:"×",title:"Remove",className:"remove",append:!0},options),("single"===this.settings.mode?function(thisRef,options){options.className="remove-single";var original,self=thisRef,html=''+options.label+"";thisRef.setup=(original=self.setup,function(){var id,render_item;options.append&&(id=$(self.$input.context).attr("id"),$("#"+id),render_item=self.settings.render.item,self.settings.render.item=function(data){return html_container=render_item.apply(thisRef,arguments),html_element=html,$("").append(html_container).append(html_element);var html_container,html_element}),original.apply(thisRef,arguments),thisRef.$control.on("click","."+options.className,function(e){e.preventDefault(),self.isLocked||self.clear()})})}:function(thisRef,options){var original,self=thisRef,html=''+options.label+"";thisRef.setup=(original=self.setup,function(){var render_item;options.append&&(render_item=self.settings.render.item,self.settings.render.item=function(data){return html_container=render_item.apply(thisRef,arguments),html_element=html,pos=html_container.search(/(<\/[^>]+>\s*)$/),html_container.substring(0,pos)+html_element+html_container.substring(pos);var html_container,html_element,pos}),original.apply(thisRef,arguments),thisRef.$control.on("click","."+options.className,function(e){if(e.preventDefault(),!self.isLocked)return e=$(e.currentTarget).parent(),self.setActiveItem(e),self.deleteSelection()&&self.setCaret(self.items.length),!1})})})(this,options)}),Selectize.define("restore_on_backspace",function(options){var original,self=this;options.text=options.text||function(option){return option[this.settings.labelField]},this.onKeyDown=(original=self.onKeyDown,function(e){var index;return e.keyCode===KEY_BACKSPACE&&""===this.$control_input.val()&&!this.$activeItems.length&&0<=(index=this.caretPos-1)&&index"+($items.length-limit)+""))}}}(),this.onFocus=function(){const original=self.onFocus;return function(e){if(original.apply(this,e),e){const $control=this.$control,$items=$control.find(".item");$items.show(),$control.find("span").remove()}}}()}); \ No newline at end of file +/** + * Selectize (v0.15.2) + * https://selectize.dev + * + * Copyright (c) 2013-2015 Brian Reavis & contributors + * Copyright (c) 2020-2022 Selectize Team & contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this + * file except in compliance with the License. You may obtain a copy of the License at: + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF + * ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + * + * @author Brian Reavis + * @author Ris Adams + */ +(function (root, factory) { + if (typeof define === 'function' && define.amd) { + define(['jquery'], factory); + } else if (typeof module === 'object' && typeof module.exports === 'object') { + module.exports = factory(require('jquery')); + } else { + root.Selectize = factory(root.jQuery); + } +}(this, function ($) { + 'use strict'; +var highlight=function(t,e){var r,a;if("string"!=typeof e||e.length)return r="string"==typeof e?new RegExp(e,"i"):e,a=function(t){var e=0;if(3===t.nodeType){var n,i,o=t.data.search(r);0<=o&&0/g,">").replace(/"/g,""")},escape_replace=function(t){return(t+"").replace(/\$/g,"$$$$")},hook={before:function(t,e,n){var i=t[e];t[e]=function(){return n.apply(t,arguments),i.apply(t,arguments)}},after:function(e,t,n){var i=e[t];e[t]=function(){var t=i.apply(e,arguments);return n.apply(e,arguments),t}}},once=function(t){var e=!1;return function(){e||(e=!0,t.apply(this,arguments))}},debounce=function(n,i){var o;return function(){var t=this,e=arguments;window.clearTimeout(o),o=window.setTimeout(function(){n.apply(t,e)},i)}},debounce_events=function(e,n,t){var i,o=e.trigger,s={};for(i in e.trigger=function(){var t=arguments[0];if(-1===n.indexOf(t))return o.apply(e,arguments);s[t]=arguments},t.apply(e,[]),e.trigger=o,s)s.hasOwnProperty(i)&&o.apply(e,s[i])},watchChildEvent=function(n,t,e,i){n.on(t,e,function(t){for(var e=t.target;e&&e.parentNode!==n[0];)e=e.parentNode;return t.currentTarget=e,i.apply(this,[t])})},getInputSelection=function(t){var e,n,i={};return void 0===t?console.warn("WARN getInputSelection cannot locate input control"):"selectionStart"in t?(i.start=t.selectionStart,i.length=t.selectionEnd-i.start):document.selection&&(t.focus(),e=document.selection.createRange(),n=document.selection.createRange().text.length,e.moveStart("character",-t.value.length),i.start=e.text.length-n,i.length=n),i},transferStyles=function(t,e,n){var i,o,s={};if(n)for(i=0,o=n.length;i").css({position:"absolute",width:"auto",padding:0,whiteSpace:"pre"}),$("
").css({position:"absolute",width:0,height:0,overflow:"hidden"}).append(Selectize.$testInput).appendTo("body")),Selectize.$testInput.text(t),transferStyles(e,Selectize.$testInput,["letterSpacing","fontSize","fontFamily","fontWeight","textTransform"]),Selectize.$testInput.width()):0},autoGrow=function(s){function t(t,e){var n,i,o;e=e||{},(t=t||window.event||{}).metaKey||t.altKey||!e.force&&!1===s.data("grow")||(e=s.val(),t.type&&"keydown"===t.type.toLowerCase()&&(n=48<=(i=t.keyCode)&&i<=57||65<=i&&i<=90||96<=i&&i<=111||186<=i&&i<=222||32===i,i===KEY_DELETE||i===KEY_BACKSPACE?(o=getInputSelection(s[0])).length?e=e.substring(0,o.start)+e.substring(o.start+o.length):i===KEY_BACKSPACE&&o.start?e=e.substring(0,o.start-1)+e.substring(o.start+1):i===KEY_DELETE&&void 0!==o.start&&(e=e.substring(0,o.start)+e.substring(o.start+1)):n&&(i=t.shiftKey,o=String.fromCharCode(t.keyCode),e+=o=i?o.toUpperCase():o.toLowerCase())),t=(n=s.attr("placeholder"))?measureString(n,s)+4:0,(i=Math.max(measureString(e,s),t)+4)===r)||(r=i,s.width(i),s.triggerHandler("resize"))}var r=null;s.on("keydown keyup update blur",t),t()},domToString=function(t){var e=document.createElement("div");return e.appendChild(t.cloneNode(!0)),e.innerHTML},logError=function(t,e){e=e||{};console.error("Selectize: "+t),e.explanation&&(console.group&&console.group(),console.error(e.explanation),console.group)&&console.groupEnd()},isJSON=function(t){try{JSON.parse(str)}catch(t){return!1}return!0},Selectize=function(t,e){var n,i,o=this,s=t[0],r=(s.selectize=o,window.getComputedStyle&&window.getComputedStyle(s,null));if(r=(r?r.getPropertyValue("direction"):s.currentStyle&&s.currentStyle.direction)||t.parents("[dir]:first").attr("dir")||"",$.extend(o,{order:0,settings:e,$input:t,tabIndex:t.attr("tabindex")||"",tagType:"select"===s.tagName.toLowerCase()?TAG_SELECT:TAG_INPUT,rtl:/rtl/i.test(r),eventNS:".selectize"+ ++Selectize.count,highlightedValue:null,isBlurring:!1,isOpen:!1,isDisabled:!1,isRequired:t.is("[required]"),isInvalid:!1,isLocked:!1,isFocused:!1,isInputHidden:!1,isSetup:!1,isShiftDown:!1,isCmdDown:!1,isCtrlDown:!1,ignoreFocus:!1,ignoreBlur:!1,ignoreHover:!1,hasOptions:!1,currentResults:null,lastValue:"",lastValidValue:"",lastOpenTarget:!1,caretPos:0,loading:0,loadedSearches:{},isDropdownClosing:!1,$activeOption:null,$activeItems:[],optgroups:{},options:{},userOptions:{},items:[],renderCache:{},onSearchChange:null===e.loadThrottle?o.onSearchChange:debounce(o.onSearchChange,e.loadThrottle)}),o.sifter=new Sifter(this.options,{diacritics:e.diacritics}),o.settings.options){for(n=0,i=o.settings.options.length;n").addClass(t.wrapperClass).addClass(a+" selectize-control").addClass(r),p=$("
").addClass(t.inputClass+" selectize-input items").appendTo(l),c=$('').appendTo(p).attr("tabindex",s.is(":disabled")?"-1":e.tabIndex),u=$(t.dropdownParent||l),r=$("
").addClass(t.dropdownClass).addClass(r+" selectize-dropdown").hide().appendTo(u),u=$("
").addClass(t.dropdownContentClass+" selectize-dropdown-content").attr("tabindex","-1").appendTo(r),d=((d=s.attr("id"))&&(c.attr("id",d+"-selectized"),$("label[for='"+d+"']").attr("for",d+"-selectized")),e.settings.copyClassesToDropdown&&r.addClass(a),l.css({width:s[0].style.width}),e.plugins.names.length&&(d="plugin-"+e.plugins.names.join(" plugin-"),l.addClass(d),r.addClass(d)),(null===t.maxItems||1[data-selectable]",function(t){t.stopImmediatePropagation()}),r.on("mouseenter","[data-selectable]",function(){return e.onOptionHover.apply(e,arguments)}),r.on("mouseup click","[data-selectable]",function(){return e.onOptionSelect.apply(e,arguments)}),watchChildEvent(p,"mouseup","*:not(input)",function(){return e.onItemSelect.apply(e,arguments)}),autoGrow(c),p.on({mousedown:function(){return e.onMouseDown.apply(e,arguments)},click:function(){return e.onClick.apply(e,arguments)}}),c.on({mousedown:function(t){""===e.$control_input.val()&&!e.settings.openOnFocus||t.stopPropagation()},keydown:function(){return e.onKeyDown.apply(e,arguments)},keypress:function(){return e.onKeyPress.apply(e,arguments)},input:function(){return e.onInput.apply(e,arguments)},resize:function(){e.positionDropdown.apply(e,[])},focus:function(){return e.ignoreBlur=!1,e.onFocus.apply(e,arguments)},paste:function(){return e.onPaste.apply(e,arguments)}}),o.on("keydown"+n,function(t){e.isCmdDown=t[IS_MAC?"metaKey":"ctrlKey"],e.isCtrlDown=t[IS_MAC?"altKey":"ctrlKey"],e.isShiftDown=t.shiftKey}),o.on("keyup"+n,function(t){t.keyCode===KEY_CTRL&&(e.isCtrlDown=!1),t.keyCode===KEY_SHIFT&&(e.isShiftDown=!1),t.keyCode===KEY_CMD&&(e.isCmdDown=!1)}),o.on("mousedown"+n,function(t){if(e.isFocused){if(t.target===e.$dropdown[0]||t.target.parentNode===e.$dropdown[0])return!1;e.$dropdown.has(t.target).length||t.target===e.$control[0]||e.blur(t.target)}}),i.on(["scroll"+n,"resize"+n].join(" "),function(){e.isOpen&&e.positionDropdown.apply(e,arguments)}),i.on("mousemove"+n,function(){e.ignoreHover=e.settings.ignoreHover}),$("
")),a=s.children().detach();s.replaceWith(d),d.replaceWith(s),this.revertSettings={$children:a,tabindex:s.attr("tabindex")},s.attr("tabindex",-1).hide().after(e.$wrapper),Array.isArray(t.items)&&(e.lastValidValue=t.items,e.setValue(t.items),delete t.items),SUPPORTS_VALIDITY_API&&s.on("invalid"+n,function(t){t.preventDefault(),e.isInvalid=!0,e.refreshState()}),e.updateOriginalInput(),e.refreshItems(),e.refreshState(),e.updatePlaceholder(),e.isSetup=!0,s.is(":disabled")&&e.disable(),e.on("change",this.onChange),s.data("selectize",e),s.addClass("selectized"),e.trigger("initialize"),!0===t.preload&&e.onSearchChange("")},setupTemplates:function(){var t=this,i=t.settings.labelField,o=t.settings.valueField,n=t.settings.optgroupLabelField;t.settings.render=$.extend({},{optgroup:function(t){return'
'+t.html+"
"},optgroup_header:function(t,e){return'
'+e(t[n])+"
"},option:function(t,e){var n=t.classes?" "+t.classes:"";return n+=""===t[o]?" selectize-dropdown-emptyoptionlabel":"","'+e(t[i])+"
"},item:function(t,e){return'
'+e(t[i])+"
"},option_create:function(t,e){return'
Add '+e(t.input)+"…
"}},t.settings.render)},setupCallbacks:function(){var t,e,n={initialize:"onInitialize",change:"onChange",item_add:"onItemAdd",item_remove:"onItemRemove",clear:"onClear",option_add:"onOptionAdd",option_remove:"onOptionRemove",option_clear:"onOptionClear",optgroup_add:"onOptionGroupAdd",optgroup_remove:"onOptionGroupRemove",optgroup_clear:"onOptionGroupClear",dropdown_open:"onDropdownOpen",dropdown_close:"onDropdownClose",type:"onType",load:"onLoad",focus:"onFocus",blur:"onBlur",dropdown_item_activate:"onDropdownItemActivate",dropdown_item_deactivate:"onDropdownItemDeactivate"};for(t in n)n.hasOwnProperty(t)&&(e=this.settings[n[t]])&&this.on(t,e)},onClick:function(t){this.isDropdownClosing||this.isFocused&&this.isOpen||(this.focus(),t.preventDefault())},onMouseDown:function(t){var e=this,n=t.isDefaultPrevented();$(t.target);if(e.isFocused||n||window.setTimeout(function(){e.focus()},0),t.target!==e.$control_input[0]||""===e.$control_input.val())return"single"===e.settings.mode?e.isOpen?e.close():e.open():(n||e.setActiveItem(null),e.settings.openOnFocus||(e.isOpen&&t.target===e.lastOpenTarget?(e.close(),e.lastOpenTarget=!1):(e.isOpen||(e.refreshOptions(),e.open()),e.lastOpenTarget=t.target))),!1},onChange:function(){""!==this.getValue()&&(this.lastValidValue=this.getValue()),this.$input.trigger("input"),this.$input.trigger("change")},onPaste:function(t){var o=this;o.isFull()||o.isInputHidden||o.isLocked?t.preventDefault():o.settings.splitOn&&setTimeout(function(){var t=o.$control_input.val();if(t.match(o.settings.splitOn))for(var e=t.trim().split(o.settings.splitOn),n=0,i=e.length;n=this.settings.maxItems},updateOriginalInput:function(t){var e,n,i,o,s,r,a=this;t=t||{},a.tagType===TAG_SELECT?(o=a.$input.find("option"),e=[],n=[],i=[],r=[],o.get().forEach(function(t){e.push(t.value)}),a.items.forEach(function(t){s=a.options[t][a.settings.labelField]||"",r.push(t),-1==e.indexOf(t)&&n.push('")}),i=e.filter(function(t){return r.indexOf(t)<0}).map(function(t){return'option[value="'+t+'"]'}),e.length-i.length+n.length!==0||a.$input.attr("multiple")||n.push(''),a.$input.find(i.join(", ")).remove(),a.$input.append(n.join(""))):(a.$input.val(a.getValue()),a.$input.attr("value",a.$input.val())),a.isSetup&&!t.silent&&a.trigger("change",a.$input.val())},updatePlaceholder:function(){var t;this.settings.placeholder&&(t=this.$control_input,this.items.length?t.removeAttr("placeholder"):t.attr("placeholder",this.settings.placeholder),t.triggerHandler("update",{force:!0}))},open:function(){var t=this;t.isLocked||t.isOpen||"multi"===t.settings.mode&&t.isFull()||(t.focus(),t.isOpen=!0,t.refreshState(),t.$dropdown.css({visibility:"hidden",display:"block"}),t.setupDropdownHeight(),t.positionDropdown(),t.$dropdown.css({visibility:"visible"}),t.trigger("dropdown_open",t.$dropdown))},close:function(){var t=this,e=t.isOpen;"single"===t.settings.mode&&t.items.length&&(t.hideInput(),t.isBlurring)&&t.$control_input[0].blur(),t.isOpen=!1,t.$dropdown.hide(),t.setActiveOption(null),t.refreshState(),e&&t.trigger("dropdown_close",t.$dropdown)},positionDropdown:function(){var t=this.$control,e="body"===this.settings.dropdownParent?t.offset():t.position(),t=(e.top+=t.outerHeight(!0),t[0].getBoundingClientRect().width);this.settings.minWidth&&this.settings.minWidth>t&&(t=this.settings.minWidth),this.$dropdown.css({width:t,top:e.top,left:e.left})},setupDropdownHeight:function(){if("object"==typeof this.settings.dropdownSize&&"auto"!==this.settings.dropdownSize.sizeType){var t=this.settings.dropdownSize.sizeValue;if("numberItems"===this.settings.dropdownSize.sizeType){for(var e=this.$dropdown_content.find("*").not(".optgroup, .highlight").not(this.settings.ignoreOnDropwdownHeight),n=0,i=0,o=0,s=0,r=0;r'+i+""+l)),{placeholder:n,options:[],optgroups:[],items:[]});if("select"===e)u(t,i);else{var o,s,r,a,l=t,p=i,n=l.attr(m);if(n)for(p.options=JSON.parse(n),o=0,s=p.options.length;owindow.innerHeight?o.top:o.bottom,i={width:t.outerWidth(),left:e.left};n===o.top?(n={bottom:e.top,top:"unset"},"body"===this.settings.dropdownParent&&(n.top=e.top-this.$dropdown.outerHeight(!0)-t.outerHeight(!0),n.bottom="unset"),Object.assign(i,n),this.$dropdown.addClass("selectize-position-top"),this.$control.addClass("selectize-position-top")):(Object.assign(i,{top:e.top,bottom:"unset"}),this.$dropdown.removeClass("selectize-position-top"),this.$control.removeClass("selectize-position-top")),this.$dropdown.css(i)}}),Selectize.define("auto_select_on_type",function(t){var n,i=this;i.onBlur=(n=i.onBlur,function(t){var e=i.getFirstItemMatchedByTextContent(i.lastValue,!0);return void 0!==e.attr("data-value")&&i.getValue()!==e.attr("data-value")&&i.setValue(e.attr("data-value")),n.apply(this,arguments)})}),Selectize.define("autofill_disable",function(t){var e,n=this;n.setup=(e=n.setup,function(){e.apply(n,arguments),n.$control_input.attr({autocomplete:"new-password",autofill:"no"})})}),Selectize.define("clear_button",function(e){var t,n=this;e=$.extend({title:"Clear",className:"clear",label:"×",html:function(t){return' '+t.label+""}},e),n.setup=(t=n.setup,function(){t.apply(n,arguments),n.$button_clear=$(e.html(e)),"single"===n.settings.mode&&n.$wrapper.addClass("single"),n.$wrapper.append(n.$button_clear),""!==n.getValue()&&0!==n.getValue().length||n.$wrapper.find("."+e.className).css("display","none"),n.on("change",function(){""===n.getValue()||0===n.getValue().length?n.$wrapper.find("."+e.className).css("display","none"):n.$wrapper.find("."+e.className).css("display","")}),n.$wrapper.on("click","."+e.className,function(t){t.preventDefault(),t.stopImmediatePropagation(),t.stopPropagation(),n.isLocked||(n.clear(),n.$wrapper.find("."+e.className).css("display","none"))})})}),Selectize.define("drag_drop",function(t){if(!$.fn.sortable)throw new Error('The "drag_drop" plugin requires jQuery UI "sortable".');var i,e,n,o;"multi"===this.settings.mode&&((i=this).lock=(e=i.lock,function(){var t=i.$control.data("sortable");return t&&t.disable(),e.apply(i,arguments)}),i.unlock=(n=i.unlock,function(){var t=i.$control.data("sortable");return t&&t.enable(),n.apply(i,arguments)}),i.setup=(o=i.setup,function(){o.apply(this,arguments);var n=i.$control.sortable({items:"[data-value]",forcePlaceholderSize:!0,disabled:i.isLocked,start:function(t,e){e.placeholder.css("width",e.helper.css("width")),n.addClass("dragging")},stop:function(){n.removeClass("dragging");var t=i.$activeItems?i.$activeItems.slice():null,e=[];n.children("[data-value]").each(function(){e.push($(this).attr("data-value"))}),i.isFocused=!1,i.setValue(e),i.isFocused=!0,i.setActiveItem(t),i.positionDropdown()}})}))}),Selectize.define("dropdown_header",function(t){var e,n=this;t=$.extend({title:"Untitled",headerClass:"selectize-dropdown-header",titleRowClass:"selectize-dropdown-header-title",labelClass:"selectize-dropdown-header-label",closeClass:"selectize-dropdown-header-close",html:function(t){return'
'+t.title+'×
'}},t),n.setup=(e=n.setup,function(){e.apply(n,arguments),n.$dropdown_header=$(t.html(t)),n.$dropdown.prepend(n.$dropdown_header),n.$dropdown_header.find("."+t.closeClass).on("click",function(){n.close()})})}),Selectize.define("optgroup_columns",function(r){function t(){var t,e,n,i,o=$("[data-group]",a.$dropdown_content),s=o.length;if(s&&a.$dropdown_content.width()){if(r.equalizeHeight){for(t=e=0;t
',t=t.firstChild,n.body.appendChild(t),e=l.width=t.offsetWidth-t.clientWidth,n.body.removeChild(t)),e});(r.equalizeHeight||r.equalizeWidth)&&(hook.after(this,"positionDropdown",t),hook.after(this,"refreshOptions",t))}),Selectize.define("remove_button",function(t){var s,e,n,i,r;"single"!==this.settings.mode&&(t=$.extend({label:"×",title:"Remove",className:"remove",append:!0},t),i=s=this,r=''+e.label+"",s.setup=(n=i.setup,function(){var o;e.append&&(o=i.settings.render.item,i.settings.render.item=function(t){return e=o.apply(s,arguments),n=r,i=e.search(/(<\/[^>]+>\s*)$/),e.substring(0,i)+n+e.substring(i);var e,n,i}),n.apply(s,arguments),s.$control.on("click","."+e.className,function(t){if(t.preventDefault(),!i.isLocked)return t=$(t.currentTarget).parent(),i.setActiveItem(t),i.deleteSelection()&&i.setCaret(i.items.length),!1})}))}),Selectize.define("restore_on_backspace",function(n){var i,t=this;n.text=n.text||function(t){return t[this.settings.labelField]},this.onKeyDown=(i=t.onKeyDown,function(t){var e;if(!(t.keyCode===KEY_BACKSPACE&&""===this.$control_input.val()&&!this.$activeItems.length&&0<=(e=this.caretPos-1)&&e"+(e.length-n)+""))}}}(),this.onFocus=function(){const e=t.onFocus;return function(t){e.apply(this,t),t&&((t=this.$control).find(".item").show(),t.find("span").remove())}}()}); + return Selectize; +})); From 9522d2919db7ece48e0ec2e6193b8f4a97894517 Mon Sep 17 00:00:00 2001 From: Franciszek Stachura Date: Sat, 11 Apr 2026 22:11:22 +0200 Subject: [PATCH 11/18] filters: Add autocomplete to labels filter --- htdocs/css/style.css | 8 ++++ patchwork/forms.py | 6 +++ .../templates/patchwork/partials/filters.html | 48 +++++++++++++++++++ .../patchwork/partials/patch-forms.html | 15 ++++++ patchwork/views/__init__.py | 6 +++ 5 files changed, 83 insertions(+) diff --git a/htdocs/css/style.css b/htdocs/css/style.css index 17a35f591..92eb1d789 100644 --- a/htdocs/css/style.css +++ b/htdocs/css/style.css @@ -475,6 +475,14 @@ select[class^=change-property-], .archive-patch-select, .add-bundle { margin: 0 .1em; } +.labels-field { + padding: 4px; + margin-right: 8px; + box-sizing: border-box; + border-radius: 4px; + min-width: 200px; +} + .patch-form-submit { font-weight: bold; padding: 4px; diff --git a/patchwork/forms.py b/patchwork/forms.py index 9d0a470ad..2499f9c17 100644 --- a/patchwork/forms.py +++ b/patchwork/forms.py @@ -148,6 +148,9 @@ def __init__(self, instance=None, project=None, *args, **kwargs): self.fields['labels'] = forms.ModelMultipleChoiceField( queryset=Label.get_project_labels(project), required=False, + widget=forms.SelectMultiple( + attrs={'class': 'labels-field', 'placeholder': 'Labels'} + ), ) class Meta: @@ -231,6 +234,9 @@ def __init__(self, project, *args, **kwargs): self.fields['labels'] = forms.ModelMultipleChoiceField( queryset=Label.get_project_labels(project), + widget=forms.SelectMultiple( + attrs={'class': 'labels-field', 'placeholder': 'Labels to add'} + ), required=False, ) self.fields['state'] = OptionalModelChoiceField( diff --git a/patchwork/templates/patchwork/partials/filters.html b/patchwork/templates/patchwork/partials/filters.html index 1175af147..cbc352713 100644 --- a/patchwork/templates/patchwork/partials/filters.html +++ b/patchwork/templates/patchwork/partials/filters.html @@ -1,5 +1,6 @@ {% load static %} +{{ project_labels|json_script:"project-labels-data" }} diff --git a/patchwork/templates/patchwork/partials/patch-forms.html b/patchwork/templates/patchwork/partials/patch-forms.html index 2dee82302..3c10edbd8 100644 --- a/patchwork/templates/patchwork/partials/patch-forms.html +++ b/patchwork/templates/patchwork/partials/patch-forms.html @@ -1,3 +1,18 @@ +{% load static %} + + +
{% if patch_form %}
diff --git a/patchwork/views/__init__.py b/patchwork/views/__init__.py index fabf09ba9..5af4b9dbe 100644 --- a/patchwork/views/__init__.py +++ b/patchwork/views/__init__.py @@ -189,10 +189,16 @@ def generic_list( if not filter_settings: filter_settings = [] + project_labels = [ + label.name + for label in Label.get_project_labels(project) + ] + filters = Filters(request) context = { 'project': project, 'projects': Project.objects.all(), + 'project_labels': project_labels, 'filters': filters, } From deb87c26705ec9949a6f3e5356d9789ad6d74ac6 Mon Sep 17 00:00:00 2001 From: Franciszek Stachura Date: Fri, 3 Apr 2026 19:48:18 +0200 Subject: [PATCH 12/18] management: Add relabel command The command re-parses subject headers of patches from given projects and adds label information to the database. The main usecase is to refresh label information on existing patches after adding a new label to the project. --- docs/deployment/management.rst | 27 +++++ patchwork/management/commands/relabel.py | 144 +++++++++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 patchwork/management/commands/relabel.py diff --git a/docs/deployment/management.rst b/docs/deployment/management.rst index 8f9eed43d..3eaefe916 100644 --- a/docs/deployment/management.rst +++ b/docs/deployment/management.rst @@ -192,3 +192,30 @@ patches for these new tags. .. option:: patch_id a patch ID number. If not supplied, all patches will be updated. + +.. _command-relabel: + +relabel +~~~~~~~ + +.. program:: manage.py relabel + +Relabel patches based on the subject line. + +.. code-block:: shell + + ./manage.py relabel [PROJECT [PROJECT...]] + +Patchwork extracts labels from square brackets at the beginning of the subject +line, eg. ``[label1, label2] Patch title``. + +Only labels created in the admin interface will be recognized. +The script prioritizes project-specific labels if both project-specific and +general labels with the same name exist. + +The script will not remove labels manually added to patches, but will re-add +labels manually removed from patches. + +.. option:: PROJECT + + list of IDs of projects to update. Relabels all projects if none specified. diff --git a/patchwork/management/commands/relabel.py b/patchwork/management/commands/relabel.py new file mode 100644 index 000000000..b57fc2ae8 --- /dev/null +++ b/patchwork/management/commands/relabel.py @@ -0,0 +1,144 @@ +# Patchwork - automated patch tracking system +# Copyright (C) 2026, The Linux Foundation +# +# SPDX-License-Identifier: GPL-2.0-or-later + +from django.core.management.base import BaseCommand + +from email.parser import HeaderParser + +from patchwork.models import Label, Patch +from patchwork.parser import clean_subject + + +# Prepares a dictionary that maps from project id to a list of +# labels of this project (including global labels, if no local version exists) +def get_labels_per_project(): + global_labels = list(Label.objects.filter(project_id=None).all()) + + labels_per_project = {} + for label in Label.objects.filter(project_id__isnull=True).all(): + labels_list = labels_per_project.get(label.project_id, []) + labels_list.append(label) + labels_per_project[label.project_id] = labels_list + + for project_labels in labels_per_project.values(): + local_label_names = [label.name for label in project_labels] + for global_label in global_labels: + if global_label.name not in local_label_names: + project_labels.append(global_label) + + return labels_per_project, global_labels + + +# Parses stripped name and labels out of patch headers +def get_name_and_labels(headers, labels): + parser = HeaderParser() + headers = parser.parsestr(headers) + subject = headers['Subject'] + prefixes = [] + if subject is None: + return None, None + + name, prefixes, cleaned_prefixes = clean_subject(subject, labels) + + return name, cleaned_prefixes + + +class Command(BaseCommand): + help = ( + 'Update labels for existing patches based on the subject line. ' + 'NOTE: Labels have to be created in the admin interface first.' + ) + + def add_arguments(self, parser): + parser.add_argument( + 'projects', + metavar='PROJECT', + nargs='*', + help='list of listIDs of projects to be relabeld', + ) + + def flush(self, patch_obj): + if len(self.patches_to_update) > 0: + patch_obj.objects.bulk_update(self.patches_to_update, ['name']) + self.patches_to_update = [] + self.patches_to_update_ids = set() + + if len(self.series_to_update) > 0: + Series.objects.bulk_update(self.series_to_update, ['name']) + self.series_to_update = [] + + if len(self.labels_to_add) > 0: + patch_obj.labels.through.objects.bulk_create(self.labels_to_add) + self.labels_to_add = [] + + def reset(self): + self.patches_to_update = [] + self.series_to_update = [] + self.patches_to_update_ids = set() + self.labels_to_add = [] + + def update_patches(self, projects): + self.reset() + + query = Patch.objects.all().only('id', 'headers', 'project_id') + + if projects: + query = query.filter(project__listid__in=projects) + + count = query.count() + + for i, patch in enumerate(query.iterator(chunk_size=1000)): + project_labels = self.labels_per_project.get( + patch.project_id, self.global_labels + ) + + name, cleaned_prefixes = get_name_and_labels( + patch.headers, [label.name for label in project_labels] + ) + if name is None or cleaned_prefixes is None: + continue + + for label in project_labels: + if ( + label.name in cleaned_prefixes + and label not in patch.labels.all() + ): + self.labels_to_add.append( + Patch.labels.through( + patch_id=patch.id, label_id=label.id + ) + ) + self.patches_to_update_ids.add(patch.id) + + if patch.name != name: + patch.name = name[:255] + self.patches_to_update.append(patch) + + if ( + patch.series is not None + and patch.series.name != name + and patch.number == 1 + and patch.series.cover_letter is None + ): + patch.series.name = name[:255] + self.series_to_update.append(patch.series) + + if (i % 100) == 0: + self.stdout.write('%06d/%06d\r' % (i, count), ending='') + self.stdout.flush() + + if len(self.patches_to_update) >= 1000: + self.flush(Patch) + + self.flush(Patch) + + def handle(self, *args, **options): + self.labels_per_project, self.global_labels = get_labels_per_project() + + self.stdout.write('\nupdating patches') + + self.update_patches(options['projects']) + + self.stdout.write('\ndone') From d17b30be107f91d08a9a00ae6ebb9c03073c6f48 Mon Sep 17 00:00:00 2001 From: Franciszek Stachura Date: Sun, 12 Apr 2026 21:15:08 +0200 Subject: [PATCH 13/18] docs: Add information on labels --- docs/usage/overview.rst | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/docs/usage/overview.rst b/docs/usage/overview.rst index 539de9ca3..b0bd0271c 100644 --- a/docs/usage/overview.rst +++ b/docs/usage/overview.rst @@ -162,6 +162,29 @@ or a cover letter or the web URL to a patch or a series: __ https://www.kernel.org/doc/html/latest/process/submitting-patches.html +Labels +~~~~~~ + +Labels are a way to organize and prioritize submissions. You can apply labels +to patches and cover letters to signify priority, category, or any other marker +you wish. + +Labels can be either global or tied to the project they are created in. Labels +can have an optional description attached, which will provide a little insight +into the purpose of the label. Labels are completely customizable and the +labels available will vary by instance. + +Labels are parsed out of the square brackets of the subject line. +For example, subject line ``[RFC, main] Add tests`` may cause the patch to be +labeled with ``RFC`` and ``main``, if such labels were created for the project +or instance. Labels can be created in the admin panel. + +.. note:: + + In order to refresh the list of labels for existing patches, for example + after updating Patchwork or adding a new label in the admin panel, + use the :ref:`command-relabel` management command. + Checks ~~~~~~ From 0b29a82311e93df7e89ebb599401177da743dfc6 Mon Sep 17 00:00:00 2001 From: Franciszek Stachura Date: Wed, 21 Oct 2026 20:52:21 +0200 Subject: [PATCH 14/18] selectize: add mod_focus_input_after_select plugin Plugin refocuses text input of a multiselect after an item from the dropdown is selected. Move all selectize plugins to a separate file. Use the plugin in all labels fields. --- htdocs/README.rst | 5 ++ htdocs/js/selectize-plugins.js | 49 +++++++++++++++++++ .../templates/patchwork/partials/filters.html | 42 ++-------------- .../patchwork/partials/patch-forms.html | 1 + templates/base.html | 1 + 5 files changed, 59 insertions(+), 39 deletions(-) create mode 100644 htdocs/js/selectize-plugins.js diff --git a/htdocs/README.rst b/htdocs/README.rst index 7f7eff94e..464fa6b34 100644 --- a/htdocs/README.rst +++ b/htdocs/README.rst @@ -133,6 +133,11 @@ js Part of Patchwork. +``selectize-plugins.js.`` + Extra plugins for selectize. + + Part of Patchwork. + ``selectize.min.js`` Selectize is the hybrid of a ``textbox`` and `` control + e.delegateTarget.parentElement.getElementsByTagName('input')[0].focus(); + prevSelect(e); + }; +}); diff --git a/patchwork/templates/patchwork/partials/filters.html b/patchwork/templates/patchwork/partials/filters.html index cbc352713..7f20bea35 100644 --- a/patchwork/templates/patchwork/partials/filters.html +++ b/patchwork/templates/patchwork/partials/filters.html @@ -1,6 +1,7 @@ {% load static %} {{ project_labels|json_script:"project-labels-data" }} + + diff --git a/patchwork/templates/patchwork/partials/patch-forms.html b/patchwork/templates/patchwork/partials/patch-forms.html index e145acdca..effa227d8 100644 --- a/patchwork/templates/patchwork/partials/patch-forms.html +++ b/patchwork/templates/patchwork/partials/patch-forms.html @@ -1,16 +1,20 @@ {% load static %} +{{ project_labels|json_script:"project-labels-data" }} + diff --git a/patchwork/templatetags/patch.py b/patchwork/templatetags/patch.py index d16a94c02..7fee28eee 100644 --- a/patchwork/templatetags/patch.py +++ b/patchwork/templatetags/patch.py @@ -107,8 +107,8 @@ def text_color(hex_color): text_color(label.color), ) output.append( - '%s' - % (style, escape(label.name)) + '%s' + % (style, escape(label.description), escape(label.name)) ) # Label name is escaped above. Color is formatted in a way that diff --git a/patchwork/views/__init__.py b/patchwork/views/__init__.py index 5af4b9dbe..01d6dd1b6 100644 --- a/patchwork/views/__init__.py +++ b/patchwork/views/__init__.py @@ -190,7 +190,7 @@ def generic_list( filter_settings = [] project_labels = [ - label.name + {"id": label.id, "name": label.name, "description": label.description} for label in Label.get_project_labels(project) ] @@ -340,9 +340,7 @@ def generic_list( ), ) ) - patches = patches.prefetch_related( - Prefetch('labels', queryset=Label.objects.only('name', 'color')), - ) + patches = patches.prefetch_related(Prefetch('labels')) # annotate with tag counts patches = patches.with_tag_counts(project) diff --git a/patchwork/views/patch.py b/patchwork/views/patch.py index 58f294048..516043dc9 100644 --- a/patchwork/views/patch.py +++ b/patchwork/views/patch.py @@ -17,6 +17,7 @@ from patchwork.models import Cover from patchwork.models import Patch from patchwork.models import Project +from patchwork.models import Label from patchwork.views import generic_list from patchwork.views import set_bundle from patchwork.views.utils import patch_to_mbox @@ -115,6 +116,12 @@ def patch_detail(request, project_id, msgid): related_same_project = [] related_different_project = [] + project_labels = [ + {"id": label.id, "name": label.name, "description": label.description} + for label in Label.get_project_labels(patch.project) + ] + + context['project_labels'] = project_labels context['comments'] = comments context['checks'] = Patch.filter_unique_checks( patch.check_set.all().select_related('user'), From 32a65730e8bb1a8c2cd261e6944f162aa0283d1b Mon Sep 17 00:00:00 2001 From: Franciszek Stachura Date: Mon, 2 Nov 2026 12:00:58 +0100 Subject: [PATCH 17/18] REST: Add modifying patch labels to API --- docs/api/schemas/latest/patchwork.yaml | 5 +++ docs/api/schemas/patchwork.j2 | 7 +++++ docs/api/schemas/v1.4/patchwork.yaml | 5 +++ patchwork/api/patch.py | 12 +++++-- patchwork/tests/unit/api/test_patch.py | 43 ++++++++++++++++++++++++++ 5 files changed, 69 insertions(+), 3 deletions(-) diff --git a/docs/api/schemas/latest/patchwork.yaml b/docs/api/schemas/latest/patchwork.yaml index f428ccad1..b26104841 100644 --- a/docs/api/schemas/latest/patchwork.yaml +++ b/docs/api/schemas/latest/patchwork.yaml @@ -2404,6 +2404,11 @@ components: type: array items: type: integer + labels: + title: Labels + type: array + items: + type: string Person: type: object title: Person diff --git a/docs/api/schemas/patchwork.j2 b/docs/api/schemas/patchwork.j2 index 1fa9eb155..0fd3975f4 100644 --- a/docs/api/schemas/patchwork.j2 +++ b/docs/api/schemas/patchwork.j2 @@ -2495,6 +2495,13 @@ components: type: array items: type: integer +{% endif %} +{% if version >= (1, 4) %} + labels: + title: Labels + type: array + items: + type: string {% endif %} Person: type: object diff --git a/docs/api/schemas/v1.4/patchwork.yaml b/docs/api/schemas/v1.4/patchwork.yaml index f2c625bc8..d2d1d68e9 100644 --- a/docs/api/schemas/v1.4/patchwork.yaml +++ b/docs/api/schemas/v1.4/patchwork.yaml @@ -2404,6 +2404,11 @@ components: type: array items: type: integer + labels: + title: Labels + type: array + items: + type: string Person: type: object title: Person diff --git a/patchwork/api/patch.py b/patchwork/api/patch.py index 407d9e718..3df4d848e 100644 --- a/patchwork/api/patch.py +++ b/patchwork/api/patch.py @@ -18,7 +18,7 @@ from rest_framework.reverse import reverse from rest_framework.serializers import SerializerMethodField from rest_framework import status -from rest_framework.serializers import StringRelatedField +from rest_framework.serializers import SlugRelatedField from patchwork.api.base import BaseHyperlinkedModelSerializer from patchwork.api.base import LinkHeaderPagination @@ -31,6 +31,7 @@ from patchwork.api.filters import PatchFilterSet from patchwork.models import Patch from patchwork.models import PatchRelation +from patchwork.models import Label from patchwork.models import State from patchwork.parser import clean_subject @@ -78,6 +79,12 @@ class PatchConflict(APIException): ) +class LabelsField(SlugRelatedField): + def get_queryset(self): + project = self.parent.parent.instance.project + return Label.get_project_labels(project) + + class PatchListSerializer(BaseHyperlinkedModelSerializer): web_url = SerializerMethodField() project = ProjectSerializer(read_only=True) @@ -96,7 +103,7 @@ class PatchListSerializer(BaseHyperlinkedModelSerializer): default=[], style={'base_template': 'input.html'}, ) - labels = StringRelatedField(many=True) + labels = LabelsField(many=True, slug_field='name') def get_web_url(self, instance): request = self.context.get('request') @@ -198,7 +205,6 @@ class Meta: 'check', 'checks', 'tags', - 'labels', ) versioned_fields = { '1.1': ('comments', 'web_url'), diff --git a/patchwork/tests/unit/api/test_patch.py b/patchwork/tests/unit/api/test_patch.py index 6caaeefe7..de05e8d60 100644 --- a/patchwork/tests/unit/api/test_patch.py +++ b/patchwork/tests/unit/api/test_patch.py @@ -529,6 +529,49 @@ def test_update_maintainer_version_1_0(self): self.assertEqual(Patch.objects.get(id=patch.id).state, state) self.assertEqual(Patch.objects.get(id=patch.id).delegate, user) + def test_update_labels(self): + """Update labels as maintainer.""" + project = create_project() + project2 = create_project() + label1 = create_label(name='label', project=project) + label2 = create_label(name='label2') + label_p2 = create_label(name='label_p2', project=project2) + label1_global = create_label(name='label', project=None) + patch = create_patch(project=project, labels=[label1_global]) + user = create_maintainer(project) + + # Test adding labels labels, make sure local labels are prioritized + + self.client.authenticate(user=user) + resp = self.client.patch( + self.api_url(patch.id), {'labels': [label1.name, label2.name]} + ) + self.assertEqual(status.HTTP_200_OK, resp.status_code, resp) + self.assertEqual( + sorted( + Patch.objects.get(id=patch.id).labels.values_list( + 'id', flat=True + ) + ), + sorted([label1.id, label2.id]), + ) + + # Test removing labels + + self.client.authenticate(user=user) + resp = self.client.patch(self.api_url(patch.id), {'labels': []}) + self.assertEqual(status.HTTP_200_OK, resp.status_code, resp) + self.assertEqual(Patch.objects.get(id=patch.id).labels.count(), 0) + + # Make sure labels from other projects cannot be added + + self.client.authenticate(user=user) + resp = self.client.patch( + self.api_url(patch.id), {'labels': [label1.name, label_p2.name]} + ) + self.assertEqual(status.HTTP_400_BAD_REQUEST, resp.status_code, resp) + self.assertEqual(Patch.objects.get(id=patch.id).labels.count(), 0) + @utils.store_samples('patch-update-error-bad-request') def test_update_invalid_state(self): """Update patch with invalid fields. From 446bd5cfe189515c1bd3f56e0e6323274dd76679 Mon Sep 17 00:00:00 2001 From: Franciszek Stachura Date: Mon, 2 Nov 2026 16:11:00 +0100 Subject: [PATCH 18/18] tests: Fix flaky series dependency test unit.test_series.SeriesDependencyTestCase.test_dependency_multi_1 sometimes fails on CI in py314-django52 environment with Postgres. I cannot reproduce this locally, but the issue could be related to unspecified ordering. --- patchwork/tests/unit/test_series.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/patchwork/tests/unit/test_series.py b/patchwork/tests/unit/test_series.py index cce754893..40a6d76df 100644 --- a/patchwork/tests/unit/test_series.py +++ b/patchwork/tests/unit/test_series.py @@ -1037,7 +1037,7 @@ def test_dependency_multi_1(self): self.assertEqual(series3.dependents.count(), 0) # Test that the ordering is correct. - d1, d2 = [dep for dep in series3.dependencies.all()] + d1, d2 = [dep for dep in series3.dependencies.order_by('pk')] # First item in the query set should be the first dependency. self.assertEqual(d1, series1)