Closes #12194: Add pre-defined custom field choices (#13219)

* 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

* Initial work on predefined choices for custom fields

* Misc cleanup

* Add IATA airport codes

* #13241: Add support for custom field choice labels

* Restore ArrayColumn

* Misc cleanup

* Change extra_choices back to a nested ArrayField to preserve choice ordering

* Hack to bypass GraphQL API test utility absent support for nested ArrayFields
This commit is contained in:
Jeremy Stretch 2023-07-28 11:24:21 -04:00 committed by GitHub
parent 9d3bb585a2
commit cf1b1a83eb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 121940 additions and 100 deletions

View File

@ -1,6 +1,8 @@
# 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.
Single- and multi-selection [custom fields](../../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.
A choice set must define a base choice set and/or a set of arbitrary extra choices.
## Fields
@ -8,9 +10,17 @@ Single- and multi-selection [custom fields documentation](../../customization/cu
The human-friendly name of the choice set.
### Base Choices
The set of pre-defined choices to include. Available sets are listed below. This is an optional setting.
* IATA airport codes
* ISO 3166 - Two-letter country codes
* UN/LOCODE - Five-character location identifiers
### Extra Choices
The list of valid choices, entered as a comma-separated list.
A set of custom choices that will be appended to the base choice set (if any).
### Order Alphabetically

View File

@ -131,12 +131,16 @@ class CustomFieldSerializer(ValidatedModelSerializer):
class CustomFieldChoiceSetSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:customfieldchoiceset-detail')
base_choices = ChoiceField(
choices=CustomFieldChoiceSetBaseChoices,
required=False
)
class Meta:
model = CustomFieldChoiceSet
fields = [
'id', 'url', 'display', 'name', 'description', 'extra_choices', 'order_alphabetically', 'choices_count',
'created', 'last_updated',
'id', 'url', 'display', 'name', 'description', 'base_choices', 'extra_choices', 'order_alphabetically',
'choices_count', 'created', 'last_updated',
]

View File

@ -1,5 +1,6 @@
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
@ -63,6 +64,26 @@ class CustomFieldChoiceSetViewSet(NetBoxModelViewSet):
serializer_class = serializers.CustomFieldChoiceSetSerializer
filterset_class = filtersets.CustomFieldChoiceSetFilterSet
@action(detail=True)
def choices(self, request, pk):
"""
Provides an endpoint to iterate through each choice in a set.
"""
choiceset = get_object_or_404(self.queryset, pk=pk)
choices = choiceset.choices
# Enable filtering
if q := request.GET.get('q'):
q = q.lower()
choices = [c for c in choices if q in c[0].lower() or q in c[1].lower()]
# Paginate data
if page := self.paginate_queryset(choices):
data = [
{'value': c[0], 'label': c[1]} for c in page
]
return self.get_paginated_response(data)
#
# Custom links

View File

@ -66,6 +66,19 @@ class CustomFieldVisibilityChoices(ChoiceSet):
)
class CustomFieldChoiceSetBaseChoices(ChoiceSet):
IATA = 'IATA'
ISO_3166 = 'ISO_3166'
UN_LOCODE = 'UN_LOCODE'
CHOICES = (
(IATA, 'IATA (Airport codes)'),
(ISO_3166, 'ISO 3166 (Country codes)'),
(UN_LOCODE, 'UN/LOCODE (Location codes)'),
)
#
# CustomLinks
#

View File

@ -0,0 +1,9 @@
from .iata import IATA
from .iso_3166 import ISO_3166
from .un_locode import UN_LOCODE
CHOICE_SETS = {
'IATA': IATA,
'ISO_3166': ISO_3166,
'UN_LOCODE': UN_LOCODE,
}

9768
netbox/extras/data/iata.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,253 @@
# Two-letter country codes defined by ISO 3166
# Source: https://datahub.io/core/country-list
ISO_3166 = [
('AD', 'AD (Andorra)'),
('AE', 'AE (United Arab Emirates)'),
('AF', 'AF (Afghanistan)'),
('AG', 'AG (Antigua and Barbuda)'),
('AI', 'AI (Anguilla)'),
('AL', 'AL (Albania)'),
('AM', 'AM (Armenia)'),
('AO', 'AO (Angola)'),
('AQ', 'AQ (Antarctica)'),
('AR', 'AR (Argentina)'),
('AS', 'AS (American Samoa)'),
('AT', 'AT (Austria)'),
('AU', 'AU (Australia)'),
('AW', 'AW (Aruba)'),
('AX', 'AX (Åland Islands)'),
('AZ', 'AZ (Azerbaijan)'),
('BA', 'BA (Bosnia and Herzegovina)'),
('BB', 'BB (Barbados)'),
('BD', 'BD (Bangladesh)'),
('BE', 'BE (Belgium)'),
('BF', 'BF (Burkina Faso)'),
('BG', 'BG (Bulgaria)'),
('BH', 'BH (Bahrain)'),
('BI', 'BI (Burundi)'),
('BJ', 'BJ (Benin)'),
('BL', 'BL (Saint Barthélemy)'),
('BM', 'BM (Bermuda)'),
('BN', 'BN (Brunei Darussalam)'),
('BO', 'BO (Bolivia, Plurinational State of)'),
('BQ', 'BQ (Bonaire, Sint Eustatius and Saba)'),
('BR', 'BR (Brazil)'),
('BS', 'BS (Bahamas)'),
('BT', 'BT (Bhutan)'),
('BV', 'BV (Bouvet Island)'),
('BW', 'BW (Botswana)'),
('BY', 'BY (Belarus)'),
('BZ', 'BZ (Belize)'),
('CA', 'CA (Canada)'),
('CC', 'CC (Cocos (Keeling) Islands)'),
('CD', 'CD (Congo, the Democratic Republic of the)'),
('CF', 'CF (Central African Republic)'),
('CG', 'CG (Congo)'),
('CH', 'CH (Switzerland)'),
('CI', "CI (Côte d'Ivoire)"),
('CK', 'CK (Cook Islands)'),
('CL', 'CL (Chile)'),
('CM', 'CM (Cameroon)'),
('CN', 'CN (China)'),
('CO', 'CO (Colombia)'),
('CR', 'CR (Costa Rica)'),
('CU', 'CU (Cuba)'),
('CV', 'CV (Cape Verde)'),
('CW', 'CW (Curaçao)'),
('CX', 'CX (Christmas Island)'),
('CY', 'CY (Cyprus)'),
('CZ', 'CZ (Czech Republic)'),
('DE', 'DE (Germany)'),
('DJ', 'DJ (Djibouti)'),
('DK', 'DK (Denmark)'),
('DM', 'DM (Dominica)'),
('DO', 'DO (Dominican Republic)'),
('DZ', 'DZ (Algeria)'),
('EC', 'EC (Ecuador)'),
('EE', 'EE (Estonia)'),
('EG', 'EG (Egypt)'),
('EH', 'EH (Western Sahara)'),
('ER', 'ER (Eritrea)'),
('ES', 'ES (Spain)'),
('ET', 'ET (Ethiopia)'),
('FI', 'FI (Finland)'),
('FJ', 'FJ (Fiji)'),
('FK', 'FK (Falkland Islands (Malvinas))'),
('FM', 'FM (Micronesia, Federated States of)'),
('FO', 'FO (Faroe Islands)'),
('FR', 'FR (France)'),
('GA', 'GA (Gabon)'),
('GB', 'GB (United Kingdom)'),
('GD', 'GD (Grenada)'),
('GE', 'GE (Georgia)'),
('GF', 'GF (French Guiana)'),
('GG', 'GG (Guernsey)'),
('GH', 'GH (Ghana)'),
('GI', 'GI (Gibraltar)'),
('GL', 'GL (Greenland)'),
('GM', 'GM (Gambia)'),
('GN', 'GN (Guinea)'),
('GP', 'GP (Guadeloupe)'),
('GQ', 'GQ (Equatorial Guinea)'),
('GR', 'GR (Greece)'),
('GS', 'GS (South Georgia and the South Sandwich Islands)'),
('GT', 'GT (Guatemala)'),
('GU', 'GU (Guam)'),
('GW', 'GW (Guinea-Bissau)'),
('GY', 'GY (Guyana)'),
('HK', 'HK (Hong Kong)'),
('HM', 'HM (Heard Island and McDonald Islands)'),
('HN', 'HN (Honduras)'),
('HR', 'HR (Croatia)'),
('HT', 'HT (Haiti)'),
('HU', 'HU (Hungary)'),
('ID', 'ID (Indonesia)'),
('IE', 'IE (Ireland)'),
('IL', 'IL (Israel)'),
('IM', 'IM (Isle of Man)'),
('IN', 'IN (India)'),
('IO', 'IO (British Indian Ocean Territory)'),
('IQ', 'IQ (Iraq)'),
('IR', 'IR (Iran, Islamic Republic of)'),
('IS', 'IS (Iceland)'),
('IT', 'IT (Italy)'),
('JE', 'JE (Jersey)'),
('JM', 'JM (Jamaica)'),
('JO', 'JO (Jordan)'),
('JP', 'JP (Japan)'),
('KE', 'KE (Kenya)'),
('KG', 'KG (Kyrgyzstan)'),
('KH', 'KH (Cambodia)'),
('KI', 'KI (Kiribati)'),
('KM', 'KM (Comoros)'),
('KN', 'KN (Saint Kitts and Nevis)'),
('KP', "KP (Korea, Democratic People's Republic of)"),
('KR', 'KR (Korea, Republic of)'),
('KW', 'KW (Kuwait)'),
('KY', 'KY (Cayman Islands)'),
('KZ', 'KZ (Kazakhstan)'),
('LA', "LA (Lao People's Democratic Republic)"),
('LB', 'LB (Lebanon)'),
('LC', 'LC (Saint Lucia)'),
('LI', 'LI (Liechtenstein)'),
('LK', 'LK (Sri Lanka)'),
('LR', 'LR (Liberia)'),
('LS', 'LS (Lesotho)'),
('LT', 'LT (Lithuania)'),
('LU', 'LU (Luxembourg)'),
('LV', 'LV (Latvia)'),
('LY', 'LY (Libya)'),
('MA', 'MA (Morocco)'),
('MC', 'MC (Monaco)'),
('MD', 'MD (Moldova, Republic of)'),
('ME', 'ME (Montenegro)'),
('MF', 'MF (Saint Martin (French part))'),
('MG', 'MG (Madagascar)'),
('MH', 'MH (Marshall Islands)'),
('MK', 'MK (Macedonia, the Former Yugoslav Republic of)'),
('ML', 'ML (Mali)'),
('MM', 'MM (Myanmar)'),
('MN', 'MN (Mongolia)'),
('MO', 'MO (Macao)'),
('MP', 'MP (Northern Mariana Islands)'),
('MQ', 'MQ (Martinique)'),
('MR', 'MR (Mauritania)'),
('MS', 'MS (Montserrat)'),
('MT', 'MT (Malta)'),
('MU', 'MU (Mauritius)'),
('MV', 'MV (Maldives)'),
('MW', 'MW (Malawi)'),
('MX', 'MX (Mexico)'),
('MY', 'MY (Malaysia)'),
('MZ', 'MZ (Mozambique)'),
('NA', 'NA (Namibia)'),
('NC', 'NC (New Caledonia)'),
('NE', 'NE (Niger)'),
('NF', 'NF (Norfolk Island)'),
('NG', 'NG (Nigeria)'),
('NI', 'NI (Nicaragua)'),
('NL', 'NL (Netherlands)'),
('NO', 'NO (Norway)'),
('NP', 'NP (Nepal)'),
('NR', 'NR (Nauru)'),
('NU', 'NU (Niue)'),
('NZ', 'NZ (New Zealand)'),
('OM', 'OM (Oman)'),
('PA', 'PA (Panama)'),
('PE', 'PE (Peru)'),
('PF', 'PF (French Polynesia)'),
('PG', 'PG (Papua New Guinea)'),
('PH', 'PH (Philippines)'),
('PK', 'PK (Pakistan)'),
('PL', 'PL (Poland)'),
('PM', 'PM (Saint Pierre and Miquelon)'),
('PN', 'PN (Pitcairn)'),
('PR', 'PR (Puerto Rico)'),
('PS', 'PS (Palestine, State of)'),
('PT', 'PT (Portugal)'),
('PW', 'PW (Palau)'),
('PY', 'PY (Paraguay)'),
('QA', 'QA (Qatar)'),
('RE', 'RE (Réunion)'),
('RO', 'RO (Romania)'),
('RS', 'RS (Serbia)'),
('RU', 'RU (Russian Federation)'),
('RW', 'RW (Rwanda)'),
('SA', 'SA (Saudi Arabia)'),
('SB', 'SB (Solomon Islands)'),
('SC', 'SC (Seychelles)'),
('SD', 'SD (Sudan)'),
('SE', 'SE (Sweden)'),
('SG', 'SG (Singapore)'),
('SH', 'SH (Saint Helena, Ascension and Tristan da Cunha)'),
('SI', 'SI (Slovenia)'),
('SJ', 'SJ (Svalbard and Jan Mayen)'),
('SK', 'SK (Slovakia)'),
('SL', 'SL (Sierra Leone)'),
('SM', 'SM (San Marino)'),
('SN', 'SN (Senegal)'),
('SO', 'SO (Somalia)'),
('SR', 'SR (Suriname)'),
('SS', 'SS (South Sudan)'),
('ST', 'ST (Sao Tome and Principe)'),
('SV', 'SV (El Salvador)'),
('SX', 'SX (Sint Maarten (Dutch part))'),
('SY', 'SY (Syrian Arab Republic)'),
('SZ', 'SZ (Swaziland)'),
('TC', 'TC (Turks and Caicos Islands)'),
('TD', 'TD (Chad)'),
('TF', 'TF (French Southern Territories)'),
('TG', 'TG (Togo)'),
('TH', 'TH (Thailand)'),
('TJ', 'TJ (Tajikistan)'),
('TK', 'TK (Tokelau)'),
('TL', 'TL (Timor-Leste)'),
('TM', 'TM (Turkmenistan)'),
('TN', 'TN (Tunisia)'),
('TO', 'TO (Tonga)'),
('TR', 'TR (Turkey)'),
('TT', 'TT (Trinidad and Tobago)'),
('TV', 'TV (Tuvalu)'),
('TW', 'TW (Taiwan, Province of China)'),
('TZ', 'TZ (Tanzania, United Republic of)'),
('UA', 'UA (Ukraine)'),
('UG', 'UG (Uganda)'),
('UM', 'UM (United States Minor Outlying Islands)'),
('US', 'US (United States)'),
('UY', 'UY (Uruguay)'),
('UZ', 'UZ (Uzbekistan)'),
('VA', 'VA (Holy See (Vatican City State))'),
('VC', 'VC (Saint Vincent and the Grenadines)'),
('VE', 'VE (Venezuela, Bolivarian Republic of)'),
('VG', 'VG (Virgin Islands, British)'),
('VI', 'VI (Virgin Islands, U.S.)'),
('VN', 'VN (Viet Nam)'),
('VU', 'VU (Vanuatu)'),
('WF', 'WF (Wallis and Futuna)'),
('WS', 'WS (Samoa)'),
('YE', 'YE (Yemen)'),
('YT', 'YT (Mayotte)'),
('ZA', 'ZA (South Africa)'),
('ZM', 'ZM (Zambia)'),
('ZW', 'ZW (Zimbabwe)')
]

111557
netbox/extras/data/un_locode.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -114,7 +114,7 @@ class CustomFieldChoiceSetFilterSet(BaseFilterSet):
class Meta:
model = CustomFieldChoiceSet
fields = [
'id', 'name', 'description', 'order_alphabetically',
'id', 'name', 'description', 'base_choices', 'order_alphabetically',
]
def search(self, queryset, name, value):

View File

@ -62,6 +62,10 @@ class CustomFieldChoiceSetBulkEditForm(BulkEditForm):
queryset=CustomFieldChoiceSet.objects.all(),
widget=forms.MultipleHiddenInput
)
base_choices = forms.ChoiceField(
choices=add_blank_choice(CustomFieldChoiceSetBaseChoices),
required=False
)
description = forms.CharField(
required=False
)
@ -70,7 +74,7 @@ class CustomFieldChoiceSetBulkEditForm(BulkEditForm):
widget=BulkEditNullBooleanSelect()
)
nullable_fields = ('description',)
nullable_fields = ('base_choices', 'description')
class CustomLinkBulkEditForm(BulkEditForm):

