1
0
mirror of https://github.com/netbox-community/netbox.git synced 2024-05-10 07:54:54 +00:00

Closes #12988: Introduce custom field choice sets (#13195)

* Initial work on custom field choice sets

* Rename choices to extra_choices (prep for #12194)

* Remove CustomField.choices

* Add & update tests

* Clean up table columns

* Add order_alphanetically boolean for choice sets

* Introduce ArrayColumn for choice lists

* Show dependent custom fields on choice set view

* Update custom fields documentation

* Introduce ArrayWidget for more convenient editing of choices

* Incorporate PR feedback

* Misc cleanup
This commit is contained in:
Jeremy Stretch
2023-07-19 10:26:24 -04:00
committed by GitHub
parent 837be4d45f
commit 96ea0ac9c7
32 changed files with 792 additions and 150 deletions

View File

@ -60,7 +60,7 @@ NetBox supports limited custom validation for custom field values. Following are
### Custom Selection Fields
Each custom selection field must have at least two choices. These are specified as a comma-separated list. Choices appear in forms in the order they are listed. Note that choice values are saved exactly as they appear, so it's best to avoid superfluous punctuation or symbols where possible.
Each custom selection field must designate a [choice set](../models/extras/customfieldchoiceset.md) containing at least two choices. These are specified as a comma-separated list.
If a default value is specified for a selection field, it must exactly match one of the provided choices. The value of a multiple selection field will always return a list, even if only one value is selected.

View File

@ -79,9 +79,9 @@ Controls how and whether the custom field is displayed within the NetBox user in
The default value to populate for the custom field when creating new objects (optional). This value must be expressed as JSON. If this is a choice or multi-choice field, this must be one of the available choices.
### Choices
### Choice Set
For choice and multi-choice custom fields only. A comma-delimited list of the available choices.
For selection and multi-select custom fields only, this is the [set of choices](./customfieldchoiceset.md) which are valid for the field.
### Cloneable

View File

@ -0,0 +1,17 @@
# Custom Field Choice Sets
Single- and multi-selection [custom fields documentation](../../customization/custom-fields.md) must define a set of valid choices from which the user may choose when defining the field value. These choices are defined as sets that may be reused among multiple custom fields.
## Fields
### Name
The human-friendly name of the choice set.
### Extra Choices
The list of valid choices, entered as a comma-separated list.
### 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

@ -7,6 +7,7 @@ __all__ = [
'NestedBookmarkSerializer',
'NestedConfigContextSerializer',
'NestedConfigTemplateSerializer',
'NestedCustomFieldChoiceSetSerializer',
'NestedCustomFieldSerializer',
'NestedCustomLinkSerializer',
'NestedExportTemplateSerializer',
@ -34,6 +35,14 @@ class NestedCustomFieldSerializer(WritableNestedSerializer):
fields = ['id', 'url', 'display', 'name']
class NestedCustomFieldChoiceSetSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:customfieldchoiceset-detail')
class Meta:
model = models.CustomFieldChoiceSet
fields = ['id', 'url', 'display', 'name', 'choices_count']
class NestedCustomLinkSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:customlink-detail')

View File

@ -35,6 +35,7 @@ __all__ = (
'ConfigContextSerializer',
'ConfigTemplateSerializer',
'ContentTypeSerializer',
'CustomFieldChoiceSetSerializer',
'CustomFieldSerializer',
'CustomLinkSerializer',
'DashboardSerializer',
@ -94,6 +95,7 @@ class CustomFieldSerializer(ValidatedModelSerializer):
)
filter_logic = ChoiceField(choices=CustomFieldFilterLogicChoices, required=False)
data_type = serializers.SerializerMethodField()
choice_set = NestedCustomFieldChoiceSetSerializer(required=False)
ui_visibility = ChoiceField(choices=CustomFieldVisibilityChoices, required=False)
class Meta:
@ -101,7 +103,7 @@ 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', 'choices', 'created',
'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choice_set', 'created',
'last_updated',
]
@ -127,6 +129,17 @@ class CustomFieldSerializer(ValidatedModelSerializer):
return 'string'
class CustomFieldChoiceSetSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:customfieldchoiceset-detail')
class Meta:
model = CustomFieldChoiceSet
fields = [
'id', 'url', 'display', 'name', 'description', 'extra_choices', 'order_alphabetically', 'choices_count',
'created', 'last_updated',
]
#
# Custom links
#

View File

@ -9,6 +9,7 @@ router.APIRootView = views.ExtrasRootView
router.register('webhooks', views.WebhookViewSet)
router.register('custom-fields', views.CustomFieldViewSet)
router.register('custom-field-choices', views.CustomFieldChoiceSetViewSet)
router.register('custom-links', views.CustomLinkViewSet)
router.register('export-templates', views.ExportTemplateViewSet)
router.register('saved-filters', views.SavedFilterViewSet)

View File

@ -1,6 +1,5 @@
from django.contrib.contenttypes.models import ContentType
from django.http import Http404
from django.shortcuts import get_object_or_404
from django_rq.queues import get_connection
from rest_framework import status
from rest_framework.decorators import action
@ -55,11 +54,17 @@ class WebhookViewSet(NetBoxModelViewSet):
class CustomFieldViewSet(NetBoxModelViewSet):
metadata_class = ContentTypeMetadata
queryset = CustomField.objects.all()
queryset = CustomField.objects.select_related('choice_set')
serializer_class = serializers.CustomFieldSerializer
filterset_class = filtersets.CustomFieldFilterSet
class CustomFieldChoiceSetViewSet(NetBoxModelViewSet):
queryset = CustomFieldChoiceSet.objects.all()
serializer_class = serializers.CustomFieldChoiceSetSerializer
filterset_class = filtersets.CustomFieldChoiceSetFilterSet
#
# Custom links
#

View File

@ -20,6 +20,7 @@ __all__ = (
'ConfigRevisionFilterSet',
'ConfigTemplateFilterSet',
'ContentTypeFilterSet',
'CustomFieldChoiceSetFilterSet',
'CustomFieldFilterSet',
'CustomLinkFilterSet',
'ExportTemplateFilterSet',
@ -74,6 +75,14 @@ class CustomFieldFilterSet(BaseFilterSet):
field_name='content_types__id'
)
content_types = ContentTypeFilter()
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
@ -93,6 +102,35 @@ class CustomFieldFilterSet(BaseFilterSet):
)
class CustomFieldChoiceSetFilterSet(BaseFilterSet):
q = django_filters.CharFilter(
method='search',
label=_('Search'),
)
choice = MultiValueCharFilter(
method='filter_by_choice'
)
class Meta:
model = CustomFieldChoiceSet
fields = [
'id', 'name', 'description', 'order_alphabetically',
]
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(description__icontains=value) |
Q(extra_choices__contains=value)
)
def filter_by_choice(self, queryset, name, value):
# TODO: Support case-insensitive matching
return queryset.filter(extra_choices__overlap=value)
class CustomLinkFilterSet(BaseFilterSet):
q = django_filters.CharFilter(
method='search',

View File

@ -4,13 +4,14 @@ from django.utils.translation import gettext as _
from extras.choices import *
from extras.models import *
from utilities.forms import BulkEditForm, add_blank_choice
from utilities.forms.fields import ColorField
from utilities.forms.fields import ColorField, DynamicModelChoiceField
from utilities.forms.widgets import BulkEditNullBooleanSelect
__all__ = (
'ConfigContextBulkEditForm',
'ConfigTemplateBulkEditForm',
'CustomFieldBulkEditForm',
'CustomFieldChoiceSetBulkEditForm',
'CustomLinkBulkEditForm',
'ExportTemplateBulkEditForm',
'JournalEntryBulkEditForm',
@ -38,6 +39,10 @@ class CustomFieldBulkEditForm(BulkEditForm):
weight = forms.IntegerField(
required=False
)
choice_set = DynamicModelChoiceField(
queryset=CustomFieldChoiceSet.objects.all(),
required=False
)
ui_visibility = forms.ChoiceField(
label=_("UI visibility"),
choices=add_blank_choice(CustomFieldVisibilityChoices),
@ -49,7 +54,23 @@ class CustomFieldBulkEditForm(BulkEditForm):
widget=BulkEditNullBooleanSelect()
)
nullable_fields = ('group_name', 'description',)
nullable_fields = ('group_name', 'description', 'choice_set')
class CustomFieldChoiceSetBulkEditForm(BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=CustomFieldChoiceSet.objects.all(),
widget=forms.MultipleHiddenInput
)
description = forms.CharField(
required=False
)
order_alphabetically = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect()
)
nullable_fields = ('description',)
class CustomLinkBulkEditForm(BulkEditForm):

View File

@ -9,10 +9,13 @@ from extras.models import *
from extras.utils import FeatureQuery
from netbox.forms import NetBoxModelImportForm
from utilities.forms import CSVModelForm
from utilities.forms.fields import CSVChoiceField, CSVContentTypeField, CSVMultipleContentTypeField, SlugField
from utilities.forms.fields import (
CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVMultipleContentTypeField, SlugField,
)
__all__ = (
'ConfigTemplateImportForm',
'CustomFieldChoiceSetImportForm',
'CustomFieldImportForm',
'CustomLinkImportForm',
'ExportTemplateImportForm',
@ -39,10 +42,11 @@ class CustomFieldImportForm(CSVModelForm):
required=False,
help_text=_("Object type (for object or multi-object fields)")
)
choices = SimpleArrayField(
base_field=forms.CharField(),
choice_set = CSVModelChoiceField(
queryset=CustomFieldChoiceSet.objects.all(),
to_field_name='name',
required=False,
help_text=_('Comma-separated list of field choices')
help_text=_('Choice set (for selection fields)')
)
ui_visibility = CSVChoiceField(
choices=CustomFieldVisibilityChoices,
@ -53,8 +57,22 @@ class CustomFieldImportForm(CSVModelForm):
model = CustomField
fields = (
'name', 'label', 'group_name', 'type', 'content_types', 'object_type', 'required', 'description',
'search_weight', 'filter_logic', 'default', 'choices', 'weight', 'validation_minimum', 'validation_maximum',
'validation_regex', 'ui_visibility', 'is_cloneable',
'search_weight', 'filter_logic', 'default', 'choice_set', 'weight', 'validation_minimum',
'validation_maximum', 'validation_regex', 'ui_visibility', 'is_cloneable',
)
class CustomFieldChoiceSetImportForm(CSVModelForm):
extra_choices = SimpleArrayField(
base_field=forms.CharField(),
required=False,
help_text=_('Comma-separated list of field choices')
)
class Meta:
model = CustomFieldChoiceSet
fields = (
'name', 'description', 'extra_choices', 'order_alphabetically',
)

View File

@ -20,6 +20,7 @@ __all__ = (
'ConfigContextFilterForm',
'ConfigRevisionFilterForm',
'ConfigTemplateFilterForm',
'CustomFieldChoiceSetFilterForm',
'CustomFieldFilterForm',
'CustomLinkFilterForm',
'ExportTemplateFilterForm',
@ -37,7 +38,8 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
fieldsets = (
(None, ('q', 'filter_id')),
('Attributes', (
'type', 'content_type_id', 'group_name', 'weight', 'required', 'ui_visibility', 'is_cloneable',
'type', 'content_type_id', 'group_name', 'weight', 'required', 'choice_set_id', 'ui_visibility',
'is_cloneable',
)),
)
content_type_id = ContentTypeMultipleChoiceField(
@ -62,6 +64,11 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
choice_set_id = DynamicModelMultipleChoiceField(
queryset=CustomFieldChoiceSet.objects.all(),
required=False,
label=_('Choice set')
)
ui_visibility = forms.ChoiceField(
choices=add_blank_choice(CustomFieldVisibilityChoices),
required=False,
@ -75,10 +82,19 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
)
class CustomFieldChoiceSetFilterForm(SavedFiltersMixin, FilterForm):
fieldsets = (
(None, ('q', 'filter_id', 'choice')),
)
choice = forms.CharField(
required=False
)
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

@ -16,9 +16,10 @@ from netbox.forms import NetBoxModelForm
from tenancy.models import Tenant, TenantGroup
from utilities.forms import BootstrapMixin, add_blank_choice
from utilities.forms.fields import (
CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, JSONField,
SlugField,
CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelChoiceField,
DynamicModelMultipleChoiceField, JSONField, SlugField,
)
from utilities.forms.widgets import ArrayWidget
from virtualization.models import Cluster, ClusterGroup, ClusterType
@ -27,6 +28,7 @@ __all__ = (
'ConfigContextForm',
'ConfigRevisionForm',
'ConfigTemplateForm',
'CustomFieldChoiceSetForm',
'CustomFieldForm',
'CustomLinkForm',
'ExportTemplateForm',
@ -50,13 +52,17 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
required=False,
help_text=_("Type of the related object (for object/multi-object fields only)")
)
choice_set = DynamicModelChoiceField(
queryset=CustomFieldChoiceSet.objects.all(),
required=False
)
fieldsets = (
('Custom Field', (
'content_types', 'name', 'label', 'group_name', 'type', 'object_type', 'required', 'description',
)),
('Behavior', ('search_weight', 'filter_logic', 'ui_visibility', 'weight', 'is_cloneable')),
('Values', ('default', 'choices')),
('Values', ('default', 'choice_set')),
('Validation', ('validation_minimum', 'validation_maximum', 'validation_regex')),
)
@ -78,6 +84,20 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
self.fields['type'].disabled = True
class CustomFieldChoiceSetForm(BootstrapMixin, forms.ModelForm):
extra_choices = forms.CharField(
widget=ArrayWidget(),
help_text=_('Enter one choice per line.')
)
class Meta:
model = CustomFieldChoiceSet
fields = ('name', 'description', 'extra_choices', 'order_alphabetically')
def clean_extra_choices(self):
return self.cleaned_data['extra_choices'].splitlines()
class CustomLinkForm(BootstrapMixin, forms.ModelForm):
content_types = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.all(),

View File

@ -25,6 +25,12 @@ class ExtrasQuery(graphene.ObjectType):
def resolve_custom_field_list(root, info, **kwargs):
return gql_query_optimizer(models.CustomField.objects.all(), info)
custom_field_choice_set = ObjectField(CustomFieldChoiceSetType)
custom_field_choice_set_list = ObjectListField(CustomFieldChoiceSetType)
def resolve_custom_field_choices_list(root, info, **kwargs):
return gql_query_optimizer(models.CustomFieldChoiceSet.objects.all(), info)
custom_link = ObjectField(CustomLinkType)
custom_link_list = ObjectListField(CustomLinkType)

View File

@ -5,6 +5,7 @@ from netbox.graphql.types import BaseObjectType, ObjectType
__all__ = (
'ConfigContextType',
'ConfigTemplateType',
'CustomFieldChoiceSetType',
'CustomFieldType',
'CustomLinkType',
'ExportTemplateType',
@ -41,6 +42,14 @@ class CustomFieldType(ObjectType):
filterset_class = filtersets.CustomFieldFilterSet
class CustomFieldChoiceSetType(ObjectType):
class Meta:
model = models.CustomFieldChoiceSet
fields = '__all__'
filterset_class = filtersets.CustomFieldChoiceSetFilterSet
class CustomLinkType(ObjectType):
class Meta:

View File

@ -0,0 +1,61 @@
import django.contrib.postgres.fields
from django.db import migrations, models
from extras.choices import CustomFieldTypeChoices
def create_choice_sets(apps, schema_editor):
"""
Create a CustomFieldChoiceSet for each CustomField with choices defined.
"""
CustomField = apps.get_model('extras', 'CustomField')
CustomFieldChoiceSet = apps.get_model('extras', 'CustomFieldChoiceSet')
# Create custom field choice sets
choice_fields = CustomField.objects.filter(
type__in=(CustomFieldTypeChoices.TYPE_SELECT, CustomFieldTypeChoices.TYPE_MULTISELECT),
choices__len__gt=0
)
for cf in choice_fields:
choiceset = CustomFieldChoiceSet.objects.create(
name=f'{cf.name} Choices',
extra_choices=cf.choices
)
cf.choice_set = choiceset
# Update custom fields to point to new choice sets
CustomField.objects.bulk_update(choice_fields, ['choice_set'])
class Migration(migrations.Migration):
dependencies = [
('extras', '0095_bookmarks'),
]
operations = [
migrations.CreateModel(
name='CustomFieldChoiceSet',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('created', models.DateTimeField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)),
('name', models.CharField(max_length=100, unique=True)),
('description', models.CharField(blank=True, max_length=200)),
('extra_choices', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), size=None)),
('order_alphabetically', models.BooleanField(default=False)),
],
options={
'ordering': ('name',),
},
),
migrations.AddField(
model_name='customfield',
name='choice_set',
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,
reverse_code=migrations.RunPython.noop
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 4.1.10 on 2023-07-17 15:22
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('extras', '0096_customfieldchoiceset'),
]
operations = [
migrations.RemoveField(
model_name='customfield',
name='choices',
),
]

View File

@ -1,6 +1,6 @@
from .change_logging import *
from .configs import *
from .customfields import CustomField
from .customfields import *
from .dashboard import *
from .models import *
from .reports import *

View File

@ -31,6 +31,7 @@ from utilities.validators import validate_regex
__all__ = (
'CustomField',
'CustomFieldChoiceSet',
'CustomFieldManager',
)
@ -158,11 +159,12 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
'example, <code>^[A-Z]{3}$</code> will limit values to exactly three uppercase letters.'
)
)
choices = ArrayField(
base_field=models.CharField(max_length=100),
choice_set = models.ForeignKey(
to='CustomFieldChoiceSet',
on_delete=models.PROTECT,
related_name='choices_for',
blank=True,
null=True,
help_text=_('Comma-separated list of available choices (for selection fields)')
null=True
)
ui_visibility = models.CharField(
max_length=50,
@ -181,8 +183,8 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
clone_fields = (
'content_types', 'type', 'object_type', 'group_name', 'description', 'required', 'search_weight',
'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choices',
'ui_visibility', 'is_cloneable',
'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex',
'choice_set', 'ui_visibility', 'is_cloneable',
)
class Meta:
@ -208,6 +210,12 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
def search_type(self):
return SEARCH_TYPES.get(self.type)
@property
def choices(self):
if self.choice_set:
return self.choice_set.choices
return []
def populate_initial_data(self, content_types):
"""
Populate initial custom field data upon either a) the creation of a new CustomField, or
@ -278,22 +286,18 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
'validation_regex': "Regular expression validation is supported only for text and URL fields"
})
# Choices can be set only on selection fields
if self.choices and self.type not in (
CustomFieldTypeChoices.TYPE_SELECT,
CustomFieldTypeChoices.TYPE_MULTISELECT
):
raise ValidationError({
'choices': "Choices may be set only for custom selection fields."
})
# Selection fields must have at least one choice defined
# Choice set must be set on selection fields, and *only* on selection fields
if self.type in (
CustomFieldTypeChoices.TYPE_SELECT,
CustomFieldTypeChoices.TYPE_MULTISELECT
) and not self.choices:
):
if not self.choice_set:
raise ValidationError({
'choice_set': "Selection fields must specify a set of choices."
})
elif self.choice_set:
raise ValidationError({
'choices': "Selection fields must specify at least one choice."
'choice_set': "Choices may be set only on selection fields."
})
# A selection field's default (if any) must be present in its available choices
@ -627,3 +631,52 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
elif self.required:
raise ValidationError("Required field cannot be empty.")
class CustomFieldChoiceSet(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
"""
Represents a set of choices available for choice and multi-choice custom fields.
"""
name = models.CharField(
max_length=100,
unique=True
)
description = models.CharField(
max_length=200,
blank=True
)
extra_choices = ArrayField(
base_field=models.CharField(max_length=100),
help_text=_('List of field choices')
)
order_alphabetically = models.BooleanField(
default=False,
help_text=_('Choices are automatically ordered alphabetically on save')
)
clone_fields = ('extra_choices', 'order_alphabetically')
class Meta:
ordering = ('name',)
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse('extras:customfieldchoiceset', args=[self.pk])
@property
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
if self.order_alphabetically:
self.extra_choices = sorted(self.choices)
return super().save(*args, **kwargs)

View File

@ -2,6 +2,7 @@ import json
import django_tables2 as tables
from django.conf import settings
from django.utils.translation import gettext as _
from extras.models import *
from netbox.tables import NetBoxTable, columns
@ -12,6 +13,7 @@ __all__ = (
'ConfigContextTable',
'ConfigRevisionTable',
'ConfigTemplateTable',
'CustomFieldChoiceSetTable',
'CustomFieldTable',
'CustomLinkTable',
'ExportTemplateTable',
@ -64,6 +66,11 @@ class CustomFieldTable(NetBoxTable):
required = columns.BooleanColumn()
ui_visibility = columns.ChoiceFieldColumn(verbose_name="UI visibility")
description = columns.MarkdownColumn()
choices = columns.ArrayColumn(
max_items=10,
orderable=False,
verbose_name=_('Choices')
)
is_cloneable = columns.BooleanColumn()
class Meta(NetBoxTable.Meta):
@ -76,6 +83,33 @@ class CustomFieldTable(NetBoxTable):
default_columns = ('pk', 'name', 'content_types', 'label', 'group_name', 'type', 'required', 'description')
class CustomFieldChoiceSetTable(NetBoxTable):
name = tables.Column(
linkify=True
)
choices = columns.ArrayColumn(
max_items=10,
accessor=tables.A('extra_choices'),
orderable=False,
verbose_name=_('Choices')
)
choice_count = tables.TemplateColumn(
accessor=tables.A('extra_choices'),
template_code='{{ value|length }}',
orderable=False,
verbose_name=_('Count')
)
order_alphabetically = columns.BooleanColumn()
class Meta(NetBoxTable.Meta):
model = CustomFieldChoiceSet
fields = (
'pk', 'id', 'name', 'description', 'choice_count', 'choices', 'order_alphabetically', 'created',
'last_updated',
)
default_columns = ('pk', 'name', 'choice_count', 'description')
class CustomLinkTable(NetBoxTable):
name = tables.Column(
linkify=True

View File

@ -98,8 +98,7 @@ class CustomFieldTest(APIViewTestCases.APIViewTestCase):
{
'content_types': ['dcim.site'],
'name': 'cf6',
'type': 'select',
'choices': ['A', 'B', 'C']
'type': 'text',
},
]
bulk_update_data = {
@ -134,6 +133,42 @@ class CustomFieldTest(APIViewTestCases.APIViewTestCase):
cf.content_types.add(site_ct)
class CustomFieldChoiceSetTest(APIViewTestCases.APIViewTestCase):
model = CustomFieldChoiceSet
brief_fields = ['choices_count', 'display', 'id', 'name', 'url']
create_data = [
{
'name': 'Choice Set 4',
'extra_choices': ['4A', '4B', '4C'],
},
{
'name': 'Choice Set 5',
'extra_choices': ['5A', '5B', '5C'],
},
{
'name': 'Choice Set 6',
'extra_choices': ['6A', '6B', '6C'],
},
]
bulk_update_data = {
'description': 'New description',
}
update_data = {
'name': 'Choice Set X',
'extra_choices': ['X1', 'X2', 'X3'],
'description': 'New description',
}
@classmethod
def setUpTestData(cls):
choice_sets = (
CustomFieldChoiceSet(name='Choice Set 1', extra_choices=['1A', '1B', '1C', '1D', '1E']),
CustomFieldChoiceSet(name='Choice Set 2', extra_choices=['2A', '2B', '2C', '2D', '2E']),
CustomFieldChoiceSet(name='Choice Set 3', extra_choices=['3A', '3B', '3C', '3D', '3E']),
)
CustomFieldChoiceSet.objects.bulk_create(choice_sets)
class CustomLinkTest(APIViewTestCases.APIViewTestCase):
model = CustomLink
brief_fields = ['display', 'id', 'name', 'url']

View File

@ -5,7 +5,7 @@ from rest_framework import status
from dcim.choices import SiteStatusChoices
from dcim.models import Site
from extras.choices import *
from extras.models import CustomField, ObjectChange, Tag
from extras.models import CustomField, CustomFieldChoiceSet, ObjectChange, Tag
from utilities.testing import APITestCase
from utilities.testing.utils import create_tags, post_data
from utilities.testing.views import ModelViewTestCase
@ -16,12 +16,16 @@ class ChangeLogViewTest(ModelViewTestCase):
@classmethod
def setUpTestData(cls):
choice_set = CustomFieldChoiceSet.objects.create(
name='Custom Field Choice Set 1',
extra_choices=['Bar', 'Foo']
)
# Create a custom field on the Site model
ct = ContentType.objects.get_for_model(Site)
cf = CustomField(
type=CustomFieldTypeChoices.TYPE_TEXT,
name='my_field',
name='cf1',
required=False
)
cf.save()
@ -30,9 +34,9 @@ class ChangeLogViewTest(ModelViewTestCase):
# Create a select custom field on the Site model
cf_select = CustomField(
type=CustomFieldTypeChoices.TYPE_SELECT,
name='my_field_select',
name='cf2',
required=False,
choices=['Bar', 'Foo']
choice_set=choice_set
)
cf_select.save()
cf_select.content_types.set([ct])
@ -43,8 +47,8 @@ class ChangeLogViewTest(ModelViewTestCase):
'name': 'Site 1',
'slug': 'site-1',
'status': SiteStatusChoices.STATUS_ACTIVE,
'cf_my_field': 'ABC',
'cf_my_field_select': 'Bar',
'cf_cf1': 'ABC',
'cf_cf2': 'Bar',
'tags': [tag.pk for tag in tags],
}
@ -65,8 +69,8 @@ class ChangeLogViewTest(ModelViewTestCase):
self.assertEqual(oc.changed_object, site)
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_CREATE)
self.assertEqual(oc.prechange_data, None)
self.assertEqual(oc.postchange_data['custom_fields']['my_field'], form_data['cf_my_field'])
self.assertEqual(oc.postchange_data['custom_fields']['my_field_select'], form_data['cf_my_field_select'])
self.assertEqual(oc.postchange_data['custom_fields']['cf1'], form_data['cf_cf1'])
self.assertEqual(oc.postchange_data['custom_fields']['cf2'], form_data['cf_cf2'])
self.assertEqual(oc.postchange_data['tags'], ['Tag 1', 'Tag 2'])
def test_update_object(self):
@ -79,8 +83,8 @@ class ChangeLogViewTest(ModelViewTestCase):
'name': 'Site X',
'slug': 'site-x',
'status': SiteStatusChoices.STATUS_PLANNED,
'cf_my_field': 'DEF',
'cf_my_field_select': 'Foo',
'cf_cf1': 'DEF',
'cf_cf2': 'Foo',
'tags': [tags[2].pk],
}
@ -102,8 +106,8 @@ class ChangeLogViewTest(ModelViewTestCase):
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_UPDATE)
self.assertEqual(oc.prechange_data['name'], 'Site 1')
self.assertEqual(oc.prechange_data['tags'], ['Tag 1', 'Tag 2'])
self.assertEqual(oc.postchange_data['custom_fields']['my_field'], form_data['cf_my_field'])
self.assertEqual(oc.postchange_data['custom_fields']['my_field_select'], form_data['cf_my_field_select'])
self.assertEqual(oc.postchange_data['custom_fields']['cf1'], form_data['cf_cf1'])
self.assertEqual(oc.postchange_data['custom_fields']['cf2'], form_data['cf_cf2'])
self.assertEqual(oc.postchange_data['tags'], ['Tag 3'])
def test_delete_object(self):
@ -111,8 +115,8 @@ class ChangeLogViewTest(ModelViewTestCase):
name='Site 1',
slug='site-1',
custom_field_data={
'my_field': 'ABC',
'my_field_select': 'Bar'
'cf1': 'ABC',
'cf2': 'Bar'
}
)
site.save()
@ -131,8 +135,8 @@ class ChangeLogViewTest(ModelViewTestCase):
self.assertEqual(oc.changed_object, None)
self.assertEqual(oc.object_repr, site.name)
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_DELETE)
self.assertEqual(oc.prechange_data['custom_fields']['my_field'], 'ABC')
self.assertEqual(oc.prechange_data['custom_fields']['my_field_select'], 'Bar')
self.assertEqual(oc.prechange_data['custom_fields']['cf1'], 'ABC')
self.assertEqual(oc.prechange_data['custom_fields']['cf2'], 'Bar')
self.assertEqual(oc.prechange_data['tags'], ['Tag 1', 'Tag 2'])
self.assertEqual(oc.postchange_data, None)
@ -213,18 +217,22 @@ class ChangeLogAPITest(APITestCase):
ct = ContentType.objects.get_for_model(Site)
cf = CustomField(
type=CustomFieldTypeChoices.TYPE_TEXT,
name='my_field',
name='cf1',
required=False
)
cf.save()
cf.content_types.set([ct])
# Create a select custom field on the Site model
choice_set = CustomFieldChoiceSet.objects.create(
name='Choice Set 1',
extra_choices=['Bar', 'Foo']
)
cf_select = CustomField(
type=CustomFieldTypeChoices.TYPE_SELECT,
name='my_field_select',
name='cf2',
required=False,
choices=['Bar', 'Foo']
choice_set=choice_set
)
cf_select.save()
cf_select.content_types.set([ct])
@ -242,8 +250,8 @@ class ChangeLogAPITest(APITestCase):
'name': 'Site 1',
'slug': 'site-1',
'custom_fields': {
'my_field': 'ABC',
'my_field_select': 'Bar',
'cf1': 'ABC',
'cf2': 'Bar',
},
'tags': [
{'name': 'Tag 1'},
@ -276,8 +284,8 @@ class ChangeLogAPITest(APITestCase):
'name': 'Site X',
'slug': 'site-x',
'custom_fields': {
'my_field': 'DEF',
'my_field_select': 'Foo',
'cf1': 'DEF',
'cf2': 'Foo',
},
'tags': [
{'name': 'Tag 3'}
@ -305,8 +313,8 @@ class ChangeLogAPITest(APITestCase):
name='Site 1',
slug='site-1',
custom_field_data={
'my_field': 'ABC',
'my_field_select': 'Bar'
'cf1': 'ABC',
'cf2': 'Bar'
}
)
site.save()
@ -323,8 +331,8 @@ class ChangeLogAPITest(APITestCase):
self.assertEqual(oc.changed_object, None)
self.assertEqual(oc.object_repr, site.name)
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_DELETE)
self.assertEqual(oc.prechange_data['custom_fields']['my_field'], 'ABC')
self.assertEqual(oc.prechange_data['custom_fields']['my_field_select'], 'Bar')
self.assertEqual(oc.prechange_data['custom_fields']['cf1'], 'ABC')
self.assertEqual(oc.prechange_data['custom_fields']['cf2'], 'Bar')
self.assertEqual(oc.prechange_data['tags'], ['Tag 1', 'Tag 2'])
self.assertEqual(oc.postchange_data, None)

View File

@ -10,7 +10,7 @@ from dcim.filtersets import SiteFilterSet
from dcim.forms import SiteImportForm
from dcim.models import Manufacturer, Rack, Site
from extras.choices import *
from extras.models import CustomField
from extras.models import CustomField, CustomFieldChoiceSet
from ipam.models import VLAN
from utilities.testing import APITestCase, TestCase
from virtualization.models import VirtualMachine
@ -272,12 +272,18 @@ class CustomFieldTest(TestCase):
CHOICES = ('Option A', 'Option B', 'Option C')
value = CHOICES[1]
# Create a set of custom field choices
choice_set = CustomFieldChoiceSet.objects.create(
name='Custom Field Choice Set 1',
extra_choices=CHOICES
)
# Create a custom field & check that initial value is null
cf = CustomField.objects.create(
name='select_field',
type=CustomFieldTypeChoices.TYPE_SELECT,
required=False,
choices=CHOICES
choice_set=choice_set
)
cf.content_types.set([self.object_type])
instance = Site.objects.first()
@ -299,12 +305,18 @@ class CustomFieldTest(TestCase):
CHOICES = ['Option A', 'Option B', 'Option C']
value = [CHOICES[1], CHOICES[2]]
# Create a set of custom field choices
choice_set = CustomFieldChoiceSet.objects.create(
name='Custom Field Choice Set 1',
extra_choices=CHOICES
)
# Create a custom field & check that initial value is null
cf = CustomField.objects.create(
name='multiselect_field',
type=CustomFieldTypeChoices.TYPE_MULTISELECT,
required=False,
choices=CHOICES
choice_set=choice_set
)
cf.content_types.set([self.object_type])
instance = Site.objects.first()
@ -438,6 +450,12 @@ class CustomFieldAPITest(APITestCase):
)
VLAN.objects.bulk_create(vlans)
# Create a set of custom field choices
choice_set = CustomFieldChoiceSet.objects.create(
name='Custom Field Choice Set 1',
extra_choices=('Foo', 'Bar', 'Baz')
)
custom_fields = (
CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='text_field', default='foo'),
CustomField(type=CustomFieldTypeChoices.TYPE_LONGTEXT, name='longtext_field', default='ABC'),
@ -452,17 +470,13 @@ class CustomFieldAPITest(APITestCase):
type=CustomFieldTypeChoices.TYPE_SELECT,
name='select_field',
default='Foo',
choices=(
'Foo', 'Bar', 'Baz'
)
choice_set=choice_set
),
CustomField(
type=CustomFieldTypeChoices.TYPE_MULTISELECT,
name='multiselect_field',
default=['Foo'],
choices=(
'Foo', 'Bar', 'Baz'
)
choice_set=choice_set
),
CustomField(
type=CustomFieldTypeChoices.TYPE_OBJECT,
@ -1024,6 +1038,12 @@ class CustomFieldImportTest(TestCase):
@classmethod
def setUpTestData(cls):
# Create a set of custom field choices
choice_set = CustomFieldChoiceSet.objects.create(
name='Custom Field Choice Set 1',
extra_choices=('Choice A', 'Choice B', 'Choice C')
)
custom_fields = (
CustomField(name='text', type=CustomFieldTypeChoices.TYPE_TEXT),
CustomField(name='longtext', type=CustomFieldTypeChoices.TYPE_LONGTEXT),
@ -1034,12 +1054,8 @@ class CustomFieldImportTest(TestCase):
CustomField(name='datetime', type=CustomFieldTypeChoices.TYPE_DATETIME),
CustomField(name='url', type=CustomFieldTypeChoices.TYPE_URL),
CustomField(name='json', type=CustomFieldTypeChoices.TYPE_JSON),
CustomField(name='select', type=CustomFieldTypeChoices.TYPE_SELECT, choices=[
'Choice A', 'Choice B', 'Choice C',
]),
CustomField(name='multiselect', type=CustomFieldTypeChoices.TYPE_MULTISELECT, choices=[
'Choice A', 'Choice B', 'Choice C',
]),
CustomField(name='select', type=CustomFieldTypeChoices.TYPE_SELECT, choice_set=choice_set),
CustomField(name='multiselect', type=CustomFieldTypeChoices.TYPE_MULTISELECT, choice_set=choice_set),
)
for cf in custom_fields:
cf.save()
@ -1203,6 +1219,11 @@ class CustomFieldModelFilterTest(TestCase):
Manufacturer(name='Manufacturer 4', slug='manufacturer-4'),
))
choice_set = CustomFieldChoiceSet.objects.create(
name='Custom Field Choice Set 1',
extra_choices=['A', 'B', 'C', 'X']
)
# Integer filtering
cf = CustomField(name='cf1', type=CustomFieldTypeChoices.TYPE_INTEGER)
cf.save()
@ -1263,7 +1284,7 @@ class CustomFieldModelFilterTest(TestCase):
cf = CustomField(
name='cf9',
type=CustomFieldTypeChoices.TYPE_SELECT,
choices=['Foo', 'Bar', 'Baz']
choice_set=choice_set
)
cf.save()
cf.content_types.set([obj_type])
@ -1272,7 +1293,7 @@ class CustomFieldModelFilterTest(TestCase):
cf = CustomField(
name='cf10',
type=CustomFieldTypeChoices.TYPE_MULTISELECT,
choices=['A', 'B', 'C', 'X']
choice_set=choice_set
)
cf.save()
cf.content_types.set([obj_type])
@ -1305,7 +1326,7 @@ class CustomFieldModelFilterTest(TestCase):
'cf6': '2016-06-26',
'cf7': 'http://a.example.com',
'cf8': 'http://a.example.com',
'cf9': 'Foo',
'cf9': 'A',
'cf10': ['A', 'X'],
'cf11': manufacturers[0].pk,
'cf12': [manufacturers[0].pk, manufacturers[3].pk],
@ -1319,7 +1340,7 @@ class CustomFieldModelFilterTest(TestCase):
'cf6': '2016-06-27',
'cf7': 'http://b.example.com',
'cf8': 'http://b.example.com',
'cf9': 'Bar',
'cf9': 'B',
'cf10': ['B', 'X'],
'cf11': manufacturers[1].pk,
'cf12': [manufacturers[1].pk, manufacturers[3].pk],
@ -1333,7 +1354,7 @@ class CustomFieldModelFilterTest(TestCase):
'cf6': '2016-06-28',
'cf7': 'http://c.example.com',
'cf8': 'http://c.example.com',
'cf9': 'Baz',
'cf9': 'C',
'cf10': ['C', 'X'],
'cf11': manufacturers[2].pk,
'cf12': [manufacturers[2].pk, manufacturers[3].pk],
@ -1399,7 +1420,7 @@ class CustomFieldModelFilterTest(TestCase):
self.assertEqual(self.filterset({'cf_cf8': ['example.com']}, self.queryset).qs.count(), 3)
def test_filter_select(self):
self.assertEqual(self.filterset({'cf_cf9': ['Foo', 'Bar']}, self.queryset).qs.count(), 2)
self.assertEqual(self.filterset({'cf_cf9': ['A', 'B']}, self.queryset).qs.count(), 2)
def test_filter_multiselect(self):
self.assertEqual(self.filterset({'cf_cf10': ['A', 'B']}, self.queryset).qs.count(), 2)

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,34 @@ 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()
filterset = CustomFieldChoiceSetFilterSet
@classmethod
def setUpTestData(cls):
choice_sets = (
CustomFieldChoiceSet(name='Choice Set 1', extra_choices=['A', 'B', 'C']),
CustomFieldChoiceSet(name='Choice Set 2', extra_choices=['D', 'E', 'F']),
CustomFieldChoiceSet(name='Choice Set 3', extra_choices=['G', 'H', 'I']),
)
CustomFieldChoiceSet.objects.bulk_create(choice_sets)
def test_name(self):
params = {'name': ['Choice Set 1', 'Choice Set 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_choice(self):
params = {'choice': ['A', 'D']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class WebhookTestCase(TestCase, BaseFilterSetTests):
queryset = Webhook.objects.all()

View File

@ -5,7 +5,7 @@ from dcim.forms import SiteForm
from dcim.models import Site
from extras.choices import CustomFieldTypeChoices
from extras.forms import SavedFilterForm
from extras.models import CustomField
from extras.models import CustomField, CustomFieldChoiceSet
class CustomFieldModelFormTest(TestCase):
@ -13,7 +13,10 @@ class CustomFieldModelFormTest(TestCase):
@classmethod
def setUpTestData(cls):
obj_type = ContentType.objects.get_for_model(Site)
CHOICES = ('A', 'B', 'C')
choice_set = CustomFieldChoiceSet.objects.create(
name='Custom Field Choice Set 1',
extra_choices=('A', 'B', 'C')
)
cf_text = CustomField.objects.create(name='text', type=CustomFieldTypeChoices.TYPE_TEXT)
cf_text.content_types.set([obj_type])
@ -42,13 +45,17 @@ class CustomFieldModelFormTest(TestCase):
cf_json = CustomField.objects.create(name='json', type=CustomFieldTypeChoices.TYPE_JSON)
cf_json.content_types.set([obj_type])
cf_select = CustomField.objects.create(name='select', type=CustomFieldTypeChoices.TYPE_SELECT, choices=CHOICES)
cf_select = CustomField.objects.create(
name='select',
type=CustomFieldTypeChoices.TYPE_SELECT,
choice_set=choice_set
)
cf_select.content_types.set([obj_type])
cf_multiselect = CustomField.objects.create(
name='multiselect',
type=CustomFieldTypeChoices.TYPE_MULTISELECT,
choices=CHOICES
choice_set=choice_set
)
cf_multiselect.content_types.set([obj_type])

View File

@ -21,6 +21,11 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase):
def setUpTestData(cls):
site_ct = ContentType.objects.get_for_model(Site)
CustomFieldChoiceSet.objects.create(
name='Choice Set 1',
extra_choices=('A', 'B', 'C')
)
custom_fields = (
CustomField(name='field1', label='Field 1', type=CustomFieldTypeChoices.TYPE_TEXT),
CustomField(name='field2', label='Field 2', type=CustomFieldTypeChoices.TYPE_TEXT),
@ -44,10 +49,10 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase):
}
cls.csv_data = (
'name,label,type,content_types,object_type,weight,search_weight,filter_logic,choices,validation_minimum,validation_maximum,validation_regex,ui_visibility',
'name,label,type,content_types,object_type,weight,search_weight,filter_logic,choice_set,validation_minimum,validation_maximum,validation_regex,ui_visibility',
'field4,Field 4,text,dcim.site,,100,1000,exact,,,,[a-z]{3},read-write',
'field5,Field 5,integer,dcim.site,,100,2000,exact,,1,100,,read-write',
'field6,Field 6,select,dcim.site,,100,3000,exact,"A,B,C",,,,read-write',
'field6,Field 6,select,dcim.site,,100,3000,exact,Choice Set 1,,,,read-write',
'field7,Field 7,object,dcim.site,dcim.region,100,4000,exact,,,,,read-write',
)
@ -64,6 +69,43 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase):
}
class CustomFieldChoiceSetTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = CustomFieldChoiceSet
@classmethod
def setUpTestData(cls):
choice_sets = (
CustomFieldChoiceSet(name='Choice Set 1', extra_choices=['1A', '1B', '1C', '1D', '1E']),
CustomFieldChoiceSet(name='Choice Set 2', extra_choices=['2A', '2B', '2C', '2D', '2E']),
CustomFieldChoiceSet(name='Choice Set 3', extra_choices=['3A', '3B', '3C', '3D', '3E']),
)
CustomFieldChoiceSet.objects.bulk_create(choice_sets)
cls.form_data = {
'name': 'Choice Set X',
'extra_choices': 'X1,X2,X3,X4,X5',
}
cls.csv_data = (
'name,extra_choices',
'Choice Set 4,"4A,4B,4C,4D,4E"',
'Choice Set 5,"5A,5B,5C,5D,5E"',
'Choice Set 6,"6A,6B,6C,6D,6E"',
)
cls.csv_update_data = (
'id,extra_choices',
f'{choice_sets[0].pk},"1X,1Y,1Z"',
f'{choice_sets[1].pk},"2X,2Y,2Z"',
f'{choice_sets[2].pk},"3X,3Y,3Z"',
)
cls.bulk_edit_data = {
'description': 'New description',
}
class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = CustomLink

View File

@ -15,6 +15,14 @@ urlpatterns = [
path('custom-fields/delete/', views.CustomFieldBulkDeleteView.as_view(), name='customfield_bulk_delete'),
path('custom-fields/<int:pk>/', include(get_model_urls('extras', 'customfield'))),
# Custom field choices
path('custom-field-choices/', views.CustomFieldChoiceSetListView.as_view(), name='customfieldchoiceset_list'),
path('custom-field-choices/add/', views.CustomFieldChoiceSetEditView.as_view(), name='customfieldchoiceset_add'),
path('custom-field-choices/import/', views.CustomFieldChoiceSetBulkImportView.as_view(), name='customfieldchoiceset_import'),
path('custom-field-choices/edit/', views.CustomFieldChoiceSetBulkEditView.as_view(), name='customfieldchoiceset_bulk_edit'),
path('custom-field-choices/delete/', views.CustomFieldChoiceSetBulkDeleteView.as_view(), name='customfieldchoiceset_bulk_delete'),
path('custom-field-choices/<int:pk>/', include(get_model_urls('extras', 'customfieldchoiceset'))),
# Custom links
path('custom-links/', views.CustomLinkListView.as_view(), name='customlink_list'),
path('custom-links/add/', views.CustomLinkEditView.as_view(), name='customlink_add'),

View File

@ -34,7 +34,7 @@ from .scripts import run_script
#
class CustomFieldListView(generic.ObjectListView):
queryset = CustomField.objects.all()
queryset = CustomField.objects.select_related('choice_set')
filterset = filtersets.CustomFieldFilterSet
filterset_form = forms.CustomFieldFilterForm
table = tables.CustomFieldTable
@ -42,38 +42,83 @@ class CustomFieldListView(generic.ObjectListView):
@register_model_view(CustomField)
class CustomFieldView(generic.ObjectView):
queryset = CustomField.objects.all()
queryset = CustomField.objects.select_related('choice_set')
@register_model_view(CustomField, 'edit')
class CustomFieldEditView(generic.ObjectEditView):
queryset = CustomField.objects.all()
queryset = CustomField.objects.select_related('choice_set')
form = forms.CustomFieldForm
@register_model_view(CustomField, 'delete')
class CustomFieldDeleteView(generic.ObjectDeleteView):
queryset = CustomField.objects.all()
queryset = CustomField.objects.select_related('choice_set')
class CustomFieldBulkImportView(generic.BulkImportView):
queryset = CustomField.objects.all()
queryset = CustomField.objects.select_related('choice_set')
model_form = forms.CustomFieldImportForm
class CustomFieldBulkEditView(generic.BulkEditView):
queryset = CustomField.objects.all()
queryset = CustomField.objects.select_related('choice_set')
filterset = filtersets.CustomFieldFilterSet
table = tables.CustomFieldTable
form = forms.CustomFieldBulkEditForm
class CustomFieldBulkDeleteView(generic.BulkDeleteView):
queryset = CustomField.objects.all()
queryset = CustomField.objects.select_related('choice_set')
filterset = filtersets.CustomFieldFilterSet
table = tables.CustomFieldTable
#
# Custom field choices
#
class CustomFieldChoiceSetListView(generic.ObjectListView):
queryset = CustomFieldChoiceSet.objects.all()
filterset = filtersets.CustomFieldChoiceSetFilterSet
filterset_form = forms.CustomFieldChoiceSetFilterForm
table = tables.CustomFieldChoiceSetTable
@register_model_view(CustomFieldChoiceSet)
class CustomFieldChoiceSetView(generic.ObjectView):
queryset = CustomFieldChoiceSet.objects.all()
@register_model_view(CustomFieldChoiceSet, 'edit')
class CustomFieldChoiceSetEditView(generic.ObjectEditView):
queryset = CustomFieldChoiceSet.objects.all()
form = forms.CustomFieldChoiceSetForm
@register_model_view(CustomFieldChoiceSet, 'delete')
class CustomFieldChoiceSetDeleteView(generic.ObjectDeleteView):
queryset = CustomFieldChoiceSet.objects.all()
class CustomFieldChoiceSetBulkImportView(generic.BulkImportView):
queryset = CustomFieldChoiceSet.objects.all()
model_form = forms.CustomFieldChoiceSetImportForm
class CustomFieldChoiceSetBulkEditView(generic.BulkEditView):
queryset = CustomFieldChoiceSet.objects.all()
filterset = filtersets.CustomFieldChoiceSetFilterSet
table = tables.CustomFieldChoiceSetTable
form = forms.CustomFieldChoiceSetBulkEditForm
class CustomFieldChoiceSetBulkDeleteView(generic.BulkDeleteView):
queryset = CustomFieldChoiceSet.objects.all()
filterset = filtersets.CustomFieldChoiceSetFilterSet
table = tables.CustomFieldChoiceSetTable
#
# Custom links
#

View File

@ -288,6 +288,7 @@ CUSTOMIZATION_MENU = Menu(
label=_('Customization'),
items=(
get_model_item('extras', 'customfield', _('Custom Fields')),
get_model_item('extras', 'customfieldchoiceset', _('Custom Field Choices')),
get_model_item('extras', 'customlink', _('Custom Links')),
get_model_item('extras', 'exporttemplate', _('Export Templates')),
get_model_item('extras', 'savedfilter', _('Saved Filters')),

View File

@ -21,6 +21,7 @@ from utilities.utils import content_type_identifier, content_type_name, get_view
__all__ = (
'ActionsColumn',
'ArrayColumn',
'BooleanColumn',
'ChoiceFieldColumn',
'ColorColumn',
@ -591,3 +592,22 @@ class MarkdownColumn(tables.TemplateColumn):
def value(self, value):
return value
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

@ -15,14 +15,6 @@
<th scope="row">Name</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">Label</th>
<td>{{ object.label|placeholder }}</td>
</tr>
<tr>
<th scope="row">Group Name</th>
<td>{{ object.group_name|placeholder }}</td>
</tr>
<tr>
<th scope="row">Type</th>
<td>
@ -30,6 +22,14 @@
{% if object.object_type %}({{ object.object_type.model|bettertitle }}){% endif %}
</td>
</tr>
<tr>
<th scope="row">Label</th>
<td>{{ object.label|placeholder }}</td>
</tr>
<tr>
<th scope="row">Group</th>
<td>{{ object.group_name|placeholder }}</td>
</tr>
<tr>
<th scope="row">Description</th>
<td>{{ object.description|markdown|placeholder }}</td>
@ -38,6 +38,27 @@
<th scope="row">Required</th>
<td>{% checkmark object.required %}</td>
</tr>
<tr>
<th scope="row">Cloneable</th>
<td>{% checkmark object.is_cloneable %}</td>
</tr>
{% if object.choice_set %}
<tr>
<th scope="row">Choice Set</th>
<td>{{ object.choice_set|linkify }} ({{ object.choice_set.choices|length }} choices)</td>
</tr>
{% endif %}
<tr>
<th scope="row">Default Value</th>
<td>{{ object.default }}</td>
</tr>
</table>
</div>
</div>
<div class="card">
<h5 class="card-header">Behavior</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">Search Weight</th>
<td>
@ -60,33 +81,6 @@
<th scope="row">UI Visibility</th>
<td>{{ object.get_ui_visibility_display }}</td>
</tr>
<tr>
<th scope="row">Cloneable</th>
<td>{% checkmark object.is_cloneable %}</td>
</tr>
</table>
</div>
</div>
<div class="card">
<h5 class="card-header">
Values
</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">Default Value</th>
<td>{{ object.default }}</td>
</tr>
<tr>
<th scope="row">Choices</th>
<td>
{% if object.choices %}
{{ object.choices|join:", " }}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
</table>
</div>
</div>
@ -94,9 +88,7 @@
</div>
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">
Assigned Models
</h5>
<h5 class="card-header">Object Types</h5>
<div class="card-body">
<table class="table table-hover attr-table">
{% for ct in object.content_types.all %}
@ -108,9 +100,7 @@
</div>
</div>
<div class="card">
<h5 class="card-header">
Validation Rules
</h5>
<h5 class="card-header">Validation Rules</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
@ -138,8 +128,8 @@
</div>
</div>
<div class="row">
<div class="col col-md-12">
{% plugin_full_width_page object %}
</div>
<div class="col col-md-12">
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,64 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% block content %}
<div class="row mb-3">
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">Custom Field Choice Set</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">Name</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">Description</th>
<td>{{ object.description|markdown|placeholder }}</td>
</tr>
<tr>
<th scope="row">Choices</th>
<td>{{ object.choices|length }}</td>
</tr>
<tr>
<th scope="row">Order Alphabetically</th>
<td>{% checkmark object.order_alphabetically %}</td>
</tr>
<tr>
<th scope="row">Used by</th>
<td>
<ul class="list-unstyled mb-0">
{% for cf in object.choices_for.all %}
<li>{{ cf|linkify }}</li>
{% endfor %}
</ul>
</td>
</tr>
</table>
</div>
</div>
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">Choices</h5>
<div class="card-body">
<table class="table table-hover attr-table">
{% for choice in object.choices %}
<tr>
<td>{{ choice }}</td>
</tr>
{% endfor %}
</table>
</div>
</div>
{% plugin_right_page object %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}

View File

@ -1,6 +1,7 @@
from django import forms
__all__ = (
'ArrayWidget',
'ClearableFileInput',
'MarkdownWidget',
'NumberWithOptions',
@ -43,3 +44,13 @@ class SlugWidget(forms.TextInput):
Subclass TextInput and add a slug regeneration button next to the form field.
"""
template_name = 'widgets/sluginput.html'
class ArrayWidget(forms.Textarea):
"""
Render each item of an array on a new line within a textarea for easy editing/
"""
def format_value(self, value):
if value is None or not len(value):
return None
return '\n'.join(value)