mirror of
https://github.com/netbox-community/netbox.git
synced 2024-05-10 07:54:54 +00:00
Initial work on #5883
This commit is contained in:
@ -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
|
||||||
|
@ -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(
|
primary_ip = tables.Column(
|
||||||
linkify=True,
|
linkify=True,
|
||||||
order_by=('primary_ip4', 'primary_ip6'),
|
order_by=('primary_ip4', 'primary_ip6'),
|
||||||
verbose_name='IP Address'
|
verbose_name='IP Address'
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
primary_ip = tables.Column(
|
|
||||||
linkify=True,
|
|
||||||
order_by=('primary_ip6', 'primary_ip4'),
|
|
||||||
verbose_name='IP Address'
|
|
||||||
)
|
|
||||||
primary_ip4 = tables.Column(
|
primary_ip4 = tables.Column(
|
||||||
linkify=True,
|
linkify=True,
|
||||||
verbose_name='IPv4 Address'
|
verbose_name='IPv4 Address'
|
||||||
|
@ -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)
|
||||||
|
@ -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
netbox/extras/forms/config.py
Normal file
67
netbox/extras/forms/config.py
Normal file
@ -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
netbox/extras/migrations/0064_configrevision.py
Normal file
20
netbox/extras/migrations/0064_configrevision.py
Normal file
@ -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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
@ -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',
|
||||||
|
@ -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
|
||||||
|
@ -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)
|
||||||
|
@ -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
netbox/netbox/config/__init__.py
Normal file
35
netbox/netbox/config/__init__.py
Normal file
@ -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
netbox/netbox/config/parameters.py
Normal file
55
netbox/netbox/config/parameters.py
Normal file
@ -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
|
||||||
|
),
|
||||||
|
|
||||||
|
)
|
@ -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 {},
|
||||||
}
|
}
|
||||||
|
@ -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(
|
||||||
|
@ -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 %}
|
||||||
|
|
||||||
|
@ -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 %}
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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(
|
||||||
|
Reference in New Issue
Block a user