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 @@

Search user

{%include "selectize_js.html" %} +{% endblock %} diff --git a/pgcommitfest/commitfest/templates/color_input.html b/pgcommitfest/commitfest/templates/color_input.html new file mode 100644 index 00000000..9e4c8cc1 --- /dev/null +++ b/pgcommitfest/commitfest/templates/color_input.html @@ -0,0 +1,2 @@ + + diff --git a/pgcommitfest/commitfest/templates/commitfest.html b/pgcommitfest/commitfest/templates/commitfest.html index 972e0bf8..8c8526a1 100644 --- a/pgcommitfest/commitfest/templates/commitfest.html +++ b/pgcommitfest/commitfest/templates/commitfest.html @@ -34,6 +34,7 @@

{{p.is_open|yesno:"Active patches,Closed patches"}}

Patch{%if sortkey == 5%}
{%elif sortkey == -5%}
{%endif%} ID{%if sortkey == 4%}
{%elif sortkey == -4%}
{%endif%} Status + Tags Ver CI status{%if sortkey == 7%}
{%elif sortkey == -7%}
{%endif%} Stats{%if sortkey == 6%}
{%elif sortkey == -6%}
{%endif%} @@ -59,6 +60,13 @@

{{p.is_open|yesno:"Active patches,Closed patches"}}

{{p.name}} {{p.id}} {{p.status|patchstatusstring}} + + {%for t in p.tag_ids%} + + {{all_tags|tagname:t}} + + {%endfor%} + {%if p.targetversion%}{{p.targetversion}}{%endif%} {%with p.cfbot_results as cfb%} diff --git a/pgcommitfest/commitfest/templates/me.html b/pgcommitfest/commitfest/templates/me.html index b6736124..bf4761b1 100644 --- a/pgcommitfest/commitfest/templates/me.html +++ b/pgcommitfest/commitfest/templates/me.html @@ -65,6 +65,13 @@

