Skip to content

Commit c0b0dc7

Browse files
committed
Add Tags for patches
A Tag is an arbitrary label for a patch in the Commitfest UI. Other than helping users identify patches of interest, it has no other semantic meaning to the CF application. Tags are created using the administrator interface. They consist of a unique name and a background color. The color should be sent from compliant browsers in #rrggbb format, which is stored without backend validation; to avoid CSS injection, any non-conforming values are replaced with black during templating.
1 parent d17a0a8 commit c0b0dc7

File tree

7 files changed

+142
-0
lines changed

7 files changed

+142
-0
lines changed

pgcommitfest/commitfest/admin.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
from django.contrib import admin
2+
from django.forms import widgets
23

34
from .models import (
45
CfbotBranch,
56
CfbotTask,
7+
ColorField,
68
CommitFest,
79
Committer,
810
MailThread,
911
MailThreadAttachment,
1012
Patch,
1113
PatchHistory,
1214
PatchOnCommitFest,
15+
Tag,
1316
TargetVersion,
1417
Topic,
1518
)
@@ -38,8 +41,24 @@ class MailThreadAttachmentAdmin(admin.ModelAdmin):
3841
)
3942

4043

44+
class ColorInput(widgets.Input):
45+
"""
46+
A color picker widget.
47+
TODO: this will be natively available in Django 5.2.
48+
"""
49+
50+
input_type = "color"
51+
52+
53+
class TagAdmin(admin.ModelAdmin):
54+
formfield_overrides = {
55+
ColorField: {"widget": ColorInput},
56+
}
57+
58+
4159
admin.site.register(Committer, CommitterAdmin)
4260
admin.site.register(CommitFest)
61+
admin.site.register(Tag, TagAdmin)
4362
admin.site.register(Topic)
4463
admin.site.register(Patch, PatchAdmin)
4564
admin.site.register(PatchHistory)
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Generated by Django 4.2.19 on 2025-05-30 18:09
2+
3+
from django.db import migrations, models
4+
import pgcommitfest.commitfest.models
5+
6+
7+
class Migration(migrations.Migration):
8+
dependencies = [
9+
("commitfest", "0010_add_failing_since_column"),
10+
]
11+
12+
operations = [
13+
migrations.CreateModel(
14+
name="Tag",
15+
fields=[
16+
(
17+
"id",
18+
models.AutoField(
19+
auto_created=True,
20+
primary_key=True,
21+
serialize=False,
22+
verbose_name="ID",
23+
),
24+
),
25+
("name", models.CharField(max_length=50, unique=True)),
26+
("color", pgcommitfest.commitfest.models.ColorField(max_length=7)),
27+
],
28+
options={
29+
"ordering": ("name",),
30+
},
31+
),
32+
migrations.AddField(
33+
model_name="patch",
34+
name="tags",
35+
field=models.ManyToManyField(
36+
blank=True, related_name="patches", to="commitfest.tag"
37+
),
38+
),
39+
]

pgcommitfest/commitfest/models.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,11 +102,37 @@ def __str__(self):
102102
return self.version
103103

104104

105+
class ColorField(models.CharField):
106+
"""
107+
A small wrapper around a CharField that can hold a #RRGGBB color code. The
108+
primary reason to have this wrapper class is so that the TagAdmin class can
109+
explicitly key off of it to inject a color picker in the admin interface.
110+
"""
111+
112+
def __init__(self, *args, **kwargs):
113+
kwargs["max_length"] = 7 # for `#RRGGBB` format
114+
super().__init__(*args, **kwargs)
115+
116+
117+
class Tag(models.Model):
118+
"""Represents a tag/label on a patch."""
119+
120+
name = models.CharField(max_length=50, unique=True)
121+
color = ColorField()
122+
123+
class Meta:
124+
ordering = ("name",)
125+
126+
def __str__(self):
127+
return self.name
128+
129+
105130
class Patch(models.Model, DiffableModel):
106131
name = models.CharField(
107132
max_length=500, blank=False, null=False, verbose_name="Description"
108133
)
109134
topic = models.ForeignKey(Topic, blank=False, null=False, on_delete=models.CASCADE)
135+
tags = models.ManyToManyField(Tag, related_name="patches", blank=True)
110136

111137
# One patch can be in multiple commitfests, if it has history
112138
commitfests = models.ManyToManyField(CommitFest, through="PatchOnCommitFest")

