Misc cleanup

This commit is contained in:
Jeremy Stretch 2023-07-19 10:09:57 -04:00
parent 3fa735021d
commit 306cfeeebb
11 changed files with 77 additions and 22 deletions

View File

@ -40,7 +40,7 @@ class NestedCustomFieldChoiceSetSerializer(WritableNestedSerializer):
class Meta:
model = models.CustomFieldChoiceSet
fields = ['id', 'url', 'display', 'name']
fields = ['id', 'url', 'display', 'name', 'choices_count']
class NestedCustomLinkSerializer(WritableNestedSerializer):

View File

@ -103,8 +103,8 @@ class CustomFieldSerializer(ValidatedModelSerializer):
fields = [
'id', 'url', 'display', 'content_types', 'type', 'object_type', 'data_type', 'name', 'label', 'group_name',
'description', 'required', 'search_weight', 'filter_logic', 'ui_visibility', 'is_cloneable', 'default',
'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choice_set', 'choices',
'created', 'last_updated',
'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choice_set', 'created',
'last_updated',
]
def validate_type(self, value):
@ -135,8 +135,8 @@ class CustomFieldChoiceSetSerializer(ValidatedModelSerializer):
class Meta:
model = CustomFieldChoiceSet
fields = [
'id', 'url', 'display', 'name', 'description', 'extra_choices', 'order_alphabetically', 'created',
'last_updated',
'id', 'url', 'display', 'name', 'description', 'extra_choices', 'order_alphabetically', 'choices_count',
'created', 'last_updated',
]

View File

@ -78,6 +78,11 @@ class CustomFieldFilterSet(BaseFilterSet):
choice_set_id = django_filters.ModelMultipleChoiceFilter(
queryset=CustomFieldChoiceSet.objects.all()
)
choice_set = django_filters.ModelMultipleChoiceFilter(
field_name='choice_set__name',
queryset=CustomFieldChoiceSet.objects.all(),
to_field_name='name'
)
class Meta:
model = CustomField
@ -122,6 +127,7 @@ class CustomFieldChoiceSetFilterSet(BaseFilterSet):
)
def filter_by_choice(self, queryset, name, value):
# TODO: Support case-insensitive matching
return queryset.filter(extra_choices__overlap=value)

View File

@ -94,7 +94,7 @@ class CustomFieldChoiceSetFilterForm(SavedFiltersMixin, FilterForm):
class CustomLinkFilterForm(SavedFiltersMixin, FilterForm):
fieldsets = (
(None, ('q', 'filter_id')),
('Attributes', ('content_types', 'enabled', 'new_window', 'weight')),
(_('Attributes'), ('content_types', 'enabled', 'new_window', 'weight')),
)
content_types = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.filter(FeatureQuery('custom_links').get_query()),

View File

@ -52,7 +52,7 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='customfield',
name='choice_set',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='custom_fields', to='extras.customfieldchoiceset'),
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='choices_for', to='extras.customfieldchoiceset'),
),
migrations.RunPython(
code=create_choice_sets,

View File

@ -162,7 +162,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
choice_set = models.ForeignKey(
to='CustomFieldChoiceSet',
on_delete=models.PROTECT,
related_name='custom_fields',
related_name='choices_for',
blank=True,
null=True
)
@ -286,18 +286,18 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
'validation_regex': "Regular expression validation is supported only for text and URL fields"
})
# Choice set must be set on selection fields
# Choice set must be set on selection fields, and *only* on selection fields
if self.type in (
CustomFieldTypeChoices.TYPE_SELECT,
CustomFieldTypeChoices.TYPE_MULTISELECT
):
if not self.choice_set:
raise ValidationError({
'choice_set': "Selection fields must define a set of choices."
'choice_set': "Selection fields must specify a set of choices."
})
elif self.choice_set:
raise ValidationError({
'choice_set': "Choices may be set only for selection fields."
'choice_set': "Choices may be set only on selection fields."
})
# A selection field's default (if any) must be present in its available choices
@ -633,7 +633,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
raise ValidationError("Required field cannot be empty.")
class CustomFieldChoiceSet(ChangeLoggedModel):
class CustomFieldChoiceSet(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
"""
Represents a set of choices available for choice and multi-choice custom fields.
"""
@ -654,6 +654,8 @@ class CustomFieldChoiceSet(ChangeLoggedModel):
help_text=_('Choices are automatically ordered alphabetically on save')
)
clone_fields = ('extra_choices', 'order_alphabetically')
class Meta:
ordering = ('name',)
@ -667,6 +669,10 @@ class CustomFieldChoiceSet(ChangeLoggedModel):
def choices(self):
return self.extra_choices
@property
def choices_count(self):
return len(self.choices)
def save(self, *args, **kwargs):
# Sort choices if alphabetical ordering is enforced