View File

@ -4,7 +4,7 @@ from django.contrib.postgres.forms import SimpleArrayField
from django.utils.safestring import mark_safe
from django.utils.translation import gettext as _
from extras.choices import CustomFieldVisibilityChoices, CustomFieldTypeChoices, JournalEntryKindChoices
from extras.choices import *
from extras.models import *
from extras.utils import FeatureQuery
from netbox.forms import NetBoxModelImportForm
@ -63,6 +63,11 @@ class CustomFieldImportForm(CSVModelForm):
class CustomFieldChoiceSetImportForm(CSVModelForm):
base_choices = CSVChoiceField(
choices=CustomFieldChoiceSetBaseChoices,
required=False,
help_text=_('The base set of predefined choices to use (if any)')
)
extra_choices = SimpleArrayField(
base_field=forms.CharField(),
required=False,

View File

@ -11,7 +11,9 @@ from extras.utils import FeatureQuery
from netbox.forms.base import NetBoxModelFilterSetForm
from tenancy.models import Tenant, TenantGroup
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice
from utilities.forms.fields import ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, TagFilterField
from utilities.forms.fields import (
ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, TagFilterField,
)
from utilities.forms.widgets import APISelectMultiple, DateTimePicker
from virtualization.models import Cluster, ClusterGroup, ClusterType
from .mixins import SavedFiltersMixin
@ -84,7 +86,12 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
class CustomFieldChoiceSetFilterForm(SavedFiltersMixin, FilterForm):
fieldsets = (
(None, ('q', 'filter_id', 'choice')),
(None, ('q', 'filter_id')),
(_('Choices'), ('base_choices', 'choice')),
)
base_choices = forms.MultipleChoiceField(
choices=CustomFieldChoiceSetBaseChoices,
required=False
)
choice = forms.CharField(
required=False

View File

@ -19,7 +19,7 @@ from utilities.forms.fields import (
CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelChoiceField,
DynamicModelMultipleChoiceField, JSONField, SlugField,
)
from utilities.forms.widgets import ArrayWidget
from utilities.forms.widgets import ChoicesWidget
from virtualization.models import Cluster, ClusterGroup, ClusterType
@ -86,16 +86,22 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
class CustomFieldChoiceSetForm(BootstrapMixin, forms.ModelForm):
extra_choices = forms.CharField(
widget=ArrayWidget(),
help_text=_('Enter one choice per line.')
widget=ChoicesWidget(),
)
class Meta:
model = CustomFieldChoiceSet
fields = ('name', 'description', 'extra_choices', 'order_alphabetically')
fields = ('name', 'description', 'base_choices', 'extra_choices', 'order_alphabetically')
def clean_extra_choices(self):
return self.cleaned_data['extra_choices'].splitlines()
data = []
for line in self.cleaned_data['extra_choices'].splitlines():
try:
value, label = line.split(',', maxsplit=1)
except ValueError:
value, label = line, line
data.append((value, label))
return data
class CustomLinkForm(BootstrapMixin, forms.ModelForm):

View File

@ -19,7 +19,7 @@ def create_choice_sets(apps, schema_editor):
for cf in choice_fields:
choiceset = CustomFieldChoiceSet.objects.create(
name=f'{cf.name} Choices',
extra_choices=cf.choices
extra_choices=tuple(zip(cf.choices, cf.choices)) # Convert list to tuple of two-tuples
)
cf.choice_set = choiceset
@ -42,7 +42,8 @@ class Migration(migrations.Migration):
('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)),
('base_choices', models.CharField(blank=True, max_length=50)),
('extra_choices', django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), size=2), blank=True, null=True, size=None)),
('order_alphabetically', models.BooleanField(default=False)),
],
options={

View File

@ -15,17 +15,18 @@ from django.utils.safestring import mark_safe
from django.utils.translation import gettext as _
from extras.choices import *
from extras.data import CHOICE_SETS
from extras.utils import FeatureQuery
from netbox.models import ChangeLoggedModel
from netbox.models.features import CloningMixin, ExportTemplatesMixin
from netbox.search import FieldTypes
from utilities import filters
from utilities.forms.fields import (
CSVChoiceField, CSVModelChoiceField, CSVModelMultipleChoiceField, CSVMultipleChoiceField, DynamicModelChoiceField,
DynamicModelMultipleChoiceField, JSONField, LaxURLField,
CSVChoiceField, CSVModelChoiceField, CSVModelMultipleChoiceField, CSVMultipleChoiceField, DynamicChoiceField,
DynamicModelChoiceField, DynamicModelMultipleChoiceField, DynamicMultipleChoiceField, JSONField, LaxURLField,
)
from utilities.forms.utils import add_blank_choice
from utilities.forms.widgets import DatePicker, DateTimePicker
from utilities.forms.widgets import APISelect, APISelectMultiple, DatePicker, DateTimePicker
from utilities.querysets import RestrictedQuerySet
from utilities.validators import validate_regex
@ -410,7 +411,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
# Select
elif self.type in (CustomFieldTypeChoices.TYPE_SELECT, CustomFieldTypeChoices.TYPE_MULTISELECT):
choices = [(c, c) for c in self.choices]
choices = self.choice_set.choices
default_choice = self.default if self.default in self.choices else None
if not required or default_choice is None:
@ -421,11 +422,17 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
initial = default_choice
if self.type == CustomFieldTypeChoices.TYPE_SELECT:
field_class = CSVChoiceField if for_csv_import else forms.ChoiceField
field = field_class(choices=choices, required=required, initial=initial)
field_class = CSVChoiceField if for_csv_import else DynamicChoiceField
widget_class = APISelect
else:
field_class = CSVMultipleChoiceField if for_csv_import else forms.MultipleChoiceField
field = field_class(choices=choices, required=required, initial=initial)
field_class = CSVMultipleChoiceField if for_csv_import else DynamicMultipleChoiceField
widget_class = APISelectMultiple
field = field_class(
choices=choices,
required=required,
initial=initial,
widget=widget_class(api_url=f'/api/extras/custom-field-choices/{self.choice_set.pk}/choices/')
)
# URL
elif self.type == CustomFieldTypeChoices.TYPE_URL:
@ -604,14 +611,14 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
# Validate selected choice
elif self.type == CustomFieldTypeChoices.TYPE_SELECT:
if value not in self.choices:
if value not in [c[0] for c in self.choices]:
raise ValidationError(
f"Invalid choice ({value}). Available choices are: {', '.join(self.choices)}"
)
# Validate all selected choices
elif self.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
if not set(value).issubset(self.choices):
if not set(value).issubset([c[0] for c in self.choices]):
raise ValidationError(
f"Invalid choice(s) ({', '.join(value)}). Available choices are: {', '.join(self.choices)}"
)
@ -645,13 +652,23 @@ class CustomFieldChoiceSet(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel
max_length=200,
blank=True
)
base_choices = models.CharField(
max_length=50,
choices=CustomFieldChoiceSetBaseChoices,
blank=True,
help_text=_('Base set of predefined choices (optional)')
)
extra_choices = ArrayField(
base_field=models.CharField(max_length=100),
help_text=_('List of field choices')
ArrayField(
base_field=models.CharField(max_length=100),
size=2
),
blank=True,
null=True
)
order_alphabetically = models.BooleanField(
default=False,
help_text=_('Choices are automatically ordered alphabetically on save')
help_text=_('Choices are automatically ordered alphabetically')
)
clone_fields = ('extra_choices', 'order_alphabetically')
@ -667,16 +684,31 @@ class CustomFieldChoiceSet(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel
@property
def choices(self):
return self.extra_choices
"""
Returns a concatenation of the base and extra choices.
"""
if not hasattr(self, '_choices'):
self._choices = []
if self.base_choices:
self._choices.extend(CHOICE_SETS.get(self.base_choices))
if self.extra_choices:
self._choices.extend(self.extra_choices)
if self.order_alphabetically:
self._choices = sorted(self._choices, key=lambda x: x[0])
return self._choices
@property
def choices_count(self):
return len(self.choices)
def clean(self):
if not self.base_choices and not self.extra_choices:
raise ValidationError(_("Must define base or extra choices."))
def save(self, *args, **kwargs):
# Sort choices if alphabetical ordering is enforced
if self.order_alphabetically:
self.extra_choices = sorted(self.choices)
self.extra_choices = sorted(self.extra_choices, key=lambda x: x[0])
return super().save(*args, **kwargs)

View File

@ -66,10 +66,12 @@ class CustomFieldTable(NetBoxTable):
required = columns.BooleanColumn()
ui_visibility = columns.ChoiceFieldColumn(verbose_name="UI visibility")
description = columns.MarkdownColumn()
choices = columns.ArrayColumn(
choice_set = tables.Column(
linkify=True
)
choices = columns.ChoicesColumn(
max_items=10,
orderable=False,
verbose_name=_('Choices')
orderable=False
)
is_cloneable = columns.BooleanColumn()
@ -77,8 +79,8 @@ class CustomFieldTable(NetBoxTable):
model = CustomField
fields = (
'pk', 'id', 'name', 'content_types', 'label', 'type', 'group_name', 'required', 'default', 'description',
'search_weight', 'filter_logic', 'ui_visibility', 'is_cloneable', 'weight', 'choices', 'created',
'last_updated',
'search_weight', 'filter_logic', 'ui_visibility', 'is_cloneable', 'weight', 'choice_set', 'choices',
'created', 'last_updated',
)
default_columns = ('pk', 'name', 'content_types', 'label', 'group_name', 'type', 'required', 'description')
@ -87,11 +89,13 @@ class CustomFieldChoiceSetTable(NetBoxTable):
name = tables.Column(
linkify=True
)
choices = columns.ArrayColumn(
base_choices = columns.ChoiceFieldColumn()
extra_choices = tables.TemplateColumn(
template_code="""{% for k, v in value.items %}{{ v }}{% if not forloop.last %}, {% endif %}{% endfor %}"""
)
choices = columns.ChoicesColumn(
max_items=10,
accessor=tables.A('extra_choices'),
orderable=False,
verbose_name=_('Choices')
orderable=False
)
choice_count = tables.TemplateColumn(
accessor=tables.A('extra_choices'),
@ -104,10 +108,10 @@ class CustomFieldChoiceSetTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = CustomFieldChoiceSet
fields = (
'pk', 'id', 'name', 'description', 'choice_count', 'choices', 'order_alphabetically', 'created',
'last_updated',
'pk', 'id', 'name', 'description', 'base_choices', 'extra_choices', 'choice_count', 'choices',
'order_alphabetically', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'choice_count', 'description')
default_columns = ('pk', 'name', 'base_choices', 'choice_count', 'description')
class CustomLinkTable(NetBoxTable):

View File

@ -139,15 +139,27 @@ class CustomFieldChoiceSetTest(APIViewTestCases.APIViewTestCase):
create_data = [
{
'name': 'Choice Set 4',
'extra_choices': ['4A', '4B', '4C'],
'extra_choices': [
['4A', 'Choice 1'],
['4B', 'Choice 2'],
['4C', 'Choice 3'],
],
},
{
'name': 'Choice Set 5',
'extra_choices': ['5A', '5B', '5C'],
'extra_choices': [
['5A', 'Choice 1'],
['5B', 'Choice 2'],
['5C', 'Choice 3'],
],
},
{
'name': 'Choice Set 6',
'extra_choices': ['6A', '6B', '6C'],
'extra_choices': [
['6A', 'Choice 1'],
['6B', 'Choice 2'],
['6C', 'Choice 3'],
],
},
]
bulk_update_data = {
@ -155,7 +167,11 @@ class CustomFieldChoiceSetTest(APIViewTestCases.APIViewTestCase):
}
update_data = {
'name': 'Choice Set X',
'extra_choices': ['X1', 'X2', 'X3'],
'extra_choices': [
['X1', 'Choice 1'],
['X2', 'Choice 2'],
['X3', 'Choice 3'],
],
'description': 'New description',
}

View File

@ -17,8 +17,8 @@ class ChangeLogViewTest(ModelViewTestCase):
@classmethod
def setUpTestData(cls):
choice_set = CustomFieldChoiceSet.objects.create(
name='Custom Field Choice Set 1',
extra_choices=['Bar', 'Foo']
name='Choice Set 1',
extra_choices=(('foo', 'Foo'), ('bar', 'Bar'))
)
# Create a custom field on the Site model
@ -48,7 +48,7 @@ class ChangeLogViewTest(ModelViewTestCase):
'slug': 'site-1',
'status': SiteStatusChoices.STATUS_ACTIVE,
'cf_cf1': 'ABC',
'cf_cf2': 'Bar',
'cf_cf2': 'bar',
'tags': [tag.pk for tag in tags],
}
@ -84,7 +84,7 @@ class ChangeLogViewTest(ModelViewTestCase):
'slug': 'site-x',
'status': SiteStatusChoices.STATUS_PLANNED,
'cf_cf1': 'DEF',
'cf_cf2': 'Foo',
'cf_cf2': 'foo',
'tags': [tags[2].pk],
}
@ -226,7 +226,7 @@ class ChangeLogAPITest(APITestCase):
# Create a select custom field on the Site model
choice_set = CustomFieldChoiceSet.objects.create(
name='Choice Set 1',
extra_choices=['Bar', 'Foo']
extra_choices=(('foo', 'Foo'), ('bar', 'Bar'))
)
cf_select = CustomField(
type=CustomFieldTypeChoices.TYPE_SELECT,
@ -251,7 +251,7 @@ class ChangeLogAPITest(APITestCase):
'slug': 'site-1',
'custom_fields': {
'cf1': 'ABC',
'cf2': 'Bar',
'cf2': 'bar',
},
'tags': [
{'name': 'Tag 1'},
@ -285,7 +285,7 @@ class ChangeLogAPITest(APITestCase):
'slug': 'site-x',
'custom_fields': {
'cf1': 'DEF',
'cf2': 'Foo',
'cf2': 'foo',
},
'tags': [
{'name': 'Tag 3'}

View File

@ -269,8 +269,12 @@ class CustomFieldTest(TestCase):
self.assertIsNone(instance.custom_field_data.get(cf.name))
def test_select_field(self):
CHOICES = ('Option A', 'Option B', 'Option C')
value = CHOICES[1]
CHOICES = (
('a', 'Option A'),
('b', 'Option B'),
('c', 'Option C'),
)
value = 'a'
# Create a set of custom field choices
choice_set = CustomFieldChoiceSet.objects.create(
@ -302,8 +306,12 @@ class CustomFieldTest(TestCase):
self.assertIsNone(instance.custom_field_data.get(cf.name))
def test_multiselect_field(self):
CHOICES = ['Option A', 'Option B', 'Option C']
value = [CHOICES[1], CHOICES[2]]
CHOICES = (
('a', 'Option A'),
('b', 'Option B'),
('c', 'Option C'),
)
value = ['a', 'b']
# Create a set of custom field choices
choice_set = CustomFieldChoiceSet.objects.create(
@ -453,7 +461,7 @@ class CustomFieldAPITest(APITestCase):
# Create a set of custom field choices
choice_set = CustomFieldChoiceSet.objects.create(
name='Custom Field Choice Set 1',
extra_choices=('Foo', 'Bar', 'Baz')
extra_choices=(('foo', 'Foo'), ('bar', 'Bar'), ('baz', 'Baz'))
)
custom_fields = (
@ -469,13 +477,13 @@ class CustomFieldAPITest(APITestCase):
CustomField(
type=CustomFieldTypeChoices.TYPE_SELECT,
name='select_field',
default='Foo',
default='foo',
choice_set=choice_set
),
CustomField(
type=CustomFieldTypeChoices.TYPE_MULTISELECT,
name='multiselect_field',
default=['Foo'],
default=['foo'],
choice_set=choice_set
),
CustomField(
@ -514,8 +522,8 @@ class CustomFieldAPITest(APITestCase):
custom_fields[6].name: '2020-01-02 12:00:00',
custom_fields[7].name: 'http://example.com/2',
custom_fields[8].name: '{"foo": 1, "bar": 2}',
custom_fields[9].name: 'Bar',
custom_fields[10].name: ['Bar', 'Baz'],
custom_fields[9].name: 'bar',
custom_fields[10].name: ['bar', 'baz'],
custom_fields[11].name: vlans[1].pk,
custom_fields[12].name: [vlans[2].pk, vlans[3].pk],
}
@ -671,8 +679,8 @@ class CustomFieldAPITest(APITestCase):
'datetime_field': datetime.datetime(2020, 1, 2, 12, 0, 0),
'url_field': 'http://example.com/2',
'json_field': '{"foo": 1, "bar": 2}',
'select_field': 'Bar',
'multiselect_field': ['Bar', 'Baz'],
'select_field': 'bar',
'multiselect_field': ['bar', 'baz'],
'object_field': VLAN.objects.get(vid=2).pk,
'multiobject_field': list(VLAN.objects.filter(vid__in=[3, 4]).values_list('pk', flat=True)),
},
@ -799,8 +807,8 @@ class CustomFieldAPITest(APITestCase):
'datetime_field': datetime.datetime(2020, 1, 2, 12, 0, 0),
'url_field': 'http://example.com/2',
'json_field': '{"foo": 1, "bar": 2}',
'select_field': 'Bar',
'multiselect_field': ['Bar', 'Baz'],
'select_field': 'bar',
'multiselect_field': ['bar', 'baz'],
'object_field': VLAN.objects.get(vid=2).pk,
'multiobject_field': list(VLAN.objects.filter(vid__in=[3, 4]).values_list('pk', flat=True)),
}
@ -1041,7 +1049,11 @@ class CustomFieldImportTest(TestCase):
# 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')
extra_choices=(
('a', 'Option A'),
('b', 'Option B'),
('c', 'Option C'),
)
)
custom_fields = (
@ -1067,8 +1079,8 @@ class CustomFieldImportTest(TestCase):
"""
data = (
('name', 'slug', 'status', 'cf_text', 'cf_longtext', 'cf_integer', 'cf_decimal', 'cf_boolean', 'cf_date', 'cf_datetime', 'cf_url', 'cf_json', 'cf_select', 'cf_multiselect'),
('Site 1', 'site-1', 'active', 'ABC', 'Foo', '123', '123.45', 'True', '2020-01-01', '2020-01-01 12:00:00', 'http://example.com/1', '{"foo": 123}', 'Choice A', '"Choice A,Choice B"'),
('Site 2', 'site-2', 'active', 'DEF', 'Bar', '456', '456.78', 'False', '2020-01-02', '2020-01-02 12:00:00', 'http://example.com/2', '{"bar": 456}', 'Choice B', '"Choice B,Choice C"'),
('Site 1', 'site-1', 'active', 'ABC', 'Foo', '123', '123.45', 'True', '2020-01-01', '2020-01-01 12:00:00', 'http://example.com/1', '{"foo": 123}', 'a', '"a,b"'),
('Site 2', 'site-2', 'active', 'DEF', 'Bar', '456', '456.78', 'False', '2020-01-02', '2020-01-02 12:00:00', 'http://example.com/2', '{"bar": 456}', 'b', '"b,c"'),
('Site 3', 'site-3', 'active', '', '', '', '', '', '', '', '', '', '', ''),
)
csv_data = '\n'.join(','.join(row) for row in data)
@ -1089,8 +1101,8 @@ class CustomFieldImportTest(TestCase):
self.assertEqual(site1.cf['datetime'].isoformat(), '2020-01-01T12:00:00+00:00')
self.assertEqual(site1.custom_field_data['url'], 'http://example.com/1')
self.assertEqual(site1.custom_field_data['json'], {"foo": 123})
self.assertEqual(site1.custom_field_data['select'], 'Choice A')
self.assertEqual(site1.custom_field_data['multiselect'], ['Choice A', 'Choice B'])
self.assertEqual(site1.custom_field_data['select'], 'a')
self.assertEqual(site1.custom_field_data['multiselect'], ['a', 'b'])
# Validate data for site 2
site2 = Site.objects.get(name='Site 2')
@ -1104,8 +1116,8 @@ class CustomFieldImportTest(TestCase):
self.assertEqual(site2.cf['datetime'].isoformat(), '2020-01-02T12:00:00+00:00')
self.assertEqual(site2.custom_field_data['url'], 'http://example.com/2')
self.assertEqual(site2.custom_field_data['json'], {"bar": 456})
self.assertEqual(site2.custom_field_data['select'], 'Choice B')
self.assertEqual(site2.custom_field_data['multiselect'], ['Choice B', 'Choice C'])
self.assertEqual(site2.custom_field_data['select'], 'b')
self.assertEqual(site2.custom_field_data['multiselect'], ['b', 'c'])
# No custom field data should be set for site 3
site3 = Site.objects.get(name='Site 3')
@ -1221,7 +1233,7 @@ class CustomFieldModelFilterTest(TestCase):
choice_set = CustomFieldChoiceSet.objects.create(
name='Custom Field Choice Set 1',
extra_choices=['A', 'B', 'C', 'X']
extra_choices=(('a', 'A'), ('b', 'B'), ('c', 'C'), ('x', 'X'))
)
# Integer filtering

View File

@ -14,8 +14,8 @@ class CustomFieldModelFormTest(TestCase):
def setUpTestData(cls):
obj_type = ContentType.objects.get_for_model(Site)
choice_set = CustomFieldChoiceSet.objects.create(
name='Custom Field Choice Set 1',
extra_choices=('A', 'B', 'C')
name='Choice Set 1',
extra_choices=(('a', 'A'), ('b', 'B'), ('c', 'C'))
)
cf_text = CustomField.objects.create(name='text', type=CustomFieldTypeChoices.TYPE_TEXT)

View File

@ -1,3 +1,4 @@
import json
import urllib.parse
import uuid
@ -23,7 +24,11 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase):
site_ct = ContentType.objects.get_for_model(Site)
CustomFieldChoiceSet.objects.create(
name='Choice Set 1',
extra_choices=('A', 'B', 'C')
extra_choices=(
('A', 'A'),
('B', 'B'),
('C', 'C'),
)
)
custom_fields = (
@ -76,29 +81,38 @@ class CustomFieldChoiceSetTestCase(ViewTestCases.PrimaryObjectViewTestCase):
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(
name='Choice Set 1',
extra_choices=(('A1', 'Choice 1'), ('A2', 'Choice 2'), ('A3', 'Choice 3'))
),
CustomFieldChoiceSet(
name='Choice Set 2',
extra_choices=(('B1', 'Choice 1'), ('B2', 'Choice 2'), ('B3', 'Choice 3'))
),
CustomFieldChoiceSet(
name='Choice Set 3',
extra_choices=(('C1', 'Choice 1'), ('C2', 'Choice 2'), ('C3', 'Choice 3'))
),
)
CustomFieldChoiceSet.objects.bulk_create(choice_sets)
cls.form_data = {
'name': 'Choice Set X',
'extra_choices': 'X1,X2,X3,X4,X5',
'extra_choices': '\n'.join(['X1,Choice 1', 'X2,Choice 2', 'X3,Choice 3'])
}
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"',
'Choice Set 4,"D1,D2,D3"',
'Choice Set 5,"E1,E2,E3"',
'Choice Set 6,"F1,F2,F3"',
)
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"',
f'{choice_sets[0].pk},"A,B,C"',
f'{choice_sets[1].pk},"A,B,C"',
f'{choice_sets[2].pk},"A,B,C"',
)
cls.bulk_edit_data = {

View File

@ -9,8 +9,8 @@ from django.db.models import DateField, DateTimeField
from django.template import Context, Template
from django.urls import reverse
from django.utils.dateparse import parse_date
from django.utils.html import escape
from django.utils.formats import date_format
from django.utils.html import escape
from django.utils.safestring import mark_safe
from django_tables2.columns import library
from django_tables2.utils import Accessor
@ -24,6 +24,7 @@ __all__ = (
'ArrayColumn',
'BooleanColumn',
'ChoiceFieldColumn',
'ChoicesColumn',
'ColorColumn',
'ColoredLabelColumn',
'ContentTypeColumn',
@ -598,16 +599,49 @@ class ArrayColumn(tables.Column):
"""
List array items as a comma-separated list.
"""
def __init__(self, *args, max_items=None, func=str, **kwargs):
self.max_items = max_items
self.func = func
super().__init__(*args, **kwargs)
def render(self, value):
omitted_count = 0
# Limit the returned items to the specified maximum number (if any)
if self.max_items:
omitted_count = len(value) - self.max_items
value = value[:self.max_items - 1]
# Apply custom processing function (if any) per item
if self.func:
value = [self.func(v) for v in value]
# Annotate omitted items (if applicable)
if omitted_count > 0:
value.append(f'({omitted_count} more)')
return ', '.join(value)
class ChoicesColumn(tables.Column):
"""
Display the human-friendly labels of a set of choices.
"""
def __init__(self, *args, max_items=None, **kwargs):
self.max_items = max_items
super().__init__(*args, **kwargs)
def render(self, value):
omitted_count = 0
value = [v[1] for v in value]
# Limit the returned items to the specified maximum number (if any)
if self.max_items:
# Limit the returned items to the specified maximum number
omitted = len(value) - self.max_items
omitted_count = len(value) - self.max_items
value = value[:self.max_items - 1]
if omitted > 0:
value.append(f'({omitted} more)')
# Annotate omitted items (if applicable)
if omitted_count > 0:
value.append(f'({omitted_count} more)')
return ', '.join(value)

View File

@ -17,6 +17,10 @@
<th scope="row">Description</th>
<td>{{ object.description|markdown|placeholder }}</td>
</tr>
<tr>
<th scope="row">Base Choices</th>
<td>{{ object.get_base_choices_display|placeholder }}</td>
</tr>
<tr>
<th scope="row">Choices</th>
<td>{{ object.choices|length }}</td>
@ -42,12 +46,19 @@
</div>
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">Choices</h5>
<h5 class="card-header">Choices ({{ object.choices|length }})</h5>
<div class="card-body">
<table class="table table-hover attr-table">
{% for choice in object.choices %}
<table class="table table-hover table-headings">
<thead>
<tr>
<td>{{ choice }}</td>
<th>Value</th>
<th>Label</th>
</tr>
</thead>
{% for value, label in object.choices %}
<tr>
<td>{{ value }}</td>
<td>{{ label }}</td>
</tr>
{% endfor %}
</table>

View File

@ -8,11 +8,49 @@ from utilities.forms import widgets
from utilities.utils import get_viewname
__all__ = (
'DynamicChoiceField',
'DynamicModelChoiceField',
'DynamicModelMultipleChoiceField',
'DynamicMultipleChoiceField',
)
#
# Choice fields
#
class DynamicChoiceField(forms.ChoiceField):
def get_bound_field(self, form, field_name):
bound_field = BoundField(form, self, field_name)
data = bound_field.value()
if data is not None:
self.choices = [
choice for choice in self.choices if choice[0] == data
]
return bound_field
class DynamicMultipleChoiceField(forms.MultipleChoiceField):
def get_bound_field(self, form, field_name):
bound_field = BoundField(form, self, field_name)
data = bound_field.value()
if data is not None:
self.choices = [
choice for choice in self.choices if choice[0] in data
]
return bound_field
#
# Model choice fields
#
class DynamicModelChoiceMixin:
"""
Override `get_bound_field()` to avoid pre-populating field choices with a SQL query. The field will be

View File

@ -2,6 +2,7 @@ from django import forms
__all__ = (
'ArrayWidget',
'ChoicesWidget',
'ClearableFileInput',
'MarkdownWidget',
'NumberWithOptions',
@ -54,3 +55,15 @@ class ArrayWidget(forms.Textarea):
if value is None or not len(value):
return None
return '\n'.join(value)
class ChoicesWidget(forms.Textarea):
"""
Render each key-value pair of a dictionary on a new line within a textarea for easy editing.
"""
def format_value(self, value):
if not value:
return None
if type(value) is list:
return '\n'.join([f'{k},{v}' for k, v in value])
return value

View File

@ -462,6 +462,9 @@ class APIViewTestCases:
if type(field) is GQLDynamic:
# Dynamic fields must specify a subselection
fields_string += f'{field_name} {{ id }}\n'
# TODO: Improve field detection logic to avoid nested ArrayFields
elif field_name == 'extra_choices':
continue
elif inspect.isclass(field.type) and issubclass(field.type, GQLUnion):
# Union types dont' have an id or consistent values
continue

View File

@ -129,13 +129,18 @@ class ModelTestCase(TestCase):
model_dict[key] = str(value)
else:
field = instance._meta.get_field(key)
# Convert ArrayFields to CSV strings
if type(instance._meta.get_field(key)) is ArrayField:
model_dict[key] = ','.join([str(v) for v in value])
if type(field) is ArrayField:
if type(field.base_field) is ArrayField:
# Handle nested arrays (e.g. choice sets)
model_dict[key] = '\n'.join([f'{k},{v}' for k, v in value])
else:
model_dict[key] = ','.join([str(v) for v in value])
# JSON
if type(instance._meta.get_field(key)) is JSONField and value is not None:
if type(field) is JSONField and value is not None:
model_dict[key] = json.dumps(value)
return model_dict