{%if user.is_authenticated%}Open patches you are subscribed to{%elif p.is_op {{p.cf_name}} {%endif%} {{p.status|patchstatusstring}} + + {%for t in p.tag_ids%} + + {{all_tags|tagname:t}} + + {%endfor%} + {%if p.targetversion%}{{p.targetversion}}{%endif%} {%with p.cfbot_results as cfb%} diff --git a/pgcommitfest/commitfest/templates/patch.html b/pgcommitfest/commitfest/templates/patch.html index b2a660e2..7fd12d3f 100644 --- a/pgcommitfest/commitfest/templates/patch.html +++ b/pgcommitfest/commitfest/templates/patch.html @@ -67,6 +67,14 @@ Topic {{patch.topic}} + + Tags + + {%for tag in patch.tags.all%} + {{tag.name}} + {%endfor%} + + Created {{patch.created}} diff --git a/pgcommitfest/commitfest/templates/selectize_js.html b/pgcommitfest/commitfest/templates/selectize_js.html index 7cd5fb7d..0991ac65 100644 --- a/pgcommitfest/commitfest/templates/selectize_js.html +++ b/pgcommitfest/commitfest/templates/selectize_js.html @@ -5,19 +5,21 @@ valueField: 'id', labelField: 'value', searchField: 'value', - load: function(query, callback) { - if (!query.length) return callback(); - $.ajax({ - 'url': '{{url}}', - 'type': 'GET', - 'dataType': 'json', - 'data': { - 'query': query, - }, - 'error': function() { callback();}, - 'success': function(res) { callback(res.values);}, - }); - }, + {%if url%} + load: function(query, callback) { + if (!query.length) return callback(); + $.ajax({ + 'url': '{{url}}', + 'type': 'GET', + 'dataType': 'json', + 'data': { + 'query': query, + }, + 'error': function() { callback();}, + 'success': function(res) { callback(res.values);}, + }); + }, + {%endif%} onFocus: function() { if (this.$input.is('[multiple]')) { return; diff --git a/pgcommitfest/commitfest/templatetags/commitfest.py b/pgcommitfest/commitfest/templatetags/commitfest.py index 25fd21f2..5c6d22e9 100644 --- a/pgcommitfest/commitfest/templatetags/commitfest.py +++ b/pgcommitfest/commitfest/templatetags/commitfest.py @@ -6,6 +6,7 @@ from django.utils.translation import ngettext_lazy import datetime +import string from uuid import uuid4 from pgcommitfest.commitfest.models import CommitFest, PatchOnCommitFest @@ -41,6 +42,58 @@ def patchstatuslabel(value): return [v for k, v in PatchOnCommitFest._STATUS_LABELS if k == i][0] +@register.filter(name="tagname") +def tagname(value, arg): + """ + Looks up a tag by ID and returns its name. The filter value is the map of + tags, and the argument is the ID. (Unlike tagcolor, there is no + argument-less variant; just use tag.name directly.) + + Example: + tag_map|tagname:tag_id + """ + return value[arg].name + + +@register.filter(name="tagdescription") +def tagdescription(value, arg): + """ + Looks up a tag by ID and returns its name. The filter value is the map of + tags, and the argument is the ID. (Unlike tagcolor, there is no + argument-less variant; just use tag.name directly.) + + Example: + tag_map|tagname:tag_id + """ + return value[arg].description + + +@register.filter(name="tagcolor") +def tagcolor(value, key=None): + """ + Returns the color code of a tag. The filter value is either a single tag, in + which case no argument should be given, or a map of tags with the tag ID as + the argument, as with the tagname filter. + + Since color codes are injected into CSS, any nonconforming inputs are + replaced with black here. (Prefer `tag|tagcolor` over `tag.color` in + templates, for this reason.) + """ + if key is not None: + code = value[key].color + else: + code = value.color + + if ( + len(code) == 7 + and code.startswith("#") + and all(c in string.hexdigits for c in code[1:]) + ): + return code + + return "#000000" + + @register.filter(is_safe=True) def label_class(value, arg): return value.label_tag(attrs={"class": arg}) diff --git a/pgcommitfest/commitfest/views.py b/pgcommitfest/commitfest/views.py index 29bcb59a..4b3d12c8 100644 --- a/pgcommitfest/commitfest/views.py +++ b/pgcommitfest/commitfest/views.py @@ -42,6 +42,7 @@ Patch, PatchHistory, PatchOnCommitFest, + Tag, UserInputError, ) @@ -87,7 +88,13 @@ def help(request): @login_required +@transaction.atomic def me(request): + curs = connection.cursor() + # Make sure the patchlist() query, the stats query and, Tag.objects.all() + # all work on the same snapshot. Needs to be first in the + # transaction.atomic decorator. + curs.execute("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ") cfs = list(CommitFest.objects.filter(status=CommitFest.STATUS_INPROGRESS)) if len(cfs) == 0: cfs = list(CommitFest.objects.filter(status=CommitFest.STATUS_OPEN)) @@ -107,7 +114,6 @@ def me(request): return patch_list.redirect # Get stats related to user for current commitfest - curs = connection.cursor() curs.execute( """SELECT ps.status, ps.statusstring, count(*) @@ -141,6 +147,7 @@ def me(request): "title": "Personal Dashboard", "patches": patch_list.patches, "statussummary": statussummary, + "all_tags": {t.id: t for t in Tag.objects.all()}, "has_filter": patch_list.has_filter, "grouping": patch_list.sortkey == 0, "sortkey": patch_list.sortkey, @@ -283,6 +290,21 @@ def patchlist(request, cf, personalized=False): # int() failed, ignore pass + if request.GET.getlist("tag") != []: + try: + tag_ids = [int(t) for t in request.GET.getlist("tag")] + for tag_id in tag_ids: + # Instead of using parameters, we just inline the tag_id. This + # is easier because we have can have multiple tags, and since + # tag_id is always an int it's safe with respect to SQL + # injection. + whereclauses.append( + f"EXISTS (SELECT 1 FROM commitfest_patch_tags tags WHERE tags.patch_id=p.id AND tags.tag_id={tag_id})" + ) + except ValueError: + # int() failed -- so just ignore this filter + pass + if request.GET.get("author", "-1") != "-1": if request.GET["author"] == "-2": whereclauses.append( @@ -506,6 +528,7 @@ def patchlist(request, cf, personalized=False): (SELECT string_agg(first_name || ' ' || last_name || ' (' || username || ')', ', ') FROM auth_user INNER JOIN commitfest_patch_authors cpa ON cpa.user_id=auth_user.id WHERE cpa.patch_id=p.id) AS author_names, (SELECT string_agg(first_name || ' ' || last_name || ' (' || username || ')', ', ') FROM auth_user INNER JOIN commitfest_patch_reviewers cpr ON cpr.user_id=auth_user.id WHERE cpr.patch_id=p.id) AS reviewer_names, (SELECT count(1) FROM commitfest_patchoncommitfest pcf WHERE pcf.patch_id=p.id) AS num_cfs, +(SELECT array_agg(tag_id) FROM commitfest_patch_tags t WHERE t.patch_id=p.id) AS tag_ids, branch.needs_rebase_since, branch.failing_since, @@ -552,7 +575,13 @@ def patchlist(request, cf, personalized=False): ) +@transaction.atomic def commitfest(request, cfid): + curs = connection.cursor() + # Make sure the patchlist() query, the stats query and, Tag.objects.all() + # all work on the same snapshot. Needs to be first in the + # transaction.atomic decorator. + curs.execute("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ") # Find ourselves cf = get_object_or_404(CommitFest, pk=cfid) @@ -561,7 +590,6 @@ def commitfest(request, cfid): return patch_list.redirect # Generate patch status summary. - curs = connection.cursor() curs.execute( "SELECT ps.status, ps.statusstring, count(*) FROM commitfest_patchoncommitfest poc INNER JOIN commitfest_patchstatus ps ON ps.status=poc.status WHERE commitfest_id=%(id)s GROUP BY ps.status ORDER BY ps.sortkey", { @@ -583,6 +611,7 @@ def commitfest(request, cfid): "form": form, "patches": patch_list.patches, "statussummary": statussummary, + "all_tags": {t.id: t for t in Tag.objects.all()}, "has_filter": patch_list.has_filter, "title": cf.title, "grouping": patch_list.sortkey == 0, @@ -755,10 +784,14 @@ def patchform(request, patchid): # Track all changes for field, values in r.diff.items(): + if field == "tags": + value = ", ".join(v.name for v in values[1]) + else: + value = values[1] PatchHistory( patch=patch, by=request.user, - what="Changed %s to %s" % (field, values[1]), + what="Changed %s to %s" % (field, value), ).save_and_notify( prevcommitter=prevcommitter, prevreviewers=prevreviewers,