mirror of
https://github.com/netbox-community/netbox.git
synced 2025-08-25 08:46:10 -06:00
Misc cleanup
This commit is contained in:
parent
3fa735021d
commit
306cfeeebb
@ -40,7 +40,7 @@ class NestedCustomFieldChoiceSetSerializer(WritableNestedSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.CustomFieldChoiceSet
|
model = models.CustomFieldChoiceSet
|
||||||
fields = ['id', 'url', 'display', 'name']
|
fields = ['id', 'url', 'display', 'name', 'choices_count']
|
||||||
|
|
||||||
|
|
||||||
class NestedCustomLinkSerializer(WritableNestedSerializer):
|
class NestedCustomLinkSerializer(WritableNestedSerializer):
|
||||||
|
@ -103,8 +103,8 @@ class CustomFieldSerializer(ValidatedModelSerializer):
|
|||||||
fields = [
|
fields = [
|
||||||
'id', 'url', 'display', 'content_types', 'type', 'object_type', 'data_type', 'name', 'label', 'group_name',
|
'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',
|
'description', 'required', 'search_weight', 'filter_logic', 'ui_visibility', 'is_cloneable', 'default',
|
||||||
'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choice_set', 'choices',
|
'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choice_set', 'created',
|
||||||
'created', 'last_updated',
|
'last_updated',
|
||||||
]
|
]
|
||||||
|
|
||||||
def validate_type(self, value):
|
def validate_type(self, value):
|
||||||
@ -135,8 +135,8 @@ class CustomFieldChoiceSetSerializer(ValidatedModelSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = CustomFieldChoiceSet
|
model = CustomFieldChoiceSet
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'url', 'display', 'name', 'description', 'extra_choices', 'order_alphabetically', 'created',
|
'id', 'url', 'display', 'name', 'description', 'extra_choices', 'order_alphabetically', 'choices_count',
|
||||||
'last_updated',
|
'created', 'last_updated',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@ -78,6 +78,11 @@ class CustomFieldFilterSet(BaseFilterSet):
|
|||||||
choice_set_id = django_filters.ModelMultipleChoiceFilter(
|
choice_set_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
queryset=CustomFieldChoiceSet.objects.all()
|
queryset=CustomFieldChoiceSet.objects.all()
|
||||||
)
|
)
|
||||||
|
choice_set = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
field_name='choice_set__name',
|
||||||
|
queryset=CustomFieldChoiceSet.objects.all(),
|
||||||
|
to_field_name='name'
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = CustomField
|
model = CustomField
|
||||||
@ -122,6 +127,7 @@ class CustomFieldChoiceSetFilterSet(BaseFilterSet):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def filter_by_choice(self, queryset, name, value):
|
def filter_by_choice(self, queryset, name, value):
|
||||||
|
# TODO: Support case-insensitive matching
|
||||||
return queryset.filter(extra_choices__overlap=value)
|
return queryset.filter(extra_choices__overlap=value)
|
||||||
|
|
||||||
|
|
||||||
|
@ -94,7 +94,7 @@ class CustomFieldChoiceSetFilterForm(SavedFiltersMixin, FilterForm):
|
|||||||
class CustomLinkFilterForm(SavedFiltersMixin, FilterForm):
|
class CustomLinkFilterForm(SavedFiltersMixin, FilterForm):
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, ('q', 'filter_id')),
|
(None, ('q', 'filter_id')),
|
||||||
('Attributes', ('content_types', 'enabled', 'new_window', 'weight')),
|
(_('Attributes'), ('content_types', 'enabled', 'new_window', 'weight')),
|
||||||
)
|
)
|
||||||
content_types = ContentTypeMultipleChoiceField(
|
content_types = ContentTypeMultipleChoiceField(
|
||||||
queryset=ContentType.objects.filter(FeatureQuery('custom_links').get_query()),
|
queryset=ContentType.objects.filter(FeatureQuery('custom_links').get_query()),
|
||||||
|
@ -52,7 +52,7 @@ class Migration(migrations.Migration):
|
|||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='customfield',
|
model_name='customfield',
|
||||||
name='choice_set',
|
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(
|
migrations.RunPython(
|
||||||
code=create_choice_sets,
|
code=create_choice_sets,
|
||||||
|
@ -162,7 +162,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
|||||||
choice_set = models.ForeignKey(
|
choice_set = models.ForeignKey(
|
||||||
to='CustomFieldChoiceSet',
|
to='CustomFieldChoiceSet',
|
||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
related_name='custom_fields',
|
related_name='choices_for',
|
||||||
blank=True,
|
blank=True,
|
||||||
null=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"
|
'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 (
|
if self.type in (
|
||||||
CustomFieldTypeChoices.TYPE_SELECT,
|
CustomFieldTypeChoices.TYPE_SELECT,
|
||||||
CustomFieldTypeChoices.TYPE_MULTISELECT
|
CustomFieldTypeChoices.TYPE_MULTISELECT
|
||||||
):
|
):
|
||||||
if not self.choice_set:
|
if not self.choice_set:
|
||||||
raise ValidationError({
|
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:
|
elif self.choice_set:
|
||||||
raise ValidationError({
|
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
|
# 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.")
|
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.
|
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')
|
help_text=_('Choices are automatically ordered alphabetically on save')
|
||||||
)
|
)
|
||||||
|
|
||||||
|
clone_fields = ('extra_choices', 'order_alphabetically')
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ('name',)
|
ordering = ('name',)
|
||||||
|
|
||||||
@ -667,6 +669,10 @@ class CustomFieldChoiceSet(ChangeLoggedModel):
|
|||||||
def choices(self):
|
def choices(self):
|
||||||
return self.extra_choices
|
return self.extra_choices
|
||||||
|
|
||||||
|
@property
|
||||||
|
def choices_count(self):
|
||||||
|
return len(self.choices)
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
|
|
||||||
# Sort choices if alphabetical ordering is enforced
|
# Sort choices if alphabetical ordering is enforced
|
||||||
|
@ -5,7 +5,7 @@ from django.conf import settings
|
|||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
from extras.models import *
|
from extras.models import *
|
||||||
from netbox.tables import ArrayColumn, NetBoxTable, columns
|
from netbox.tables import NetBoxTable, columns
|
||||||
from .template_code import *
|
from .template_code import *
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
@ -66,7 +66,8 @@ class CustomFieldTable(NetBoxTable):
|
|||||||
required = columns.BooleanColumn()
|
required = columns.BooleanColumn()
|
||||||
ui_visibility = columns.ChoiceFieldColumn(verbose_name="UI visibility")
|
ui_visibility = columns.ChoiceFieldColumn(verbose_name="UI visibility")
|
||||||
description = columns.MarkdownColumn()
|
description = columns.MarkdownColumn()
|
||||||
choices = ArrayColumn(
|
choices = columns.ArrayColumn(
|
||||||
|
max_items=10,
|
||||||
orderable=False,
|
orderable=False,
|
||||||
verbose_name=_('Choices')
|
verbose_name=_('Choices')
|
||||||
)
|
)
|
||||||
@ -86,7 +87,8 @@ class CustomFieldChoiceSetTable(NetBoxTable):
|
|||||||
name = tables.Column(
|
name = tables.Column(
|
||||||
linkify=True
|
linkify=True
|
||||||
)
|
)
|
||||||
choices = ArrayColumn(
|
choices = columns.ArrayColumn(
|
||||||
|
max_items=10,
|
||||||
accessor=tables.A('extra_choices'),
|
accessor=tables.A('extra_choices'),
|
||||||
orderable=False,
|
orderable=False,
|
||||||
verbose_name=_('Choices')
|
verbose_name=_('Choices')
|
||||||
|
@ -135,7 +135,7 @@ class CustomFieldTest(APIViewTestCases.APIViewTestCase):
|
|||||||
|
|
||||||
class CustomFieldChoiceSetTest(APIViewTestCases.APIViewTestCase):
|
class CustomFieldChoiceSetTest(APIViewTestCases.APIViewTestCase):
|
||||||
model = CustomFieldChoiceSet
|
model = CustomFieldChoiceSet
|
||||||
brief_fields = ['display', 'id', 'name', 'url']
|
brief_fields = ['choices_count', 'display', 'id', 'name', 'url']
|
||||||
create_data = [
|
create_data = [
|
||||||
{
|
{
|
||||||
'name': 'Choice Set 4',
|
'name': 'Choice Set 4',
|
||||||
|
@ -27,7 +27,11 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(cls):
|
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 = (
|
custom_fields = (
|
||||||
CustomField(
|
CustomField(
|
||||||
@ -54,11 +58,31 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests):
|
|||||||
filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED,
|
filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED,
|
||||||
ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN
|
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)
|
CustomField.objects.bulk_create(custom_fields)
|
||||||
custom_fields[0].content_types.add(content_types[0])
|
custom_fields[0].content_types.add(ContentType.objects.get_by_natural_key('dcim', 'site'))
|
||||||
custom_fields[1].content_types.add(content_types[1])
|
custom_fields[1].content_types.add(ContentType.objects.get_by_natural_key('dcim', 'rack'))
|
||||||
custom_fields[2].content_types.add(content_types[2])
|
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):
|
def test_name(self):
|
||||||
params = {'name': ['Custom Field 1', 'Custom Field 2']}
|
params = {'name': ['Custom Field 1', 'Custom Field 2']}
|
||||||
@ -67,7 +91,7 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests):
|
|||||||
def test_content_types(self):
|
def test_content_types(self):
|
||||||
params = {'content_types': 'dcim.site'}
|
params = {'content_types': 'dcim.site'}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
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)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||||
|
|
||||||
def test_required(self):
|
def test_required(self):
|
||||||
@ -86,6 +110,12 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests):
|
|||||||
params = {'ui_visibility': CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE}
|
params = {'ui_visibility': CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
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):
|
class CustomFieldChoiceSetTestCase(TestCase, BaseFilterSetTests):
|
||||||
queryset = CustomFieldChoiceSet.objects.all()
|
queryset = CustomFieldChoiceSet.objects.all()
|
||||||
|
@ -598,5 +598,16 @@ class ArrayColumn(tables.Column):
|
|||||||
"""
|
"""
|
||||||
List array items as a comma-separated list.
|
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):
|
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)
|
return ', '.join(value)
|
||||||
|
@ -29,7 +29,7 @@
|
|||||||
<th scope="row">Used by</th>
|
<th scope="row">Used by</th>
|
||||||
<td>
|
<td>
|
||||||
<ul class="list-unstyled mb-0">
|
<ul class="list-unstyled mb-0">
|
||||||
{% for cf in object.custom_fields.all %}
|
{% for cf in object.choices_for.all %}
|
||||||
<li>{{ cf|linkify }}</li>
|
<li>{{ cf|linkify }}</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
|
Loading…
Reference in New Issue
Block a user