mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-15 11:42:52 -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
|
# 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
|
## Fields
|
||||||
|
|
||||||
@ -8,9 +10,17 @@ Single- and multi-selection [custom fields documentation](../../customization/cu
|
|||||||
|
|
||||||
The human-friendly name of the choice set.
|
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
|
### 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
|
### Order Alphabetically
|
||||||
|
|
||||||
|
@ -131,12 +131,16 @@ class CustomFieldSerializer(ValidatedModelSerializer):
|
|||||||
|
|
||||||
class CustomFieldChoiceSetSerializer(ValidatedModelSerializer):
|
class CustomFieldChoiceSetSerializer(ValidatedModelSerializer):
|
||||||
url = serializers.HyperlinkedIdentityField(view_name='extras-api:customfieldchoiceset-detail')
|
url = serializers.HyperlinkedIdentityField(view_name='extras-api:customfieldchoiceset-detail')
|
||||||
|
base_choices = ChoiceField(
|
||||||
|
choices=CustomFieldChoiceSetBaseChoices,
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = CustomFieldChoiceSet
|
model = CustomFieldChoiceSet
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'url', 'display', 'name', 'description', 'extra_choices', 'order_alphabetically', 'choices_count',
|
'id', 'url', 'display', 'name', 'description', 'base_choices', 'extra_choices', 'order_alphabetically',
|
||||||
'created', 'last_updated',
|
'choices_count', 'created', 'last_updated',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.http import Http404
|
from django.http import Http404
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
from django_rq.queues import get_connection
|
from django_rq.queues import get_connection
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
@ -63,6 +64,26 @@ class CustomFieldChoiceSetViewSet(NetBoxModelViewSet):
|
|||||||
serializer_class = serializers.CustomFieldChoiceSetSerializer
|
serializer_class = serializers.CustomFieldChoiceSetSerializer
|
||||||
filterset_class = filtersets.CustomFieldChoiceSetFilterSet
|
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
|
# 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
|
# 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:
|
class Meta:
|
||||||
model = CustomFieldChoiceSet
|
model = CustomFieldChoiceSet
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'name', 'description', 'order_alphabetically',
|
'id', 'name', 'description', 'base_choices', 'order_alphabetically',
|
||||||
]
|
]
|
||||||
|
|
||||||
def search(self, queryset, name, value):
|
def search(self, queryset, name, value):
|
||||||
|
@ -62,6 +62,10 @@ class CustomFieldChoiceSetBulkEditForm(BulkEditForm):
|
|||||||
queryset=CustomFieldChoiceSet.objects.all(),
|
queryset=CustomFieldChoiceSet.objects.all(),
|
||||||
widget=forms.MultipleHiddenInput
|
widget=forms.MultipleHiddenInput
|
||||||
)
|
)
|
||||||
|
base_choices = forms.ChoiceField(
|
||||||
|
choices=add_blank_choice(CustomFieldChoiceSetBaseChoices),
|
||||||
|
required=False
|
||||||
|
)
|
||||||
description = forms.CharField(
|
description = forms.CharField(
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
@ -70,7 +74,7 @@ class CustomFieldChoiceSetBulkEditForm(BulkEditForm):
|
|||||||
widget=BulkEditNullBooleanSelect()
|
widget=BulkEditNullBooleanSelect()
|
||||||
)
|
)
|
||||||
|
|
||||||
nullable_fields = ('description',)
|
nullable_fields = ('base_choices', 'description')
|
||||||
|
|
||||||
|
|
||||||
class CustomLinkBulkEditForm(BulkEditForm):
|
class CustomLinkBulkEditForm(BulkEditForm):
|
||||||
|
@ -4,7 +4,7 @@ from django.contrib.postgres.forms import SimpleArrayField
|
|||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
from extras.choices import CustomFieldVisibilityChoices, CustomFieldTypeChoices, JournalEntryKindChoices
|
from extras.choices import *
|
||||||
from extras.models import *
|
from extras.models import *
|
||||||
from extras.utils import FeatureQuery
|
from extras.utils import FeatureQuery
|
||||||
from netbox.forms import NetBoxModelImportForm
|
from netbox.forms import NetBoxModelImportForm
|
||||||
@ -63,6 +63,11 @@ class CustomFieldImportForm(CSVModelForm):
|
|||||||
|
|
||||||
|
|
||||||
class CustomFieldChoiceSetImportForm(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(
|
extra_choices = SimpleArrayField(
|
||||||
base_field=forms.CharField(),
|
base_field=forms.CharField(),
|
||||||
required=False,
|
required=False,
|
||||||
|
@ -11,7 +11,9 @@ from extras.utils import FeatureQuery
|
|||||||
from netbox.forms.base import NetBoxModelFilterSetForm
|
from netbox.forms.base import NetBoxModelFilterSetForm
|
||||||
from tenancy.models import Tenant, TenantGroup
|
from tenancy.models import Tenant, TenantGroup
|
||||||
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice
|
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 utilities.forms.widgets import APISelectMultiple, DateTimePicker
|
||||||
from virtualization.models import Cluster, ClusterGroup, ClusterType
|
from virtualization.models import Cluster, ClusterGroup, ClusterType
|
||||||
from .mixins import SavedFiltersMixin
|
from .mixins import SavedFiltersMixin
|
||||||
@ -84,7 +86,12 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
|
|||||||
|
|
||||||
class CustomFieldChoiceSetFilterForm(SavedFiltersMixin, FilterForm):
|
class CustomFieldChoiceSetFilterForm(SavedFiltersMixin, FilterForm):
|
||||||
fieldsets = (
|
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(
|
choice = forms.CharField(
|
||||||
required=False
|
required=False
|
||||||
|
@ -19,7 +19,7 @@ from utilities.forms.fields import (
|
|||||||
CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelChoiceField,
|
CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelChoiceField,
|
||||||
DynamicModelMultipleChoiceField, JSONField, SlugField,
|
DynamicModelMultipleChoiceField, JSONField, SlugField,
|
||||||
)
|
)
|
||||||
from utilities.forms.widgets import ArrayWidget
|
from utilities.forms.widgets import ChoicesWidget
|
||||||
from virtualization.models import Cluster, ClusterGroup, ClusterType
|
from virtualization.models import Cluster, ClusterGroup, ClusterType
|
||||||
|
|
||||||
|
|
||||||
@ -86,16 +86,22 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
|
|||||||
|
|
||||||
class CustomFieldChoiceSetForm(BootstrapMixin, forms.ModelForm):
|
class CustomFieldChoiceSetForm(BootstrapMixin, forms.ModelForm):
|
||||||
extra_choices = forms.CharField(
|
extra_choices = forms.CharField(
|
||||||
widget=ArrayWidget(),
|
widget=ChoicesWidget(),
|
||||||
help_text=_('Enter one choice per line.')
|
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = CustomFieldChoiceSet
|
model = CustomFieldChoiceSet
|
||||||
fields = ('name', 'description', 'extra_choices', 'order_alphabetically')
|
fields = ('name', 'description', 'base_choices', 'extra_choices', 'order_alphabetically')
|
||||||
|
|
||||||
def clean_extra_choices(self):
|
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):
|
class CustomLinkForm(BootstrapMixin, forms.ModelForm):
|
||||||
|
@ -19,7 +19,7 @@ def create_choice_sets(apps, schema_editor):
|
|||||||
for cf in choice_fields:
|
for cf in choice_fields:
|
||||||
choiceset = CustomFieldChoiceSet.objects.create(
|
choiceset = CustomFieldChoiceSet.objects.create(
|
||||||
name=f'{cf.name} Choices',
|
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
|
cf.choice_set = choiceset
|
||||||
|
|
||||||
@ -42,7 +42,8 @@ class Migration(migrations.Migration):
|
|||||||
('last_updated', models.DateTimeField(auto_now=True, null=True)),
|
('last_updated', models.DateTimeField(auto_now=True, null=True)),
|
||||||
('name', models.CharField(max_length=100, unique=True)),
|
('name', models.CharField(max_length=100, unique=True)),
|
||||||
('description', models.CharField(blank=True, max_length=200)),
|
('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)),
|
('order_alphabetically', models.BooleanField(default=False)),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
|
@ -15,17 +15,18 @@ from django.utils.safestring import mark_safe
|
|||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
from extras.choices import *
|
from extras.choices import *
|
||||||
|
from extras.data import CHOICE_SETS
|
||||||
from extras.utils import FeatureQuery
|
from extras.utils import FeatureQuery
|
||||||
from netbox.models import ChangeLoggedModel
|
from netbox.models import ChangeLoggedModel
|
||||||
from netbox.models.features import CloningMixin, ExportTemplatesMixin
|
from netbox.models.features import CloningMixin, ExportTemplatesMixin
|
||||||
from netbox.search import FieldTypes
|
from netbox.search import FieldTypes
|
||||||
from utilities import filters
|
from utilities import filters
|
||||||
from utilities.forms.fields import (
|
from utilities.forms.fields import (
|
||||||
CSVChoiceField, CSVModelChoiceField, CSVModelMultipleChoiceField, CSVMultipleChoiceField, DynamicModelChoiceField,
|
CSVChoiceField, CSVModelChoiceField, CSVModelMultipleChoiceField, CSVMultipleChoiceField, DynamicChoiceField,
|
||||||
DynamicModelMultipleChoiceField, JSONField, LaxURLField,
|
DynamicModelChoiceField, DynamicModelMultipleChoiceField, DynamicMultipleChoiceField, JSONField, LaxURLField,
|
||||||
)
|
)
|
||||||
from utilities.forms.utils import add_blank_choice
|
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.querysets import RestrictedQuerySet
|
||||||
from utilities.validators import validate_regex
|
from utilities.validators import validate_regex
|
||||||
|
|
||||||
@ -410,7 +411,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
|||||||
|
|
||||||
# Select
|
# Select
|
||||||
elif self.type in (CustomFieldTypeChoices.TYPE_SELECT, CustomFieldTypeChoices.TYPE_MULTISELECT):
|
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
|
default_choice = self.default if self.default in self.choices else None
|
||||||
|
|
||||||
if not required or default_choice is None:
|
if not required or default_choice is None:
|
||||||
@ -421,11 +422,17 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
|||||||
initial = default_choice
|
initial = default_choice
|
||||||
|
|
||||||
if self.type == CustomFieldTypeChoices.TYPE_SELECT:
|
if self.type == CustomFieldTypeChoices.TYPE_SELECT:
|
||||||
field_class = CSVChoiceField if for_csv_import else forms.ChoiceField
|
field_class = CSVChoiceField if for_csv_import else DynamicChoiceField
|
||||||
field = field_class(choices=choices, required=required, initial=initial)
|
widget_class = APISelect
|
||||||
else:
|
else:
|
||||||
field_class = CSVMultipleChoiceField if for_csv_import else forms.MultipleChoiceField
|
field_class = CSVMultipleChoiceField if for_csv_import else DynamicMultipleChoiceField
|
||||||
field = field_class(choices=choices, required=required, initial=initial)
|
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
|
# URL
|
||||||
elif self.type == CustomFieldTypeChoices.TYPE_URL:
|
elif self.type == CustomFieldTypeChoices.TYPE_URL:
|
||||||
@ -604,14 +611,14 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
|||||||
|
|
||||||
# Validate selected choice
|
# Validate selected choice
|
||||||
elif self.type == CustomFieldTypeChoices.TYPE_SELECT:
|
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(
|
raise ValidationError(
|
||||||
f"Invalid choice ({value}). Available choices are: {', '.join(self.choices)}"
|
f"Invalid choice ({value}). Available choices are: {', '.join(self.choices)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Validate all selected choices
|
# Validate all selected choices
|
||||||
elif self.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
|
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(
|
raise ValidationError(
|
||||||
f"Invalid choice(s) ({', '.join(value)}). Available choices are: {', '.join(self.choices)}"
|
f"Invalid choice(s) ({', '.join(value)}). Available choices are: {', '.join(self.choices)}"
|
||||||
)
|
)
|
||||||
@ -645,13 +652,23 @@ class CustomFieldChoiceSet(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel
|
|||||||
max_length=200,
|
max_length=200,
|
||||||
blank=True
|
blank=True
|
||||||
)
|
)
|
||||||
|
base_choices = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
choices=CustomFieldChoiceSetBaseChoices,
|
||||||
|
blank=True,
|
||||||
|
help_text=_('Base set of predefined choices (optional)')
|
||||||
|
)
|
||||||
extra_choices = ArrayField(
|
extra_choices = ArrayField(
|
||||||
base_field=models.CharField(max_length=100),
|
ArrayField(
|
||||||
help_text=_('List of field choices')
|
base_field=models.CharField(max_length=100),
|
||||||
|
size=2
|
||||||
|
),
|
||||||
|
blank=True,
|
||||||
|
null=True
|
||||||
)
|
)
|
||||||
order_alphabetically = models.BooleanField(
|
order_alphabetically = models.BooleanField(
|
||||||
default=False,
|
default=False,
|
||||||
help_text=_('Choices are automatically ordered alphabetically on save')
|
help_text=_('Choices are automatically ordered alphabetically')
|
||||||
)
|
)
|
||||||
|
|
||||||
clone_fields = ('extra_choices', 'order_alphabetically')
|
clone_fields = ('extra_choices', 'order_alphabetically')
|
||||||
@ -667,16 +684,31 @@ class CustomFieldChoiceSet(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def choices(self):
|
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
|
@property
|
||||||
def choices_count(self):
|
def choices_count(self):
|
||||||
return len(self.choices)
|
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):
|
def save(self, *args, **kwargs):
|
||||||
|
|
||||||
# Sort choices if alphabetical ordering is enforced
|
# Sort choices if alphabetical ordering is enforced
|
||||||
if self.order_alphabetically:
|
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)
|
return super().save(*args, **kwargs)
|
||||||
|
@ -66,10 +66,12 @@ class CustomFieldTable(NetBoxTable):
|
|||||||
required = columns.BooleanColumn()
|
required = columns.BooleanColumn()
|
||||||
ui_visibility = columns.ChoiceFieldColumn(verbose_name="UI visibility")
|
ui_visibility = columns.ChoiceFieldColumn(verbose_name="UI visibility")
|
||||||
description = columns.MarkdownColumn()
|
description = columns.MarkdownColumn()
|
||||||
choices = columns.ArrayColumn(
|
choice_set = tables.Column(
|
||||||
|
linkify=True
|
||||||
|
)
|
||||||
|
choices = columns.ChoicesColumn(
|
||||||
max_items=10,
|
max_items=10,
|
||||||
orderable=False,
|
orderable=False
|
||||||
verbose_name=_('Choices')
|
|
||||||
)
|
)
|
||||||
is_cloneable = columns.BooleanColumn()
|
is_cloneable = columns.BooleanColumn()
|
||||||
|
|
||||||
@ -77,8 +79,8 @@ class CustomFieldTable(NetBoxTable):
|
|||||||
model = CustomField
|
model = CustomField
|
||||||
fields = (
|
fields = (
|
||||||
'pk', 'id', 'name', 'content_types', 'label', 'type', 'group_name', 'required', 'default', 'description',
|
'pk', 'id', 'name', 'content_types', 'label', 'type', 'group_name', 'required', 'default', 'description',
|
||||||
'search_weight', 'filter_logic', 'ui_visibility', 'is_cloneable', 'weight', 'choices', 'created',
|
'search_weight', 'filter_logic', 'ui_visibility', 'is_cloneable', 'weight', 'choice_set', 'choices',
|
||||||
'last_updated',
|
'created', 'last_updated',
|
||||||
)
|
)
|
||||||
default_columns = ('pk', 'name', 'content_types', 'label', 'group_name', 'type', 'required', 'description')
|
default_columns = ('pk', 'name', 'content_types', 'label', 'group_name', 'type', 'required', 'description')
|
||||||
|
|
||||||
@ -87,11 +89,13 @@ class CustomFieldChoiceSetTable(NetBoxTable):
|
|||||||
name = tables.Column(
|
name = tables.Column(
|
||||||
linkify=True
|
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,
|
max_items=10,
|
||||||
accessor=tables.A('extra_choices'),
|
orderable=False
|
||||||
orderable=False,
|
|
||||||
verbose_name=_('Choices')
|
|
||||||
)
|
)
|
||||||
choice_count = tables.TemplateColumn(
|
choice_count = tables.TemplateColumn(
|
||||||
accessor=tables.A('extra_choices'),
|
accessor=tables.A('extra_choices'),
|
||||||
@ -104,10 +108,10 @@ class CustomFieldChoiceSetTable(NetBoxTable):
|
|||||||
class Meta(NetBoxTable.Meta):
|
class Meta(NetBoxTable.Meta):
|
||||||
model = CustomFieldChoiceSet
|
model = CustomFieldChoiceSet
|
||||||
fields = (
|
fields = (
|
||||||
'pk', 'id', 'name', 'description', 'choice_count', 'choices', 'order_alphabetically', 'created',
|
'pk', 'id', 'name', 'description', 'base_choices', 'extra_choices', 'choice_count', 'choices',
|
||||||
'last_updated',
|
'order_alphabetically', 'created', 'last_updated',
|
||||||
)
|
)
|
||||||
default_columns = ('pk', 'name', 'choice_count', 'description')
|
default_columns = ('pk', 'name', 'base_choices', 'choice_count', 'description')
|
||||||
|
|
||||||
|
|
||||||
class CustomLinkTable(NetBoxTable):
|
class CustomLinkTable(NetBoxTable):
|
||||||
|
@ -139,15 +139,27 @@ class CustomFieldChoiceSetTest(APIViewTestCases.APIViewTestCase):
|
|||||||
create_data = [
|
create_data = [
|
||||||
{
|
{
|
||||||
'name': 'Choice Set 4',
|
'name': 'Choice Set 4',
|
||||||
'extra_choices': ['4A', '4B', '4C'],
|
'extra_choices': [
|
||||||
|
['4A', 'Choice 1'],
|
||||||
|
['4B', 'Choice 2'],
|
||||||
|
['4C', 'Choice 3'],
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'name': 'Choice Set 5',
|
'name': 'Choice Set 5',
|
||||||
'extra_choices': ['5A', '5B', '5C'],
|
'extra_choices': [
|
||||||
|
['5A', 'Choice 1'],
|
||||||
|
['5B', 'Choice 2'],
|
||||||
|
['5C', 'Choice 3'],
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'name': 'Choice Set 6',
|
'name': 'Choice Set 6',
|
||||||
'extra_choices': ['6A', '6B', '6C'],
|
'extra_choices': [
|
||||||
|
['6A', 'Choice 1'],
|
||||||
|
['6B', 'Choice 2'],
|
||||||
|
['6C', 'Choice 3'],
|
||||||
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
bulk_update_data = {
|
bulk_update_data = {
|
||||||
@ -155,7 +167,11 @@ class CustomFieldChoiceSetTest(APIViewTestCases.APIViewTestCase):
|
|||||||
}
|
}
|
||||||
update_data = {
|
update_data = {
|
||||||
'name': 'Choice Set X',
|
'name': 'Choice Set X',
|
||||||
'extra_choices': ['X1', 'X2', 'X3'],
|
'extra_choices': [
|
||||||
|
['X1', 'Choice 1'],
|
||||||
|
['X2', 'Choice 2'],
|
||||||
|
['X3', 'Choice 3'],
|
||||||
|
],
|
||||||
'description': 'New description',
|
'description': 'New description',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -17,8 +17,8 @@ class ChangeLogViewTest(ModelViewTestCase):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
choice_set = CustomFieldChoiceSet.objects.create(
|
choice_set = CustomFieldChoiceSet.objects.create(
|
||||||
name='Custom Field Choice Set 1',
|
name='Choice Set 1',
|
||||||
extra_choices=['Bar', 'Foo']
|
extra_choices=(('foo', 'Foo'), ('bar', 'Bar'))
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create a custom field on the Site model
|
# Create a custom field on the Site model
|
||||||
@ -48,7 +48,7 @@ class ChangeLogViewTest(ModelViewTestCase):
|
|||||||
'slug': 'site-1',
|
'slug': 'site-1',
|
||||||
'status': SiteStatusChoices.STATUS_ACTIVE,
|
'status': SiteStatusChoices.STATUS_ACTIVE,
|
||||||
'cf_cf1': 'ABC',
|
'cf_cf1': 'ABC',
|
||||||
'cf_cf2': 'Bar',
|
'cf_cf2': 'bar',
|
||||||
'tags': [tag.pk for tag in tags],
|
'tags': [tag.pk for tag in tags],
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -84,7 +84,7 @@ class ChangeLogViewTest(ModelViewTestCase):
|
|||||||
'slug': 'site-x',
|
'slug': 'site-x',
|
||||||
'status': SiteStatusChoices.STATUS_PLANNED,
|
'status': SiteStatusChoices.STATUS_PLANNED,
|
||||||
'cf_cf1': 'DEF',
|
'cf_cf1': 'DEF',
|
||||||
'cf_cf2': 'Foo',
|
'cf_cf2': 'foo',
|
||||||
'tags': [tags[2].pk],
|
'tags': [tags[2].pk],
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -226,7 +226,7 @@ class ChangeLogAPITest(APITestCase):
|
|||||||
# Create a select custom field on the Site model
|
# Create a select custom field on the Site model
|
||||||
choice_set = CustomFieldChoiceSet.objects.create(
|
choice_set = CustomFieldChoiceSet.objects.create(
|
||||||
name='Choice Set 1',
|
name='Choice Set 1',
|
||||||
extra_choices=['Bar', 'Foo']
|
extra_choices=(('foo', 'Foo'), ('bar', 'Bar'))
|
||||||
)
|
)
|
||||||
cf_select = CustomField(
|
cf_select = CustomField(
|
||||||
type=CustomFieldTypeChoices.TYPE_SELECT,
|
type=CustomFieldTypeChoices.TYPE_SELECT,
|
||||||
@ -251,7 +251,7 @@ class ChangeLogAPITest(APITestCase):
|
|||||||
'slug': 'site-1',
|
'slug': 'site-1',
|
||||||
'custom_fields': {
|
'custom_fields': {
|
||||||
'cf1': 'ABC',
|
'cf1': 'ABC',
|
||||||
'cf2': 'Bar',
|
'cf2': 'bar',
|
||||||
},
|
},
|
||||||
'tags': [
|
'tags': [
|
||||||
{'name': 'Tag 1'},
|
{'name': 'Tag 1'},
|
||||||
@ -285,7 +285,7 @@ class ChangeLogAPITest(APITestCase):
|
|||||||
'slug': 'site-x',
|
'slug': 'site-x',
|
||||||
'custom_fields': {
|
'custom_fields': {
|
||||||
'cf1': 'DEF',
|
'cf1': 'DEF',
|
||||||
'cf2': 'Foo',
|
'cf2': 'foo',
|
||||||
},
|
},
|
||||||
'tags': [
|
'tags': [
|
||||||
{'name': 'Tag 3'}
|
{'name': 'Tag 3'}
|
||||||
|
@ -269,8 +269,12 @@ class CustomFieldTest(TestCase):
|
|||||||
self.assertIsNone(instance.custom_field_data.get(cf.name))
|
self.assertIsNone(instance.custom_field_data.get(cf.name))
|
||||||
|
|
||||||
def test_select_field(self):
|
def test_select_field(self):
|
||||||
CHOICES = ('Option A', 'Option B', 'Option C')
|
CHOICES = (
|
||||||
value = CHOICES[1]
|
('a', 'Option A'),
|
||||||
|
('b', 'Option B'),
|
||||||
|
('c', 'Option C'),
|
||||||
|
)
|
||||||
|
value = 'a'
|
||||||
|
|
||||||
# Create a set of custom field choices
|
# Create a set of custom field choices
|
||||||
choice_set = CustomFieldChoiceSet.objects.create(
|
choice_set = CustomFieldChoiceSet.objects.create(
|
||||||
@ -302,8 +306,12 @@ class CustomFieldTest(TestCase):
|
|||||||
self.assertIsNone(instance.custom_field_data.get(cf.name))
|
self.assertIsNone(instance.custom_field_data.get(cf.name))
|
||||||
|
|
||||||
def test_multiselect_field(self):
|
def test_multiselect_field(self):
|
||||||
CHOICES = ['Option A', 'Option B', 'Option C']
|
CHOICES = (
|
||||||
value = [CHOICES[1], CHOICES[2]]
|
('a', 'Option A'),
|
||||||
|
('b', 'Option B'),
|
||||||
|
('c', 'Option C'),
|
||||||
|
)
|
||||||
|
value = ['a', 'b']
|
||||||
|
|
||||||
# Create a set of custom field choices
|
# Create a set of custom field choices
|
||||||
choice_set = CustomFieldChoiceSet.objects.create(
|
choice_set = CustomFieldChoiceSet.objects.create(
|
||||||
@ -453,7 +461,7 @@ class CustomFieldAPITest(APITestCase):
|
|||||||
# Create a set of custom field choices
|
# Create a set of custom field choices
|
||||||
choice_set = CustomFieldChoiceSet.objects.create(
|
choice_set = CustomFieldChoiceSet.objects.create(
|
||||||
name='Custom Field Choice Set 1',
|
name='Custom Field Choice Set 1',
|
||||||
extra_choices=('Foo', 'Bar', 'Baz')
|
extra_choices=(('foo', 'Foo'), ('bar', 'Bar'), ('baz', 'Baz'))
|
||||||
)
|
)
|
||||||
|
|
||||||
custom_fields = (
|
custom_fields = (
|
||||||
@ -469,13 +477,13 @@ class CustomFieldAPITest(APITestCase):
|
|||||||
CustomField(
|
CustomField(
|
||||||
type=CustomFieldTypeChoices.TYPE_SELECT,
|
type=CustomFieldTypeChoices.TYPE_SELECT,
|
||||||
name='select_field',
|
name='select_field',
|
||||||
default='Foo',
|
default='foo',
|
||||||
choice_set=choice_set
|
choice_set=choice_set
|
||||||
),
|
),
|
||||||
CustomField(
|
CustomField(
|
||||||
type=CustomFieldTypeChoices.TYPE_MULTISELECT,
|
type=CustomFieldTypeChoices.TYPE_MULTISELECT,
|
||||||
name='multiselect_field',
|
name='multiselect_field',
|
||||||
default=['Foo'],
|
default=['foo'],
|
||||||
choice_set=choice_set
|
choice_set=choice_set
|
||||||
),
|
),
|
||||||
CustomField(
|
CustomField(
|
||||||
@ -514,8 +522,8 @@ class CustomFieldAPITest(APITestCase):
|
|||||||
custom_fields[6].name: '2020-01-02 12:00:00',
|
custom_fields[6].name: '2020-01-02 12:00:00',
|
||||||
custom_fields[7].name: 'http://example.com/2',
|
custom_fields[7].name: 'http://example.com/2',
|
||||||
custom_fields[8].name: '{"foo": 1, "bar": 2}',
|
custom_fields[8].name: '{"foo": 1, "bar": 2}',
|
||||||
custom_fields[9].name: 'Bar',
|
custom_fields[9].name: 'bar',
|
||||||
custom_fields[10].name: ['Bar', 'Baz'],
|
custom_fields[10].name: ['bar', 'baz'],
|
||||||
custom_fields[11].name: vlans[1].pk,
|
custom_fields[11].name: vlans[1].pk,
|
||||||
custom_fields[12].name: [vlans[2].pk, vlans[3].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),
|
'datetime_field': datetime.datetime(2020, 1, 2, 12, 0, 0),
|
||||||
'url_field': 'http://example.com/2',
|
'url_field': 'http://example.com/2',
|
||||||
'json_field': '{"foo": 1, "bar": 2}',
|
'json_field': '{"foo": 1, "bar": 2}',
|
||||||
'select_field': 'Bar',
|
'select_field': 'bar',
|
||||||
'multiselect_field': ['Bar', 'Baz'],
|
'multiselect_field': ['bar', 'baz'],
|
||||||
'object_field': VLAN.objects.get(vid=2).pk,
|
'object_field': VLAN.objects.get(vid=2).pk,
|
||||||
'multiobject_field': list(VLAN.objects.filter(vid__in=[3, 4]).values_list('pk', flat=True)),
|
'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),
|
'datetime_field': datetime.datetime(2020, 1, 2, 12, 0, 0),
|
||||||
'url_field': 'http://example.com/2',
|
'url_field': 'http://example.com/2',
|
||||||
'json_field': '{"foo": 1, "bar": 2}',
|
'json_field': '{"foo": 1, "bar": 2}',
|
||||||
'select_field': 'Bar',
|
'select_field': 'bar',
|
||||||
'multiselect_field': ['Bar', 'Baz'],
|
'multiselect_field': ['bar', 'baz'],
|
||||||
'object_field': VLAN.objects.get(vid=2).pk,
|
'object_field': VLAN.objects.get(vid=2).pk,
|
||||||
'multiobject_field': list(VLAN.objects.filter(vid__in=[3, 4]).values_list('pk', flat=True)),
|
'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
|
# Create a set of custom field choices
|
||||||
choice_set = CustomFieldChoiceSet.objects.create(
|
choice_set = CustomFieldChoiceSet.objects.create(
|
||||||
name='Custom Field Choice Set 1',
|
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 = (
|
custom_fields = (
|
||||||
@ -1067,8 +1079,8 @@ class CustomFieldImportTest(TestCase):
|
|||||||
"""
|
"""
|
||||||
data = (
|
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'),
|
('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 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}', 'Choice B', '"Choice B,Choice C"'),
|
('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', '', '', '', '', '', '', '', '', '', '', ''),
|
('Site 3', 'site-3', 'active', '', '', '', '', '', '', '', '', '', '', ''),
|
||||||
)
|
)
|
||||||
csv_data = '\n'.join(','.join(row) for row in data)
|
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.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['url'], 'http://example.com/1')
|
||||||
self.assertEqual(site1.custom_field_data['json'], {"foo": 123})
|
self.assertEqual(site1.custom_field_data['json'], {"foo": 123})
|
||||||
self.assertEqual(site1.custom_field_data['select'], 'Choice A')
|
self.assertEqual(site1.custom_field_data['select'], 'a')
|
||||||
self.assertEqual(site1.custom_field_data['multiselect'], ['Choice A', 'Choice B'])
|
self.assertEqual(site1.custom_field_data['multiselect'], ['a', 'b'])
|
||||||
|
|
||||||
# Validate data for site 2
|
# Validate data for site 2
|
||||||
site2 = Site.objects.get(name='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.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['url'], 'http://example.com/2')
|
||||||
self.assertEqual(site2.custom_field_data['json'], {"bar": 456})
|
self.assertEqual(site2.custom_field_data['json'], {"bar": 456})
|
||||||
self.assertEqual(site2.custom_field_data['select'], 'Choice B')
|
self.assertEqual(site2.custom_field_data['select'], 'b')
|
||||||
self.assertEqual(site2.custom_field_data['multiselect'], ['Choice B', 'Choice C'])
|
self.assertEqual(site2.custom_field_data['multiselect'], ['b', 'c'])
|
||||||
|
|
||||||
# No custom field data should be set for site 3
|
# No custom field data should be set for site 3
|
||||||
site3 = Site.objects.get(name='Site 3')
|
site3 = Site.objects.get(name='Site 3')
|
||||||
@ -1221,7 +1233,7 @@ class CustomFieldModelFilterTest(TestCase):
|
|||||||
|
|
||||||
choice_set = CustomFieldChoiceSet.objects.create(
|
choice_set = CustomFieldChoiceSet.objects.create(
|
||||||
name='Custom Field Choice Set 1',
|
name='Custom Field Choice Set 1',
|
||||||
extra_choices=['A', 'B', 'C', 'X']
|
extra_choices=(('a', 'A'), ('b', 'B'), ('c', 'C'), ('x', 'X'))
|
||||||
)
|
)
|
||||||
|
|
||||||
# Integer filtering
|
# Integer filtering
|
||||||
|
@ -14,8 +14,8 @@ class CustomFieldModelFormTest(TestCase):
|
|||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
obj_type = ContentType.objects.get_for_model(Site)
|
obj_type = ContentType.objects.get_for_model(Site)
|
||||||
choice_set = CustomFieldChoiceSet.objects.create(
|
choice_set = CustomFieldChoiceSet.objects.create(
|
||||||
name='Custom Field Choice Set 1',
|
name='Choice Set 1',
|
||||||
extra_choices=('A', 'B', 'C')
|
extra_choices=(('a', 'A'), ('b', 'B'), ('c', 'C'))
|
||||||
)
|
)
|
||||||
|
|
||||||
cf_text = CustomField.objects.create(name='text', type=CustomFieldTypeChoices.TYPE_TEXT)
|
cf_text = CustomField.objects.create(name='text', type=CustomFieldTypeChoices.TYPE_TEXT)
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import json
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
@ -23,7 +24,11 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
|||||||
site_ct = ContentType.objects.get_for_model(Site)
|
site_ct = ContentType.objects.get_for_model(Site)
|
||||||
CustomFieldChoiceSet.objects.create(
|
CustomFieldChoiceSet.objects.create(
|
||||||
name='Choice Set 1',
|
name='Choice Set 1',
|
||||||
extra_choices=('A', 'B', 'C')
|
extra_choices=(
|
||||||
|
('A', 'A'),
|
||||||
|
('B', 'B'),
|
||||||
|
('C', 'C'),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
custom_fields = (
|
custom_fields = (
|
||||||
@ -76,29 +81,38 @@ class CustomFieldChoiceSetTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
|||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
|
|
||||||
choice_sets = (
|
choice_sets = (
|
||||||
CustomFieldChoiceSet(name='Choice Set 1', extra_choices=['1A', '1B', '1C', '1D', '1E']),
|
CustomFieldChoiceSet(
|
||||||
CustomFieldChoiceSet(name='Choice Set 2', extra_choices=['2A', '2B', '2C', '2D', '2E']),
|
name='Choice Set 1',
|
||||||
CustomFieldChoiceSet(name='Choice Set 3', extra_choices=['3A', '3B', '3C', '3D', '3E']),
|
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)
|
CustomFieldChoiceSet.objects.bulk_create(choice_sets)
|
||||||
|
|
||||||
cls.form_data = {
|
cls.form_data = {
|
||||||
'name': 'Choice Set X',
|
'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 = (
|
cls.csv_data = (
|
||||||
'name,extra_choices',
|
'name,extra_choices',
|
||||||
'Choice Set 4,"4A,4B,4C,4D,4E"',
|
'Choice Set 4,"D1,D2,D3"',
|
||||||
'Choice Set 5,"5A,5B,5C,5D,5E"',
|
'Choice Set 5,"E1,E2,E3"',
|
||||||
'Choice Set 6,"6A,6B,6C,6D,6E"',
|
'Choice Set 6,"F1,F2,F3"',
|
||||||
)
|
)
|
||||||
|
|
||||||
cls.csv_update_data = (
|
cls.csv_update_data = (
|
||||||
'id,extra_choices',
|
'id,extra_choices',
|
||||||
f'{choice_sets[0].pk},"1X,1Y,1Z"',
|
f'{choice_sets[0].pk},"A,B,C"',
|
||||||
f'{choice_sets[1].pk},"2X,2Y,2Z"',
|
f'{choice_sets[1].pk},"A,B,C"',
|
||||||
f'{choice_sets[2].pk},"3X,3Y,3Z"',
|
f'{choice_sets[2].pk},"A,B,C"',
|
||||||
)
|
)
|
||||||
|
|
||||||
cls.bulk_edit_data = {
|
cls.bulk_edit_data = {
|
||||||
|
@ -9,8 +9,8 @@ from django.db.models import DateField, DateTimeField
|
|||||||
from django.template import Context, Template
|
from django.template import Context, Template
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.dateparse import parse_date
|
from django.utils.dateparse import parse_date
|
||||||
from django.utils.html import escape
|
|
||||||
from django.utils.formats import date_format
|
from django.utils.formats import date_format
|
||||||
|
from django.utils.html import escape
|
||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
from django_tables2.columns import library
|
from django_tables2.columns import library
|
||||||
from django_tables2.utils import Accessor
|
from django_tables2.utils import Accessor
|
||||||
@ -24,6 +24,7 @@ __all__ = (
|
|||||||
'ArrayColumn',
|
'ArrayColumn',
|
||||||
'BooleanColumn',
|
'BooleanColumn',
|
||||||
'ChoiceFieldColumn',
|
'ChoiceFieldColumn',
|
||||||
|
'ChoicesColumn',
|
||||||
'ColorColumn',
|
'ColorColumn',
|
||||||
'ColoredLabelColumn',
|
'ColoredLabelColumn',
|
||||||
'ContentTypeColumn',
|
'ContentTypeColumn',
|
||||||
@ -598,16 +599,49 @@ class ArrayColumn(tables.Column):
|
|||||||
"""
|
"""
|
||||||
List array items as a comma-separated list.
|
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):
|
def __init__(self, *args, max_items=None, **kwargs):
|
||||||
self.max_items = max_items
|
self.max_items = max_items
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
def render(self, value):
|
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:
|
if self.max_items:
|
||||||
# Limit the returned items to the specified maximum number
|
omitted_count = len(value) - self.max_items
|
||||||
omitted = len(value) - self.max_items
|
|
||||||
value = value[:self.max_items - 1]
|
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)
|
return ', '.join(value)
|
||||||
|
@ -17,6 +17,10 @@
|
|||||||
<th scope="row">Description</th>
|
<th scope="row">Description</th>
|
||||||
<td>{{ object.description|markdown|placeholder }}</td>
|
<td>{{ object.description|markdown|placeholder }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Base Choices</th>
|
||||||
|
<td>{{ object.get_base_choices_display|placeholder }}</td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Choices</th>
|
<th scope="row">Choices</th>
|
||||||
<td>{{ object.choices|length }}</td>
|
<td>{{ object.choices|length }}</td>
|
||||||
@ -42,12 +46,19 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col col-md-6">
|
<div class="col col-md-6">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h5 class="card-header">Choices</h5>
|
<h5 class="card-header">Choices ({{ object.choices|length }})</h5>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<table class="table table-hover attr-table">
|
<table class="table table-hover table-headings">
|
||||||
{% for choice in object.choices %}
|
<thead>
|
||||||
<tr>
|
<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>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</table>
|
</table>
|
||||||
|
@ -8,11 +8,49 @@ from utilities.forms import widgets
|
|||||||
from utilities.utils import get_viewname
|
from utilities.utils import get_viewname
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
|
'DynamicChoiceField',
|
||||||
'DynamicModelChoiceField',
|
'DynamicModelChoiceField',
|
||||||
'DynamicModelMultipleChoiceField',
|
'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:
|
class DynamicModelChoiceMixin:
|
||||||
"""
|
"""
|
||||||
Override `get_bound_field()` to avoid pre-populating field choices with a SQL query. The field will be
|
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__ = (
|
__all__ = (
|
||||||
'ArrayWidget',
|
'ArrayWidget',
|
||||||
|
'ChoicesWidget',
|
||||||
'ClearableFileInput',
|
'ClearableFileInput',
|
||||||
'MarkdownWidget',
|
'MarkdownWidget',
|
||||||
'NumberWithOptions',
|
'NumberWithOptions',
|
||||||
@ -54,3 +55,15 @@ class ArrayWidget(forms.Textarea):
|
|||||||
if value is None or not len(value):
|
if value is None or not len(value):
|
||||||
return None
|
return None
|
||||||
return '\n'.join(value)
|
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:
|
if type(field) is GQLDynamic:
|
||||||
# Dynamic fields must specify a subselection
|
# Dynamic fields must specify a subselection
|
||||||
fields_string += f'{field_name} {{ id }}\n'
|
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):
|
elif inspect.isclass(field.type) and issubclass(field.type, GQLUnion):
|
||||||
# Union types dont' have an id or consistent values
|
# Union types dont' have an id or consistent values
|
||||||
continue
|
continue
|
||||||
|
@ -129,13 +129,18 @@ class ModelTestCase(TestCase):
|
|||||||
model_dict[key] = str(value)
|
model_dict[key] = str(value)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
|
field = instance._meta.get_field(key)
|
||||||
|
|
||||||
# Convert ArrayFields to CSV strings
|
# Convert ArrayFields to CSV strings
|
||||||
if type(instance._meta.get_field(key)) is ArrayField:
|
if type(field) is ArrayField:
|
||||||
model_dict[key] = ','.join([str(v) for v in value])
|
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
|
# 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)
|
model_dict[key] = json.dumps(value)
|
||||||
|
|
||||||
return model_dict
|
return model_dict
|
||||||
|
Loading…
Reference in New Issue
Block a user