Просмотр исходного кода

Merge pull request #4693 from netbox-community/develop

Release v2.8.5
Jeremy Stretch 5 лет назад
Родитель
Сommit
68599351aa

+ 3 - 4
.github/ISSUE_TEMPLATE/bug_report.md

@@ -30,10 +30,9 @@ about: Report a reproducible bug in the current release of NetBox
     library such as pynetbox.
 -->
 ### Steps to Reproduce
-1. Disable any installed plugins by commenting out the `PLUGINS` setting in
-   `configuration.py`.
-2.
-3.
+1. 
+2. 
+3. 
 
 <!-- What did you expect to happen? -->
 ### Expected Behavior

+ 17 - 2
docs/configuration/optional-settings.md

@@ -86,7 +86,12 @@ CORS_ORIGIN_WHITELIST = [
 
 Default: False
 
-This setting enables debugging. This should be done only during development or troubleshooting. Never enable debugging on a production system, as it can expose sensitive data to unauthenticated users.
+This setting enables debugging. This should be done only during development or troubleshooting. Note that only clients
+which access NetBox from a recognized [internal IP address](#internal_ips) will see debugging tools in the user
+interface.
+
+!!! warning
+    Never enable debugging on a production system, as it can expose sensitive data to unauthenticated users.
 
 ---
 
@@ -184,6 +189,16 @@ HTTP_PROXIES = {
 
 ---
 
+## INTERNAL_IPS
+
+Default: `('127.0.0.1', '::1',)`
+
+A list of IP addresses recognized as internal to the system, used to control the display of debugging output. For
+example, the debugging toolbar will be viewable only when a client is accessing NetBox from one of the listed IP
+addresses (and [`DEBUG`](#debug) is true).
+
+---
+
 ## LOGGING
 
 By default, all messages of INFO severity or higher will be logged to the console. Additionally, if `DEBUG` is False and email access has been configured, ERROR and CRITICAL messages will be emailed to the users defined in `ADMINS`.
@@ -385,7 +400,7 @@ When remote user authentication is in use, this is the name of the HTTP header w
 
 ## REMOTE_AUTH_AUTO_CREATE_USER
 
-Default: `True`
+Default: `False`
 
 If true, NetBox will automatically create local accounts for users authenticated via a remote service. (Requires `REMOTE_AUTH_ENABLED`.)
 

+ 1 - 1
docs/index.md

@@ -49,7 +49,7 @@ NetBox is built on the [Django](https://djangoproject.com/) Python framework and
 | HTTP service       | nginx or Apache   |
 | WSGI service       | gunicorn or uWSGI |
 | Application        | Django/Python     |
-| Database           | PostgreSQL 9.4+   |
+| Database           | PostgreSQL 9.6+   |
 | Task queuing       | Redis/django-rq   |
 | Live device access | NAPALM            |
 

+ 2 - 2
docs/installation/1-postgresql.md

@@ -3,7 +3,7 @@
 This section entails the installation and configuration of a local PostgreSQL database. If you already have a PostgreSQL database service in place, skip to [the next section](2-redis.md).
 
 !!! warning
-    NetBox requires PostgreSQL 9.4 or higher. Please note that MySQL and other relational databases are **not** supported.
+    NetBox requires PostgreSQL 9.6 or higher. Please note that MySQL and other relational databases are **not** supported.
 
 The installation instructions provided here have been tested to work on Ubuntu 18.04 and CentOS 7.5. The particular commands needed to install dependencies on other distributions may vary significantly. Unfortunately, this is outside the control of the NetBox maintainers. Please consult your distribution's documentation for assistance with any errors.
 
@@ -51,7 +51,7 @@ At a minimum, we need to create a database for NetBox and assign it a username a
 
 ```no-highlight
 # sudo -u postgres psql
-psql (9.4.5)
+psql (10.10)
 Type "help" for help.
 
 postgres=# CREATE DATABASE netbox;

+ 27 - 1
docs/release-notes/version-2.8.md

@@ -1,6 +1,32 @@
 # NetBox v2.8
 
-v2.8.4 (2020-05-13)
+## v2.8.5 (2020-05-26)
+
+**Note:** The minimum required version of PostgreSQL is now 9.6.
+
+### Enhancements
+
+* [#4650](https://github.com/netbox-community/netbox/issues/4650) - Expose `INTERNAL_IPS` configuration parameter
+* [#4651](https://github.com/netbox-community/netbox/issues/4651) - Add `csrf_token` context for plugin templates
+* [#4652](https://github.com/netbox-community/netbox/issues/4652) - Add permissions context for plugin templates
+* [#4665](https://github.com/netbox-community/netbox/issues/4665) - Add NEMA L14 and L21 power port/outlet types
+* [#4672](https://github.com/netbox-community/netbox/issues/4672) - Set default color for rack and devices roles
+
+### Bug Fixes
+
+* [#3304](https://github.com/netbox-community/netbox/issues/3304) - Fix caching invalidation issue related to device/virtual machine primary IP addresses
+* [#4525](https://github.com/netbox-community/netbox/issues/4525) - Allow passing initial data to custom script MultiObjectVar
+* [#4644](https://github.com/netbox-community/netbox/issues/4644) - Fix ordering of services table by parent
+* [#4646](https://github.com/netbox-community/netbox/issues/4646) - Correct UI link for reports with custom name
+* [#4647](https://github.com/netbox-community/netbox/issues/4647) - Fix caching invalidation issue related to assigning new IP addresses to interfaces
+* [#4648](https://github.com/netbox-community/netbox/issues/4648) - Fix bulk CSV import of child devices
+* [#4649](https://github.com/netbox-community/netbox/issues/4649) - Fix interface assignment for bulk-imported IP addresses
+* [#4676](https://github.com/netbox-community/netbox/issues/4676) - Set default value of `REMOTE_AUTH_AUTO_CREATE_USER` as `False` in docs
+* [#4684](https://github.com/netbox-community/netbox/issues/4684) - Respect `comments` field when importing device type in YAML/JSON format
+
+---
+
+## v2.8.4 (2020-05-13)
 
 ### Enhancements
 

+ 16 - 0
netbox/dcim/choices.py

@@ -276,6 +276,10 @@ class PowerPortTypeChoices(ChoiceSet):
     TYPE_NEMA_L620P = 'nema-l6-20p'
     TYPE_NEMA_L630P = 'nema-l6-30p'
     TYPE_NEMA_L650P = 'nema-l6-50p'
+    TYPE_NEMA_L1420P = 'nema-l14-20p'
+    TYPE_NEMA_L1430P = 'nema-l14-30p'
+    TYPE_NEMA_L2120P = 'nema-l21-20p'
+    TYPE_NEMA_L2130P = 'nema-l21-30p'
     # California style
     TYPE_CS6361C = 'cs6361c'
     TYPE_CS6365C = 'cs6365c'
@@ -337,6 +341,10 @@ class PowerPortTypeChoices(ChoiceSet):
             (TYPE_NEMA_L620P, 'NEMA L6-20P'),
             (TYPE_NEMA_L630P, 'NEMA L6-30P'),
             (TYPE_NEMA_L650P, 'NEMA L6-50P'),
+            (TYPE_NEMA_L1420P, 'NEMA L14-20P'),
+            (TYPE_NEMA_L1430P, 'NEMA L14-30P'),
+            (TYPE_NEMA_L2120P, 'NEMA L21-20P'),
+            (TYPE_NEMA_L2130P, 'NEMA L21-30P'),
         )),
         ('California Style', (
             (TYPE_CS6361C, 'CS6361C'),
@@ -405,6 +413,10 @@ class PowerOutletTypeChoices(ChoiceSet):
     TYPE_NEMA_L620R = 'nema-l6-20r'
     TYPE_NEMA_L630R = 'nema-l6-30r'
     TYPE_NEMA_L650R = 'nema-l6-50r'
+    TYPE_NEMA_L1420R = 'nema-l14-20r'
+    TYPE_NEMA_L1430R = 'nema-l14-30r'
+    TYPE_NEMA_L2120R = 'nema-l21-20r'
+    TYPE_NEMA_L2130R = 'nema-l21-30r'
     # California style
     TYPE_CS6360C = 'CS6360C'
     TYPE_CS6364C = 'CS6364C'
@@ -467,6 +479,10 @@ class PowerOutletTypeChoices(ChoiceSet):
             (TYPE_NEMA_L620R, 'NEMA L6-20R'),
             (TYPE_NEMA_L630R, 'NEMA L6-30R'),
             (TYPE_NEMA_L650R, 'NEMA L6-50R'),
+            (TYPE_NEMA_L1420R, 'NEMA L14-20R'),
+            (TYPE_NEMA_L1430R, 'NEMA L14-30R'),
+            (TYPE_NEMA_L2120R, 'NEMA L21-20R'),
+            (TYPE_NEMA_L2130R, 'NEMA L21-30R'),
         )),
         ('California Style', (
             (TYPE_CS6360C, 'CS6360C'),

+ 2 - 2
netbox/dcim/filters.py

@@ -4,7 +4,7 @@ from django.contrib.auth.models import User
 from extras.filters import CustomFieldFilterSet, LocalConfigContextFilterSet, CreatedUpdatedFilterSet
 from tenancy.filters import TenancyFilterSet
 from tenancy.models import Tenant
-from utilities.constants import COLOR_CHOICES
+from utilities.choices import ColorChoices
 from utilities.filters import (
     BaseFilterSet, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter,
     NameSlugSearchFilterSet, TagFilter, TreeNodeMultipleChoiceFilter,
@@ -1084,7 +1084,7 @@ class CableFilterSet(BaseFilterSet):
         choices=CableStatusChoices
     )
     color = django_filters.MultipleChoiceFilter(
-        choices=COLOR_CHOICES
+        choices=ColorChoices
     )
     device_id = MultiValueNumberFilter(
         method='filter_device'

+ 16 - 1
netbox/dcim/forms.py

@@ -932,6 +932,7 @@ class DeviceTypeImportForm(BootstrapMixin, forms.ModelForm):
         model = DeviceType
         fields = [
             'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role',
+            'comments',
         ]
 
 
@@ -1956,7 +1957,7 @@ class ChildDeviceCSVForm(BaseDeviceCSVForm):
         help_text='Parent device'
     )
     device_bay = CSVModelChoiceField(
-        queryset=Device.objects.all(),
+        queryset=DeviceBay.objects.all(),
         to_field_name='name',
         help_text='Device bay in which this device is installed'
     )
@@ -1976,6 +1977,20 @@ class ChildDeviceCSVForm(BaseDeviceCSVForm):
             params = {f"device__{self.fields['parent'].to_field_name}": data.get('parent')}
             self.fields['device_bay'].queryset = self.fields['device_bay'].queryset.filter(**params)
 
+    def clean(self):
+        super().clean()
+
+        # Set parent_bay reverse relationship
+        device_bay = self.cleaned_data.get('device_bay')
+        if device_bay:
+            self.instance.parent_bay = device_bay
+
+        # Inherit site and rack from parent device
+        parent = self.cleaned_data.get('parent')
+        if parent:
+            self.instance.site = parent.site
+            self.instance.rack = parent.rack
+
 
 class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
     pk = forms.ModelMultipleChoiceField(

+ 24 - 0
netbox/dcim/migrations/0106_role_default_color.py

@@ -0,0 +1,24 @@
+# Generated by Django 3.0.6 on 2020-05-26 13:33
+
+from django.db import migrations
+import utilities.fields
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0105_interface_name_collation'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='devicerole',
+            name='color',
+            field=utilities.fields.ColorField(default='9e9e9e', max_length=6),
+        ),
+        migrations.AlterField(
+            model_name='rackrole',
+            name='color',
+            field=utilities.fields.ColorField(default='9e9e9e', max_length=6),
+        ),
+    ]

+ 7 - 2
netbox/dcim/models/__init__.py

@@ -23,6 +23,7 @@ from dcim.fields import ASNField
 from dcim.elevations import RackElevationSVG
 from extras.models import ConfigContextModel, CustomFieldModel, ObjectChange, TaggedItem
 from extras.utils import extras_features
+from utilities.choices import ColorChoices
 from utilities.fields import ColorField, NaturalOrderingField
 from utilities.models import ChangeLoggedModel
 from utilities.utils import serialize_object, to_meters
@@ -379,7 +380,9 @@ class RackRole(ChangeLoggedModel):
     slug = models.SlugField(
         unique=True
     )
-    color = ColorField()
+    color = ColorField(
+        default=ColorChoices.COLOR_GREY
+    )
     description = models.CharField(
         max_length=200,
         blank=True,
@@ -1190,7 +1193,9 @@ class DeviceRole(ChangeLoggedModel):
     slug = models.SlugField(
         unique=True
     )
-    color = ColorField()
+    color = ColorField(
+        default=ColorChoices.COLOR_GREY
+    )
     vm_role = models.BooleanField(
         default=True,
         verbose_name='VM Role',

+ 2 - 0
netbox/dcim/tests/test_views.py

@@ -366,6 +366,7 @@ manufacturer: Generic
 model: TEST-1000
 slug: test-1000
 u_height: 2
+comments: test comment
 console-ports:
   - name: Console Port 1
     type: de-9
@@ -456,6 +457,7 @@ device-bays:
         self.assertHttpStatus(response, 200)
 
         dt = DeviceType.objects.get(model='TEST-1000')
+        self.assertEqual(dt.comments, 'test comment')
 
         # Verify all of the components were created
         self.assertEqual(dt.consoleport_templates.count(), 3)

+ 1 - 1
netbox/dcim/views.py

@@ -1105,7 +1105,7 @@ class DeviceView(PermissionRequiredMixin, View):
     def get(self, request, pk):
 
         device = get_object_or_404(Device.objects.prefetch_related(
-            'site__region', 'rack__group', 'tenant__group', 'device_role', 'platform'
+            'site__region', 'rack__group', 'tenant__group', 'device_role', 'platform', 'primary_ip4', 'primary_ip6'
         ), pk=pk)
 
         # VirtualChassis members

+ 3 - 3
netbox/extras/forms.py

@@ -432,11 +432,11 @@ class ScriptForm(BootstrapMixin, forms.Form):
 
     def __init__(self, vars, *args, commit_default=True, **kwargs):
 
-        super().__init__(*args, **kwargs)
-
         # Dynamically populate fields for variables
         for name, var in vars.items():
-            self.fields[name] = var.as_field()
+            self.base_fields[name] = var.as_field()
+
+        super().__init__(*args, **kwargs)
 
         # Toggle default commit behavior based on Meta option
         if not commit_default:

+ 2 - 1
netbox/extras/models/tags.py

@@ -3,6 +3,7 @@ from django.urls import reverse
 from django.utils.text import slugify
 from taggit.models import TagBase, GenericTaggedItemBase
 
+from utilities.choices import ColorChoices
 from utilities.fields import ColorField
 from utilities.models import ChangeLoggedModel
 
@@ -13,7 +14,7 @@ from utilities.models import ChangeLoggedModel
 
 class Tag(TagBase, ChangeLoggedModel):
     color = ColorField(
-        default='9e9e9e'
+        default=ColorChoices.COLOR_GREY
     )
     description = models.CharField(
         max_length=200,

+ 2 - 2
netbox/extras/reports.py

@@ -92,7 +92,7 @@ class Report(object):
         self.active_test = None
         self.failed = False
 
-        self.logger = logging.getLogger(f"netbox.reports.{self.module}.{self.name}")
+        self.logger = logging.getLogger(f"netbox.reports.{self.full_name}")
 
         # Compile test methods and initialize results skeleton
         test_methods = []
@@ -120,7 +120,7 @@ class Report(object):
 
     @property
     def full_name(self):
-        return '.'.join([self.module, self.name])
+        return '.'.join([self.__module__, self.__class__.__name__])
 
     def _log(self, obj, message, level=LOG_DEFAULT):
         """

+ 2 - 0
netbox/extras/templatetags/plugins.py

@@ -18,6 +18,8 @@ def _get_registered_content(obj, method, template_context):
         'object': obj,
         'request': template_context['request'],
         'settings': template_context['settings'],
+        'csrf_token': template_context['csrf_token'],
+        'perms': template_context['perms'],
     }
 
     model_name = obj._meta.label_lower

+ 6 - 13
netbox/ipam/forms.py

@@ -618,7 +618,12 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
         if self.instance and self.instance.interface:
             self.fields['interface'].queryset = Interface.objects.filter(
                 device=self.instance.interface.device, virtual_machine=self.instance.interface.virtual_machine
-            )
+            ).prefetch_related(
+                'device__primary_ip4',
+                'device__primary_ip6',
+                'virtual_machine__primary_ip4',
+                'virtual_machine__primary_ip6',
+            )  # We prefetch the primary address fields to ensure cache invalidation does not balk on the save()
         else:
             self.fields['interface'].choices = []
 
@@ -775,18 +780,6 @@ class IPAddressCSVForm(CustomFieldModelCSVForm):
 
     def save(self, *args, **kwargs):
 
-        # Set interface
-        if self.cleaned_data['device'] and self.cleaned_data['interface_name']:
-            self.instance.interface = Interface.objects.get(
-                device=self.cleaned_data['device'],
-                name=self.cleaned_data['interface_name']
-            )
-        elif self.cleaned_data['virtual_machine'] and self.cleaned_data['interface_name']:
-            self.instance.interface = Interface.objects.get(
-                virtual_machine=self.cleaned_data['virtual_machine'],
-                name=self.cleaned_data['interface_name']
-            )
-
         ipaddress = super().save(*args, **kwargs)
 
         # Set as primary for device/VM

+ 3 - 0
netbox/ipam/tables.py

@@ -667,6 +667,9 @@ class ServiceTable(BaseTable):
         viewname='ipam:service',
         args=[Accessor('pk')]
     )
+    parent = tables.LinkColumn(
+        order_by=('device', 'virtual_machine')
+    )
     tags = TagColumn(
         url_name='ipam:service_list'
     )

+ 4 - 0
netbox/netbox/configuration.example.py

@@ -132,6 +132,10 @@ EXEMPT_VIEW_PERMISSIONS = [
 #     'https': 'http://10.10.1.10:1080',
 # }
 
+# IP addresses recognized as internal to the system. The debugging toolbar will be available only to clients accessing
+# NetBox from an internal IP.
+INTERNAL_IPS = ('127.0.0.1', '::1')
+
 # Enable custom logging. Please see the Django documentation for detailed guidance on configuring custom logs:
 #   https://docs.djangoproject.com/en/stable/topics/logging/
 LOGGING = {}

+ 2 - 10
netbox/netbox/settings.py

@@ -16,7 +16,7 @@ from django.core.validators import URLValidator
 # Environment setup
 #
 
-VERSION = '2.8.4'
+VERSION = '2.8.5'
 
 # Hostname
 HOSTNAME = platform.node()
@@ -78,6 +78,7 @@ EMAIL = getattr(configuration, 'EMAIL', {})
 ENFORCE_GLOBAL_UNIQUE = getattr(configuration, 'ENFORCE_GLOBAL_UNIQUE', False)
 EXEMPT_VIEW_PERMISSIONS = getattr(configuration, 'EXEMPT_VIEW_PERMISSIONS', [])
 HTTP_PROXIES = getattr(configuration, 'HTTP_PROXIES', None)
+INTERNAL_IPS = getattr(configuration, 'INTERNAL_IPS', ('127.0.0.1', '::1'))
 LOGGING = getattr(configuration, 'LOGGING', {})
 LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False)
 LOGIN_TIMEOUT = getattr(configuration, 'LOGIN_TIMEOUT', None)
@@ -615,15 +616,6 @@ RQ_QUEUES = {
     'check_releases': RQ_PARAMS,
 }
 
-#
-# Django debug toolbar
-#
-
-INTERNAL_IPS = (
-    '127.0.0.1',
-    '::1',
-)
-
 
 #
 # NetBox internal settings

+ 1 - 1
netbox/templates/exceptions/programming_error.html

@@ -10,7 +10,7 @@
         <code>python3 manage.py migrate</code> from the command line.
     </p>
     <p>
-        <i class="fa fa-warning"></i> <strong>Unsupported PostgreSQL version</strong> - Ensure that PostgreSQL version 9.4 or higher is in use. You
+        <i class="fa fa-warning"></i> <strong>Unsupported PostgreSQL version</strong> - Ensure that PostgreSQL version 9.6 or higher is in use. You
         can check this by connecting to the database using NetBox's credentials and issuing a query for
         <code>SELECT VERSION()</code>.
     </p>

+ 64 - 0
netbox/utilities/choices.py

@@ -80,6 +80,70 @@ def unpack_grouped_choices(choices):
     return unpacked_choices
 
 
+#
+# Generic color choices
+#
+
+class ColorChoices(ChoiceSet):
+    COLOR_DARK_RED = 'aa1409'
+    COLOR_RED = 'f44336'
+    COLOR_PINK = 'e91e63'
+    COLOR_ROSE = 'ffe4e1'
+    COLOR_FUCHSIA = 'ff66ff'
+    COLOR_PURPLE = '9c27b0'
+    COLOR_DARK_PURPLE = '673ab7'
+    COLOR_INDIGO = '3f51b5'
+    COLOR_BLUE = '2196f3'
+    COLOR_LIGHT_BLUE = '03a9f4'
+    COLOR_CYAN = '00bcd4'
+    COLOR_TEAL = '009688'
+    COLOR_AQUA = '00ffff'
+    COLOR_DARK_GREEN = '2f6a31'
+    COLOR_GREEN = '4caf50'
+    COLOR_LIGHT_GREEN = '8bc34a'
+    COLOR_LIME = 'cddc39'
+    COLOR_YELLOW = 'ffeb3b'
+    COLOR_AMBER = 'ffc107'
+    COLOR_ORANGE = 'ff9800'
+    COLOR_DARK_ORANGE = 'ff5722'
+    COLOR_BROWN = '795548'
+    COLOR_LIGHT_GREY = 'c0c0c0'
+    COLOR_GREY = '9e9e9e'
+    COLOR_DARK_GREY = '607d8b'
+    COLOR_BLACK = '111111'
+    COLOR_WHITE = 'ffffff'
+
+    CHOICES = (
+        (COLOR_DARK_RED, 'Dark red'),
+        (COLOR_RED, 'Red'),
+        (COLOR_PINK, 'Pink'),
+        (COLOR_ROSE, 'Rose'),
+        (COLOR_FUCHSIA, 'Fuchsia'),
+        (COLOR_PURPLE, 'Purple'),
+        (COLOR_DARK_PURPLE, 'Dark purple'),
+        (COLOR_INDIGO, 'Indigo'),
+        (COLOR_BLUE, 'Blue'),
+        (COLOR_LIGHT_BLUE, 'Light blue'),
+        (COLOR_CYAN, 'Cyan'),
+        (COLOR_TEAL, 'Teal'),
+        (COLOR_AQUA, 'Aqua'),
+        (COLOR_DARK_GREEN, 'Dark green'),
+        (COLOR_GREEN, 'Green'),
+        (COLOR_LIGHT_GREEN, 'Light green'),
+        (COLOR_LIME, 'Lime'),
+        (COLOR_YELLOW, 'Yellow'),
+        (COLOR_AMBER, 'Amber'),
+        (COLOR_ORANGE, 'Orange'),
+        (COLOR_DARK_ORANGE, 'Dark orange'),
+        (COLOR_BROWN, 'Brown'),
+        (COLOR_LIGHT_GREY, 'Light grey'),
+        (COLOR_GREY, 'Grey'),
+        (COLOR_DARK_GREY, 'Dark grey'),
+        (COLOR_BLACK, 'Black'),
+        (COLOR_WHITE, 'White'),
+    )
+
+
 #
 # Button color choices
 #

+ 0 - 31
netbox/utilities/constants.py

@@ -1,34 +1,3 @@
-COLOR_CHOICES = (
-    ('aa1409', 'Dark red'),
-    ('f44336', 'Red'),
-    ('e91e63', 'Pink'),
-    ('ffe4e1', 'Rose'),
-    ('ff66ff', 'Fuschia'),
-    ('9c27b0', 'Purple'),
-    ('673ab7', 'Dark purple'),
-    ('3f51b5', 'Indigo'),
-    ('2196f3', 'Blue'),
-    ('03a9f4', 'Light blue'),
-    ('00bcd4', 'Cyan'),
-    ('009688', 'Teal'),
-    ('00ffff', 'Aqua'),
-    ('2f6a31', 'Dark green'),
-    ('4caf50', 'Green'),
-    ('8bc34a', 'Light green'),
-    ('cddc39', 'Lime'),
-    ('ffeb3b', 'Yellow'),
-    ('ffc107', 'Amber'),
-    ('ff9800', 'Orange'),
-    ('ff5722', 'Dark orange'),
-    ('795548', 'Brown'),
-    ('c0c0c0', 'Light grey'),
-    ('9e9e9e', 'Grey'),
-    ('607d8b', 'Dark grey'),
-    ('111111', 'Black'),
-    ('ffffff', 'White'),
-)
-
-
 #
 # Filter lookup expressions
 #

+ 14 - 6
netbox/utilities/forms.py

@@ -14,8 +14,7 @@ from django.forms import BoundField
 from django.forms.models import fields_for_model
 from django.urls import reverse
 
-from .choices import unpack_grouped_choices
-from .constants import *
+from .choices import ColorChoices, unpack_grouped_choices
 from .validators import EnhancedURLValidator
 
 NUMERIC_EXPANSION_PATTERN = r'\[((?:\d+[?:,-])+\d+)\]'
@@ -163,7 +162,7 @@ class ColorSelect(forms.Select):
     option_template_name = 'widgets/colorselect_option.html'
 
     def __init__(self, *args, **kwargs):
-        kwargs['choices'] = add_blank_choice(COLOR_CHOICES)
+        kwargs['choices'] = add_blank_choice(ColorChoices)
         super().__init__(*args, **kwargs)
         self.attrs['class'] = 'netbox-select2-color-picker'
 
@@ -607,15 +606,18 @@ class DynamicModelChoiceMixin:
     filter = django_filters.ModelChoiceFilter
     widget = APISelect
 
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
+    def _get_initial_value(self, initial_data, field_name):
+        return initial_data.get(field_name)
 
     def get_bound_field(self, form, field_name):
         bound_field = BoundField(form, self, field_name)
 
+        # Override initial() to allow passing multiple values
+        bound_field.initial = self._get_initial_value(form.initial, field_name)
+
         # Modify the QuerySet of the field before we return it. Limit choices to any data already bound: Options
         # will be populated on-demand via the APISelect widget.
-        data = self.prepare_value(bound_field.data or bound_field.initial)
+        data = bound_field.value()
         if data:
             filter = self.filter(field_name=self.to_field_name or 'pk', queryset=self.queryset)
             self.queryset = filter.filter(self.queryset, data)
@@ -648,6 +650,12 @@ class DynamicModelMultipleChoiceField(DynamicModelChoiceMixin, forms.ModelMultip
     filter = django_filters.ModelMultipleChoiceFilter
     widget = APISelectMultiple
 
+    def _get_initial_value(self, initial_data, field_name):
+        # If a QueryDict has been passed as initial form data, get *all* listed values
+        if hasattr(initial_data, 'getlist'):
+            return initial_data.getlist(field_name)
+        return initial_data.get(field_name)
+
 
 class LaxURLField(forms.URLField):
     """