mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-14 01:41:22 -06: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 * 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:
parent
9d3bb585a2
commit
cf1b1a83eb
@ -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
|
||||
|
||||
|
@ -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',
|
||||
]
|
||||
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
#
|
||||
|
9
netbox/extras/data/__init__.py
Normal file
9
netbox/extras/data/__init__.py
Normal 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
9768
netbox/extras/data/iata.py
Normal file
File diff suppressed because it is too large
Load Diff
253
netbox/extras/data/iso_3166.py
Normal file
253
netbox/extras/data/iso_3166.py
Normal 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
111557
netbox/extras/data/un_locode.py
Normal file
File diff suppressed because it is too large
Load Diff
@ -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):
|
||||
|
@ -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):
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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):
|
||||
|
@ -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={
|
||||
|
@ -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)
|
||||
|
@ -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):
|
||||
|
@ -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',
|
||||
}
|
||||
|
||||
|
@ -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'}
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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 = {
|
||||
|
@ -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)
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user