diff --git a/biome.json b/biome.json index 1c3d6648..4e121e69 100644 --- a/biome.json +++ b/biome.json @@ -10,6 +10,7 @@ "ignore": [], "include": [ "media/commitfest/js/commitfest.js", + "media/commitfest/js/change_tag.js", "media/commitfest/css/commitfest.css", "biome.json" ] diff --git a/media/commitfest/js/change_tag.js b/media/commitfest/js/change_tag.js new file mode 100644 index 00000000..89277837 --- /dev/null +++ b/media/commitfest/js/change_tag.js @@ -0,0 +1,45 @@ +// An input validator for the color picker. Points out low-contrast tag color +// choices. +const inputs = document.getElementsByClassName("color-picker"); +for (let i = 0; i < inputs.length; i++) { + inputs[i].addEventListener("change", (event) => { + // Don't do anything if the color code doesn't pass default validity. + const element = event.target; + element.setCustomValidity(""); + if (!element.validity.valid) { + return; + } + + // Break the #rrggbb color code into RGB components. + color = Number.parseInt(element.value.substr(1), 16); + red = ((color & 0xff0000) >> 16) / 255; + green = ((color & 0x00ff00) >> 8) / 255; + blue = (color & 0x0000ff) / 255; + + // Compare the contrast ratio against white. All the magic math comes from + // Web Content Accessibility Guidelines (WCAG) 2.2, Technique G18: + // + // https://www.w3.org/WAI/WCAG22/Techniques/general/G18.html + // + function l(val) { + if (val <= 0.04045) { + return val / 12.92; + } + return ((val + 0.055) / 1.055) ** 2.4; + } + + lum = 0.2126 * l(red) + 0.7152 * l(green) + 0.0722 * l(blue); + contrast = (1 + 0.05) / (lum + 0.05); + + // Complain if we're below WCAG 2.2 recommendations. + if (contrast < 4.5) { + element.setCustomValidity( + `Consider choosing a darker color. (Tag text is small and white.)\n\nContrast ratio: ${Math.trunc(contrast * 10) / 10} (< 4.5)`, + ); + + // The admin form uses novalidate, so manually display the browser's + // validity popup. (The user can still ignore it if desired.) + element.reportValidity(); + } + }); +} diff --git a/pgcommitfest/commitfest/admin.py b/pgcommitfest/commitfest/admin.py index 8c8d62e5..90d54c4a 100644 --- a/pgcommitfest/commitfest/admin.py +++ b/pgcommitfest/commitfest/admin.py @@ -1,8 +1,10 @@ from django.contrib import admin +from django.forms import widgets from .models import ( CfbotBranch, CfbotTask, + ColorField, CommitFest, Committer, MailThread, @@ -10,6 +12,7 @@ Patch, PatchHistory, PatchOnCommitFest, + Tag, TargetVersion, Topic, ) @@ -38,8 +41,26 @@ class MailThreadAttachmentAdmin(admin.ModelAdmin): ) +class ColorInput(widgets.Input): + """ + A color picker widget. + """ + + input_type = "color" + template_name = "color_input.html" + + +class TagAdmin(admin.ModelAdmin): + # Customize the Tag form with a color picker and soft validation. + change_form_template = "change_tag_form.html" + formfield_overrides = { + ColorField: {"widget": ColorInput}, + } + + admin.site.register(Committer, CommitterAdmin) admin.site.register(CommitFest) +admin.site.register(Tag, TagAdmin) admin.site.register(Topic) admin.site.register(Patch, PatchAdmin) admin.site.register(PatchHistory) diff --git a/pgcommitfest/commitfest/fixtures/commitfest_data.json b/pgcommitfest/commitfest/fixtures/commitfest_data.json index d51214dc..39f577f4 100644 --- a/pgcommitfest/commitfest/fixtures/commitfest_data.json +++ b/pgcommitfest/commitfest/fixtures/commitfest_data.json @@ -92,6 +92,267 @@ "version": "18" } }, +{ + "model": "commitfest.tag", + "pk": 1, + "fields": { + "name": "Bugfix", + "color": "#a51d2d", + "description": "Fixes a bug" + } +}, +{ + "model": "commitfest.tag", + "pk": 2, + "fields": { + "name": "Backport", + "color": "#3d3846", + "description": "Once merged should be backported to old branches" + } +}, +{ + "model": "commitfest.tag", + "pk": 3, + "fields": { + "name": "Missing Tests", + "color": "#c66424", + "description": "Author should add tests" + } +}, +{ + "model": "commitfest.tag", + "pk": 4, + "fields": { + "name": "Missing Docs", + "color": "#c66424", + "description": "Author should add documentation" + } +}, +{ + "model": "commitfest.tag", + "pk": 5, + "fields": { + "name": "Missing Benchmarks", + "color": "#c66424", + "description": "Author should do additional benchmarks" + } +}, +{ + "model": "commitfest.tag", + "pk": 6, + "fields": { + "name": "Help - User Testing", + "color": "#07732e", + "description": "Reviewers are requested to try out the patch and provide user feedback on UX/UI" + } +}, +{ + "model": "commitfest.tag", + "pk": 7, + "fields": { + "name": "Help - Bikeshedding", + "color": "#07732e", + "description": "Reviewers are requested to propose or vote on stylistic changes like a user facing function name" + } +}, +{ + "model": "commitfest.tag", + "pk": 8, + "fields": { + "name": "Help - Docs", + "color": "#07732e", + "description": "Reviewers are requested to help review or write documentation" + } +}, +{ + "model": "commitfest.tag", + "pk": 9, + "fields": { + "name": "Help - Benchmarks", + "color": "#07732e", + "description": "Reviewers are requested to help discuss or do benchmarks and discuss performance impact" + } +}, +{ + "model": "commitfest.tag", + "pk": 10, + "fields": { + "name": "Good First Review", + "color": "#613583", + "description": "An easy to review patch for a new reviewer" + } +}, +{ + "model": "commitfest.tag", + "pk": 11, + "fields": { + "name": "libpq", + "color": "#1a5fb4", + "description": "Makes significant libpq changes" + } +}, +{ + "model": "commitfest.tag", + "pk": 12, + "fields": { + "name": "psql", + "color": "#1a5fb4", + "description": "Makes significant psql changes" + } +}, +{ + "model": "commitfest.tag", + "pk": 13, + "fields": { + "name": "Performance", + "color": "#1a5fb4", + "description": "Improves performance" + } +}, +{ + "model": "commitfest.tag", + "pk": 14, + "fields": { + "name": "Logical Replication", + "color": "#1a5fb4", + "description": "Makes significant logical replication changes" + } +}, +{ + "model": "commitfest.tag", + "pk": 15, + "fields": { + "name": "Physical Replication", + "color": "#1a5fb4", + "description": "Makes significant physical replication changes" + } +}, +{ + "model": "commitfest.tag", + "pk": 16, + "fields": { + "name": "Refactoring Only", + "color": "#1a5fb4", + "description": "Only refactores code without changing functionality" + } +}, +{ + "model": "commitfest.tag", + "pk": 17, + "fields": { + "name": "Comments Only", + "color": "#1a5fb4", + "description": "Only updates code comments" + } +}, +{ + "model": "commitfest.tag", + "pk": 18, + "fields": { + "name": "Docs Only", + "color": "#1a5fb4", + "description": "Only updates user facing documentation" + } +}, +{ + "model": "commitfest.tag", + "pk": 19, + "fields": { + "name": "CI", + "color": "#1a5fb4", + "description": "Makes changes to the CI configuration or scripts" + } +}, +{ + "model": "commitfest.tag", + "pk": 20, + "fields": { + "name": "Testing", + "color": "#1a5fb4", + "description": "Adds/modifies tests or improves our testing frameworks" + } +}, +{ + "model": "commitfest.tag", + "pk": 21, + "fields": { + "name": "Monitoring", + "color": "#1a5fb4", + "description": "Adds new monitoring features or improves existing ones" + } +}, +{ + "model": "commitfest.tag", + "pk": 22, + "fields": { + "name": "GUC", + "color": "#1a5fb4", + "description": "Adds/modifies configuration parameters (GUCs)" + } +}, +{ + "model": "commitfest.tag", + "pk": 23, + "fields": { + "name": "PL/pgSQL", + "color": "#1a5fb4", + "description": "Involves changes to PL/pgSQL" + } +}, +{ + "model": "commitfest.tag", + "pk": 24, + "fields": { + "name": "PL/Python", + "color": "#1a5fb4", + "description": "Involves changes to PL/Python" + } +}, +{ + "model": "commitfest.tag", + "pk": 25, + "fields": { + "name": "PL/Perl", + "color": "#1a5fb4", + "description": "Involves changes to PL/Perl" + } +}, +{ + "model": "commitfest.tag", + "pk": 26, + "fields": { + "name": "PL/Tcl", + "color": "#1a5fb4", + "description": "Involves changes to PL/Perl" + } +}, +{ + "model": "commitfest.tag", + "pk": 27, + "fields": { + "name": "dblink", + "color": "#1a5fb4", + "description": "Involves changes to dblink" + } +}, +{ + "model": "commitfest.tag", + "pk": 28, + "fields": { + "name": "postgres_fdw", + "color": "#1a5fb4", + "description": "Involves changes to postgres_fdw" + } +}, +{ + "model": "commitfest.tag", + "pk": 29, + "fields": { + "name": "Security", + "color": "#a51d2d", + "description": "Improves security" + } +}, { "model": "commitfest.patch", "pk": 1, @@ -103,8 +364,15 @@ "targetversion": null, "committer": null, "created": "2025-01-26T10:48:31.579", - "modified": "2025-01-26T10:53:20.498", + "modified": "2025-06-15T21:26:10.549", "lastmail": "2025-01-20T06:53:39", + "tags": [ + 2, + 1, + 9, + 6, + 4 + ], "authors": [ 1 ], @@ -126,8 +394,13 @@ "targetversion": null, "committer": null, "created": "2025-01-26T10:51:17.305", - "modified": "2025-01-26T10:51:19.631", + "modified": "2025-06-15T21:26:24.284", "lastmail": "2025-01-20T14:20:10", + "tags": [ + 10, + 7, + 6 + ], "authors": [], "reviewers": [], "subscribers": [], @@ -149,6 +422,7 @@ "created": "2025-01-26T11:02:07.467", "modified": "2025-01-26T11:02:10.911", "lastmail": "2025-01-20T13:26:55", + "tags": [], "authors": [], "reviewers": [], "subscribers": [], @@ -168,8 +442,11 @@ "targetversion": null, "committer": null, "created": "2025-01-31T13:30:19.744", - "modified": "2025-01-31T13:30:21.305", + "modified": "2025-06-15T21:27:56.667", "lastmail": "2025-01-20T12:44:40", + "tags": [ + 1 + ], "authors": [], "reviewers": [], "subscribers": [], @@ -191,6 +468,7 @@ "created": "2025-02-16T21:59:04.131", "modified": "2025-02-16T22:03:24.902", "lastmail": "2025-01-20T14:01:53", + "tags": [], "authors": [], "reviewers": [], "subscribers": [], @@ -212,6 +490,7 @@ "created": "2025-02-16T22:03:58.476", "modified": "2025-02-16T22:04:23.180", "lastmail": "2025-01-19T23:55:17", + "tags": [], "authors": [], "reviewers": [], "subscribers": [], @@ -233,6 +512,7 @@ "created": "2025-03-01T22:27:53.214", "modified": "2025-03-01T22:27:53.221", "lastmail": "2025-01-18T07:14:02", + "tags": [], "authors": [], "reviewers": [], "subscribers": [], @@ -254,6 +534,7 @@ "created": "2025-02-01T00:00:00", "modified": "2025-02-01T00:00:00", "lastmail": "2025-02-01T00:00:00", + "tags": [], "authors": [ 3, 6 @@ -576,6 +857,39 @@ "what": "Attached mail thread example@message-31" } }, +{ + "model": "commitfest.patchhistory", + "pk": 20, + "fields": { + "patch": 1, + "date": "2025-06-15T21:26:10.546", + "by": 1, + "by_cfbot": false, + "what": "Changed tags to Backport, Bugfix, Help - Benchmarks, Help - User Testing, Missing Docs" + } +}, +{ + "model": "commitfest.patchhistory", + "pk": 21, + "fields": { + "patch": 2, + "date": "2025-06-15T21:26:24.282", + "by": 1, + "by_cfbot": false, + "what": "Changed tags to Good First Review, Help Bikeshedding, Help - User Testing" + } +}, +{ + "model": "commitfest.patchhistory", + "pk": 22, + "fields": { + "patch": 4, + "date": "2025-06-15T21:27:56.664", + "by": 1, + "by_cfbot": false, + "what": "Changed tags to Bugfix" + } +}, { "model": "commitfest.mailthread", "pk": 1, diff --git a/pgcommitfest/commitfest/forms.py b/pgcommitfest/commitfest/forms.py index c3b9a18d..2ec9fc2c 100644 --- a/pgcommitfest/commitfest/forms.py +++ b/pgcommitfest/commitfest/forms.py @@ -5,7 +5,7 @@ from django.http import Http404 from .ajax import _archivesAPI -from .models import MailThread, Patch, PatchOnCommitFest, TargetVersion +from .models import MailThread, Patch, PatchOnCommitFest, Tag, TargetVersion from .widgets import ThreadPickWidget @@ -13,11 +13,13 @@ class CommitFestFilterForm(forms.Form): selectize_fields = { "author": "/lookups/user", "reviewer": "/lookups/user", + "tag": None, } text = forms.CharField(max_length=50, required=False) status = forms.ChoiceField(required=False) targetversion = forms.ChoiceField(required=False) + tag = forms.MultipleChoiceField(required=False, label="Tag (type to search)") author = forms.ChoiceField(required=False, label="Author (type to search)") reviewer = forms.ChoiceField(required=False, label="Reviewer (type to search)") sortkey = forms.IntegerField(required=False) @@ -59,6 +61,7 @@ def __init__(self, data, *args, **kwargs): ) self.fields["author"].choices = userchoices self.fields["reviewer"].choices = userchoices + self.fields["tag"].choices = list(Tag.objects.all().values_list("id", "name")) for f in ( "status", @@ -72,6 +75,7 @@ class PatchForm(forms.ModelForm): selectize_fields = { "authors": "/lookups/user", "reviewers": "/lookups/user", + "tags": None, } class Meta: @@ -94,8 +98,14 @@ def __init__(self, *args, **kwargs): x.user.username, ) + self.fields["authors"].widget.attrs["class"] = "add-user-picker" + self.fields["reviewers"].widget.attrs["class"] = "add-user-picker" + # Selectize multiple fields -- don't pre-populate everything for field, url in list(self.selectize_fields.items()): + if url is None: + continue + # If this is a postback of a selectize field, it may contain ids that are not currently # stored in the field. They must still be among the *allowed* values of course, which # are handled by the existing queryset on the field. diff --git a/pgcommitfest/commitfest/migrations/0013_tag_patch_tags.py b/pgcommitfest/commitfest/migrations/0013_tag_patch_tags.py new file mode 100644 index 00000000..3bc89517 --- /dev/null +++ b/pgcommitfest/commitfest/migrations/0013_tag_patch_tags.py @@ -0,0 +1,187 @@ +# Generated by Django 4.2.19 on 2025-05-30 18:09 + +from django.db import migrations, models + +import pgcommitfest.commitfest.models + + +def add_initial_tags(apps, schema_editor): + Tag = apps.get_model("commitfest", "Tag") + Tag.objects.bulk_create( + [ + Tag(name="Bugfix", color="#a51d2d", description="Fixes a bug"), + Tag( + name="Backport", + color="#3d3846", + description="Once merged should be backported to old branches", + ), + Tag( + name="Missing Tests", + color="#c66424", + description="Author should add tests", + ), + Tag( + name="Missing Docs", + color="#c66424", + description="Author should add documentation", + ), + Tag( + name="Missing Benchmarks", + color="#c66424", + description="Author should do additional benchmarks", + ), + Tag( + name="Help - User Testing", + color="#07732e", + description="Reviewers are requested to try out the patch and provide user feedback on UX/UI", + ), + Tag( + name="Help - Bikeshedding", + color="#07732e", + description="Reviewers are requested to propose or vote on stylistic changes like a user facing function name", + ), + Tag( + name="Help - Docs", + color="#07732e", + description="Reviewers are requested to help review or write documentation", + ), + Tag( + name="Help - Benchmarks", + color="#07732e", + description="Reviewers are requested to help discuss or do benchmarks and discuss performance impact", + ), + Tag( + name="Good First Review", + color="#613583", + description="An easy to review patch for a new reviewer", + ), + Tag( + name="libpq", + color="#1a5fb4", + description="Makes significant libpq changes", + ), + Tag( + name="psql", + color="#1a5fb4", + description="Makes significant psql changes", + ), + Tag( + name="Performance", + color="#1a5fb4", + description="Improves performance", + ), + Tag( + name="Logical Replication", + color="#1a5fb4", + description="Makes significant logical replication changes", + ), + Tag( + name="Physical Replication", + color="#1a5fb4", + description="Makes significant physical replication changes", + ), + Tag( + name="Refactoring Only", + color="#1a5fb4", + description="Only refactores code without changing functionality", + ), + Tag( + name="Comments Only", + color="#1a5fb4", + description="Only updates code comments", + ), + Tag( + name="Docs Only", + color="#1a5fb4", + description="Only updates user facing documentation", + ), + Tag( + name="CI", + color="#1a5fb4", + description="Makes changes to the CI configuration or scripts", + ), + Tag( + name="Testing", + color="#1a5fb4", + description="Adds/modifies tests or improves our testing frameworks", + ), + Tag( + name="Monitoring", + color="#1a5fb4", + description="Adds new monitoring features or improves existing ones", + ), + Tag( + name="GUC", + color="#1a5fb4", + description="Adds/modifies configuration parameters (GUCs)", + ), + Tag( + name="PL/pgSQL", + color="#1a5fb4", + description="Involves changes to PL/pgSQL", + ), + Tag( + name="PL/Python", + color="#1a5fb4", + description="Involves changes to PL/Python", + ), + Tag( + name="PL/Perl", + color="#1a5fb4", + description="Involves changes to PL/Perl", + ), + Tag( + name="PL/Tcl", + color="#1a5fb4", + description="Involves changes to PL/Perl", + ), + Tag( + name="dblink", + color="#1a5fb4", + description="Involves changes to dblink", + ), + Tag( + name="postgres_fdw", + color="#1a5fb4", + description="Involves changes to postgres_fdw", + ), + Tag(name="Security", color="#a51d2d", description="Improves security"), + ] + ) + + +class Migration(migrations.Migration): + dependencies = [ + ("commitfest", "0012_add_status_related_constraints"), + ] + + operations = [ + migrations.CreateModel( + name="Tag", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=50, unique=True)), + ("color", pgcommitfest.commitfest.models.ColorField(max_length=7)), + ("description", models.CharField(max_length=500)), + ], + options={ + "ordering": ("name",), + }, + ), + migrations.AddField( + model_name="patch", + name="tags", + field=models.ManyToManyField( + blank=True, related_name="patches", to="commitfest.tag" + ), + ), + migrations.RunPython(add_initial_tags, migrations.RunPython.noop), + ] diff --git a/pgcommitfest/commitfest/models.py b/pgcommitfest/commitfest/models.py index ec417723..e27bf981 100644 --- a/pgcommitfest/commitfest/models.py +++ b/pgcommitfest/commitfest/models.py @@ -300,6 +300,32 @@ def __str__(self): return self.version +class ColorField(models.CharField): + """ + A small wrapper around a CharField that can hold a #RRGGBB color code. The + primary reason to have this wrapper class is so that the TagAdmin class can + explicitly key off of it to inject a color picker in the admin interface. + """ + + def __init__(self, *args, **kwargs): + kwargs["max_length"] = 7 # for `#RRGGBB` format + super().__init__(*args, **kwargs) + + +class Tag(models.Model): + """Represents a tag/label on a patch.""" + + name = models.CharField(max_length=50, unique=True) + color = ColorField() + description = models.CharField(max_length=500) + + class Meta: + ordering = ("name",) + + def __str__(self): + return self.name + + class UserInputError(ValueError): pass @@ -309,6 +335,7 @@ class Patch(models.Model, DiffableModel): max_length=500, blank=False, null=False, verbose_name="Description" ) topic = models.ForeignKey(Topic, blank=False, null=False, on_delete=models.CASCADE) + tags = models.ManyToManyField(Tag, related_name="patches", blank=True) # One patch can be in multiple commitfests, if it has history commitfests = models.ManyToManyField(CommitFest, through="PatchOnCommitFest") diff --git a/pgcommitfest/commitfest/templates/base_form.html b/pgcommitfest/commitfest/templates/base_form.html index 40518ca9..788627a9 100644 --- a/pgcommitfest/commitfest/templates/base_form.html +++ b/pgcommitfest/commitfest/templates/base_form.html @@ -75,7 +75,7 @@