pgcommitfest/commitfest/templates/commitfest.html

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ <h3>{{p.is_open|yesno:"Active patches,Closed patches"}}</h3>
3434
<th><a href="#" style="color:#333333;" onclick="return sortpatches(5);">Patch</a>{%if sortkey == 5%}<div style="float:right;"><i class="glyphicon glyphicon-arrow-down"></i></div>{%elif sortkey == -5%}<div style="float:right;"><i class="glyphicon glyphicon-arrow-up"></i></div>{%endif%}</th>
3535
<th><a href="#" style="color:#333333;" onclick="return sortpatches(4);">ID</a>{%if sortkey == 4%}<div style="float:right;"><i class="glyphicon glyphicon-arrow-down"></i></div>{%elif sortkey == -4%}<div style="float:right;"><i class="glyphicon glyphicon-arrow-up"></i></div>{%endif%}</th>
3636
<th>Status</th>
37+
<th>Tags</th>
3738
<th>Ver</th>
3839
<th><a href="#" style="color:#333333;" onclick="return sortpatches(7);">CI status</a>{%if sortkey == 7%}<div style="float:right;"><i class="glyphicon glyphicon-arrow-down"></i></div>{%elif sortkey == -7%}<div style="float:right;"><i class="glyphicon glyphicon-arrow-up"></i></div>{%endif%}</th>
3940
<th><a href="#" style="color:#333333;" onclick="return sortpatches(6);">Stats</a>{%if sortkey == 6%}<div style="float:right;"><i class="glyphicon glyphicon-arrow-down"></i></div>{%elif sortkey == -6%}<div style="float:right;"><i class="glyphicon glyphicon-arrow-up"></i></div>{%endif%}</th>
@@ -59,6 +60,11 @@ <h3>{{p.is_open|yesno:"Active patches,Closed patches"}}</h3>
5960
<td><a href="/patch/{{p.id}}/">{{p.name}}</a></td>
6061
<td>{{p.id}}</td>
6162
<td><span class="label label-{{p.status|patchstatuslabel}}">{{p.status|patchstatusstring}}</span></td>
63+
<td style="width: min-content;">
64+
{%for t in p.tag_ids%}
65+
<span class="label" style="background: {{all_tags|tagcolor:t}};">{{all_tags|tagname:t}}</span>
66+
{%endfor%}
67+
</td>
6268
<td>{%if p.targetversion%}<span class="label label-default">{{p.targetversion}}</span>{%endif%}</td>
6369
<td class="cfbot-summary">
6470
{%with p.cfbot_results as cfb%}

pgcommitfest/commitfest/templates/patch.html

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,14 @@
6767
<th>Topic</th>
6868
<td>{{patch.topic}}</td>
6969
</tr>
70+
<tr>
71+
<th>Tags</th>
72+
<td>
73+
{%for tag in patch.tags.all%}
74+
<span class="label" style="background: {{tag|tagcolor}};">{{tag.name}}</span>
75+
{%endfor%}
76+
</td>
77+
</tr>
7078
<tr>
7179
<th>Created</th>
7280
<td>{{patch.created}}</td>

pgcommitfest/commitfest/templatetags/commitfest.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from django.utils.translation import ngettext_lazy
77

88
import datetime
9+
import string
910
from uuid import uuid4
1011

1112
from pgcommitfest.commitfest.models import CommitFest, PatchOnCommitFest
@@ -41,6 +42,45 @@ def patchstatuslabel(value):
4142
return [v for k, v in PatchOnCommitFest._STATUS_LABELS if k == i][0]
4243

4344

45+
@register.filter(name="tagname")
46+
def tagname(value, arg):
47+
"""
48+
Looks up a tag by ID and returns its name. The filter value is the map of
49+
tags, and the argument is the ID. (Unlike tagcolor, there is no
50+
argument-less variant; just use tag.name directly.)
51+
52+
Example:
53+
tag_map|tagname:tag_id
54+
"""
55+
return value[arg].name
56+
57+
58+
@register.filter(name="tagcolor")
59+
def tagcolor(value, key=None):
60+
"""
61+
Returns the color code of a tag. The filter value is either a single tag, in
62+
which case no argument should be given, or a map of tags with the tag ID as
63+
the argument, as with the tagname filter.
64+
65+
Since color codes are injected into CSS, any nonconforming inputs are
66+
replaced with black here. (Prefer `tag|tagcolor` over `tag.color` in
67+
templates, for this reason.)
68+
"""
69+
if key is not None:
70+
code = value[key].color
71+
else:
72+
code = value.color
73+
74+
if (
75+
len(code) == 7
76+
and code.startswith("#")
77+
and all(c in string.hexdigits for c in code[1:])
78+
):
79+
return code
80+
81+
return "#000000"
82+
83+
4484
@register.filter(is_safe=True)
4585
def label_class(value, arg):
4686
return value.label_tag(attrs={"class": arg})

pgcommitfest/commitfest/views.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
Patch,
4343
PatchHistory,
4444
PatchOnCommitFest,
45+
Tag,
4546
)
4647

4748

@@ -485,6 +486,7 @@ def patchlist(request, cf, personalized=False):
485486
(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,
486487
(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,
487488
(SELECT count(1) FROM commitfest_patchoncommitfest pcf WHERE pcf.patch_id=p.id) AS num_cfs,
489+
(SELECT array_agg(tag_id) FROM commitfest_patch_tags t WHERE t.patch_id=p.id) AS tag_ids,
488490
489491
branch.needs_rebase_since,
490492
branch.failing_since,
@@ -531,6 +533,7 @@ def patchlist(request, cf, personalized=False):
531533
)
532534

533535

536+
@transaction.atomic # tie the patchlist() query to Tag.objects.all()
534537
def commitfest(request, cfid):
535538
# Find ourselves
536539
cf = get_object_or_404(CommitFest, pk=cfid)
@@ -562,6 +565,7 @@ def commitfest(request, cfid):
562565
"form": form,
563566
"patches": patch_list.patches,
564567
"statussummary": statussummary,
568+
"all_tags": {t.id: t for t in Tag.objects.all()},
565569
"has_filter": patch_list.has_filter,
566570
"title": cf.title,
567571
"grouping": patch_list.sortkey == 0,

0 commit comments

Comments
 (0)