Closes #19648: Add support for colored Custom Field Choice Set values (#21984)

Fixes #19648
This commit is contained in:
Martin Hauser 2026-04-24 19:37:32 +02:00 committed by GitHub
parent 0563cc4585
commit 5f802bb18f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 564 additions and 28 deletions

View File

@ -103,6 +103,8 @@ The default value to populate for the custom field when creating new objects (op
For selection and multi-select custom fields only, this is the [set of choices](./customfieldchoiceset.md) which are valid for the field.
Choice sets may optionally define colors for individual values. Colored choices are rendered as badges on object detail pages for selection and multiple-selection custom fields.
### Cloneable
If enabled, values from this field will be automatically pre-populated when cloning existing objects.

View File

@ -22,6 +22,28 @@ The set of pre-defined choices to include. Available sets are listed below. This
A set of custom choices that will be appended to the base choice set (if any).
### Choice Colors
Optional color bindings for individual choice values. Each color is bound to a choice by its value rather than its label.
When editing a choice set in the UI, enter one mapping per line using the format `value:color`. Supported colors are:
* `blue`
* `indigo`
* `purple`
* `pink`
* `red`
* `orange`
* `yellow`
* `green`
* `teal`
* `cyan`
* `gray`
* `black`
* `white`
Colored choices are rendered as badges on object detail pages for selection and multiple-selection custom fields.
### Order Alphabetically
If enabled, the choices list will be automatically ordered alphabetically. If disabled, choices will appear in the order in which they were defined.

View File

@ -27,13 +27,17 @@ class CustomFieldChoiceSetSerializer(OwnerMixin, ChangeLogMessageSerializer, Val
max_length=2
)
)
choice_colors = serializers.DictField(
child=serializers.ChoiceField(choices=CustomFieldChoiceColorChoices),
required=False,
)
choices_count = serializers.IntegerField(read_only=True)
class Meta:
model = CustomFieldChoiceSet
fields = [
'id', 'url', 'display_url', 'display', 'name', 'description', 'base_choices', 'extra_choices',
'order_alphabetically', 'choices_count', 'owner', 'created', 'last_updated',
'choice_colors', 'order_alphabetically', 'choices_count', 'owner', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'name', 'description', 'choices_count')

View File

@ -95,6 +95,39 @@ class CustomFieldChoiceSetBaseChoices(ChoiceSet):
)
class CustomFieldChoiceColorChoices(ChoiceSet):
BLUE = 'blue'
INDIGO = 'indigo'
PURPLE = 'purple'
PINK = 'pink'
RED = 'red'
ORANGE = 'orange'
YELLOW = 'yellow'
GREEN = 'green'
TEAL = 'teal'
CYAN = 'cyan'
GRAY = 'gray'
BLACK = 'black'
WHITE = 'white'
CHOICES = (
(BLUE, _('Blue'), BLUE),
(INDIGO, _('Indigo'), INDIGO),
(PURPLE, _('Purple'), PURPLE),
(PINK, _('Pink'), PINK),
(RED, _('Red'), RED),
(ORANGE, _('Orange'), ORANGE),
(YELLOW, _('Yellow'), YELLOW),
(GREEN, _('Green'), GREEN),
(TEAL, _('Teal'), TEAL),
(CYAN, _('Cyan'), CYAN),
(GRAY, _('Gray'), GRAY),
(BLACK, _('Black'), BLACK),
(WHITE, _('White'), WHITE),
)
#
# CustomLinks
#

View File

@ -200,6 +200,12 @@ class CustomFieldChoiceSetFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet
choice = MultiValueCharFilter(
method='filter_by_choice'
)
choice_colors = django_filters.MultipleChoiceFilter(
choices=CustomFieldChoiceColorChoices,
method='filter_by_choice_colors',
label=_('Choice colors'),
distinct=False,
)
class Meta:
model = CustomFieldChoiceSet
@ -219,6 +225,25 @@ class CustomFieldChoiceSetFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet
# TODO: Support case-insensitive matching
return queryset.filter(extra_choices__overlap=value)
def filter_by_choice_colors(self, queryset, name, value):
if not value:
return queryset
choice_color_keys = set()
for choice_colors in queryset.values_list('choice_colors', flat=True):
if isinstance(choice_colors, dict):
choice_color_keys.update(choice_colors.keys())
if not choice_color_keys:
return queryset.none()
params = Q()
for key in choice_color_keys:
for color in value:
params |= Q(choice_colors__contains={key: color})
return queryset.filter(params)
@register_filterset
class CustomLinkFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet):

