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

Merge pull request #7648 from netbox-community/5883-dyanmic-config

Closes #5883: Enable dynamic configuration of some settings
Jeremy Stretch 4 лет назад
Родитель
Сommit
3a85edba3d
41 измененных файлов с 911 добавлено и 357 удалено
  1. 1 1
      docs/additional-features/napalm.md
  2. 137 0
      docs/configuration/dynamic-settings.md
  3. 6 3
      docs/configuration/index.md
  4. 0 134
      docs/configuration/optional-settings.md
  5. 8 0
      docs/release-notes/version-3.1.md
  6. 2 2
      docs/rest-api/overview.md
  7. 1 0
      mkdocs.yml
  8. 3 2
      netbox/dcim/api/serializers.py
  9. 8 5
      netbox/dcim/api/views.py
  10. 2 2
      netbox/dcim/models/devices.py
  11. 7 3
      netbox/dcim/models/racks.py
  12. 5 12
      netbox/dcim/tables/devices.py
  13. 120 2
      netbox/extras/admin.py
  14. 1 0
      netbox/extras/forms/__init__.py
  15. 79 0
      netbox/extras/forms/config.py
  16. 20 0
      netbox/extras/migrations/0064_configrevision.py
  17. 2 1
      netbox/extras/models/__init__.py
  18. 66 52
      netbox/extras/models/models.py
  19. 13 1
      netbox/extras/signals.py
  20. 8 5
      netbox/ipam/api/mixins.py
  21. 4 4
      netbox/ipam/models/ip.py
  22. 7 6
      netbox/netbox/api/pagination.py
  23. 105 0
      netbox/netbox/config/__init__.py
  24. 140 0
      netbox/netbox/config/parameters.py
  25. 0 50
      netbox/netbox/configuration.example.py
  26. 2 0
      netbox/netbox/context_processors.py
  27. 18 4
      netbox/netbox/middleware.py
  28. 12 39
      netbox/netbox/settings.py
  29. 53 0
      netbox/netbox/tests/test_config.py
  30. 37 0
      netbox/templates/admin/extras/configrevision/restore.html
  31. 5 5
      netbox/templates/base/layout.html
  32. 2 2
      netbox/templates/dcim/site.html
  33. 1 1
      netbox/templates/inc/paginator.html
  34. 2 2
      netbox/templates/login.html
  35. 2 2
      netbox/users/views.py
  36. 18 7
      netbox/utilities/paginator.py
  37. 2 1
      netbox/utilities/templatetags/helpers.py
  38. 2 2
      netbox/utilities/tests/test_api.py
  39. 7 2
      netbox/utilities/validators.py
  40. 2 2
      netbox/virtualization/models.py
  41. 1 3
      netbox/virtualization/tables.py

+ 1 - 1
docs/additional-features/napalm.md

@@ -29,7 +29,7 @@ GET /api/dcim/devices/1/napalm/?method=get_environment
 
 
 ## Authentication
 ## Authentication
 
 
-By default, the [`NAPALM_USERNAME`](../configuration/optional-settings.md#napalm_username) and [`NAPALM_PASSWORD`](../configuration/optional-settings.md#napalm_password) configuration parameters are used for NAPALM authentication. They can be overridden for an individual API call by specifying the `X-NAPALM-Username` and `X-NAPALM-Password` headers.
+By default, the [`NAPALM_USERNAME`](../configuration/dynamic-settings.md#napalm_username) and [`NAPALM_PASSWORD`](../configuration/dynamic-settings.md#napalm_password) configuration parameters are used for NAPALM authentication. They can be overridden for an individual API call by specifying the `X-NAPALM-Username` and `X-NAPALM-Password` headers.
 
 
 ```
 ```
 $ curl "http://localhost/api/dcim/devices/1/napalm/?method=get_environment" \
 $ curl "http://localhost/api/dcim/devices/1/napalm/?method=get_environment" \

+ 137 - 0
docs/configuration/dynamic-settings.md

@@ -0,0 +1,137 @@
+# Dynamic Configuration Settings
+
+These configuration parameters are primarily controlled via NetBox's admin interface (under Admin > Extras > Configuration Revisions). These setting may also be overridden in `configuration.py`; this will prevent them from being modified via the UI.
+
+---
+
+## ALLOWED_URL_SCHEMES
+
+Default: `('file', 'ftp', 'ftps', 'http', 'https', 'irc', 'mailto', 'sftp', 'ssh', 'tel', 'telnet', 'tftp', 'vnc', 'xmpp')`
+
+A list of permitted URL schemes referenced when rendering links within NetBox. Note that only the schemes specified in this list will be accepted: If adding your own, be sure to replicate all of the default values as well (excluding those schemes which are not desirable).
+
+---
+
+## BANNER_TOP
+
+## BANNER_BOTTOM
+
+Setting these variables will display custom content in a banner at the top and/or bottom of the page, respectively. HTML is allowed. To replicate the content of the top banner in the bottom banner, set:
+
+```python
+BANNER_TOP = 'Your banner text'
+BANNER_BOTTOM = BANNER_TOP
+```
+
+---
+
+## BANNER_LOGIN
+
+This defines custom content to be displayed on the login page above the login form. HTML is allowed.
+
+---
+
+## ENFORCE_GLOBAL_UNIQUE
+
+Default: False
+
+By default, NetBox will permit users to create duplicate prefixes and IP addresses in the global table (that is, those which are not assigned to any VRF). This behavior can be disabled by setting `ENFORCE_GLOBAL_UNIQUE` to True.
+
+---
+
+## MAINTENANCE_MODE
+
+Default: False
+
+Setting this to True will display a "maintenance mode" banner at the top of every page. Additionally, NetBox will no longer update a user's "last active" time upon login. This is to allow new logins when the database is in a read-only state. Recording of login times will resume when maintenance mode is disabled.
+
+---
+
+## MAPS_URL
+
+Default: `https://maps.google.com/?q=` (Google Maps)
+
+This specifies the URL to use when presenting a map of a physical location by street address or GPS coordinates. The URL must accept either a free-form street address or a comma-separated pair of numeric coordinates appended to it.
+
+---
+
+## MAX_PAGE_SIZE
+
+Default: 1000
+
+A web user or API consumer can request an arbitrary number of objects by appending the "limit" parameter to the URL (e.g. `?limit=1000`). This parameter defines the maximum acceptable limit. Setting this to `0` or `None` will allow a client to retrieve _all_ matching objects at once with no limit by specifying `?limit=0`.
+
+---
+
+## NAPALM_USERNAME
+
+## NAPALM_PASSWORD
+
+NetBox will use these credentials when authenticating to remote devices via the supported [NAPALM integration](../additional-features/napalm.md), if installed. Both parameters are optional.
+
+!!! note
+    If SSH public key authentication has been set up on the remote device(s) for the system account under which NetBox runs, these parameters are not needed.
+
+---
+
+## NAPALM_ARGS
+
+A dictionary of optional arguments to pass to NAPALM when instantiating a network driver. See the NAPALM documentation for a [complete list of optional arguments](https://napalm.readthedocs.io/en/latest/support/#optional-arguments). An example:
+
+```python
+NAPALM_ARGS = {
+    'api_key': '472071a93b60a1bd1fafb401d9f8ef41',
+    'port': 2222,
+}
+```
+
+Some platforms (e.g. Cisco IOS) require an argument named `secret` to be passed in addition to the normal password. If desired, you can use the configured `NAPALM_PASSWORD` as the value for this argument:
+
+```python
+NAPALM_USERNAME = 'username'
+NAPALM_PASSWORD = 'MySecretPassword'
+NAPALM_ARGS = {
+    'secret': NAPALM_PASSWORD,
+    # Include any additional args here
+}
+```
+
+---
+
+## NAPALM_TIMEOUT
+
+Default: 30 seconds
+
+The amount of time (in seconds) to wait for NAPALM to connect to a device.
+
+---
+
+## PAGINATE_COUNT
+
+Default: 50
+
+The default maximum number of objects to display per page within each list of objects.
+
+---
+
+## PREFER_IPV4
+
+Default: False
+
+When determining the primary IP address for a device, IPv6 is preferred over IPv4 by default. Set this to True to prefer IPv4 instead.
+
+---
+
+## RACK_ELEVATION_DEFAULT_UNIT_HEIGHT
+
+Default: 22
+
+Default height (in pixels) of a unit within a rack elevation. For best results, this should be approximately one tenth of `RACK_ELEVATION_DEFAULT_UNIT_WIDTH`.
+
+---
+
+## RACK_ELEVATION_DEFAULT_UNIT_WIDTH
+
+Default: 220
+
+Default width (in pixels) of a unit within a rack elevation.

+ 6 - 3
docs/configuration/index.md

@@ -1,18 +1,21 @@
 # NetBox Configuration
 # NetBox Configuration
 
 
-NetBox's local configuration is stored in `$INSTALL_ROOT/netbox/netbox/configuration.py`. An example configuration is provided as `configuration.example.py`. You may copy or rename the example configuration and make changes as appropriate. NetBox will not run without a configuration file.
+NetBox's local configuration is stored in `$INSTALL_ROOT/netbox/netbox/configuration.py`. An example configuration is provided as `configuration.example.py`. You may copy or rename the example configuration and make changes as appropriate. NetBox will not run without a configuration file.  While NetBox has many configuration settings, only a few of them must be defined at the time of installation: these are defined under "required settings" below.
 
 
-While NetBox has many configuration settings, only a few of them must be defined at the time of installation.
+Some configuration parameters may alternatively be defined either in `configuration.py` or within the administrative section of the user interface. Settings which are "hard-coded" in the configuration file take precedence over those defined via the UI.
 
 
 ## Configuration Parameters
 ## Configuration Parameters
 
 
 * [Required settings](required-settings.md)
 * [Required settings](required-settings.md)
 * [Optional settings](optional-settings.md)
 * [Optional settings](optional-settings.md)
+* [Dynamic settings](dynamic-settings.md)
 
 
 ## Changing the Configuration
 ## Changing the Configuration
 
 
-Configuration settings may be changed at any time. However, the WSGI service (e.g. Gunicorn) must be restarted before the changes will take effect:
+The configuration file may be modified at any time. However, the WSGI service (e.g. Gunicorn) must be restarted before the changes will take effect:
 
 
 ```no-highlight
 ```no-highlight
 $ sudo systemctl restart netbox
 $ sudo systemctl restart netbox
 ```
 ```
+
+Configuration parameters which are set via the admin UI (those listed under "dynamic settings") take effect immediately.

+ 0 - 134
docs/configuration/optional-settings.md

@@ -13,33 +13,6 @@ ADMINS = [
 
 
 ---
 ---
 
 
-## ALLOWED_URL_SCHEMES
-
-Default: `('file', 'ftp', 'ftps', 'http', 'https', 'irc', 'mailto', 'sftp', 'ssh', 'tel', 'telnet', 'tftp', 'vnc', 'xmpp')`
-
-A list of permitted URL schemes referenced when rendering links within NetBox. Note that only the schemes specified in this list will be accepted: If adding your own, be sure to replicate all of the default values as well (excluding those schemes which are not desirable).
-
----
-
-## BANNER_TOP
-
-## BANNER_BOTTOM
-
-Setting these variables will display custom content in a banner at the top and/or bottom of the page, respectively. HTML is allowed. To replicate the content of the top banner in the bottom banner, set:
-
-```python
-BANNER_TOP = 'Your banner text'
-BANNER_BOTTOM = BANNER_TOP
-```
-
----
-
-## BANNER_LOGIN
-
-This defines custom content to be displayed on the login page above the login form. HTML is allowed.
-
----
-
 ## BASE_PATH
 ## BASE_PATH
 
 
 Default: None
 Default: None
@@ -168,14 +141,6 @@ Email is sent from NetBox only for critical events or if configured for [logging
 
 
 ---
 ---
 
 
-## ENFORCE_GLOBAL_UNIQUE
-
-Default: False
-
-By default, NetBox will permit users to create duplicate prefixes and IP addresses in the global table (that is, those which are not assigned to any VRF). This behavior can be disabled by setting `ENFORCE_GLOBAL_UNIQUE` to True.
-
----
-
 ## EXEMPT_VIEW_PERMISSIONS
 ## EXEMPT_VIEW_PERMISSIONS
 
 
 Default: Empty list
 Default: Empty list
@@ -299,30 +264,6 @@ The lifetime (in seconds) of the authentication cookie issued to a NetBox user u
 
 
 ---
 ---
 
 
-## MAINTENANCE_MODE
-
-Default: False
-
-Setting this to True will display a "maintenance mode" banner at the top of every page. Additionally, NetBox will no longer update a user's "last active" time upon login. This is to allow new logins when the database is in a read-only state. Recording of login times will resume when maintenance mode is disabled.
-
----
-
-## MAPS_URL
-
-Default: `https://maps.google.com/?q=` (Google Maps)
-
-This specifies the URL to use when presenting a map of a physical location by street address or GPS coordinates. The URL must accept either a free-form street address or a comma-separated pair of numeric coordinates appended to it.
-
----
-
-## MAX_PAGE_SIZE
-
-Default: 1000
-
-A web user or API consumer can request an arbitrary number of objects by appending the "limit" parameter to the URL (e.g. `?limit=1000`). This parameter defines the maximum acceptable limit. Setting this to `0` or `None` will allow a client to retrieve _all_ matching objects at once with no limit by specifying `?limit=0`.
-
----
-
 ## MEDIA_ROOT
 ## MEDIA_ROOT
 
 
 Default: $INSTALL_ROOT/netbox/media/
 Default: $INSTALL_ROOT/netbox/media/
@@ -339,57 +280,6 @@ Toggle the availability Prometheus-compatible metrics at `/metrics`. See the [Pr
 
 
 ---
 ---
 
 
-## NAPALM_USERNAME
-
-## NAPALM_PASSWORD
-
-NetBox will use these credentials when authenticating to remote devices via the supported [NAPALM integration](../additional-features/napalm.md), if installed. Both parameters are optional.
-
-!!! note
-    If SSH public key authentication has been set up on the remote device(s) for the system account under which NetBox runs, these parameters are not needed.
-
----
-
-## NAPALM_ARGS
-
-A dictionary of optional arguments to pass to NAPALM when instantiating a network driver. See the NAPALM documentation for a [complete list of optional arguments](https://napalm.readthedocs.io/en/latest/support/#optional-arguments). An example:
-
-```python
-NAPALM_ARGS = {
-    'api_key': '472071a93b60a1bd1fafb401d9f8ef41',
-    'port': 2222,
-}
-```
-
-Some platforms (e.g. Cisco IOS) require an argument named `secret` to be passed in addition to the normal password. If desired, you can use the configured `NAPALM_PASSWORD` as the value for this argument:
-
-```python
-NAPALM_USERNAME = 'username'
-NAPALM_PASSWORD = 'MySecretPassword'
-NAPALM_ARGS = {
-    'secret': NAPALM_PASSWORD,
-    # Include any additional args here
-}
-```
-
----
-
-## NAPALM_TIMEOUT
-
-Default: 30 seconds
-
-The amount of time (in seconds) to wait for NAPALM to connect to a device.
-
----
-
-## PAGINATE_COUNT
-
-Default: 50
-
-The default maximum number of objects to display per page within each list of objects.
-
----
-
 ## PLUGINS
 ## PLUGINS
 
 
 Default: Empty
 Default: Empty
@@ -423,30 +313,6 @@ Note that a plugin must be listed in `PLUGINS` for its configuration to take eff
 
 
 ---
 ---
 
 
-## PREFER_IPV4
-
-Default: False
-
-When determining the primary IP address for a device, IPv6 is preferred over IPv4 by default. Set this to True to prefer IPv4 instead.
-
----
-
-## RACK_ELEVATION_DEFAULT_UNIT_HEIGHT
-
-Default: 22
-
-Default height (in pixels) of a unit within a rack elevation. For best results, this should be approximately one tenth of `RACK_ELEVATION_DEFAULT_UNIT_WIDTH`.
-
----
-
-## RACK_ELEVATION_DEFAULT_UNIT_WIDTH
-
-Default: 220
-
-Default width (in pixels) of a unit within a rack elevation.
-
----
-
 ## REMOTE_AUTH_AUTO_CREATE_USER
 ## REMOTE_AUTH_AUTO_CREATE_USER
 
 
 Default: `False`
 Default: `False`

+ 8 - 0
docs/release-notes/version-3.1.md

@@ -29,6 +29,14 @@ Both types of connection include SSID and authentication attributes. Additionall
 * Channel - A predefined channel within a standardized band
 * Channel - A predefined channel within a standardized band
 * Channel frequency & width - Customizable channel attributes (e.g. for licensed bands)
 * Channel frequency & width - Customizable channel attributes (e.g. for licensed bands)
 
 
+#### Dynamic Configuration Updates ([#5883](https://github.com/netbox-community/netbox/issues/5883))
+
+Some parameters of NetBox's configuration are now accessible via the admin UI. These parameters can be modified by an administrator and take effect immediately upon application: There is no need to restart NetBox. Additionally, each iteration of the dynamic configuration is preserved in the database, and can be restored by an administrator at any time.
+
+Dynamic configuration parameters may also still be defined within `configuration.py`, and the settings defined here take precedence over those defined via the user interface.
+
+For a complete list of supported parameters, please see the [dynamic configuration documentation](../configuration/dynamic-settings.md). 
+
 #### Conditional Webhooks ([#6238](https://github.com/netbox-community/netbox/issues/6238))
 #### Conditional Webhooks ([#6238](https://github.com/netbox-community/netbox/issues/6238))
 
 
 Webhooks now include a `conditions` field, which may be used to specify conditions under which a webhook triggers. For example, you may wish to generate outgoing requests for a device webhook only when its status is "active" or "staged". This can be done by declaring conditional logic in JSON:
 Webhooks now include a `conditions` field, which may be used to specify conditions under which a webhook triggers. For example, you may wish to generate outgoing requests for a device webhook only when its status is "active" or "staged". This can be done by declaring conditional logic in JSON:

+ 2 - 2
docs/rest-api/overview.md

@@ -308,7 +308,7 @@ Vary: Accept
 }
 }
 ```
 ```
 
 
-The default page is determined by the [`PAGINATE_COUNT`](../configuration/optional-settings.md#paginate_count) configuration parameter, which defaults to 50. However, this can be overridden per request by specifying the desired `offset` and `limit` query parameters. For example, if you wish to retrieve a hundred devices at a time, you would make a request for:
+The default page is determined by the [`PAGINATE_COUNT`](../configuration/dynamic-settings.md#paginate_count) configuration parameter, which defaults to 50. However, this can be overridden per request by specifying the desired `offset` and `limit` query parameters. For example, if you wish to retrieve a hundred devices at a time, you would make a request for:
 
 
 ```
 ```
 http://netbox/api/dcim/devices/?limit=100
 http://netbox/api/dcim/devices/?limit=100
@@ -325,7 +325,7 @@ The response will return devices 1 through 100. The URL provided in the `next` a
 }
 }
 ```
 ```
 
 
-The maximum number of objects that can be returned is limited by the [`MAX_PAGE_SIZE`](../configuration/optional-settings.md#max_page_size) configuration parameter, which is 1000 by default. Setting this to `0` or `None` will remove the maximum limit. An API consumer can then pass `?limit=0` to retrieve _all_ matching objects with a single request.
+The maximum number of objects that can be returned is limited by the [`MAX_PAGE_SIZE`](../configuration/dynamic-settings.md#max_page_size) configuration parameter, which is 1000 by default. Setting this to `0` or `None` will remove the maximum limit. An API consumer can then pass `?limit=0` to retrieve _all_ matching objects with a single request.
 
 
 !!! warning
 !!! warning
     Disabling the page size limit introduces a potential for very resource-intensive requests, since one API request can effectively retrieve an entire table from the database.
     Disabling the page size limit introduces a potential for very resource-intensive requests, since one API request can effectively retrieve an entire table from the database.

+ 1 - 0
mkdocs.yml

@@ -51,6 +51,7 @@ nav:
         - Configuring NetBox: 'configuration/index.md'
         - Configuring NetBox: 'configuration/index.md'
         - Required Settings: 'configuration/required-settings.md'
         - Required Settings: 'configuration/required-settings.md'
         - Optional Settings: 'configuration/optional-settings.md'
         - Optional Settings: 'configuration/optional-settings.md'
+        - Dynamic Settings: 'configuration/dynamic-settings.md'
     - Core Functionality:
     - Core Functionality:
         - IP Address Management: 'core-functionality/ipam.md'
         - IP Address Management: 'core-functionality/ipam.md'
         - VLAN Management: 'core-functionality/vlans.md'
         - VLAN Management: 'core-functionality/vlans.md'

+ 3 - 2
netbox/dcim/api/serializers.py

@@ -13,6 +13,7 @@ from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField
 from netbox.api.serializers import (
 from netbox.api.serializers import (
     NestedGroupModelSerializer, PrimaryModelSerializer, ValidatedModelSerializer, WritableNestedSerializer,
     NestedGroupModelSerializer, PrimaryModelSerializer, ValidatedModelSerializer, WritableNestedSerializer,
 )
 )
+from netbox.config import ConfigItem
 from tenancy.api.nested_serializers import NestedTenantSerializer
 from tenancy.api.nested_serializers import NestedTenantSerializer
 from users.api.nested_serializers import NestedUserSerializer
 from users.api.nested_serializers import NestedUserSerializer
 from utilities.api import get_serializer_for_model
 from utilities.api import get_serializer_for_model
@@ -229,10 +230,10 @@ class RackElevationDetailFilterSerializer(serializers.Serializer):
         default=RackElevationDetailRenderChoices.RENDER_JSON
         default=RackElevationDetailRenderChoices.RENDER_JSON
     )
     )
     unit_width = serializers.IntegerField(
     unit_width = serializers.IntegerField(
-        default=settings.RACK_ELEVATION_DEFAULT_UNIT_WIDTH
+        default=ConfigItem('RACK_ELEVATION_DEFAULT_UNIT_WIDTH')
     )
     )
     unit_height = serializers.IntegerField(
     unit_height = serializers.IntegerField(
-        default=settings.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT
+        default=ConfigItem('RACK_ELEVATION_DEFAULT_UNIT_HEIGHT')
     )
     )
     legend_width = serializers.IntegerField(
     legend_width = serializers.IntegerField(
         default=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT
         default=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT

+ 8 - 5
netbox/dcim/api/views.py

@@ -1,7 +1,6 @@
 import socket
 import socket
 from collections import OrderedDict
 from collections import OrderedDict
 
 
-from django.conf import settings
 from django.http import Http404, HttpResponse, HttpResponseForbidden
 from django.http import Http404, HttpResponse, HttpResponseForbidden
 from django.shortcuts import get_object_or_404
 from django.shortcuts import get_object_or_404
 from drf_yasg import openapi
 from drf_yasg import openapi
@@ -21,6 +20,7 @@ from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
 from netbox.api.exceptions import ServiceUnavailable
 from netbox.api.exceptions import ServiceUnavailable
 from netbox.api.metadata import ContentTypeMetadata
 from netbox.api.metadata import ContentTypeMetadata
 from netbox.api.views import ModelViewSet
 from netbox.api.views import ModelViewSet
+from netbox.config import get_config
 from utilities.api import get_serializer_for_model
 from utilities.api import get_serializer_for_model
 from utilities.utils import count_related, decode_dict
 from utilities.utils import count_related, decode_dict
 from virtualization.models import VirtualMachine
 from virtualization.models import VirtualMachine
@@ -457,9 +457,12 @@ class DeviceViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet):
 
 
         napalm_methods = request.GET.getlist('method')
         napalm_methods = request.GET.getlist('method')
         response = OrderedDict([(m, None) for m in napalm_methods])
         response = OrderedDict([(m, None) for m in napalm_methods])
-        username = settings.NAPALM_USERNAME
-        password = settings.NAPALM_PASSWORD
-        optional_args = settings.NAPALM_ARGS.copy()
+
+        config = get_config()
+        username = config.NAPALM_USERNAME
+        password = config.NAPALM_PASSWORD
+        timeout = config.NAPALM_TIMEOUT
+        optional_args = config.NAPALM_ARGS.copy()
         if device.platform.napalm_args is not None:
         if device.platform.napalm_args is not None:
             optional_args.update(device.platform.napalm_args)
             optional_args.update(device.platform.napalm_args)
 
 
@@ -481,7 +484,7 @@ class DeviceViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet):
             hostname=host,
             hostname=host,
             username=username,
             username=username,
             password=password,
             password=password,
-            timeout=settings.NAPALM_TIMEOUT,
+            timeout=timeout,
             optional_args=optional_args
             optional_args=optional_args
         )
         )
         try:
         try:

+ 2 - 2
netbox/dcim/models/devices.py

@@ -1,7 +1,6 @@
 from collections import OrderedDict
 from collections import OrderedDict
 
 
 import yaml
 import yaml
-from django.conf import settings
 from django.contrib.contenttypes.fields import GenericRelation
 from django.contrib.contenttypes.fields import GenericRelation
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.core.validators import MaxValueValidator, MinValueValidator
@@ -15,6 +14,7 @@ from dcim.constants import *
 from extras.models import ConfigContextModel
 from extras.models import ConfigContextModel
 from extras.querysets import ConfigContextModelQuerySet
 from extras.querysets import ConfigContextModelQuerySet
 from extras.utils import extras_features
 from extras.utils import extras_features
+from netbox.config import ConfigItem
 from netbox.models import OrganizationalModel, PrimaryModel
 from netbox.models import OrganizationalModel, PrimaryModel
 from utilities.choices import ColorChoices
 from utilities.choices import ColorChoices
 from utilities.fields import ColorField, NaturalOrderingField
 from utilities.fields import ColorField, NaturalOrderingField
@@ -815,7 +815,7 @@ class Device(PrimaryModel, ConfigContextModel):
 
 
     @property
     @property
     def primary_ip(self):
     def primary_ip(self):
-        if settings.PREFER_IPV4 and self.primary_ip4:
+        if ConfigItem('PREFER_IPV4')() and self.primary_ip4:
             return self.primary_ip4
             return self.primary_ip4
         elif self.primary_ip6:
         elif self.primary_ip6:
             return self.primary_ip6
             return self.primary_ip6

+ 7 - 3
netbox/dcim/models/racks.py

@@ -1,6 +1,5 @@
 from collections import OrderedDict
 from collections import OrderedDict
 
 
-from django.conf import settings
 from django.contrib.auth.models import User
 from django.contrib.auth.models import User
 from django.contrib.contenttypes.fields import GenericRelation
 from django.contrib.contenttypes.fields import GenericRelation
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
@@ -15,6 +14,7 @@ from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
 from dcim.svg import RackElevationSVG
 from dcim.svg import RackElevationSVG
 from extras.utils import extras_features
 from extras.utils import extras_features
+from netbox.config import get_config
 from netbox.models import OrganizationalModel, PrimaryModel
 from netbox.models import OrganizationalModel, PrimaryModel
 from utilities.choices import ColorChoices
 from utilities.choices import ColorChoices
 from utilities.fields import ColorField, NaturalOrderingField
 from utilities.fields import ColorField, NaturalOrderingField
@@ -373,8 +373,8 @@ class Rack(PrimaryModel):
             self,
             self,
             face=DeviceFaceChoices.FACE_FRONT,
             face=DeviceFaceChoices.FACE_FRONT,
             user=None,
             user=None,
-            unit_width=settings.RACK_ELEVATION_DEFAULT_UNIT_WIDTH,
-            unit_height=settings.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT,
+            unit_width=None,
+            unit_height=None,
             legend_width=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT,
             legend_width=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT,
             include_images=True,
             include_images=True,
             base_url=None
             base_url=None
@@ -393,6 +393,10 @@ class Rack(PrimaryModel):
         :param base_url: Base URL for links and images. If none, URLs will be relative.
         :param base_url: Base URL for links and images. If none, URLs will be relative.
         """
         """
         elevation = RackElevationSVG(self, user=user, include_images=include_images, base_url=base_url)
         elevation = RackElevationSVG(self, user=user, include_images=include_images, base_url=base_url)
+        if unit_width is None or unit_height is None:
+            config = get_config()
+            unit_width = unit_width or config.RACK_ELEVATION_DEFAULT_UNIT_WIDTH
+            unit_height = unit_height or config.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT
 
 
         return elevation.render(face, unit_width, unit_height, legend_width)
         return elevation.render(face, unit_width, unit_height, legend_width)
 
 

+ 5 - 12
netbox/dcim/tables/devices.py

@@ -160,18 +160,11 @@ class DeviceTable(BaseTable):
         linkify=True,
         linkify=True,
         verbose_name='Type'
         verbose_name='Type'
     )
     )
-    if settings.PREFER_IPV4:
-        primary_ip = tables.Column(
-            linkify=True,
-            order_by=('primary_ip4', 'primary_ip6'),
-            verbose_name='IP Address'
-        )
-    else:
-        primary_ip = tables.Column(
-            linkify=True,
-            order_by=('primary_ip6', 'primary_ip4'),
-            verbose_name='IP Address'
-        )
+    primary_ip = tables.Column(
+        linkify=True,
+        order_by=('primary_ip4', 'primary_ip6'),
+        verbose_name='IP Address'
+    )
     primary_ip4 = tables.Column(
     primary_ip4 = tables.Column(
         linkify=True,
         linkify=True,
         verbose_name='IPv4 Address'
         verbose_name='IPv4 Address'

+ 120 - 2
netbox/extras/admin.py

@@ -1,10 +1,128 @@
 from django.contrib import admin
 from django.contrib import admin
+from django.shortcuts import get_object_or_404, redirect
+from django.template.response import TemplateResponse
+from django.urls import path, reverse
+from django.utils.html import format_html
 
 
-from .models import JobResult
+from netbox.config import get_config, PARAMS
+from .forms import ConfigRevisionForm
+from .models import ConfigRevision, JobResult
+
+
+@admin.register(ConfigRevision)
+class ConfigRevisionAdmin(admin.ModelAdmin):
+    fieldsets = [
+        ('Rack Elevations', {
+            'fields': ('RACK_ELEVATION_DEFAULT_UNIT_HEIGHT', 'RACK_ELEVATION_DEFAULT_UNIT_WIDTH'),
+        }),
+        ('IPAM', {
+            'fields': ('ENFORCE_GLOBAL_UNIQUE', 'PREFER_IPV4'),
+        }),
+        ('Security', {
+            'fields': ('ALLOWED_URL_SCHEMES',),
+        }),
+        ('Banners', {
+            'fields': ('BANNER_LOGIN', 'BANNER_TOP', 'BANNER_BOTTOM'),
+        }),
+        ('Pagination', {
+            'fields': ('PAGINATE_COUNT', 'MAX_PAGE_SIZE'),
+        }),
+        ('NAPALM', {
+            'fields': ('NAPALM_USERNAME', 'NAPALM_PASSWORD', 'NAPALM_TIMEOUT', 'NAPALM_ARGS'),
+        }),
+        ('Miscellaneous', {
+            'fields': ('MAINTENANCE_MODE', 'MAPS_URL'),
+        }),
+        ('Config Revision', {
+            'fields': ('comment',),
+        })
+    ]
+    form = ConfigRevisionForm
+    list_display = ('id', 'is_active', 'created', 'comment', 'restore_link')
+    ordering = ('-id',)
+    readonly_fields = ('data',)
+
+    def get_changeform_initial_data(self, request):
+        """
+        Populate initial form data from the most recent ConfigRevision.
+        """
+        latest_revision = ConfigRevision.objects.last()
+        initial = latest_revision.data if latest_revision else {}
+        initial.update(super().get_changeform_initial_data(request))
+
+        return initial
+
+    # Permissions
+
+    def has_add_permission(self, request):
+        # Only superusers may modify the configuration.
+        return request.user.is_superuser
+
+    def has_change_permission(self, request, obj=None):
+        # ConfigRevisions cannot be modified once created.
+        return False
+
+    def has_delete_permission(self, request, obj=None):
+        # Only inactive ConfigRevisions may be deleted (must be superuser).
+        return request.user.is_superuser and (
+            obj is None or not obj.is_active()
+        )
+
+    # List display methods
+
+    def restore_link(self, obj):
+        if obj.is_active():
+            return ''
+        return format_html(
+            '<a href="{url}" class="button">Restore</a>',
+            url=reverse('admin:extras_configrevision_restore', args=(obj.pk,))
+        )
+    restore_link.short_description = "Actions"
+
+    # URLs
+
+    def get_urls(self):
+        urls = [
+            path('<int:pk>/restore/', self.admin_site.admin_view(self.restore), name='extras_configrevision_restore'),
+        ]
+
+        return urls + super().get_urls()
+
+    # Views
+
+    def restore(self, request, pk):
+        # Get the ConfigRevision being restored
+        candidate_config = get_object_or_404(ConfigRevision, pk=pk)
+
+        if request.method == 'POST':
+            candidate_config.activate()
+            self.message_user(request, f"Restored configuration revision #{pk}")
+
+            return redirect(reverse('admin:extras_configrevision_changelist'))
+
+        # Get the current ConfigRevision
+        config_version = get_config().version
+        current_config = ConfigRevision.objects.filter(pk=config_version).first()
+
+        params = []
+        for param in PARAMS:
+            params.append((
+                param.name,
+                current_config.data.get(param.name, None),
+                candidate_config.data.get(param.name, None)
+            ))
+
+        context = self.admin_site.each_context(request)
+        context.update({
+            'object': candidate_config,
+            'params': params,
+        })
+
+        return TemplateResponse(request, 'admin/extras/configrevision/restore.html', context)
 
 
 
 
 #
 #
-# Reports
+# Reports & scripts
 #
 #
 
 
 @admin.register(JobResult)
 @admin.register(JobResult)

+ 1 - 0
netbox/extras/forms/__init__.py

@@ -3,4 +3,5 @@ from .filtersets import *
 from .bulk_edit import *
 from .bulk_edit import *
 from .bulk_import import *
 from .bulk_import import *
 from .customfields import *
 from .customfields import *
+from .config import *
 from .scripts import *
 from .scripts import *

+ 79 - 0
netbox/extras/forms/config.py

@@ -0,0 +1,79 @@
+from django import forms
+from django.conf import settings
+
+from netbox.config import get_config, PARAMS
+
+__all__ = (
+    'ConfigRevisionForm',
+)
+
+
+EMPTY_VALUES = ('', None, [], ())
+
+
+class FormMetaclass(forms.models.ModelFormMetaclass):
+
+    def __new__(mcs, name, bases, attrs):
+
+        # Emulate a declared field for each supported configuration parameter
+        param_fields = {}
+        for param in PARAMS:
+            field_kwargs = {
+                'required': False,
+                'label': param.label,
+                'help_text': param.description,
+            }
+            field_kwargs.update(**param.field_kwargs)
+            param_fields[param.name] = param.field(**field_kwargs)
+        attrs.update(param_fields)
+
+        return super().__new__(mcs, name, bases, attrs)
+
+
+class ConfigRevisionForm(forms.BaseModelForm, metaclass=FormMetaclass):
+    """
+    Form for creating a new ConfigRevision.
+    """
+    class Meta:
+        widgets = {
+            'comment': forms.Textarea(),
+        }
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Append current parameter values to form field help texts and check for static configurations
+        config = get_config()
+        for param in PARAMS:
+            value = getattr(config, param.name)
+            is_static = hasattr(settings, param.name)
+            if value:
+                help_text = f'<br />Current value: <strong>{value}</strong>'
+                if is_static:
+                    help_text += ' (defined statically)'
+                elif value == param.default:
+                    help_text += ' (default)'
+                self.fields[param.name].help_text += help_text
+            if is_static:
+                self.fields[param.name].disabled = True
+
+    def save(self, commit=True):
+        instance = super().save(commit=False)
+
+        # Populate JSON data on the instance
+        instance.data = self.render_json()
+
+        if commit:
+            instance.save()
+
+        return instance
+
+    def render_json(self):
+        json = {}
+
+        # Iterate through each field and populate non-empty values
+        for field_name in self.declared_fields:
+            if field_name in self.cleaned_data and self.cleaned_data[field_name] not in EMPTY_VALUES:
+                json[field_name] = self.cleaned_data[field_name]
+
+        return json

+ 20 - 0
netbox/extras/migrations/0064_configrevision.py

@@ -0,0 +1,20 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('extras', '0063_webhook_conditions'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='ConfigRevision',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
+                ('created', models.DateTimeField(auto_now_add=True)),
+                ('comment', models.CharField(blank=True, max_length=200)),
+                ('data', models.JSONField(blank=True, null=True)),
+            ],
+        ),
+    ]

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

@@ -1,12 +1,13 @@
 from .change_logging import ObjectChange
 from .change_logging import ObjectChange
 from .configcontexts import ConfigContext, ConfigContextModel
 from .configcontexts import ConfigContext, ConfigContextModel
 from .customfields import CustomField
 from .customfields import CustomField
-from .models import CustomLink, ExportTemplate, ImageAttachment, JobResult, JournalEntry, Report, Script, Webhook
+from .models import *
 from .tags import Tag, TaggedItem
 from .tags import Tag, TaggedItem
 
 
 __all__ = (
 __all__ = (
     'ConfigContext',
     'ConfigContext',
     'ConfigContextModel',
     'ConfigContextModel',
+    'ConfigRevision',
     'CustomField',
     'CustomField',
     'CustomLink',
     'CustomLink',
     'ExportTemplate',
     'ExportTemplate',

+ 66 - 52
netbox/extras/models/models.py

@@ -1,9 +1,11 @@
 import json
 import json
 import uuid
 import uuid
 
 
+from django.contrib import admin
 from django.contrib.auth.models import User
 from django.contrib.auth.models import User
 from django.contrib.contenttypes.fields import GenericForeignKey
 from django.contrib.contenttypes.fields import GenericForeignKey
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
+from django.core.cache import cache
 from django.core.validators import ValidationError
 from django.core.validators import ValidationError
 from django.db import models
 from django.db import models
 from django.http import HttpResponse
 from django.http import HttpResponse
@@ -20,8 +22,8 @@ from netbox.models import BigIDModel, ChangeLoggedModel
 from utilities.querysets import RestrictedQuerySet
 from utilities.querysets import RestrictedQuerySet
 from utilities.utils import render_jinja2
 from utilities.utils import render_jinja2
 
 
-
 __all__ = (
 __all__ = (
+    'ConfigRevision',
     'CustomLink',
     'CustomLink',
     'ExportTemplate',
     'ExportTemplate',
     'ImageAttachment',
     'ImageAttachment',
@@ -33,10 +35,6 @@ __all__ = (
 )
 )
 
 
 
 
-#
-# Webhooks
-#
-
 @extras_features('webhooks')
 @extras_features('webhooks')
 class Webhook(ChangeLoggedModel):
 class Webhook(ChangeLoggedModel):
     """
     """
@@ -181,10 +179,6 @@ class Webhook(ChangeLoggedModel):
             return json.dumps(context, cls=JSONEncoder)
             return json.dumps(context, cls=JSONEncoder)
 
 
 
 
-#
-# Custom links
-#
-
 @extras_features('webhooks')
 @extras_features('webhooks')
 class CustomLink(ChangeLoggedModel):
 class CustomLink(ChangeLoggedModel):
     """
     """
@@ -240,10 +234,6 @@ class CustomLink(ChangeLoggedModel):
         return reverse('extras:customlink', args=[self.pk])
         return reverse('extras:customlink', args=[self.pk])
 
 
 
 
-#
-# Export templates
-#
-
 @extras_features('webhooks')
 @extras_features('webhooks')
 class ExportTemplate(ChangeLoggedModel):
 class ExportTemplate(ChangeLoggedModel):
     content_type = models.ForeignKey(
     content_type = models.ForeignKey(
@@ -333,10 +323,6 @@ class ExportTemplate(ChangeLoggedModel):
         return response
         return response
 
 
 
 
-#
-# Image attachments
-#
-
 class ImageAttachment(BigIDModel):
 class ImageAttachment(BigIDModel):
     """
     """
     An uploaded image which is associated with an object.
     An uploaded image which is associated with an object.
@@ -409,11 +395,6 @@ class ImageAttachment(BigIDModel):
             return None
             return None
 
 
 
 
-#
-# Journal entries
-#
-
-
 @extras_features('webhooks')
 @extras_features('webhooks')
 class JournalEntry(ChangeLoggedModel):
 class JournalEntry(ChangeLoggedModel):
     """
     """
@@ -463,36 +444,6 @@ class JournalEntry(ChangeLoggedModel):
         return JournalEntryKindChoices.CSS_CLASSES.get(self.kind)
         return JournalEntryKindChoices.CSS_CLASSES.get(self.kind)
 
 
 
 
-#
-# Custom scripts
-#
-
-@extras_features('job_results')
-class Script(models.Model):
-    """
-    Dummy model used to generate permissions for custom scripts. Does not exist in the database.
-    """
-    class Meta:
-        managed = False
-
-
-#
-# Reports
-#
-
-@extras_features('job_results')
-class Report(models.Model):
-    """
-    Dummy model used to generate permissions for reports. Does not exist in the database.
-    """
-    class Meta:
-        managed = False
-
-
-#
-# Job results
-#
-
 class JobResult(BigIDModel):
 class JobResult(BigIDModel):
     """
     """
     This model stores the results from running a user-defined report.
     This model stores the results from running a user-defined report.
@@ -582,3 +533,66 @@ class JobResult(BigIDModel):
         func.delay(*args, job_id=str(job_result.job_id), job_result=job_result, **kwargs)
         func.delay(*args, job_id=str(job_result.job_id), job_result=job_result, **kwargs)
 
 
         return job_result
         return job_result
+
+
+class ConfigRevision(models.Model):
+    """
+    An atomic revision of NetBox's configuration.
+    """
+    created = models.DateTimeField(
+        auto_now_add=True
+    )
+    comment = models.CharField(
+        max_length=200,
+        blank=True
+    )
+    data = models.JSONField(
+        blank=True,
+        null=True,
+        verbose_name='Configuration data'
+    )
+
+    def __str__(self):
+        return f'Config revision #{self.pk} ({self.created})'
+
+    def __getattr__(self, item):
+        if item in self.data:
+            return self.data[item]
+        return super().__getattribute__(item)
+
+    def activate(self):
+        """
+        Cache the configuration data.
+        """
+        cache.set('config', self.data, None)
+        cache.set('config_version', self.pk, None)
+
+    @admin.display(boolean=True)
+    def is_active(self):
+        return cache.get('config_version') == self.pk
+
+
+#
+# Custom scripts & reports
+#
+
+@extras_features('job_results')
+class Script(models.Model):
+    """
+    Dummy model used to generate permissions for custom scripts. Does not exist in the database.
+    """
+    class Meta:
+        managed = False
+
+
+#
+# Reports
+#
+
+@extras_features('job_results')
+class Report(models.Model):
+    """
+    Dummy model used to generate permissions for reports. Does not exist in the database.
+    """
+    class Meta:
+        managed = False

+ 13 - 1
netbox/extras/signals.py

@@ -8,7 +8,7 @@ from django_prometheus.models import model_deletes, model_inserts, model_updates
 
 
 from netbox.signals import post_clean
 from netbox.signals import post_clean
 from .choices import ObjectChangeActionChoices
 from .choices import ObjectChangeActionChoices
-from .models import CustomField, ObjectChange
+from .models import ConfigRevision, CustomField, ObjectChange
 from .webhooks import enqueue_object, get_snapshots, serialize_for_webhook
 from .webhooks import enqueue_object, get_snapshots, serialize_for_webhook
 
 
 
 
@@ -161,3 +161,15 @@ def run_custom_validators(sender, instance, **kwargs):
     validators = settings.CUSTOM_VALIDATORS.get(model_name, [])
     validators = settings.CUSTOM_VALIDATORS.get(model_name, [])
     for validator in validators:
     for validator in validators:
         validator(instance)
         validator(instance)
+
+
+#
+# Dynamic configuration
+#
+
+@receiver(post_save, sender=ConfigRevision)
+def update_config(sender, instance, **kwargs):
+    """
+    Update the cached NetBox configuration when a new ConfigRevision is created.
+    """
+    instance.activate()

+ 8 - 5
netbox/ipam/api/mixins.py

@@ -1,4 +1,3 @@
-from django.conf import settings
 from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
 from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
 from django.db import transaction
 from django.db import transaction
 from django.shortcuts import get_object_or_404
 from django.shortcuts import get_object_or_404
@@ -9,6 +8,7 @@ from rest_framework.decorators import action
 from rest_framework.response import Response
 from rest_framework.response import Response
 
 
 from ipam.models import *
 from ipam.models import *
+from netbox.config import get_config
 from utilities.constants import ADVISORY_LOCK_KEYS
 from utilities.constants import ADVISORY_LOCK_KEYS
 from . import serializers
 from . import serializers
 
 
@@ -160,12 +160,15 @@ class AvailableIPsMixin:
 
 
         # Determine the maximum number of IPs to return
         # Determine the maximum number of IPs to return
         else:
         else:
+            config = get_config()
+            PAGINATE_COUNT = config.PAGINATE_COUNT
+            MAX_PAGE_SIZE = config.MAX_PAGE_SIZE
             try:
             try:
-                limit = int(request.query_params.get('limit', settings.PAGINATE_COUNT))
+                limit = int(request.query_params.get('limit', PAGINATE_COUNT))
             except ValueError:
             except ValueError:
-                limit = settings.PAGINATE_COUNT
-            if settings.MAX_PAGE_SIZE:
-                limit = min(limit, settings.MAX_PAGE_SIZE)
+                limit = PAGINATE_COUNT
+            if MAX_PAGE_SIZE:
+                limit = min(limit, MAX_PAGE_SIZE)
 
 
             # Calculate available IPs within the parent
             # Calculate available IPs within the parent
             ip_list = []
             ip_list = []

+ 4 - 4
netbox/ipam/models/ip.py

@@ -1,10 +1,9 @@
 import netaddr
 import netaddr
-from django.conf import settings
 from django.contrib.contenttypes.fields import GenericForeignKey
 from django.contrib.contenttypes.fields import GenericForeignKey
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
 from django.db import models
 from django.db import models
-from django.db.models import F, Q
+from django.db.models import F
 from django.urls import reverse
 from django.urls import reverse
 from django.utils.functional import cached_property
 from django.utils.functional import cached_property
 
 
@@ -17,6 +16,7 @@ from ipam.fields import IPNetworkField, IPAddressField
 from ipam.managers import IPAddressManager
 from ipam.managers import IPAddressManager
 from ipam.querysets import PrefixQuerySet
 from ipam.querysets import PrefixQuerySet
 from ipam.validators import DNSValidator
 from ipam.validators import DNSValidator
+from netbox.config import get_config
 from utilities.querysets import RestrictedQuerySet
 from utilities.querysets import RestrictedQuerySet
 from virtualization.models import VirtualMachine
 from virtualization.models import VirtualMachine
 
 
@@ -316,7 +316,7 @@ class Prefix(PrimaryModel):
                 })
                 })
 
 
             # Enforce unique IP space (if applicable)
             # Enforce unique IP space (if applicable)
-            if (self.vrf is None and settings.ENFORCE_GLOBAL_UNIQUE) or (self.vrf and self.vrf.enforce_unique):
+            if (self.vrf is None and get_config().ENFORCE_GLOBAL_UNIQUE) or (self.vrf and self.vrf.enforce_unique):
                 duplicate_prefixes = self.get_duplicates()
                 duplicate_prefixes = self.get_duplicates()
                 if duplicate_prefixes:
                 if duplicate_prefixes:
                     raise ValidationError({
                     raise ValidationError({
@@ -811,7 +811,7 @@ class IPAddress(PrimaryModel):
                 })
                 })
 
 
             # Enforce unique IP space (if applicable)
             # Enforce unique IP space (if applicable)
-            if (self.vrf is None and settings.ENFORCE_GLOBAL_UNIQUE) or (self.vrf and self.vrf.enforce_unique):
+            if (self.vrf is None and get_config().ENFORCE_GLOBAL_UNIQUE) or (self.vrf and self.vrf.enforce_unique):
                 duplicate_ips = self.get_duplicates()
                 duplicate_ips = self.get_duplicates()
                 if duplicate_ips and (
                 if duplicate_ips and (
                         self.role not in IPADDRESS_ROLES_NONUNIQUE or
                         self.role not in IPADDRESS_ROLES_NONUNIQUE or

+ 7 - 6
netbox/netbox/api/pagination.py

@@ -1,7 +1,8 @@
-from django.conf import settings
 from django.db.models import QuerySet
 from django.db.models import QuerySet
 from rest_framework.pagination import LimitOffsetPagination
 from rest_framework.pagination import LimitOffsetPagination
 
 
+from netbox.config import get_config
+
 
 
 class OptionalLimitOffsetPagination(LimitOffsetPagination):
 class OptionalLimitOffsetPagination(LimitOffsetPagination):
     """
     """
@@ -9,6 +10,8 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination):
     matching a query, but retains the same format as a paginated request. The limit can only be disabled if
     matching a query, but retains the same format as a paginated request. The limit can only be disabled if
     MAX_PAGE_SIZE has been set to 0 or None.
     MAX_PAGE_SIZE has been set to 0 or None.
     """
     """
+    def __init__(self):
+        self.default_limit = get_config().PAGINATE_COUNT
 
 
     def paginate_queryset(self, queryset, request, view=None):
     def paginate_queryset(self, queryset, request, view=None):
 
 
@@ -40,11 +43,9 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination):
                 if limit < 0:
                 if limit < 0:
                     raise ValueError()
                     raise ValueError()
                 # Enforce maximum page size, if defined
                 # Enforce maximum page size, if defined
-                if settings.MAX_PAGE_SIZE:
-                    if limit == 0:
-                        return settings.MAX_PAGE_SIZE
-                    else:
-                        return min(limit, settings.MAX_PAGE_SIZE)
+                MAX_PAGE_SIZE = get_config().MAX_PAGE_SIZE
+                if MAX_PAGE_SIZE:
+                    return MAX_PAGE_SIZE if limit == 0 else min(limit, MAX_PAGE_SIZE)
                 return limit
                 return limit
             except (KeyError, ValueError):
             except (KeyError, ValueError):
                 pass
                 pass

+ 105 - 0
netbox/netbox/config/__init__.py

@@ -0,0 +1,105 @@
+import logging
+import threading
+
+from django.conf import settings
+from django.core.cache import cache
+from django.db.utils import DatabaseError
+
+from .parameters import PARAMS
+
+__all__ = (
+    'clear_config',
+    'ConfigItem',
+    'get_config',
+    'PARAMS',
+)
+
+_thread_locals = threading.local()
+
+logger = logging.getLogger('netbox.config')
+
+
+def get_config():
+    """
+    Return the current NetBox configuration, pulling it from cache if not already loaded in memory.
+    """
+    if not hasattr(_thread_locals, 'config'):
+        _thread_locals.config = Config()
+        logger.debug("Initialized configuration")
+    return _thread_locals.config
+
+
+def clear_config():
+    """
+    Delete the currently loaded configuration, if any.
+    """
+    if hasattr(_thread_locals, 'config'):
+        del _thread_locals.config
+        logger.debug("Cleared configuration")
+
+
+class Config:
+    """
+    Fetch and store in memory the current NetBox configuration. This class must be instantiated prior to access, and
+    must be re-instantiated each time it's necessary to check for updates to the cached config.
+    """
+    def __init__(self):
+        self._populate_from_cache()
+        if not self.config or not self.version:
+            self._populate_from_db()
+        self.defaults = {param.name: param.default for param in PARAMS}
+
+    def __getattr__(self, item):
+
+        # Check for hard-coded configuration in settings.py
+        if hasattr(settings, item):
+            return getattr(settings, item)
+
+        # Return config value from cache
+        if item in self.config:
+            return self.config[item]
+
+        # Fall back to the parameter's default value
+        if item in self.defaults:
+            return self.defaults[item]
+
+        raise AttributeError(f"Invalid configuration parameter: {item}")
+
+    def _populate_from_cache(self):
+        """Populate config data from Redis cache"""
+        self.config = cache.get('config') or {}
+        self.version = cache.get('config_version')
+        if self.config:
+            logger.debug("Loaded configuration data from cache")
+
+    def _populate_from_db(self):
+        """Cache data from latest ConfigRevision, then populate from cache"""
+        from extras.models import ConfigRevision
+
+        try:
+            revision = ConfigRevision.objects.last()
+            if revision is None:
+                logger.debug("No previous configuration found in database; proceeding with default values")
+                return
+            logger.debug("Loaded configuration data from database")
+        except DatabaseError:
+            # The database may not be available yet (e.g. when running a management command)
+            logger.warning(f"Skipping config initialization (database unavailable)")
+            return
+
+        revision.activate()
+        logger.debug("Filled cache with data from latest ConfigRevision")
+        self._populate_from_cache()
+
+
+class ConfigItem:
+    """
+    A callable to retrieve a configuration parameter from the cache. This can serve as a placeholder to defer
+    referencing a configuration parameter.
+    """
+    def __init__(self, item):
+        self.item = item
+
+    def __call__(self):
+        config = get_config()
+        return getattr(config, self.item)

+ 140 - 0
netbox/netbox/config/parameters.py

@@ -0,0 +1,140 @@
+from django import forms
+from django.contrib.postgres.forms import SimpleArrayField
+
+
+class ConfigParam:
+
+    def __init__(self, name, label, default, description='', field=None, field_kwargs=None):
+        self.name = name
+        self.label = label
+        self.default = default
+        self.field = field or forms.CharField
+        self.description = description
+        self.field_kwargs = field_kwargs or {}
+
+
+PARAMS = (
+
+    # Banners
+    ConfigParam(
+        name='BANNER_LOGIN',
+        label='Login banner',
+        default='',
+        description="Additional content to display on the login page"
+    ),
+    ConfigParam(
+        name='BANNER_TOP',
+        label='Top banner',
+        default='',
+        description="Additional content to display at the top of every page"
+    ),
+    ConfigParam(
+        name='BANNER_BOTTOM',
+        label='Bottom banner',
+        default='',
+        description="Additional content to display at the bottom of every page"
+    ),
+
+    # IPAM
+    ConfigParam(
+        name='ENFORCE_GLOBAL_UNIQUE',
+        label='Globally unique IP space',
+        default=False,
+        description="Enforce unique IP addressing within the global table",
+        field=forms.BooleanField
+    ),
+    ConfigParam(
+        name='PREFER_IPV4',
+        label='Prefer IPv4',
+        default=False,
+        description="Prefer IPv4 addresses over IPv6",
+        field=forms.BooleanField
+    ),
+
+    # Racks
+    ConfigParam(
+        name='RACK_ELEVATION_DEFAULT_UNIT_HEIGHT',
+        label='Rack unit height',
+        default=22,
+        description="Default unit height for rendered rack elevations",
+        field=forms.IntegerField
+    ),
+    ConfigParam(
+        name='RACK_ELEVATION_DEFAULT_UNIT_WIDTH',
+        label='Rack unit width',
+        default=220,
+        description="Default unit width for rendered rack elevations",
+        field=forms.IntegerField
+    ),
+
+    # Security
+    ConfigParam(
+        name='ALLOWED_URL_SCHEMES',
+        label='Allowed URL schemes',
+        default=(
+            'file', 'ftp', 'ftps', 'http', 'https', 'irc', 'mailto', 'sftp', 'ssh', 'tel', 'telnet', 'tftp', 'vnc',
+            'xmpp',
+        ),
+        description="Permitted schemes for URLs in user-provided content",
+        field=SimpleArrayField,
+        field_kwargs={'base_field': forms.CharField()}
+    ),
+
+    # Pagination
+    ConfigParam(
+        name='PAGINATE_COUNT',
+        label='Default page size',
+        default=50,
+        field=forms.IntegerField
+    ),
+    ConfigParam(
+        name='MAX_PAGE_SIZE',
+        label='Maximum page size',
+        default=1000,
+        field=forms.IntegerField
+    ),
+
+    # NAPALM
+    ConfigParam(
+        name='NAPALM_USERNAME',
+        label='NAPALM username',
+        default='',
+        description="Username to use when connecting to devices via NAPALM"
+    ),
+    ConfigParam(
+        name='NAPALM_PASSWORD',
+        label='NAPALM password',
+        default='',
+        description="Password to use when connecting to devices via NAPALM"
+    ),
+    ConfigParam(
+        name='NAPALM_TIMEOUT',
+        label='NAPALM timeout',
+        default=30,
+        description="NAPALM connection timeout (in seconds)",
+        field=forms.IntegerField
+    ),
+    ConfigParam(
+        name='NAPALM_ARGS',
+        label='NAPALM arguments',
+        default={},
+        description="Additional arguments to pass when invoking a NAPALM driver (as JSON data)",
+        field=forms.JSONField
+    ),
+
+    # Miscellaneous
+    ConfigParam(
+        name='MAINTENANCE_MODE',
+        label='Maintenance mode',
+        default=False,
+        description="Enable maintenance mode",
+        field=forms.BooleanField
+    ),
+    ConfigParam(
+        name='MAPS_URL',
+        label='Maps URL',
+        default='https://maps.google.com/?q=',
+        description="Base URL for mapping geographic locations"
+    ),
+
+)

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

@@ -72,19 +72,6 @@ ADMINS = [
     # ('John Doe', 'jdoe@example.com'),
     # ('John Doe', 'jdoe@example.com'),
 ]
 ]
 
 
-# URL schemes that are allowed within links in NetBox
-ALLOWED_URL_SCHEMES = (
-    'file', 'ftp', 'ftps', 'http', 'https', 'irc', 'mailto', 'sftp', 'ssh', 'tel', 'telnet', 'tftp', 'vnc', 'xmpp',
-)
-
-# Optionally display a persistent banner at the top and/or bottom of every page. HTML is allowed. To display the same
-# content in both banners, define BANNER_TOP and set BANNER_BOTTOM = BANNER_TOP.
-BANNER_TOP = ''
-BANNER_BOTTOM = ''
-
-# Text to include on the login page above the login form. HTML is allowed.
-BANNER_LOGIN = ''
-
 # Base URL path if accessing NetBox within a directory. For example, if installed at https://example.com/netbox/, set:
 # Base URL path if accessing NetBox within a directory. For example, if installed at https://example.com/netbox/, set:
 # BASE_PATH = 'netbox/'
 # BASE_PATH = 'netbox/'
 BASE_PATH = ''
 BASE_PATH = ''
@@ -134,10 +121,6 @@ EMAIL = {
     'FROM_EMAIL': '',
     'FROM_EMAIL': '',
 }
 }
 
 
-# Enforcement of unique IP space can be toggled on a per-VRF basis. To enforce unique IP space within the global table
-# (all prefixes and IP addresses not assigned to a VRF), set ENFORCE_GLOBAL_UNIQUE to True.
-ENFORCE_GLOBAL_UNIQUE = False
-
 # Exempt certain models from the enforcement of view permissions. Models listed here will be viewable by all users and
 # Exempt certain models from the enforcement of view permissions. Models listed here will be viewable by all users and
 # by anonymous users. List models in the form `<app>.<model>`. Add '*' to this list to exempt all models.
 # by anonymous users. List models in the form `<app>.<model>`. Add '*' to this list to exempt all models.
 EXEMPT_VIEW_PERMISSIONS = [
 EXEMPT_VIEW_PERMISSIONS = [
@@ -175,17 +158,6 @@ LOGIN_REQUIRED = False
 # re-authenticate. (Default: 1209600 [14 days])
 # re-authenticate. (Default: 1209600 [14 days])
 LOGIN_TIMEOUT = None
 LOGIN_TIMEOUT = None
 
 
-# Setting this to True will display a "maintenance mode" banner at the top of every page.
-MAINTENANCE_MODE = False
-
-# The URL to use when mapping physical addresses or GPS coordinates
-MAPS_URL = 'https://maps.google.com/?q='
-
-# An API consumer can request an arbitrary number of objects =by appending the "limit" parameter to the URL (e.g.
-# "?limit=1000"). This setting defines the maximum limit. Setting it to 0 or None will allow an API consumer to request
-# all objects by specifying "?limit=0".
-MAX_PAGE_SIZE = 1000
-
 # The file path where uploaded media such as image attachments are stored. A trailing slash is not needed. Note that
 # The file path where uploaded media such as image attachments are stored. A trailing slash is not needed. Note that
 # the default value of this setting is derived from the installed location.
 # the default value of this setting is derived from the installed location.
 # MEDIA_ROOT = '/opt/netbox/netbox/media'
 # MEDIA_ROOT = '/opt/netbox/netbox/media'
@@ -203,20 +175,6 @@ MAX_PAGE_SIZE = 1000
 # Expose Prometheus monitoring metrics at the HTTP endpoint '/metrics'
 # Expose Prometheus monitoring metrics at the HTTP endpoint '/metrics'
 METRICS_ENABLED = False
 METRICS_ENABLED = False
 
 
-# Credentials that NetBox will uses to authenticate to devices when connecting via NAPALM.
-NAPALM_USERNAME = ''
-NAPALM_PASSWORD = ''
-
-# NAPALM timeout (in seconds). (Default: 30)
-NAPALM_TIMEOUT = 30
-
-# NAPALM optional arguments (see https://napalm.readthedocs.io/en/latest/support/#optional-arguments). Arguments must
-# be provided as a dictionary.
-NAPALM_ARGS = {}
-
-# Determine how many objects to display per page within a list. (Default: 50)
-PAGINATE_COUNT = 50
-
 # Enable installed plugins. Add the name of each plugin to the list.
 # Enable installed plugins. Add the name of each plugin to the list.
 PLUGINS = []
 PLUGINS = []
 
 
@@ -229,14 +187,6 @@ PLUGINS = []
 #     }
 #     }
 # }
 # }
 
 
-# When determining the primary IP address for a device, IPv6 is preferred over IPv4 by default. Set this to True to
-# prefer IPv4 instead.
-PREFER_IPV4 = False
-
-# Rack elevation size defaults, in pixels. For best results, the ratio of width to height should be roughly 10:1.
-RACK_ELEVATION_DEFAULT_UNIT_HEIGHT = 22
-RACK_ELEVATION_DEFAULT_UNIT_WIDTH = 220
-
 # Remote authentication support
 # Remote authentication support
 REMOTE_AUTH_ENABLED = False
 REMOTE_AUTH_ENABLED = False
 REMOTE_AUTH_BACKEND = 'netbox.authentication.RemoteUserBackend'
 REMOTE_AUTH_BACKEND = 'netbox.authentication.RemoteUserBackend'

+ 2 - 0
netbox/netbox/context_processors.py

@@ -1,6 +1,7 @@
 from django.conf import settings as django_settings
 from django.conf import settings as django_settings
 
 
 from extras.registry import registry
 from extras.registry import registry
+from netbox.config import get_config
 
 
 
 
 def settings_and_registry(request):
 def settings_and_registry(request):
@@ -9,6 +10,7 @@ def settings_and_registry(request):
     """
     """
     return {
     return {
         'settings': django_settings,
         'settings': django_settings,
+        'config': get_config(),
         'registry': registry,
         'registry': registry,
         'preferences': request.user.config if request.user.is_authenticated else {},
         'preferences': request.user.config if request.user.is_authenticated else {},
     }
     }

+ 18 - 4
netbox/netbox/middleware.py

@@ -11,11 +11,12 @@ from django.http import Http404, HttpResponseRedirect
 from django.urls import reverse
 from django.urls import reverse
 
 
 from extras.context_managers import change_logging
 from extras.context_managers import change_logging
+from netbox.config import clear_config
 from netbox.views import server_error
 from netbox.views import server_error
 from utilities.api import is_api_request, rest_api_server_error
 from utilities.api import is_api_request, rest_api_server_error
 
 
 
 
-class LoginRequiredMiddleware(object):
+class LoginRequiredMiddleware:
     """
     """
     If LOGIN_REQUIRED is True, redirect all non-authenticated users to the login page.
     If LOGIN_REQUIRED is True, redirect all non-authenticated users to the login page.
     """
     """
@@ -114,7 +115,7 @@ class RemoteUserMiddleware(RemoteUserMiddleware_):
         return groups
         return groups
 
 
 
 
-class ObjectChangeMiddleware(object):
+class ObjectChangeMiddleware:
     """
     """
     This middleware performs three functions in response to an object being created, updated, or deleted:
     This middleware performs three functions in response to an object being created, updated, or deleted:
 
 
@@ -144,7 +145,7 @@ class ObjectChangeMiddleware(object):
         return response
         return response
 
 
 
 
-class APIVersionMiddleware(object):
+class APIVersionMiddleware:
     """
     """
     If the request is for an API endpoint, include the API version as a response header.
     If the request is for an API endpoint, include the API version as a response header.
     """
     """
@@ -159,7 +160,20 @@ class APIVersionMiddleware(object):
         return response
         return response
 
 
 
 
-class ExceptionHandlingMiddleware(object):
+class DynamicConfigMiddleware:
+    """
+    Store the cached NetBox configuration in thread-local storage for the duration of the request.
+    """
+    def __init__(self, get_response):
+        self.get_response = get_response
+
+    def __call__(self, request):
+        response = self.get_response(request)
+        clear_config()
+        return response
+
+
+class ExceptionHandlingMiddleware:
     """
     """
     Intercept certain exceptions which are likely indicative of installation issues and provide helpful instructions
     Intercept certain exceptions which are likely indicative of installation issues and provide helpful instructions
     to the user.
     to the user.

+ 12 - 39
netbox/netbox/settings.py

@@ -11,6 +11,8 @@ from django.contrib.messages import constants as messages
 from django.core.exceptions import ImproperlyConfigured, ValidationError
 from django.core.exceptions import ImproperlyConfigured, ValidationError
 from django.core.validators import URLValidator
 from django.core.validators import URLValidator
 
 
+from netbox.config import PARAMS
+
 
 
 #
 #
 # Environment setup
 # Environment setup
@@ -68,14 +70,8 @@ DATABASE = getattr(configuration, 'DATABASE')
 REDIS = getattr(configuration, 'REDIS')
 REDIS = getattr(configuration, 'REDIS')
 SECRET_KEY = getattr(configuration, 'SECRET_KEY')
 SECRET_KEY = getattr(configuration, 'SECRET_KEY')
 
 
-# Set optional parameters
+# Set static config parameters
 ADMINS = getattr(configuration, 'ADMINS', [])
 ADMINS = getattr(configuration, 'ADMINS', [])
-ALLOWED_URL_SCHEMES = getattr(configuration, 'ALLOWED_URL_SCHEMES', (
-    'file', 'ftp', 'ftps', 'http', 'https', 'irc', 'mailto', 'sftp', 'ssh', 'tel', 'telnet', 'tftp', 'vnc', 'xmpp',
-))
-BANNER_BOTTOM = getattr(configuration, 'BANNER_BOTTOM', '')
-BANNER_LOGIN = getattr(configuration, 'BANNER_LOGIN', '')
-BANNER_TOP = getattr(configuration, 'BANNER_TOP', '')
 BASE_PATH = getattr(configuration, 'BASE_PATH', '')
 BASE_PATH = getattr(configuration, 'BASE_PATH', '')
 if BASE_PATH:
 if BASE_PATH:
     BASE_PATH = BASE_PATH.strip('/') + '/'  # Enforce trailing slash only
     BASE_PATH = BASE_PATH.strip('/') + '/'  # Enforce trailing slash only
@@ -90,30 +86,19 @@ DEBUG = getattr(configuration, 'DEBUG', False)
 DEVELOPER = getattr(configuration, 'DEVELOPER', False)
 DEVELOPER = getattr(configuration, 'DEVELOPER', False)
 DOCS_ROOT = getattr(configuration, 'DOCS_ROOT', os.path.join(os.path.dirname(BASE_DIR), 'docs'))
 DOCS_ROOT = getattr(configuration, 'DOCS_ROOT', os.path.join(os.path.dirname(BASE_DIR), 'docs'))
 EMAIL = getattr(configuration, 'EMAIL', {})
 EMAIL = getattr(configuration, 'EMAIL', {})
-ENFORCE_GLOBAL_UNIQUE = getattr(configuration, 'ENFORCE_GLOBAL_UNIQUE', False)
 EXEMPT_VIEW_PERMISSIONS = getattr(configuration, 'EXEMPT_VIEW_PERMISSIONS', [])
 EXEMPT_VIEW_PERMISSIONS = getattr(configuration, 'EXEMPT_VIEW_PERMISSIONS', [])
 GRAPHQL_ENABLED = getattr(configuration, 'GRAPHQL_ENABLED', True)
 GRAPHQL_ENABLED = getattr(configuration, 'GRAPHQL_ENABLED', True)
 HTTP_PROXIES = getattr(configuration, 'HTTP_PROXIES', None)
 HTTP_PROXIES = getattr(configuration, 'HTTP_PROXIES', None)
 INTERNAL_IPS = getattr(configuration, 'INTERNAL_IPS', ('127.0.0.1', '::1'))
 INTERNAL_IPS = getattr(configuration, 'INTERNAL_IPS', ('127.0.0.1', '::1'))
 LOGGING = getattr(configuration, 'LOGGING', {})
 LOGGING = getattr(configuration, 'LOGGING', {})
+LOGIN_PERSISTENCE = getattr(configuration, 'LOGIN_PERSISTENCE', False)
 LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False)
 LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False)
 LOGIN_TIMEOUT = getattr(configuration, 'LOGIN_TIMEOUT', None)
 LOGIN_TIMEOUT = getattr(configuration, 'LOGIN_TIMEOUT', None)
-MAINTENANCE_MODE = getattr(configuration, 'MAINTENANCE_MODE', False)
-MAPS_URL = getattr(configuration, 'MAPS_URL', 'https://maps.google.com/?q=')
-MAX_PAGE_SIZE = getattr(configuration, 'MAX_PAGE_SIZE', 1000)
 MEDIA_ROOT = getattr(configuration, 'MEDIA_ROOT', os.path.join(BASE_DIR, 'media')).rstrip('/')
 MEDIA_ROOT = getattr(configuration, 'MEDIA_ROOT', os.path.join(BASE_DIR, 'media')).rstrip('/')
 METRICS_ENABLED = getattr(configuration, 'METRICS_ENABLED', False)
 METRICS_ENABLED = getattr(configuration, 'METRICS_ENABLED', False)
-NAPALM_ARGS = getattr(configuration, 'NAPALM_ARGS', {})
-NAPALM_PASSWORD = getattr(configuration, 'NAPALM_PASSWORD', '')
-NAPALM_TIMEOUT = getattr(configuration, 'NAPALM_TIMEOUT', 30)
-NAPALM_USERNAME = getattr(configuration, 'NAPALM_USERNAME', '')
-PAGINATE_COUNT = getattr(configuration, 'PAGINATE_COUNT', 50)
-LOGIN_PERSISTENCE = getattr(configuration, 'LOGIN_PERSISTENCE', False)
 PLUGINS = getattr(configuration, 'PLUGINS', [])
 PLUGINS = getattr(configuration, 'PLUGINS', [])
 PLUGINS_CONFIG = getattr(configuration, 'PLUGINS_CONFIG', {})
 PLUGINS_CONFIG = getattr(configuration, 'PLUGINS_CONFIG', {})
