jeremystretch 4 lat temu
rodzic
commit
82243732a1

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

@@ -15,6 +15,7 @@ from dcim.constants import *
 from extras.models import ConfigContextModel
 from extras.querysets import ConfigContextModelQuerySet
 from extras.utils import extras_features
+from netbox.config import ConfigResolver
 from netbox.models import OrganizationalModel, PrimaryModel
 from utilities.choices import ColorChoices
 from utilities.fields import ColorField, NaturalOrderingField
@@ -815,7 +816,8 @@ class Device(PrimaryModel, ConfigContextModel):
 
     @property
     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
         elif self.primary_ip6:
             return self.primary_ip6

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

@@ -160,18 +160,11 @@ class DeviceTable(BaseTable):
         linkify=True,
         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(
         linkify=True,
         verbose_name='IPv4 Address'

+ 66 - 2
netbox/extras/admin.py

@@ -1,10 +1,74 @@
 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)

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

@@ -3,4 +3,5 @@ from .filtersets import *
 from .bulk_edit import *
 from .bulk_import import *
 from .customfields import *
+from .config 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 .configcontexts import ConfigContext, ConfigContextModel
 from .customfields import CustomField
-from .models import CustomLink, ExportTemplate, ImageAttachment, JobResult, JournalEntry, Report, Script, Webhook
+from .models import *
 from .tags import Tag, TaggedItem
 
 __all__ = (
     'ConfigContext',
     'ConfigContextModel',
+    'ConfigRevision',
     'CustomField',
     'CustomLink',
     'ExportTemplate',

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

@@ -1,9 +1,11 @@
 import json
 import uuid
 
+from django.contrib import admin
 from django.contrib.auth.models import User
 from django.contrib.contenttypes.fields import GenericForeignKey
 from django.contrib.contenttypes.models import ContentType
+from django.core.cache import cache
 from django.core.validators import ValidationError
 from django.db import models
 from django.http import HttpResponse
@@ -20,8 +22,8 @@ from netbox.models import BigIDModel, ChangeLoggedModel
 from utilities.querysets import RestrictedQuerySet
 from utilities.utils import render_jinja2
 
-
 __all__ = (
+    'ConfigRevision',
     'CustomLink',
     'ExportTemplate',
     'ImageAttachment',
@@ -33,10 +35,6 @@ __all__ = (
 )
 
 
-#
-# Webhooks
-#
-
 @extras_features('webhooks')
 class Webhook(ChangeLoggedModel):
     """
@@ -181,10 +179,6 @@ class Webhook(ChangeLoggedModel):
             return json.dumps(context, cls=JSONEncoder)
 
 
-#
-# Custom links
-#
-
 @extras_features('webhooks')
 class CustomLink(ChangeLoggedModel):
     """
@@ -240,10 +234,6 @@ class CustomLink(ChangeLoggedModel):
         return reverse('extras:customlink', args=[self.pk])
 
 
-#
-# Export templates
-#
-
 @extras_features('webhooks')
 class ExportTemplate(ChangeLoggedModel):
     content_type = models.ForeignKey(
@@ -333,10 +323,6 @@ class ExportTemplate(ChangeLoggedModel):
         return response
 
 
-#
-# Image attachments
-#
-
 class ImageAttachment(BigIDModel):
     """
     An uploaded image which is associated with an object.
@@ -409,11 +395,6 @@ class ImageAttachment(BigIDModel):
             return None
 
 
-#
-# Journal entries
-#
-
-
 @extras_features('webhooks')
 class JournalEntry(ChangeLoggedModel):
     """
@@ -463,36 +444,6 @@ class JournalEntry(ChangeLoggedModel):
         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):
     """
     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)
 
         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.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.dispatch import receiver, Signal
 from django_prometheus.models import model_deletes, model_inserts, model_updates
 
 from netbox.signals import post_clean
 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
 
 
@@ -161,3 +162,16 @@ def run_custom_validators(sender, instance, **kwargs):
     validators = settings.CUSTOM_VALIDATORS.get(model_name, [])
     for validator in validators:
         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.querysets import PrefixQuerySet
 from ipam.validators import DNSValidator
+from netbox.config import ConfigResolver
 from utilities.querysets import RestrictedQuerySet
 from virtualization.models import VirtualMachine
 
@@ -316,7 +317,8 @@ class Prefix(PrimaryModel):
                 })
 
             # 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()
                 if duplicate_prefixes:
                     raise ValidationError({
@@ -811,7 +813,8 @@ class IPAddress(PrimaryModel):
                 })
 
             # 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()
                 if duplicate_ips and (
                         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 extras.registry import registry
+from netbox.config import ConfigResolver
 
 
 def settings_and_registry(request):
@@ -9,6 +10,7 @@ def settings_and_registry(request):
     """
     return {
         'settings': django_settings,
+        'config': ConfigResolver(),
         'registry': registry,
         '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.validators import URLValidator
 
+from netbox.config import PARAMS
+
 
 #
 # Environment setup
@@ -68,18 +70,11 @@ DATABASE = getattr(configuration, 'DATABASE')
 REDIS = getattr(configuration, 'REDIS')
 SECRET_KEY = getattr(configuration, 'SECRET_KEY')
 
-# Set optional parameters
+# Set static config parameters
 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', '')
 if BASE_PATH:
     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_REGEX_WHITELIST = getattr(configuration, 'CORS_ORIGIN_REGEX_WHITELIST', [])
 CORS_ORIGIN_WHITELIST = getattr(configuration, 'CORS_ORIGIN_WHITELIST', [])
@@ -90,30 +85,12 @@ DEBUG = getattr(configuration, 'DEBUG', False)
 DEVELOPER = getattr(configuration, 'DEVELOPER', False)
 DOCS_ROOT = getattr(configuration, 'DOCS_ROOT', os.path.join(os.path.dirname(BASE_DIR), 'docs'))
 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)
 INTERNAL_IPS = getattr(configuration, 'INTERNAL_IPS', ('127.0.0.1', '::1'))
 LOGGING = getattr(configuration, 'LOGGING', {})
-LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False)
-LOGIN_TIMEOUT = getattr(configuration, 'LOGIN_TIMEOUT', None)
-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('/')
-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_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_BACKEND = getattr(configuration, 'REMOTE_AUTH_BACKEND', 'netbox.authentication.RemoteUserBackend')
 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_USERS = getattr(configuration, 'REMOTE_AUTH_STAFF_USERS', [])
 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('/')
 RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300)
 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_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
 if RELEASE_CHECK_URL:
     validator = URLValidator(

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

@@ -58,9 +58,9 @@
 
         </nav>
 
-        {% if settings.BANNER_TOP %}
+        {% if config.BANNER_TOP %}
           <div class="alert alert-info text-center mx-3" role="alert">
-            {{ settings.BANNER_TOP|safe }}
+            {{ config.BANNER_TOP|safe }}
           </div>
         {% endif %}
 
@@ -98,9 +98,9 @@
           {% endblock %}
         </div>
 
-        {% if settings.BANNER_BOTTOM %}
+        {% if config.BANNER_BOTTOM %}
           <div class="alert alert-info text-center mx-3" role="alert">
-            {{ settings.BANNER_BOTTOM|safe }}
+            {{ config.BANNER_BOTTOM|safe }}
           </div>
         {% endif %}
 

+ 2 - 2
netbox/templates/login.html

@@ -7,9 +7,9 @@
   <main class="login-container text-center">
 
     {# 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">
-        {{ settings.BANNER_LOGIN|safe }}
+        {{ config.BANNER_LOGIN|safe }}
       </div>
     {% 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.core.exceptions import ValidationError
 from django.core.validators import MinValueValidator
@@ -9,6 +8,7 @@ from dcim.models import BaseInterface, Device
 from extras.models import ConfigContextModel
 from extras.querysets import ConfigContextModelQuerySet
 from extras.utils import extras_features
+from netbox.config import ConfigResolver
 from netbox.models import OrganizationalModel, PrimaryModel
 from utilities.fields import NaturalOrderingField
 from utilities.ordering import naturalize_interface
@@ -340,7 +340,8 @@ class VirtualMachine(PrimaryModel, ConfigContextModel):
 
     @property
     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
         elif self.primary_ip6:
             return self.primary_ip6

+ 1 - 3
netbox/virtualization/tables.py

@@ -17,8 +17,6 @@ __all__ = (
     'VMInterfaceTable',
 )
 
-PRIMARY_IP_ORDERING = ('primary_ip4', 'primary_ip6') if settings.PREFER_IPV4 else ('primary_ip6', 'primary_ip4')
-
 VMINTERFACE_BUTTONS = """
 {% 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">
@@ -136,7 +134,7 @@ class VirtualMachineTable(BaseTable):
     )
     primary_ip = tables.Column(
         linkify=True,
-        order_by=PRIMARY_IP_ORDERING,
+        order_by=('primary_ip4', 'primary_ip6'),
         verbose_name='IP Address'
     )
     tags = TagColumn(