mirror of
https://github.com/netbox-community/netbox.git
synced 2024-05-10 07:54:54 +00:00
* 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:
@ -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.
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
17
docs/models/extras/customfieldchoiceset.md
Normal file
17
docs/models/extras/customfieldchoiceset.md
Normal 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.
|
@ -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')
|
||||
|
||||
|
@ -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
|
||||
#
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
#
|
||||
|
@ -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',
|
||||
|
@ -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):
|
||||
|
@ -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',
|
||||
)
|
||||
|
||||
|
||||
|
@ -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()),
|
||||
|
@ -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(),
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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:
|
||||
|
61
netbox/extras/migrations/0096_customfieldchoiceset.py
Normal file
61
netbox/extras/migrations/0096_customfieldchoiceset.py
Normal 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
|
||||
),
|
||||
]
|
17
netbox/extras/migrations/0097_customfield_remove_choices.py
Normal file
17
netbox/extras/migrations/0097_customfield_remove_choices.py
Normal 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',
|
||||
),
|
||||
]
|
@ -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 *
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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']
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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()
|
||||
|
@ -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])
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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'),
|
||||
|
@ -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
|
||||
#
|
||||
|
@ -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')),
|
||||
|
@ -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)
|
||||
|
@ -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 %}
|
||||
|
64
netbox/templates/extras/customfieldchoiceset.html
Normal file
64
netbox/templates/extras/customfieldchoiceset.html
Normal 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 %}
|
@ -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)
|
||||
|
Reference in New Issue
Block a user