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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
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 ### 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. 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. 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 ### 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', 'NestedBookmarkSerializer',
'NestedConfigContextSerializer', 'NestedConfigContextSerializer',
'NestedConfigTemplateSerializer', 'NestedConfigTemplateSerializer',
'NestedCustomFieldChoiceSetSerializer',
'NestedCustomFieldSerializer', 'NestedCustomFieldSerializer',
'NestedCustomLinkSerializer', 'NestedCustomLinkSerializer',
'NestedExportTemplateSerializer', 'NestedExportTemplateSerializer',
@ -34,6 +35,14 @@ class NestedCustomFieldSerializer(WritableNestedSerializer):
fields = ['id', 'url', 'display', 'name'] 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): class NestedCustomLinkSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:customlink-detail') url = serializers.HyperlinkedIdentityField(view_name='extras-api:customlink-detail')

View File

@ -35,6 +35,7 @@ __all__ = (
'ConfigContextSerializer', 'ConfigContextSerializer',
'ConfigTemplateSerializer', 'ConfigTemplateSerializer',
'ContentTypeSerializer', 'ContentTypeSerializer',
'CustomFieldChoiceSetSerializer',
'CustomFieldSerializer', 'CustomFieldSerializer',
'CustomLinkSerializer', 'CustomLinkSerializer',
'DashboardSerializer', 'DashboardSerializer',
@ -94,6 +95,7 @@ class CustomFieldSerializer(ValidatedModelSerializer):
) )
filter_logic = ChoiceField(choices=CustomFieldFilterLogicChoices, required=False) filter_logic = ChoiceField(choices=CustomFieldFilterLogicChoices, required=False)
data_type = serializers.SerializerMethodField() data_type = serializers.SerializerMethodField()
choice_set = NestedCustomFieldChoiceSetSerializer(required=False)
ui_visibility = ChoiceField(choices=CustomFieldVisibilityChoices, required=False) ui_visibility = ChoiceField(choices=CustomFieldVisibilityChoices, required=False)
class Meta: class Meta:
@ -101,7 +103,7 @@ 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', 'choices', 'created', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choice_set', 'created',
'last_updated', 'last_updated',
] ]
@ -127,6 +129,17 @@ class CustomFieldSerializer(ValidatedModelSerializer):
return 'string' 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 # Custom links
# #

View File

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

View File

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

View File

@ -20,6 +20,7 @@ __all__ = (
'ConfigRevisionFilterSet', 'ConfigRevisionFilterSet',
'ConfigTemplateFilterSet', 'ConfigTemplateFilterSet',
'ContentTypeFilterSet', 'ContentTypeFilterSet',
'CustomFieldChoiceSetFilterSet',
'CustomFieldFilterSet', 'CustomFieldFilterSet',
'CustomLinkFilterSet', 'CustomLinkFilterSet',
'ExportTemplateFilterSet', 'ExportTemplateFilterSet',
@ -74,6 +75,14 @@ class CustomFieldFilterSet(BaseFilterSet):
field_name='content_types__id' field_name='content_types__id'
) )
content_types = ContentTypeFilter() 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: class Meta:
model = CustomField 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): class CustomLinkFilterSet(BaseFilterSet):
q = django_filters.CharFilter( q = django_filters.CharFilter(
method='search', method='search',

View File

@ -4,13 +4,14 @@ from django.utils.translation import gettext as _
from extras.choices import * from extras.choices import *
from extras.models import * from extras.models import *
from utilities.forms import BulkEditForm, add_blank_choice 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 from utilities.forms.widgets import BulkEditNullBooleanSelect
__all__ = ( __all__ = (
'ConfigContextBulkEditForm', 'ConfigContextBulkEditForm',
'ConfigTemplateBulkEditForm', 'ConfigTemplateBulkEditForm',
'CustomFieldBulkEditForm', 'CustomFieldBulkEditForm',
'CustomFieldChoiceSetBulkEditForm',
'CustomLinkBulkEditForm', 'CustomLinkBulkEditForm',
'ExportTemplateBulkEditForm', 'ExportTemplateBulkEditForm',
'JournalEntryBulkEditForm', 'JournalEntryBulkEditForm',
@ -38,6 +39,10 @@ class CustomFieldBulkEditForm(BulkEditForm):
weight = forms.IntegerField( weight = forms.IntegerField(
required=False required=False
) )
choice_set = DynamicModelChoiceField(
queryset=CustomFieldChoiceSet.objects.all(),
required=False
)
ui_visibility = forms.ChoiceField( ui_visibility = forms.ChoiceField(
label=_("UI visibility"), label=_("UI visibility"),
choices=add_blank_choice(CustomFieldVisibilityChoices), choices=add_blank_choice(CustomFieldVisibilityChoices),
@ -49,7 +54,23 @@ class CustomFieldBulkEditForm(BulkEditForm):
widget=BulkEditNullBooleanSelect() 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): class CustomLinkBulkEditForm(BulkEditForm):

View File

@ -9,10 +9,13 @@ from extras.models import *
from extras.utils import FeatureQuery from extras.utils import FeatureQuery
from netbox.forms import NetBoxModelImportForm from netbox.forms import NetBoxModelImportForm
from utilities.forms import CSVModelForm 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__ = ( __all__ = (
'ConfigTemplateImportForm', 'ConfigTemplateImportForm',
'CustomFieldChoiceSetImportForm',
'CustomFieldImportForm', 'CustomFieldImportForm',
'CustomLinkImportForm', 'CustomLinkImportForm',
'ExportTemplateImportForm', 'ExportTemplateImportForm',
@ -39,10 +42,11 @@ class CustomFieldImportForm(CSVModelForm):
required=False, required=False,
help_text=_("Object type (for object or multi-object fields)") help_text=_("Object type (for object or multi-object fields)")
) )
choices = SimpleArrayField( choice_set = CSVModelChoiceField(
base_field=forms.CharField(), queryset=CustomFieldChoiceSet.objects.all(),
to_field_name='name',
required=False, required=False,
help_text=_('Comma-separated list of field choices') help_text=_('Choice set (for selection fields)')
) )
ui_visibility = CSVChoiceField( ui_visibility = CSVChoiceField(
choices=CustomFieldVisibilityChoices, choices=CustomFieldVisibilityChoices,
@ -53,8 +57,22 @@ class CustomFieldImportForm(CSVModelForm):
model = CustomField model = CustomField
fields = ( fields = (
'name', 'label', 'group_name', 'type', 'content_types', 'object_type', 'required', 'description', 'name', 'label', 'group_name', 'type', 'content_types', 'object_type', 'required', 'description',
'search_weight', 'filter_logic', 'default', 'choices', 'weight', 'validation_minimum', 'validation_maximum', 'search_weight', 'filter_logic', 'default', 'choice_set', 'weight', 'validation_minimum',
'validation_regex', 'ui_visibility', 'is_cloneable', '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', 'ConfigContextFilterForm',
'ConfigRevisionFilterForm', 'ConfigRevisionFilterForm',
'ConfigTemplateFilterForm', 'ConfigTemplateFilterForm',
'CustomFieldChoiceSetFilterForm',
'CustomFieldFilterForm', 'CustomFieldFilterForm',
'CustomLinkFilterForm', 'CustomLinkFilterForm',
'ExportTemplateFilterForm', 'ExportTemplateFilterForm',
@ -37,7 +38,8 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
fieldsets = ( fieldsets = (
(None, ('q', 'filter_id')), (None, ('q', 'filter_id')),
('Attributes', ( ('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( content_type_id = ContentTypeMultipleChoiceField(
@ -62,6 +64,11 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
choices=BOOLEAN_WITH_BLANK_CHOICES choices=BOOLEAN_WITH_BLANK_CHOICES
) )
) )
choice_set_id = DynamicModelMultipleChoiceField(
queryset=CustomFieldChoiceSet.objects.all(),
required=False,
label=_('Choice set')
)
ui_visibility = forms.ChoiceField( ui_visibility = forms.ChoiceField(
choices=add_blank_choice(CustomFieldVisibilityChoices), choices=add_blank_choice(CustomFieldVisibilityChoices),
required=False, 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): 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()),

View File

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

View File

@ -25,6 +25,12 @@ class ExtrasQuery(graphene.ObjectType):
def resolve_custom_field_list(root, info, **kwargs): def resolve_custom_field_list(root, info, **kwargs):
return gql_query_optimizer(models.CustomField.objects.all(), info) 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 = ObjectField(CustomLinkType)
custom_link_list = ObjectListField(CustomLinkType) custom_link_list = ObjectListField(CustomLinkType)

View File

@ -5,6 +5,7 @@ from netbox.graphql.types import BaseObjectType, ObjectType
__all__ = ( __all__ = (
'ConfigContextType', 'ConfigContextType',
'ConfigTemplateType', 'ConfigTemplateType',
'CustomFieldChoiceSetType',
'CustomFieldType', 'CustomFieldType',
'CustomLinkType', 'CustomLinkType',
'ExportTemplateType', 'ExportTemplateType',
@ -41,6 +42,14 @@ class CustomFieldType(ObjectType):
filterset_class = filtersets.CustomFieldFilterSet filterset_class = filtersets.CustomFieldFilterSet
class CustomFieldChoiceSetType(ObjectType):
class Meta:
model = models.CustomFieldChoiceSet
fields = '__all__'
filterset_class = filtersets.CustomFieldChoiceSetFilterSet
class CustomLinkType(ObjectType): class CustomLinkType(ObjectType):
class Meta: 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 .change_logging import *
from .configs import * from .configs import *
from .customfields import CustomField from .customfields import *
from .dashboard import * from .dashboard import *
from .models import * from .models import *
from .reports import * from .reports import *

View File

@ -31,6 +31,7 @@ from utilities.validators import validate_regex
__all__ = ( __all__ = (
'CustomField', 'CustomField',
'CustomFieldChoiceSet',
'CustomFieldManager', 'CustomFieldManager',
) )
@ -158,11 +159,12 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
'example, <code>^[A-Z]{3}$</code> will limit values to exactly three uppercase letters.' 'example, <code>^[A-Z]{3}$</code> will limit values to exactly three uppercase letters.'
) )
) )
choices = ArrayField( choice_set = models.ForeignKey(
base_field=models.CharField(max_length=100), to='CustomFieldChoiceSet',
on_delete=models.PROTECT,
related_name='choices_for',
blank=True, blank=True,
null=True, null=True
help_text=_('Comma-separated list of available choices (for selection fields)')
) )
ui_visibility = models.CharField( ui_visibility = models.CharField(
max_length=50, max_length=50,
@ -181,8 +183,8 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
clone_fields = ( clone_fields = (
'content_types', 'type', 'object_type', 'group_name', 'description', 'required', 'search_weight', 'content_types', 'type', 'object_type', 'group_name', 'description', 'required', 'search_weight',
'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choices', 'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex',
'ui_visibility', 'is_cloneable', 'choice_set', 'ui_visibility', 'is_cloneable',
) )
class Meta: class Meta:
@ -208,6 +210,12 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
def search_type(self): def search_type(self):
return SEARCH_TYPES.get(self.type) 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): def populate_initial_data(self, content_types):
""" """
Populate initial custom field data upon either a) the creation of a new CustomField, or 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" 'validation_regex': "Regular expression validation is supported only for text and URL fields"
}) })
# Choices can be set only on selection fields # Choice set must be set on selection fields, and *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
if self.type in ( if self.type in (
CustomFieldTypeChoices.TYPE_SELECT, CustomFieldTypeChoices.TYPE_SELECT,
CustomFieldTypeChoices.TYPE_MULTISELECT CustomFieldTypeChoices.TYPE_MULTISELECT
) and not self.choices: ):
if not self.choice_set:
raise ValidationError({ raise ValidationError({
'choices': "Selection fields must specify at least one choice." 'choice_set': "Selection fields must specify a set of choices."
})
elif self.choice_set:
raise ValidationError({
'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
@ -627,3 +631,52 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
elif self.required: elif self.required:
raise ValidationError("Required field cannot be empty.") 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 import django_tables2 as tables
from django.conf import settings from django.conf import settings
from django.utils.translation import gettext as _
from extras.models import * from extras.models import *
from netbox.tables import NetBoxTable, columns from netbox.tables import NetBoxTable, columns
@ -12,6 +13,7 @@ __all__ = (
'ConfigContextTable', 'ConfigContextTable',
'ConfigRevisionTable', 'ConfigRevisionTable',
'ConfigTemplateTable', 'ConfigTemplateTable',
'CustomFieldChoiceSetTable',
'CustomFieldTable', 'CustomFieldTable',
'CustomLinkTable', 'CustomLinkTable',
'ExportTemplateTable', 'ExportTemplateTable',
@ -64,6 +66,11 @@ 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 = columns.ArrayColumn(
max_items=10,
orderable=False,
verbose_name=_('Choices')
)
is_cloneable = columns.BooleanColumn() is_cloneable = columns.BooleanColumn()
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
@ -76,6 +83,33 @@ class CustomFieldTable(NetBoxTable):
default_columns = ('pk', 'name', 'content_types', 'label', 'group_name', 'type', 'required', 'description') 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): class CustomLinkTable(NetBoxTable):
name = tables.Column( name = tables.Column(
linkify=True linkify=True

View File

@ -98,8 +98,7 @@ class CustomFieldTest(APIViewTestCases.APIViewTestCase):
{ {
'content_types': ['dcim.site'], 'content_types': ['dcim.site'],
'name': 'cf6', 'name': 'cf6',
'type': 'select', 'type': 'text',
'choices': ['A', 'B', 'C']
}, },
] ]
bulk_update_data = { bulk_update_data = {
@ -134,6 +133,42 @@ class CustomFieldTest(APIViewTestCases.APIViewTestCase):
cf.content_types.add(site_ct) 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): class CustomLinkTest(APIViewTestCases.APIViewTestCase):
model = CustomLink model = CustomLink
brief_fields = ['display', 'id', 'name', 'url'] brief_fields = ['display', 'id', 'name', 'url']

View File

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

View File

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

View File

@ -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,34 @@ 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):
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): class WebhookTestCase(TestCase, BaseFilterSetTests):
queryset = Webhook.objects.all() queryset = Webhook.objects.all()

View File

@ -5,7 +5,7 @@ from dcim.forms import SiteForm
from dcim.models import Site from dcim.models import Site
from extras.choices import CustomFieldTypeChoices from extras.choices import CustomFieldTypeChoices
from extras.forms import SavedFilterForm from extras.forms import SavedFilterForm
from extras.models import CustomField from extras.models import CustomField, CustomFieldChoiceSet
class CustomFieldModelFormTest(TestCase): class CustomFieldModelFormTest(TestCase):
@ -13,7 +13,10 @@ class CustomFieldModelFormTest(TestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
obj_type = ContentType.objects.get_for_model(Site) 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 = CustomField.objects.create(name='text', type=CustomFieldTypeChoices.TYPE_TEXT)
cf_text.content_types.set([obj_type]) 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 = CustomField.objects.create(name='json', type=CustomFieldTypeChoices.TYPE_JSON)
cf_json.content_types.set([obj_type]) 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_select.content_types.set([obj_type])
cf_multiselect = CustomField.objects.create( cf_multiselect = CustomField.objects.create(
name='multiselect', name='multiselect',
type=CustomFieldTypeChoices.TYPE_MULTISELECT, type=CustomFieldTypeChoices.TYPE_MULTISELECT,
choices=CHOICES choice_set=choice_set
) )
cf_multiselect.content_types.set([obj_type]) cf_multiselect.content_types.set([obj_type])

View File

@ -21,6 +21,11 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase):
def setUpTestData(cls): def setUpTestData(cls):
site_ct = ContentType.objects.get_for_model(Site) site_ct = ContentType.objects.get_for_model(Site)
CustomFieldChoiceSet.objects.create(
name='Choice Set 1',
extra_choices=('A', 'B', 'C')
)
custom_fields = ( custom_fields = (
CustomField(name='field1', label='Field 1', type=CustomFieldTypeChoices.TYPE_TEXT), CustomField(name='field1', label='Field 1', type=CustomFieldTypeChoices.TYPE_TEXT),
CustomField(name='field2', label='Field 2', 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 = ( 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', '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', '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', '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): class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = CustomLink model = CustomLink

View File

@ -15,6 +15,14 @@ urlpatterns = [
path('custom-fields/delete/', views.CustomFieldBulkDeleteView.as_view(), name='customfield_bulk_delete'), path('custom-fields/delete/', views.CustomFieldBulkDeleteView.as_view(), name='customfield_bulk_delete'),
path('custom-fields/<int:pk>/', include(get_model_urls('extras', 'customfield'))), 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 # Custom links
path('custom-links/', views.CustomLinkListView.as_view(), name='customlink_list'), path('custom-links/', views.CustomLinkListView.as_view(), name='customlink_list'),
path('custom-links/add/', views.CustomLinkEditView.as_view(), name='customlink_add'), 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): class CustomFieldListView(generic.ObjectListView):
queryset = CustomField.objects.all() queryset = CustomField.objects.select_related('choice_set')
filterset = filtersets.CustomFieldFilterSet filterset = filtersets.CustomFieldFilterSet
filterset_form = forms.CustomFieldFilterForm filterset_form = forms.CustomFieldFilterForm
table = tables.CustomFieldTable table = tables.CustomFieldTable
@ -42,38 +42,83 @@ class CustomFieldListView(generic.ObjectListView):
@register_model_view(CustomField) @register_model_view(CustomField)
class CustomFieldView(generic.ObjectView): class CustomFieldView(generic.ObjectView):
queryset = CustomField.objects.all() queryset = CustomField.objects.select_related('choice_set')
@register_model_view(CustomField, 'edit') @register_model_view(CustomField, 'edit')
class CustomFieldEditView(generic.ObjectEditView): class CustomFieldEditView(generic.ObjectEditView):
queryset = CustomField.objects.all() queryset = CustomField.objects.select_related('choice_set')
form = forms.CustomFieldForm form = forms.CustomFieldForm
@register_model_view(CustomField, 'delete') @register_model_view(CustomField, 'delete')
class CustomFieldDeleteView(generic.ObjectDeleteView): class CustomFieldDeleteView(generic.ObjectDeleteView):
queryset = CustomField.objects.all() queryset = CustomField.objects.select_related('choice_set')
class CustomFieldBulkImportView(generic.BulkImportView): class CustomFieldBulkImportView(generic.BulkImportView):
queryset = CustomField.objects.all() queryset = CustomField.objects.select_related('choice_set')
model_form = forms.CustomFieldImportForm model_form = forms.CustomFieldImportForm
class CustomFieldBulkEditView(generic.BulkEditView): class CustomFieldBulkEditView(generic.BulkEditView):
queryset = CustomField.objects.all() queryset = CustomField.objects.select_related('choice_set')
filterset = filtersets.CustomFieldFilterSet filterset = filtersets.CustomFieldFilterSet
table = tables.CustomFieldTable table = tables.CustomFieldTable
form = forms.CustomFieldBulkEditForm form = forms.CustomFieldBulkEditForm
class CustomFieldBulkDeleteView(generic.BulkDeleteView): class CustomFieldBulkDeleteView(generic.BulkDeleteView):
queryset = CustomField.objects.all() queryset = CustomField.objects.select_related('choice_set')
filterset = filtersets.CustomFieldFilterSet filterset = filtersets.CustomFieldFilterSet
table = tables.CustomFieldTable 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 # Custom links
# #

View File

@ -288,6 +288,7 @@ CUSTOMIZATION_MENU = Menu(
label=_('Customization'), label=_('Customization'),
items=( items=(
get_model_item('extras', 'customfield', _('Custom Fields')), 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', 'customlink', _('Custom Links')),
get_model_item('extras', 'exporttemplate', _('Export Templates')), get_model_item('extras', 'exporttemplate', _('Export Templates')),
get_model_item('extras', 'savedfilter', _('Saved Filters')), 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__ = ( __all__ = (
'ActionsColumn', 'ActionsColumn',
'ArrayColumn',
'BooleanColumn', 'BooleanColumn',
'ChoiceFieldColumn', 'ChoiceFieldColumn',
'ColorColumn', 'ColorColumn',
@ -591,3 +592,22 @@ class MarkdownColumn(tables.TemplateColumn):
def value(self, value): def value(self, value):
return 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> <th scope="row">Name</th>
<td>{{ object.name }}</td> <td>{{ object.name }}</td>
</tr> </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> <tr>
<th scope="row">Type</th> <th scope="row">Type</th>
<td> <td>
@ -30,6 +22,14 @@
{% if object.object_type %}({{ object.object_type.model|bettertitle }}){% endif %} {% if object.object_type %}({{ object.object_type.model|bettertitle }}){% endif %}
</td> </td>
</tr> </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> <tr>
<th scope="row">Description</th> <th scope="row">Description</th>
<td>{{ object.description|markdown|placeholder }}</td> <td>{{ object.description|markdown|placeholder }}</td>
@ -38,6 +38,27 @@
<th scope="row">Required</th> <th scope="row">Required</th>
<td>{% checkmark object.required %}</td> <td>{% checkmark object.required %}</td>
</tr> </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> <tr>
<th scope="row">Search Weight</th> <th scope="row">Search Weight</th>
<td> <td>
@ -60,33 +81,6 @@
<th scope="row">UI Visibility</th> <th scope="row">UI Visibility</th>
<td>{{ object.get_ui_visibility_display }}</td> <td>{{ object.get_ui_visibility_display }}</td>
</tr> </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> </table>
</div> </div>
</div> </div>
@ -94,9 +88,7 @@
</div> </div>
<div class="col col-md-6"> <div class="col col-md-6">
<div class="card"> <div class="card">
<h5 class="card-header"> <h5 class="card-header">Object Types</h5>
Assigned Models
</h5>
<div class="card-body"> <div class="card-body">
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
{% for ct in object.content_types.all %} {% for ct in object.content_types.all %}
@ -108,9 +100,7 @@
</div> </div>
</div> </div>
<div class="card"> <div class="card">
<h5 class="card-header"> <h5 class="card-header">Validation Rules</h5>
Validation Rules
</h5>
<div class="card-body"> <div class="card-body">
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
<tr> <tr>

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 from django import forms
__all__ = ( __all__ = (
'ArrayWidget',
'ClearableFileInput', 'ClearableFileInput',
'MarkdownWidget', 'MarkdownWidget',
'NumberWithOptions', 'NumberWithOptions',
@ -43,3 +44,13 @@ class SlugWidget(forms.TextInput):
Subclass TextInput and add a slug regeneration button next to the form field. Subclass TextInput and add a slug regeneration button next to the form field.
""" """
template_name = 'widgets/sluginput.html' 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)