Fixes #19648
This commit is contained in:
parent
0563cc4585
commit
5f802bb18f
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
#
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
]
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue