jeremystretch hace 4 años
padre
commit
82243732a1

+ 3 - 1
netbox/dcim/models/devices.py

@@ -15,6 +15,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 ConfigResolver
 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 +816,8 @@ class Device(PrimaryModel, ConfigContextModel):
 
 
     @property
     @property
     def primary_ip(self):
     def primary_ip(self):
-        if settings.PREFER_IPV4 and self.primary_ip4:
+        config = ConfigResolver()
+        if 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

+ 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'

+ 66 - 2
netbox/extras/admin.py

@@ -1,10 +1,74 @@
 from django.contrib import admin
 from django.contrib import admin
 
 
-from .models import JobResult
+from .forms import ConfigRevisionForm
+from .models import ConfigRevision, JobResult
+
+
+@admin.register(ConfigRevision)
+class ConfigRevisionAdmin(admin.ModelAdmin):
+    fieldsets = [
+        # ('Authentication', {
+        #     'fields': ('LOGIN_REQUIRED', 'LOGIN_PERSISTENCE', 'LOGIN_TIMEOUT'),
+        # }),
+        # ('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', 'EXEMPT_VIEW_PERMISSIONS',
+        #     ),
+        # }),
+        ('Banners', {
+            'fields': ('BANNER_LOGIN', 'BANNER_TOP', 'BANNER_BOTTOM'),
+        }),
+        # ('Logging', {
+        #     'fields': ('CHANGELOG_RETENTION',),
+        # }),
+        # ('Pagination', {
+        #     'fields': ('MAX_PAGE_SIZE', 'PAGINATE_COUNT'),
+        # }),
+        # ('Miscellaneous', {
+        #     'fields': ('GRAPHQL_ENABLED', 'METRICS_ENABLED', 'MAINTENANCE_MODE', 'MAPS_URL'),
+        # }),
+        ('Config Revision', {
+            'fields': ('comment',),
+        })
+    ]
+    form = ConfigRevisionForm
+    list_display = ('id', 'is_active', 'created', 'comment')
+    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
+
+    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()
+        )
 
 
 
 
 #
 #
-# 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 *

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

@@ -0,0 +1,67 @@
+from django import forms
+
+from netbox.config.parameters import 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:
+            help_text = f'{param.description}<br />' if param.description else ''
+            # help_text += f'Current value: <strong>{getattr(settings, param.name)}</strong>'
+            param_fields[param.name] = param.field(
+                required=False,
+                label=param.label,
+                help_text=help_text,
+                **param.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)
+
+        # Bugfix for django-timezone-field: Add empty choice to default options
+        # self.fields['TIME_ZONE'].choices = [('', ''), *self.fields['TIME_ZONE'].choices]
+
+    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',

+ 59 - 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,59 @@ 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)
+
+    @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

+ 15 - 1
netbox/extras/signals.py

@@ -2,13 +2,14 @@ import logging
 
 
 from django.conf import settings
 from django.conf import settings
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
+from django.core.cache import cache
 from django.db.models.signals import m2m_changed, post_save, pre_delete
 from django.db.models.signals import m2m_changed, post_save, pre_delete
 from django.dispatch import receiver, Signal
 from django.dispatch import receiver, Signal
 from django_prometheus.models import model_deletes, model_inserts, model_updates
 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 +162,16 @@ 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.