View File

@ -5,7 +5,7 @@ from django.conf import settings
from django.utils.translation import gettext as _
from extras.models import *
from netbox.tables import ArrayColumn, NetBoxTable, columns
from netbox.tables import NetBoxTable, columns
from .template_code import *
__all__ = (
@ -66,7 +66,8 @@ class CustomFieldTable(NetBoxTable):
required = columns.BooleanColumn()
ui_visibility = columns.ChoiceFieldColumn(verbose_name="UI visibility")
description = columns.MarkdownColumn()
choices = ArrayColumn(
choices = columns.ArrayColumn(
max_items=10,
orderable=False,
verbose_name=_('Choices')
)
@ -86,7 +87,8 @@ class CustomFieldChoiceSetTable(NetBoxTable):
name = tables.Column(
linkify=True
)
choices = ArrayColumn(
choices = columns.ArrayColumn(
max_items=10,
accessor=tables.A('extra_choices'),
orderable=False,
verbose_name=_('Choices')

View File

@ -135,7 +135,7 @@ class CustomFieldTest(APIViewTestCases.APIViewTestCase):
class CustomFieldChoiceSetTest(APIViewTestCases.APIViewTestCase):
model = CustomFieldChoiceSet
brief_fields = ['display', 'id', 'name', 'url']
brief_fields = ['choices_count', 'display', 'id', 'name', 'url']
create_data = [
{
'name': 'Choice Set 4',

View File

@ -27,7 +27,11 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests):
@classmethod
def setUpTestData(cls):
content_types = ContentType.objects.filter(model__in=['site', 'rack', 'device'])
choice_sets = (
CustomFieldChoiceSet(name='Choice Set 1', extra_choices=['A', 'B', 'C']),
CustomFieldChoiceSet(name='Choice Set 2', extra_choices=['D', 'E', 'F']),
)
CustomFieldChoiceSet.objects.bulk_create(choice_sets)
custom_fields = (
CustomField(
@ -54,11 +58,31 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests):
filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED,
ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN
),
CustomField(
name='Custom Field 4',
type=CustomFieldTypeChoices.TYPE_SELECT,
required=False,
weight=400,
filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED,
ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN,
choice_set=choice_sets[0]
),
CustomField(
name='Custom Field 5',
type=CustomFieldTypeChoices.TYPE_MULTISELECT,
required=False,
weight=500,
filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED,
ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN,
choice_set=choice_sets[1]
),
)
CustomField.objects.bulk_create(custom_fields)
custom_fields[0].content_types.add(content_types[0])
custom_fields[1].content_types.add(content_types[1])
custom_fields[2].content_types.add(content_types[2])
custom_fields[0].content_types.add(ContentType.objects.get_by_natural_key('dcim', 'site'))
custom_fields[1].content_types.add(ContentType.objects.get_by_natural_key('dcim', 'rack'))
custom_fields[2].content_types.add(ContentType.objects.get_by_natural_key('dcim', 'device'))
custom_fields[3].content_types.add(ContentType.objects.get_by_natural_key('dcim', 'device'))
custom_fields[4].content_types.add(ContentType.objects.get_by_natural_key('dcim', 'device'))
def test_name(self):
params = {'name': ['Custom Field 1', 'Custom Field 2']}
@ -67,7 +91,7 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests):
def test_content_types(self):
params = {'content_types': 'dcim.site'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'content_type_id': [ContentType.objects.get_for_model(Site).pk]}
params = {'content_type_id': [ContentType.objects.get_by_natural_key('dcim', 'site').pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_required(self):
@ -86,6 +110,12 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests):
params = {'ui_visibility': CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_choice_set(self):
params = {'choice_set': ['Choice Set 1', 'Choice Set 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'choice_set_id': CustomFieldChoiceSet.objects.values_list('pk', flat=True)}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class CustomFieldChoiceSetTestCase(TestCase, BaseFilterSetTests):
queryset = CustomFieldChoiceSet.objects.all()

View File

@ -598,5 +598,16 @@ class ArrayColumn(tables.Column):
"""
List array items as a comma-separated list.
"""
def __init__(self, *args, max_items=None, **kwargs):
self.max_items = max_items
super().__init__(*args, **kwargs)
def render(self, value):
if self.max_items:
# Limit the returned items to the specified maximum number
omitted = len(value) - self.max_items
value = value[:self.max_items - 1]
if omitted > 0:
value.append(f'({omitted} more)')
return ', '.join(value)

View File

@ -29,7 +29,7 @@
<th scope="row">Used by</th>
<td>
<ul class="list-unstyled mb-0">
{% for cf in object.custom_fields.all %}
{% for cf in object.choices_for.all %}
<li>{{ cf|linkify }}</li>
{% endfor %}
</ul>