-PREFER_IPV4 = getattr(configuration, 'PREFER_IPV4', False)
-RACK_ELEVATION_DEFAULT_UNIT_HEIGHT = getattr(configuration, 'RACK_ELEVATION_DEFAULT_UNIT_HEIGHT', 22)
-RACK_ELEVATION_DEFAULT_UNIT_WIDTH = getattr(configuration, 'RACK_ELEVATION_DEFAULT_UNIT_WIDTH', 220)
+RELEASE_CHECK_URL = getattr(configuration, 'RELEASE_CHECK_URL', None)
 REMOTE_AUTH_AUTO_CREATE_USER = getattr(configuration, 'REMOTE_AUTH_AUTO_CREATE_USER', False)
 REMOTE_AUTH_AUTO_CREATE_USER = getattr(configuration, 'REMOTE_AUTH_AUTO_CREATE_USER', False)
 REMOTE_AUTH_BACKEND = getattr(configuration, 'REMOTE_AUTH_BACKEND', 'netbox.authentication.RemoteUserBackend')
 REMOTE_AUTH_BACKEND = getattr(configuration, 'REMOTE_AUTH_BACKEND', 'netbox.authentication.RemoteUserBackend')
 REMOTE_AUTH_DEFAULT_GROUPS = getattr(configuration, 'REMOTE_AUTH_DEFAULT_GROUPS', [])
 REMOTE_AUTH_DEFAULT_GROUPS = getattr(configuration, 'REMOTE_AUTH_DEFAULT_GROUPS', [])