+    """
+    cache.set('config', instance.data, None)
+    cache.set('config_version', instance.pk, None)

+ 5 - 2
netbox/ipam/models/ip.py

@@ -17,6 +17,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 ConfigResolver
 from utilities.querysets import RestrictedQuerySet
 from utilities.querysets import RestrictedQuerySet
 from virtualization.models import VirtualMachine
 from virtualization.models import VirtualMachine
 
 
@@ -316,7 +317,8 @@ 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):
+            config = ConfigResolver()
+            if (self.vrf is None and 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 +813,8 @@ 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):
+            config = ConfigResolver()
+            if (self.vrf is None and 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

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

@@ -0,0 +1,35 @@
+from django.conf import settings
+from django.core.cache import cache
+
+from .parameters import PARAMS
+
+__all__ = (
+    'ConfigResolver',
+    'PARAMS',
+)
+
+
+class ConfigResolver:
+    """
+    Active NetBox configuration.
+    """
+    def __init__(self):
+        self.config = cache.get('config')
+        self.version = self.config.get('config_version')
+        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}")

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

@@ -0,0 +1,55 @@
+from django import forms
+
+
+class OptionalBooleanSelect(forms.Select):
+    """
+    An optional boolean field (yes/no/default).
+    """
+    def __init__(self, attrs=None):
+        choices = (
+            ('', 'Default'),
+            (True, 'Yes'),
+            (False, 'No'),
+        )
+        super().__init__(attrs, choices)
+
+
+class OptionalBooleanField(forms.NullBooleanField):
+    widget = OptionalBooleanSelect
+
+
+class ConfigParam:
+
+    def __init__(self, name, label, default, description=None, 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('BANNER_LOGIN', 'Login banner', ''),
+    ConfigParam('BANNER_TOP', 'Top banner', ''),
+    ConfigParam('BANNER_BOTTOM', 'Bottom banner', ''),
+
+    # IPAM
+    ConfigParam(
+        name='ENFORCE_GLOBAL_UNIQUE',
+        label='Globally unique IP space',
+        default=False,
+        description="Enforce unique IP addressing within the global table",
+        field=OptionalBooleanField
+    ),
+    ConfigParam(
+        name='PREFER_IPV4',
+        label='Prefer IPv4',
+        default=False,
+        description="Prefer IPv4 addresses over IPv6",
+        field=OptionalBooleanField
+    ),
+
+)

+ 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 ConfigResolver
 
 
 
 
 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': ConfigResolver(),
         'registry': registry,
         'registry': registry,
         'preferences': request.user.config if request.user.is_authenticated else {},
         'preferences': request.user.config if request.user.is_authenticated else {},
     }
     }

+ 30 - 27
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,18 +70,11 @@ 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
-CHANGELOG_RETENTION = getattr(configuration, 'CHANGELOG_RETENTION', 90)
 CORS_ORIGIN_ALLOW_ALL = getattr(configuration, 'CORS_ORIGIN_ALLOW_ALL', False)
 CORS_ORIGIN_ALLOW_ALL = getattr(configuration, 'CORS_ORIGIN_ALLOW_ALL', False)
 CORS_ORIGIN_REGEX_WHITELIST = getattr(configuration, 'CORS_ORIGIN_REGEX_WHITELIST', [])
 CORS_ORIGIN_REGEX_WHITELIST = getattr(configuration, 'CORS_ORIGIN_REGEX_WHITELIST', [])
 CORS_ORIGIN_WHITELIST = getattr(configuration, 'CORS_ORIGIN_WHITELIST', [])
 CORS_ORIGIN_WHITELIST = getattr(configuration, 'CORS_ORIGIN_WHITELIST', [])
@@ -90,30 +85,12 @@ 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', [])
-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_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False)
-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)
-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)
 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 +104,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 +117,33 @@ 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)
+
+ALLOWED_URL_SCHEMES = getattr(configuration, 'ALLOWED_URL_SCHEMES', (
+    'file', 'ftp', 'ftps', 'http', 'https', 'irc', 'mailto', 'sftp', 'ssh', 'tel', 'telnet', 'tftp', 'vnc', 'xmpp',
+))
+CHANGELOG_RETENTION = getattr(configuration, 'CHANGELOG_RETENTION', 90)
+EXEMPT_VIEW_PERMISSIONS = getattr(configuration, 'EXEMPT_VIEW_PERMISSIONS', [])
+GRAPHQL_ENABLED = getattr(configuration, 'GRAPHQL_ENABLED', True)
+LOGIN_PERSISTENCE = getattr(configuration, 'LOGIN_PERSISTENCE', False)
+LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False)
+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)
+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)
+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)
+
 # Validate update repo URL and timeout
 # Validate update repo URL and timeout
 if RELEASE_CHECK_URL:
 if RELEASE_CHECK_URL:
     validator = URLValidator(
     validator = URLValidator(

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

@@ -58,9 +58,9 @@
 
 
         </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 %}
 
 
@@ -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/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 %}
 
 

+ 3 - 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 ConfigResolver
 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,8 @@ class VirtualMachine(PrimaryModel, ConfigContextModel):
 
 
     @property
     @property
     def primary_ip(self):
     def primary_ip(self):
-        if settings.PREFER_IPV4 and self.primary_ip4:
+        config = ConfigResolver()
+        if 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(