View File

@ -99,11 +99,19 @@ class CustomFieldChoiceSetImportForm(OwnerCSVMixin, CSVModelForm):
'"choice1:First Choice,choice2:Second Choice"'
)
)
choice_colors = SimpleArrayField(
base_field=forms.CharField(),
required=False,
help_text=_(
'Quoted string of comma-separated color mappings in the format '
'"choice1:red,choice2:green". Supported colors: {colors}'
).format(colors=', '.join(CustomFieldChoiceColorChoices.values())),
)
class Meta:
model = CustomFieldChoiceSet
fields = (
'name', 'description', 'base_choices', 'extra_choices', 'order_alphabetically', 'owner',
'name', 'description', 'base_choices', 'extra_choices', 'choice_colors', 'order_alphabetically', 'owner',
)
def clean_extra_choices(self):
@ -120,6 +128,28 @@ class CustomFieldChoiceSetImportForm(OwnerCSVMixin, CSVModelForm):
return data
return None
def clean_choice_colors(self):
if isinstance(self.cleaned_data['choice_colors'], list):
data = {}
for line in self.cleaned_data['choice_colors']:
try:
value, color = re.split(r'(?<!\\):', line, maxsplit=1)
value = value.replace('\\:', ':')
except ValueError as e:
raise forms.ValidationError(
_("Invalid color mapping '{line}'. Use the format value:color.").format(line=line)
) from e
value = value.strip()
color = color.strip()
if value in data:
raise forms.ValidationError(
_("Duplicate color mapping defined for choice '{value}'.").format(value=value)
)
data[value] = color
return data
return {}
class CustomLinkImportForm(OwnerCSVMixin, CSVModelForm):
object_types = CSVMultipleContentTypeField(

View File

@ -128,7 +128,7 @@ class CustomFieldChoiceSetFilterForm(OwnerFilterMixin, SavedFiltersMixin, Filter
model = CustomFieldChoiceSet
fieldsets = (
FieldSet('q', 'filter_id'),
FieldSet('base_choices', 'choice', name=_('Choices')),
FieldSet('base_choices', 'choice', 'choice_colors', name=_('Choices')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
base_choices = forms.MultipleChoiceField(
@ -138,6 +138,11 @@ class CustomFieldChoiceSetFilterForm(OwnerFilterMixin, SavedFiltersMixin, Filter
choice = forms.CharField(
required=False
)
choice_colors = forms.MultipleChoiceField(
choices=CustomFieldChoiceColorChoices,
required=False,
label=_('Choice colors'),
)
class CustomLinkFilterForm(OwnerFilterMixin, SavedFiltersMixin, FilterForm):

View File

@ -197,17 +197,33 @@ class CustomFieldChoiceSetForm(ChangelogMessageMixin, OwnerMixin, forms.ModelFor
'colon. Example:'
) + ' <code>choice1:First Choice</code>')
)
choice_colors = forms.CharField(
widget=ChoicesWidget(),
required=False,
help_text=mark_safe(
_(
'Bind an optional color to a choice by its value. Enter one mapping per line as '
'<code>value:color</code>. Example:'
)
+ ' <code>choice1:red</code><br />'
+ _('Supported colors: {colors}').format(
colors=', '.join(f'<code>{color}</code>' for color in CustomFieldChoiceColorChoices.values())
)
),
)
fieldsets = (
FieldSet(
'name', 'description', 'base_choices', 'extra_choices', 'order_alphabetically',
'name', 'description', 'base_choices', 'extra_choices', 'choice_colors', 'order_alphabetically',
name=_('Custom Field Choice Set')
),
)
class Meta:
model = CustomFieldChoiceSet
fields = ('name', 'description', 'base_choices', 'extra_choices', 'order_alphabetically', 'owner')
fields = (
'name', 'description', 'base_choices', 'extra_choices', 'choice_colors', 'order_alphabetically', 'owner'
)
def __init__(self, *args, initial=None, **kwargs):
super().__init__(*args, initial=initial, **kwargs)
@ -234,9 +250,25 @@ class CustomFieldChoiceSetForm(ChangelogMessageMixin, OwnerMixin, forms.ModelFor
self.initial['extra_choices'] = '\n'.join(choices)
# Convert choice_colors JSONField from model to CharField for form
if 'choice_colors' in self.initial:
choice_colors = self.initial.get('choice_colors') or {}
if isinstance(choice_colors, str):
choice_colors = json.loads(choice_colors)
mappings = []
for value, color in sorted(choice_colors.items()):
value = value.replace(':', '\\:')
mappings.append(f'{value}:{color}')
self.initial['choice_colors'] = '\n'.join(mappings)
def clean_extra_choices(self):
data = []
for line in self.cleaned_data['extra_choices'].splitlines():
if not line.strip():
continue
try:
value, label = re.split(r'(?<!\\):', line, maxsplit=1)
value = value.replace('\\:', ':')
@ -246,6 +278,28 @@ class CustomFieldChoiceSetForm(ChangelogMessageMixin, OwnerMixin, forms.ModelFor
data.append((value.strip(), label.strip()))
return data
def clean_choice_colors(self):
data = {}
for line in self.cleaned_data['choice_colors'].splitlines():
if not line.strip():
continue
try:
value, color = re.split(r'(?<!\\):', line, maxsplit=1)
value = value.replace('\\:', ':')
except ValueError as e:
raise forms.ValidationError(
_("Invalid color mapping '{line}'. Use the format value:color.").format(line=line)
) from e
value = value.strip()
color = color.strip()
if value in data:
raise forms.ValidationError(
_("Duplicate color mapping defined for choice '{value}'.").format(value=value)
)
data[value] = color
return data
class CustomLinkForm(ChangelogMessageMixin, OwnerMixin, forms.ModelForm):
object_types = ContentTypeMultipleChoiceField(

View File

@ -3,6 +3,7 @@ import strawberry
from extras.choices import *
__all__ = (
'CustomFieldChoiceColorEnum',
'CustomFieldChoiceSetBaseEnum',
'CustomFieldFilterLogicEnum',
'CustomFieldTypeEnum',
@ -15,6 +16,7 @@ __all__ = (
)
CustomFieldChoiceColorEnum = strawberry.enum(CustomFieldChoiceColorChoices.as_enum())
CustomFieldChoiceSetBaseEnum = strawberry.enum(CustomFieldChoiceSetBaseChoices.as_enum())
CustomFieldFilterLogicEnum = strawberry.enum(CustomFieldFilterLogicChoices.as_enum(prefix='filter'))
CustomFieldTypeEnum = strawberry.enum(CustomFieldTypeChoices.as_enum(prefix='type'))

View File

@ -2,6 +2,7 @@ from typing import TYPE_CHECKING, Annotated
import strawberry
import strawberry_django
from django.db.models import Q, QuerySet
from strawberry.scalars import ID
from strawberry_django import BaseFilterLookup, FilterLookup, StrFilterLookup
@ -199,6 +200,33 @@ class CustomFieldChoiceSetFilter(ChangeLoggedModelFilter):
)
order_alphabetically: FilterLookup[bool] | None = strawberry_django.filter_field()
@strawberry_django.filter_field(resolve_value=True)
def choice_colors(
self,
queryset: QuerySet,
value: list[Annotated['CustomFieldChoiceColorEnum', strawberry.lazy('extras.graphql.enums')]],
prefix: str,
) -> tuple[QuerySet, Q]:
if not value:
return queryset, Q()
field_name = f'{prefix}choice_colors'
choice_values = set()
for mapping in queryset.exclude(**{f'{field_name}__isnull': True}).values_list(field_name, flat=True):
if isinstance(mapping, dict):
choice_values.update(mapping.keys())
if not choice_values:
return queryset, Q(pk__in=[])
params = Q()
for color in value:
for choice_value in choice_values:
params |= Q(**{f'{field_name}__contains': {choice_value: color}})
return queryset, params
@strawberry_django.filter_type(models.CustomLink, lookups=True)
class CustomLinkFilter(ChangeLoggedModelFilter):

View File

@ -2,6 +2,7 @@ from typing import TYPE_CHECKING, Annotated
import strawberry
import strawberry_django
from strawberry.scalars import JSON
from core.graphql.mixins import SyncedDataMixin
from extras import models
@ -106,7 +107,7 @@ class CustomFieldType(OwnerMixin, ObjectType):
@strawberry_django.type(
models.CustomFieldChoiceSet,
exclude=['extra_choices'],
exclude=['extra_choices', 'choice_colors'],
filters=CustomFieldChoiceSetFilter,
pagination=True
)
@ -114,6 +115,7 @@ class CustomFieldChoiceSetType(OwnerMixin, ObjectType):
choices_for: list[Annotated["CustomFieldType", strawberry.lazy('extras.graphql.types')]]
extra_choices: list[list[str]] | None
choice_colors: JSON
@strawberry_django.type(

View File

@ -0,0 +1,15 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('extras', '0137_default_ordering_indexes'),
]
operations = [
migrations.AddField(
model_name='customfieldchoiceset',
name='choice_colors',
field=models.JSONField(blank=True, default=dict),
),
]

View File

@ -317,6 +317,11 @@ class CustomField(CloningMixin, ExportTemplatesMixin, OwnerMixin, ChangeLoggedMo
self._choice_map = dict(self.choices)
return self._choice_map.get(value, value)
def get_choice_color(self, value):
if self.choice_set:
return self.choice_set.get_choice_color(value)
return None
def populate_initial_data(self, content_types):
"""
Populate initial custom field data upon either a) the creation of a new CustomField, or
@ -880,12 +885,16 @@ class CustomFieldChoiceSet(CloningMixin, ExportTemplatesMixin, OwnerMixin, Chang
blank=True,
null=True
)
choice_colors = models.JSONField(
default=dict,
blank=True,
)
order_alphabetically = models.BooleanField(
default=False,
help_text=_('Choices are automatically ordered alphabetically')
)
clone_fields = ('extra_choices', 'order_alphabetically')
clone_fields = ('extra_choices', 'choice_colors', 'order_alphabetically')
class Meta:
ordering = ('name',)
@ -919,6 +928,24 @@ class CustomFieldChoiceSet(CloningMixin, ExportTemplatesMixin, OwnerMixin, Chang
self._choices = sorted(self._choices, key=lambda x: x[0])
return self._choices
@property
def colors(self):
"""
Return merged color mappings from the selected base choice set (if it defines colors)
and any custom color overrides defined on this choice set.
"""
if not hasattr(self, '_colors'):
self._colors = {}
if self.base_choices:
base_choice_set = CHOICE_SETS.get(self.base_choices)
self._colors.update(getattr(base_choice_set, 'colors', {}))
if self.choice_colors:
self._colors.update(self.choice_colors)
return self._colors
def get_choice_color(self, value):
return self.colors.get(value)
@property
def choices_count(self):
return len(self.choices)
@ -934,25 +961,56 @@ class CustomFieldChoiceSet(CloningMixin, ExportTemplatesMixin, OwnerMixin, Chang
if not self.base_choices and not self.extra_choices:
raise ValidationError(_("Must define base or extra choices."))
# Check for duplicate values in extra_choices
choice_values = [c[0] for c in self.extra_choices] if self.extra_choices else []
if len(set(choice_values)) != len(choice_values):
# At least one duplicate value is present. Find the first one and raise an error.
_seen = []
for value in choice_values:
if value in _seen:
if self.choice_colors is None:
self.choice_colors = {}
elif not isinstance(self.choice_colors, dict):
raise ValidationError({
'choice_colors': _('Color mappings must be defined as a JSON object.')
})
valid_choice_values = set()
extra_choice_values = set()
if self.base_choices:
valid_choice_values.update(CHOICE_SETS.get(self.base_choices).values())
if self.extra_choices:
for value, _label in self.extra_choices:
if value in extra_choice_values:
raise ValidationError(_("Duplicate value '{value}' found in extra choices.").format(value=value))
_seen.append(value)
extra_choice_values.add(value)
valid_choice_values.update(extra_choice_values)
invalid_choice_values = set()
invalid_colors = set()
valid_colors = set(CustomFieldChoiceColorChoices.values())
for value, color in self.choice_colors.items():
if value not in valid_choice_values:
invalid_choice_values.add(value)
if color not in valid_colors:
invalid_colors.add(color)
if invalid_choice_values:
raise ValidationError({
'choice_colors': _(
'Color mappings must reference an existing choice value. Invalid value(s): {values}.'
).format(values=', '.join(sorted(invalid_choice_values)))
})
if invalid_colors:
raise ValidationError({
'choice_colors': _(
'Invalid color value(s): {colors}. Use a supported named color.'
).format(colors=', '.join(sorted(invalid_colors)))
})
# Check whether any choices have been removed. If so, check whether any of the removed
# choices are still set in custom field data for any object.
original_choices = set([
c[0] for c in self._original_extra_choices
]) if self._original_extra_choices else set()
current_choices = set([
c[0] for c in self.extra_choices
]) if self.extra_choices else set()
if removed_choices := original_choices - current_choices:
if removed_choices := original_choices - valid_choice_values:
for custom_field in self.choices_for.all():
for object_type in custom_field.object_types.all():
model = object_type.model_class()

View File

@ -215,6 +215,10 @@ class CustomFieldChoiceSetTest(APIViewTestCases.APIViewTestCase):
['4B', 'Choice 2'],
['4C', 'Choice 3'],
],
'choice_colors': {
'4A': 'red',
'4B': 'green',
},
},
{
'name': 'Choice Set 5',
@ -223,6 +227,9 @@ class CustomFieldChoiceSetTest(APIViewTestCases.APIViewTestCase):
['5B', 'Choice 2'],
['5C', 'Choice 3'],
],
'choice_colors': {
'5C': 'blue',
},
},
{
'name': 'Choice Set 6',
@ -243,6 +250,10 @@ class CustomFieldChoiceSetTest(APIViewTestCases.APIViewTestCase):
['X2', 'Choice 2'],
['X3', 'Choice 3'],
],
'choice_colors': {
'X1': 'red',
'X3': 'green',
},
'description': 'New description',
}
@ -281,6 +292,38 @@ class CustomFieldChoiceSetTest(APIViewTestCases.APIViewTestCase):
response = self.client.post(self._get_list_url(), data, format='json', **self.header)
self.assertEqual(response.status_code, 400)
def test_invalid_choice_color(self):
self.add_permissions('extras.add_customfieldchoiceset')
data = {
'name': 'test',
'extra_choices': [
['choice1', 'Choice 1'],
['choice2', 'Choice 2'],
],
'choice_colors': {
'choice1': 'magenta',
},
}
response = self.client.post(self._get_list_url(), data, format='json', **self.header)
self.assertEqual(response.status_code, 400)
def test_invalid_choice_color_reference(self):
self.add_permissions('extras.add_customfieldchoiceset')
data = {
'name': 'test',
'extra_choices': [
['choice1', 'Choice 1'],
['choice2', 'Choice 2'],
],
'choice_colors': {
'choice3': 'red',
},
}
response = self.client.post(self._get_list_url(), data, format='json', **self.header)
self.assertEqual(response.status_code, 400)
class CustomLinkTest(APIViewTestCases.APIViewTestCase):
model = CustomLink

View File

@ -399,6 +399,73 @@ class CustomFieldTest(TestCase):
instance.refresh_from_db()
self.assertIsNone(instance.custom_field_data.get(cf.name))
def test_choice_set_colors(self):
choice_set = CustomFieldChoiceSet(
name='Test Choice Set',
extra_choices=(
('a', 'Option A'),
('b', 'Option B'),
),
choice_colors={
'a': CustomFieldChoiceColorChoices.RED,
'b': CustomFieldChoiceColorChoices.GREEN,
},
)
choice_set.full_clean()
self.assertEqual(
choice_set.colors,
{
'a': CustomFieldChoiceColorChoices.RED,
'b': CustomFieldChoiceColorChoices.GREEN,
},
)
def test_choice_set_invalid_color_mapping_value(self):
choice_set = CustomFieldChoiceSet(
name='Test Choice Set',
extra_choices=(
('a', 'Option A'),
('b', 'Option B'),
),
choice_colors={'c': CustomFieldChoiceColorChoices.RED},
)
with self.assertRaises(ValidationError) as cm:
choice_set.full_clean()
self.assertIn('choice_colors', cm.exception.message_dict)
def test_choice_set_invalid_color_value(self):
choice_set = CustomFieldChoiceSet(
name='Test Choice Set',
extra_choices=(
('a', 'Option A'),
('b', 'Option B'),
),
choice_colors={'a': 'magenta'},
)
with self.assertRaises(ValidationError) as cm:
choice_set.full_clean()
self.assertIn('choice_colors', cm.exception.message_dict)
def test_choice_set_invalid_color_mapping_structure(self):
choice_set = CustomFieldChoiceSet(
name='Test Choice Set',
extra_choices=(
('a', 'Option A'),
('b', 'Option B'),
),
choice_colors=['a:red'],
)
with self.assertRaises(ValidationError) as cm:
choice_set.full_clean()
self.assertIn('choice_colors', cm.exception.message_dict)
def test_remove_selected_choice(self):
"""
Removing a ChoiceSet choice that is referenced by an object should raise

View File

@ -160,8 +160,21 @@ class CustomFieldChoiceSetTestCase(TestCase, ChangeLoggedFilterSetTests):
@classmethod
def setUpTestData(cls):
choice_sets = (
CustomFieldChoiceSet(name='Choice Set 1', extra_choices=['A', 'B', 'C'], description='foobar1'),
CustomFieldChoiceSet(name='Choice Set 2', extra_choices=['D', 'E', 'F'], description='foobar2'),
CustomFieldChoiceSet(
name='Choice Set 1',
extra_choices=['A', 'B', 'C'],
choice_colors={'A': CustomFieldChoiceColorChoices.RED},
description='foobar1',
),
CustomFieldChoiceSet(
name='Choice Set 2',
extra_choices=['D', 'E', 'F'],
choice_colors={
'D': CustomFieldChoiceColorChoices.GREEN,
'E': CustomFieldChoiceColorChoices.RED,
},
description='foobar2',
),
CustomFieldChoiceSet(name='Choice Set 3', extra_choices=['G', 'H', 'I'], description='foobar3'),
)
CustomFieldChoiceSet.objects.bulk_create(choice_sets)
@ -178,6 +191,16 @@ class CustomFieldChoiceSetTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'choice': ['A', 'D']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_choice_colors(self):
params = {'choice_colors': [CustomFieldChoiceColorChoices.RED]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'choice_colors': [CustomFieldChoiceColorChoices.GREEN]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'choice_colors': [CustomFieldChoiceColorChoices.YELLOW]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
def test_description(self):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

View File

@ -115,6 +115,32 @@ class CustomFieldChoiceSetFormTest(TestCase):
# cleaned extra choices are correct, which does actually mean a list of tuples
self.assertEqual(updated.extra_choices, [('foo:bar', 'label'), ('value', 'label:with:colons')])
def test_choice_colors_round_trip_on_edit(self):
choice_set = CustomFieldChoiceSet.objects.create(
name='Test Choice Set',
extra_choices=[['foo:bar', 'label'], ['choice2', 'Choice 2']],
choice_colors={'foo:bar': 'red', 'choice2': 'green'},
)
form = CustomFieldChoiceSetForm(instance=choice_set)
initial_choices = form.initial['extra_choices']
initial_choice_colors = form.initial['choice_colors']
self.assertEqual(initial_choice_colors, 'choice2:green\nfoo\\:bar:red')
form = CustomFieldChoiceSetForm(
{
'name': choice_set.name,
'extra_choices': initial_choices,
'choice_colors': initial_choice_colors,
},
instance=choice_set,
)
self.assertTrue(form.is_valid())
updated = form.save()
self.assertEqual(updated.choice_colors, {'choice2': 'green', 'foo:bar': 'red'})
class SavedFilterFormTest(TestCase):

View File

@ -167,7 +167,15 @@ class CustomFieldChoiceSetView(generic.ObjectView):
page_number = request.GET.get('page', 1)
except ValueError:
page_number = 1
paginator = EnhancedPaginator(instance.choices, per_page)
choice_rows = [
{
'value': value,
'label': label,
'color': instance.get_choice_color(value),
}
for value, label in instance.choices
]
paginator = EnhancedPaginator(choice_rows, per_page)
try:
choices = paginator.page(page_number)
except EmptyPage:

View File

@ -1,4 +1,5 @@
{% extends "ui/panels/_base.html" %}
{% load helpers %}
{% load i18n %}
{% block panel_content %}
@ -7,13 +8,21 @@
<tr>
<th>{% trans "Value" %}</th>
<th>{% trans "Label" %}</th>
<th>{% trans "Color" %}</th>
</tr>
</thead>
<tbody>
{% for value, label in choices %}
{% for choice in choices %}
<tr>
<td>{{ value }}</td>
<td>{{ label }}</td>
<td>{{ choice.value }}</td>
<td>{{ choice.label }}</td>
<td>
{% if choice.color %}
{% badge choice.color bg_color=choice.color %}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>

View File

@ -18,8 +18,26 @@
<a href="{{ value }}">{{ value|truncatechars:70 }}</a>
{% elif customfield.type == 'json' and value is not None %}
<pre>{{ value|json }}</pre>
{% elif customfield.type == 'select' and value %}
{% if color %}
{% badge value bg_color=color %}
{% else %}
{{ value }}
{% endif %}
{% elif customfield.type == 'multiselect' and value %}
{{ value|join:", " }}
{% if value_has_colors %}
<div class="d-flex flex-wrap gap-1">
{% for choice_label, choice_color in value %}
{% if choice_color %}
{% badge choice_label bg_color=choice_color %}
{% else %}
{% badge choice_label %}
{% endif %}
{% endfor %}
</div>
{% else %}
{{ value|join:", " }}
{% endif %}
{% elif customfield.type == 'object' and value %}
{{ value|linkify }}
{% elif customfield.type == 'multiobject' and value %}

View File

@ -46,14 +46,23 @@ def customfield_value(customfield, value):
customfield: A CustomField instance
value: The custom field value applied to an object
"""
color = None
value_has_colors = False
if value:
if customfield.type == CustomFieldTypeChoices.TYPE_SELECT:
color = customfield.get_choice_color(value)
value = customfield.get_choice_label(value)
elif customfield.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
value = [customfield.get_choice_label(v) for v in value]
value = [(customfield.get_choice_label(v), customfield.get_choice_color(v)) for v in value]
value_has_colors = any(choice_color for _, choice_color in value)
if not value_has_colors:
value = [choice_label for choice_label, _ in value]
return {
'customfield': customfield,
'value': value,
'color': color,
'value_has_colors': value_has_colors,
}

View File

@ -3,10 +3,63 @@ from unittest.mock import patch
from django.template.loader import render_to_string
from django.test import TestCase, override_settings
from utilities.templatetags.builtins.tags import badge, static_with_params
from core.models import ObjectType
from dcim.models import Site
from extras.choices import CustomFieldTypeChoices
from extras.models import CustomField, CustomFieldChoiceSet
from utilities.templatetags.builtins.tags import badge, customfield_value, static_with_params
from utilities.templatetags.helpers import _humanize_capacity, humanize_speed
class CustomFieldValueTagTest(TestCase):
@classmethod
def setUpTestData(cls):
object_type = ObjectType.objects.get_for_model(Site)
choice_set = CustomFieldChoiceSet.objects.create(
name='Choice Set 1',
extra_choices=(('a', 'Option A'), ('b', 'Option B')),
choice_colors={'a': 'red'},
)
cls.select_field = CustomField.objects.create(
name='select_field',
type=CustomFieldTypeChoices.TYPE_SELECT,
choice_set=choice_set,
)
cls.select_field.object_types.set([object_type])
cls.multiselect_field = CustomField.objects.create(
name='multiselect_field',
type=CustomFieldTypeChoices.TYPE_MULTISELECT,
choice_set=choice_set,
)
cls.multiselect_field.object_types.set([object_type])
def test_select_choice_context_includes_color(self):
context = customfield_value(self.select_field, 'a')
self.assertEqual(context['value'], 'Option A')
self.assertEqual(context['color'], 'red')
def test_multiselect_choice_context_includes_colors(self):
context = customfield_value(self.multiselect_field, ['a', 'b'])
self.assertTrue(context['value_has_colors'])
self.assertEqual(
context['value'],
[
('Option A', 'red'),
('Option B', None),
],
)
def test_multiselect_choice_context_without_colors_preserves_plain_labels(self):
context = customfield_value(self.multiselect_field, ['b'])
self.assertFalse(context['value_has_colors'])
self.assertEqual(context['value'], ['Option B'])
class StaticWithParamsTest(TestCase):
"""
Test the static_with_params template tag functionality.