@@ -127,7 +112,6 @@ REMOTE_AUTH_SUPERUSERS = getattr(configuration, 'REMOTE_AUTH_SUPERUSERS', [])
 REMOTE_AUTH_STAFF_GROUPS = getattr(configuration, 'REMOTE_AUTH_STAFF_GROUPS', [])
 REMOTE_AUTH_STAFF_GROUPS = getattr(configuration, 'REMOTE_AUTH_STAFF_GROUPS', [])
 REMOTE_AUTH_STAFF_USERS = getattr(configuration, 'REMOTE_AUTH_STAFF_USERS', [])
 REMOTE_AUTH_STAFF_USERS = getattr(configuration, 'REMOTE_AUTH_STAFF_USERS', [])
 REMOTE_AUTH_GROUP_SEPARATOR = getattr(configuration, 'REMOTE_AUTH_GROUP_SEPARATOR', '|')
 REMOTE_AUTH_GROUP_SEPARATOR = getattr(configuration, 'REMOTE_AUTH_GROUP_SEPARATOR', '|')
-RELEASE_CHECK_URL = getattr(configuration, 'RELEASE_CHECK_URL', None)
 REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/')
 REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/')
 RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300)
 RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300)
 SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/')
 SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/')
@@ -141,6 +125,11 @@ STORAGE_CONFIG = getattr(configuration, 'STORAGE_CONFIG', {})
 TIME_FORMAT = getattr(configuration, 'TIME_FORMAT', 'g:i a')
 TIME_FORMAT = getattr(configuration, 'TIME_FORMAT', 'g:i a')
 TIME_ZONE = getattr(configuration, 'TIME_ZONE', 'UTC')
 TIME_ZONE = getattr(configuration, 'TIME_ZONE', 'UTC')
 
 
+# Check for hard-coded dynamic config parameters
+for param in PARAMS:
+    if hasattr(configuration, param.name):
+        globals()[param.name] = getattr(configuration, param.name)
+
 # Validate update repo URL and timeout
 # Validate update repo URL and timeout
 if RELEASE_CHECK_URL:
 if RELEASE_CHECK_URL:
     validator = URLValidator(
     validator = URLValidator(
@@ -346,6 +335,7 @@ MIDDLEWARE = [
     'netbox.middleware.ExceptionHandlingMiddleware',
     'netbox.middleware.ExceptionHandlingMiddleware',
     'netbox.middleware.RemoteUserMiddleware',
     'netbox.middleware.RemoteUserMiddleware',
     'netbox.middleware.LoginRequiredMiddleware',
     'netbox.middleware.LoginRequiredMiddleware',
+    'netbox.middleware.DynamicConfigMiddleware',
     'netbox.middleware.APIVersionMiddleware',
     'netbox.middleware.APIVersionMiddleware',
     'netbox.middleware.ObjectChangeMiddleware',
     'netbox.middleware.ObjectChangeMiddleware',
     'django_prometheus.middleware.PrometheusAfterMiddleware',
     'django_prometheus.middleware.PrometheusAfterMiddleware',
@@ -466,7 +456,7 @@ REST_FRAMEWORK = {
     ),
     ),
     'DEFAULT_VERSION': REST_FRAMEWORK_VERSION,
     'DEFAULT_VERSION': REST_FRAMEWORK_VERSION,
     'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.AcceptHeaderVersioning',
     'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.AcceptHeaderVersioning',
-    'PAGE_SIZE': PAGINATE_COUNT,
+    # 'PAGE_SIZE': PAGINATE_COUNT,
     'SCHEMA_COERCE_METHOD_NAMES': {
     'SCHEMA_COERCE_METHOD_NAMES': {
         # Default mappings
         # Default mappings
         'retrieve': 'read',
         'retrieve': 'read',
@@ -565,23 +555,6 @@ RQ_QUEUES = {
 }
 }
 
 
 
 
-#
-# NetBox internal settings
-#
-
-# Pagination
-if MAX_PAGE_SIZE and PAGINATE_COUNT > MAX_PAGE_SIZE:
-    raise ImproperlyConfigured(
-        f"PAGINATE_COUNT ({PAGINATE_COUNT}) must be less than or equal to MAX_PAGE_SIZE ({MAX_PAGE_SIZE}), if set."
-    )
-PER_PAGE_DEFAULTS = [
-    25, 50, 100, 250, 500, 1000
-]
-if PAGINATE_COUNT not in PER_PAGE_DEFAULTS:
-    PER_PAGE_DEFAULTS.append(PAGINATE_COUNT)
-    PER_PAGE_DEFAULTS = sorted(PER_PAGE_DEFAULTS)
-
-
 #
 #
 # Plugins
 # Plugins
 #
 #

+ 53 - 0
netbox/netbox/tests/test_config.py

@@ -0,0 +1,53 @@
+from django.conf import settings
+from django.core.cache import cache
+from django.test import override_settings, TestCase
+
+from extras.models import ConfigRevision
+from netbox.config import clear_config, get_config
+
+
+# Prefix cache keys to avoid interfering with the local environment
+CACHES = settings.CACHES
+CACHES['default'].update({'KEY_PREFIX': 'TEST-'})
+
+
+class ConfigTestCase(TestCase):
+
+    @override_settings(CACHES=CACHES)
+    def test_config_init_empty(self):
+        cache.clear()
+
+        config = get_config()
+        self.assertEqual(config.config, {})
+        self.assertEqual(config.version, None)
+
+        clear_config()
+
+    @override_settings(CACHES=CACHES)
+    def test_config_init_from_db(self):
+        CONFIG_DATA = {'BANNER_TOP': 'A'}
+        cache.clear()
+
+        # Create a config but don't load it into the cache
+        configrevision = ConfigRevision.objects.create(data=CONFIG_DATA)
+
+        config = get_config()
+        self.assertEqual(config.config, CONFIG_DATA)
+        self.assertEqual(config.version, configrevision.pk)
+
+        clear_config()
+
+    @override_settings(CACHES=CACHES)
+    def test_config_init_from_cache(self):
+        CONFIG_DATA = {'BANNER_TOP': 'B'}
+        cache.clear()
+
+        # Create a config and load it into the cache
+        configrevision = ConfigRevision.objects.create(data=CONFIG_DATA)
+        configrevision.activate()
+
+        config = get_config()
+        self.assertEqual(config.config, CONFIG_DATA)
+        self.assertEqual(config.version, configrevision.pk)
+
+        clear_config()

+ 37 - 0
netbox/templates/admin/extras/configrevision/restore.html

@@ -0,0 +1,37 @@
+{% extends "admin/base_site.html" %}
+{% load static %}
+
+{% block content %}
+  <p>Restore configuration #{{ object.pk }} from <strong>{{ object.created }}</strong>?</p>
+
+  <table>
+    <thead>
+      <tr>
+        <th>Parameter</th>
+        <th>Current Value</th>
+        <th>New Value</th>
+        <th></th>
+      </tr>
+    </thead>
+    <tbody>
+      {% for param, current, new in params %}
+        <tr{% if current != new %} style="color: #d7a50d"{% endif %}>
+          <td>{{ param }}</td>
+          <td>{{ current }}</td>
+          <td>{{ new }}</td>
+          <td>{% if current != new %}<img src="{% static 'admin/img/icon-changelink.svg' %}" alt="*" title="Changed">{% endif %}</td>
+        </tr>
+      {% endfor %}
+    </tbody>
+  </table>
+
+  <form method="post">
+    {% csrf_token %}
+    <div class="submit-row" style="margin-top: 20px">
+      <input type="submit" name="restore" value="Restore" class="default" style="float: left" />
+      <a href="{% url 'admin:extras_configrevision_changelist' %}" style="float: left; margin: 2px 0; padding: 10px 15px">Cancel</a>
+    </div>
+  </form>
+{% endblock content %}
+
+

+ 5 - 5
netbox/templates/base/layout.html

@@ -58,13 +58,13 @@
 
 
         </nav>
         </nav>
 
 
-        {% if settings.BANNER_TOP %}
+        {% if config.BANNER_TOP %}
           <div class="alert alert-info text-center mx-3" role="alert">
           <div class="alert alert-info text-center mx-3" role="alert">
-            {{ settings.BANNER_TOP|safe }}
+            {{ config.BANNER_TOP|safe }}
           </div>
           </div>
         {% endif %}
         {% endif %}
 
 
-        {% if settings.MAINTENANCE_MODE %}
+        {% if config.MAINTENANCE_MODE %}
           <div class="alert alert-warning text-center mx-3" role="alert">
           <div class="alert alert-warning text-center mx-3" role="alert">
             <h4><i class="mdi mdi-alert"></i> Maintenance Mode</h4>
             <h4><i class="mdi mdi-alert"></i> Maintenance Mode</h4>
             <span>NetBox is currently in maintenance mode. Functionality may be limited.</span>
             <span>NetBox is currently in maintenance mode. Functionality may be limited.</span>
@@ -98,9 +98,9 @@
           {% endblock %}
           {% endblock %}
         </div>
         </div>
 
 
-        {% if settings.BANNER_BOTTOM %}
+        {% if config.BANNER_BOTTOM %}
           <div class="alert alert-info text-center mx-3" role="alert">
           <div class="alert alert-info text-center mx-3" role="alert">
-            {{ settings.BANNER_BOTTOM|safe }}
+            {{ config.BANNER_BOTTOM|safe }}
           </div>
           </div>
         {% endif %}
         {% endif %}
 
 

+ 2 - 2
netbox/templates/dcim/site.html

@@ -100,7 +100,7 @@
                         <td>
                         <td>
                             {% if object.physical_address %}
                             {% if object.physical_address %}
                                 <div class="float-end noprint">
                                 <div class="float-end noprint">
-                                    <a href="{{ settings.MAPS_URL }}{{ object.physical_address|urlencode }}" target="_blank" class="btn btn-primary btn-sm">
+                                    <a href="{{ config.MAPS_URL }}{{ object.physical_address|urlencode }}" target="_blank" class="btn btn-primary btn-sm">
                                         <i class="mdi mdi-map-marker"></i> Map It
                                         <i class="mdi mdi-map-marker"></i> Map It
                                     </a>
                                     </a>
                                 </div>
                                 </div>
@@ -119,7 +119,7 @@
                         <td>
                         <td>
                             {% if object.latitude and object.longitude %}
                             {% if object.latitude and object.longitude %}
                                 <div class="float-end noprint">
                                 <div class="float-end noprint">
-                                    <a href="{{ settings.MAPS_URL }}{{ object.latitude }},{{ object.longitude }}" target="_blank" class="btn btn-primary btn-sm">
+                                    <a href="{{ config.MAPS_URL }}{{ object.latitude }},{{ object.longitude }}" target="_blank" class="btn btn-primary btn-sm">
                                         <i class="mdi mdi-map-marker"></i> Map It
                                         <i class="mdi mdi-map-marker"></i> Map It
                                     </a>
                                     </a>
                                 </div>
                                 </div>

+ 1 - 1
netbox/templates/inc/paginator.html

@@ -36,7 +36,7 @@
         {% endfor %}
         {% endfor %}
         <div class="input-group input-group-sm">
         <div class="input-group input-group-sm">
             <select name="per_page" class="form-select per-page">
             <select name="per_page" class="form-select per-page">
-            {% for n in settings.PER_PAGE_DEFAULTS %}
+            {% for n in page.paginator.get_page_lengths %}
                 <option value="{{ n }}"{% if page.paginator.per_page == n %} selected="selected"{% endif %}>{{ n }}</option>
                 <option value="{{ n }}"{% if page.paginator.per_page == n %} selected="selected"{% endif %}>{{ n }}</option>
             {% endfor %}
             {% endfor %}
             </select>
             </select>

+ 2 - 2
netbox/templates/login.html

@@ -7,9 +7,9 @@
   <main class="login-container text-center">
   <main class="login-container text-center">
 
 
     {# Login banner #}
     {# Login banner #}
-    {% if settings.BANNER_LOGIN %}
+    {% if config.BANNER_LOGIN %}
       <div class="alert alert-secondary mw-90 mw-md-75 mw-lg-80 mw-xl-75 mw-xxl-50" role="alert">
       <div class="alert alert-secondary mw-90 mw-md-75 mw-lg-80 mw-xl-75 mw-xxl-50" role="alert">
-        {{ settings.BANNER_LOGIN|safe }}
+        {{ config.BANNER_LOGIN|safe }}
       </div>
       </div>
     {% endif %}
     {% endif %}
 
 

+ 2 - 2
netbox/users/views.py

@@ -1,6 +1,5 @@
 import logging
 import logging
 
 
-from django.conf import settings
 from django.contrib import messages
 from django.contrib import messages
 from django.contrib.auth import login as auth_login, logout as auth_logout, update_session_auth_hash
 from django.contrib.auth import login as auth_login, logout as auth_logout, update_session_auth_hash
 from django.contrib.auth.mixins import LoginRequiredMixin
 from django.contrib.auth.mixins import LoginRequiredMixin
@@ -14,6 +13,7 @@ from django.utils.http import is_safe_url
 from django.views.decorators.debug import sensitive_post_parameters
 from django.views.decorators.debug import sensitive_post_parameters
 from django.views.generic import View
 from django.views.generic import View
 
 
+from netbox.config import get_config
 from utilities.forms import ConfirmationForm
 from utilities.forms import ConfirmationForm
 from .forms import LoginForm, PasswordChangeForm, TokenForm
 from .forms import LoginForm, PasswordChangeForm, TokenForm
 from .models import Token
 from .models import Token
@@ -53,7 +53,7 @@ class LoginView(View):
 
 
             # If maintenance mode is enabled, assume the database is read-only, and disable updating the user's
             # If maintenance mode is enabled, assume the database is read-only, and disable updating the user's
             # last_login time upon authentication.
             # last_login time upon authentication.
-            if settings.MAINTENANCE_MODE:
+            if get_config().MAINTENANCE_MODE:
                 logger.warning("Maintenance mode enabled: disabling update of most recent login time")
                 logger.warning("Maintenance mode enabled: disabling update of most recent login time")
                 user_logged_in.disconnect(update_last_login, dispatch_uid='update_last_login')
                 user_logged_in.disconnect(update_last_login, dispatch_uid='update_last_login')
 
 

+ 18 - 7
netbox/utilities/paginator.py

@@ -1,8 +1,12 @@
-from django.conf import settings
 from django.core.paginator import Paginator, Page
 from django.core.paginator import Paginator, Page
 
 
+from netbox.config import get_config
+
 
 
 class EnhancedPaginator(Paginator):
 class EnhancedPaginator(Paginator):
+    default_page_lengths = (
+        25, 50, 100, 250, 500, 1000
+    )
 
 
     def __init__(self, object_list, per_page, orphans=None, **kwargs):
     def __init__(self, object_list, per_page, orphans=None, **kwargs):
 
 
@@ -10,9 +14,9 @@ class EnhancedPaginator(Paginator):
         try:
         try:
             per_page = int(per_page)
             per_page = int(per_page)
             if per_page < 1:
             if per_page < 1:
-                per_page = settings.PAGINATE_COUNT
+                per_page = get_config().PAGINATE_COUNT
         except ValueError:
         except ValueError:
-            per_page = settings.PAGINATE_COUNT
+            per_page = get_config().PAGINATE_COUNT
 
 
         # Set orphans count based on page size
         # Set orphans count based on page size
         if orphans is None and per_page <= 50:
         if orphans is None and per_page <= 50:
@@ -25,6 +29,11 @@ class EnhancedPaginator(Paginator):
     def _get_page(self, *args, **kwargs):
     def _get_page(self, *args, **kwargs):
         return EnhancedPage(*args, **kwargs)
         return EnhancedPage(*args, **kwargs)
 
 
+    def get_page_lengths(self):
+        if self.per_page not in self.default_page_lengths:
+            return sorted([*self.default_page_lengths, self.per_page])
+        return self.default_page_lengths
+
 
 
 class EnhancedPage(Page):
 class EnhancedPage(Page):
 
 
@@ -57,17 +66,19 @@ def get_paginate_count(request):
 
 
     Return the lesser of the calculated value and MAX_PAGE_SIZE.
     Return the lesser of the calculated value and MAX_PAGE_SIZE.
     """
     """
+    config = get_config()
+
     if 'per_page' in request.GET:
     if 'per_page' in request.GET:
         try:
         try:
             per_page = int(request.GET.get('per_page'))
             per_page = int(request.GET.get('per_page'))
             if request.user.is_authenticated:
             if request.user.is_authenticated:
                 request.user.config.set('pagination.per_page', per_page, commit=True)
                 request.user.config.set('pagination.per_page', per_page, commit=True)
-            return min(per_page, settings.MAX_PAGE_SIZE)
+            return min(per_page, config.MAX_PAGE_SIZE)
         except ValueError:
         except ValueError:
             pass
             pass
 
 
     if request.user.is_authenticated:
     if request.user.is_authenticated:
-        per_page = request.user.config.get('pagination.per_page', settings.PAGINATE_COUNT)
-        return min(per_page, settings.MAX_PAGE_SIZE)
+        per_page = request.user.config.get('pagination.per_page', config.PAGINATE_COUNT)
+        return min(per_page, config.MAX_PAGE_SIZE)
 
 
-    return min(settings.PAGINATE_COUNT, settings.MAX_PAGE_SIZE)
+    return min(config.PAGINATE_COUNT, config.MAX_PAGE_SIZE)

+ 2 - 1
netbox/utilities/templatetags/helpers.py

@@ -14,6 +14,7 @@ from django.utils.html import strip_tags
 from django.utils.safestring import mark_safe
 from django.utils.safestring import mark_safe
 from markdown import markdown
 from markdown import markdown
 
 
+from netbox.config import get_config
 from utilities.forms import get_selected_values, TableConfigForm
 from utilities.forms import get_selected_values, TableConfigForm
 from utilities.utils import foreground_color
 from utilities.utils import foreground_color
 
 
@@ -44,7 +45,7 @@ def render_markdown(value):
     value = strip_tags(value)
     value = strip_tags(value)
 
 
     # Sanitize Markdown links
     # Sanitize Markdown links
-    schemes = '|'.join(settings.ALLOWED_URL_SCHEMES)
+    schemes = '|'.join(get_config().ALLOWED_URL_SCHEMES)
     pattern = fr'\[(.+)\]\((?!({schemes})).*:(.+)\)'
     pattern = fr'\[(.+)\]\((?!({schemes})).*:(.+)\)'
     value = re.sub(pattern, '[\\1](\\3)', value, flags=re.IGNORECASE)
     value = re.sub(pattern, '[\\1](\\3)', value, flags=re.IGNORECASE)
 
 

+ 2 - 2
netbox/utilities/tests/test_api.py

@@ -1,6 +1,5 @@
 import urllib.parse
 import urllib.parse
 
 
-from django.conf import settings
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.test import Client, TestCase, override_settings
 from django.test import Client, TestCase, override_settings
 from django.urls import reverse
 from django.urls import reverse
@@ -10,6 +9,7 @@ from dcim.models import Region, Site
 from extras.choices import CustomFieldTypeChoices
 from extras.choices import CustomFieldTypeChoices
 from extras.models import CustomField
 from extras.models import CustomField
 from ipam.models import VLAN
 from ipam.models import VLAN
+from netbox.config import get_config
 from utilities.testing import APITestCase, disable_warnings
 from utilities.testing import APITestCase, disable_warnings
 
 
 
 
@@ -137,7 +137,7 @@ class APIPaginationTestCase(APITestCase):
 
 
     def test_default_page_size(self):
     def test_default_page_size(self):
         response = self.client.get(self.url, format='json', **self.header)
         response = self.client.get(self.url, format='json', **self.header)
-        page_size = settings.PAGINATE_COUNT
+        page_size = get_config().PAGINATE_COUNT
         self.assertLess(page_size, 100, "Default page size not sufficient for data set")
         self.assertLess(page_size, 100, "Default page size not sufficient for data set")
 
 
         self.assertHttpStatus(response, status.HTTP_200_OK)
         self.assertHttpStatus(response, status.HTTP_200_OK)

+ 7 - 2
netbox/utilities/validators.py

@@ -1,9 +1,10 @@
 import re
 import re
 
 
-from django.conf import settings
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
 from django.core.validators import _lazy_re_compile, BaseValidator, URLValidator
 from django.core.validators import _lazy_re_compile, BaseValidator, URLValidator
 
 
+from netbox.config import get_config
+
 
 
 class EnhancedURLValidator(URLValidator):
 class EnhancedURLValidator(URLValidator):
     """
     """
@@ -19,7 +20,11 @@ class EnhancedURLValidator(URLValidator):
         r'(?::\d{2,5})?'                    # Port number
         r'(?::\d{2,5})?'                    # Port number
         r'(?:[/?#][^\s]*)?'                 # Path
         r'(?:[/?#][^\s]*)?'                 # Path
         r'\Z', re.IGNORECASE)
         r'\Z', re.IGNORECASE)
-    schemes = settings.ALLOWED_URL_SCHEMES
+
+    def __init__(self, schemes=None, **kwargs):
+        super().__init__(**kwargs)
+        if schemes is not None:
+            self.schemes = get_config().ALLOWED_URL_SCHEMES
 
 
 
 
 class ExclusionValidator(BaseValidator):
 class ExclusionValidator(BaseValidator):

+ 2 - 2
netbox/virtualization/models.py

@@ -1,4 +1,3 @@
-from django.conf import settings
 from django.contrib.contenttypes.fields import GenericRelation
 from django.contrib.contenttypes.fields import GenericRelation
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
 from django.core.validators import MinValueValidator
 from django.core.validators import MinValueValidator
@@ -9,6 +8,7 @@ from dcim.models import BaseInterface, Device
 from extras.models import ConfigContextModel
 from extras.models import ConfigContextModel
 from extras.querysets import ConfigContextModelQuerySet
 from extras.querysets import ConfigContextModelQuerySet
 from extras.utils import extras_features
 from extras.utils import extras_features
+from netbox.config import get_config
 from netbox.models import OrganizationalModel, PrimaryModel
 from netbox.models import OrganizationalModel, PrimaryModel
 from utilities.fields import NaturalOrderingField
 from utilities.fields import NaturalOrderingField
 from utilities.ordering import naturalize_interface
 from utilities.ordering import naturalize_interface
@@ -340,7 +340,7 @@ class VirtualMachine(PrimaryModel, ConfigContextModel):
 
 
     @property
     @property
     def primary_ip(self):
     def primary_ip(self):
-        if settings.PREFER_IPV4 and self.primary_ip4:
+        if get_config().PREFER_IPV4 and self.primary_ip4:
             return self.primary_ip4
             return self.primary_ip4
         elif self.primary_ip6:
         elif self.primary_ip6:
             return self.primary_ip6
             return self.primary_ip6

+ 1 - 3
netbox/virtualization/tables.py

@@ -17,8 +17,6 @@ __all__ = (
     'VMInterfaceTable',
     'VMInterfaceTable',
 )
 )
 
 
-PRIMARY_IP_ORDERING = ('primary_ip4', 'primary_ip6') if settings.PREFER_IPV4 else ('primary_ip6', 'primary_ip4')
-
 VMINTERFACE_BUTTONS = """
 VMINTERFACE_BUTTONS = """
 {% if perms.ipam.add_ipaddress %}
 {% if perms.ipam.add_ipaddress %}
     <a href="{% url 'ipam:ipaddress_add' %}?vminterface={{ record.pk }}&return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-sm btn-success" title="Add IP Address">
     <a href="{% url 'ipam:ipaddress_add' %}?vminterface={{ record.pk }}&return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-sm btn-success" title="Add IP Address">
@@ -136,7 +134,7 @@ class VirtualMachineTable(BaseTable):
     )
     )
     primary_ip = tables.Column(
     primary_ip = tables.Column(
         linkify=True,
         linkify=True,
-        order_by=PRIMARY_IP_ORDERING,
+        order_by=('primary_ip4', 'primary_ip6'),
         verbose_name='IP Address'
         verbose_name='IP Address'
     )
     )
     tags = TagColumn(
     tags = TagColumn(