mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-23 04:22:01 -06:00
* Move ConfigRevision model & write migrations * Move ConfigRevision resources from extras to core * Extend migration to update original content type for ConfigRevision
This commit is contained in:
parent
18422e1d26
commit
975a647d9a
@ -9,6 +9,7 @@ from .choices import *
|
||||
from .models import *
|
||||
|
||||
__all__ = (
|
||||
'ConfigRevisionFilterSet',
|
||||
'DataFileFilterSet',
|
||||
'DataSourceFilterSet',
|
||||
'JobFilterSet',
|
||||
@ -123,3 +124,23 @@ class JobFilterSet(BaseFilterSet):
|
||||
Q(user__username__icontains=value) |
|
||||
Q(name__icontains=value)
|
||||
)
|
||||
|
||||
|
||||
class ConfigRevisionFilterSet(BaseFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label=_('Search'),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ConfigRevision
|
||||
fields = [
|
||||
'id',
|
||||
]
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(comment__icontains=value)
|
||||
)
|
||||
|
@ -12,6 +12,7 @@ from utilities.forms.fields import ContentTypeChoiceField, DynamicModelMultipleC
|
||||
from utilities.forms.widgets import APISelectMultiple, DateTimePicker
|
||||
|
||||
__all__ = (
|
||||
'ConfigRevisionFilterForm',
|
||||
'DataFileFilterForm',
|
||||
'DataSourceFilterForm',
|
||||
'JobFilterForm',
|
||||
@ -123,3 +124,9 @@ class JobFilterForm(SavedFiltersMixin, FilterForm):
|
||||
api_url='/api/users/users/',
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class ConfigRevisionFilterForm(SavedFiltersMixin, FilterForm):
|
||||
fieldsets = (
|
||||
(None, ('q', 'filter_id')),
|
||||
)
|
||||
|
@ -1,22 +1,28 @@
|
||||
import copy
|
||||
import json
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from core.forms.mixins import SyncedDataMixin
|
||||
from core.models import *
|
||||
from netbox.config import get_config, PARAMS
|
||||
from netbox.forms import NetBoxModelForm
|
||||
from netbox.registry import registry
|
||||
from netbox.utils import get_data_backend_choices
|
||||
from utilities.forms import get_field_value
|
||||
from utilities.forms import BootstrapMixin, get_field_value
|
||||
from utilities.forms.fields import CommentField
|
||||
from utilities.forms.widgets import HTMXSelect
|
||||
|
||||
__all__ = (
|
||||
'ConfigRevisionForm',
|
||||
'DataSourceForm',
|
||||
'ManagedFileForm',
|
||||
)
|
||||
|
||||
EMPTY_VALUES = ('', None, [], ())
|
||||
|
||||
|
||||
class DataSourceForm(NetBoxModelForm):
|
||||
type = forms.ChoiceField(
|
||||
@ -111,3 +117,113 @@ class ManagedFileForm(SyncedDataMixin, NetBoxModelForm):
|
||||
new_file.write(self.cleaned_data['upload_file'].read())
|
||||
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class ConfigFormMetaclass(forms.models.ModelFormMetaclass):
|
||||
|
||||
def __new__(mcs, name, bases, attrs):
|
||||
|
||||
# Emulate a declared field for each supported configuration parameter
|
||||
param_fields = {}
|
||||
for param in PARAMS:
|
||||
field_kwargs = {
|
||||
'required': False,
|
||||
'label': param.label,
|
||||
'help_text': param.description,
|
||||
}
|
||||
field_kwargs.update(**param.field_kwargs)
|
||||
param_fields[param.name] = param.field(**field_kwargs)
|
||||
attrs.update(param_fields)
|
||||
|
||||
return super().__new__(mcs, name, bases, attrs)
|
||||
|
||||
|
||||
class ConfigRevisionForm(BootstrapMixin, forms.ModelForm, metaclass=ConfigFormMetaclass):
|
||||
"""
|
||||
Form for creating a new ConfigRevision.
|
||||
"""
|
||||
|
||||
fieldsets = (
|
||||
(_('Rack Elevations'), ('RACK_ELEVATION_DEFAULT_UNIT_HEIGHT', 'RACK_ELEVATION_DEFAULT_UNIT_WIDTH')),
|
||||
(_('Power'), ('POWERFEED_DEFAULT_VOLTAGE', 'POWERFEED_DEFAULT_AMPERAGE', 'POWERFEED_DEFAULT_MAX_UTILIZATION')),
|
||||
(_('IPAM'), ('ENFORCE_GLOBAL_UNIQUE', 'PREFER_IPV4')),
|
||||
(_('Security'), ('ALLOWED_URL_SCHEMES',)),
|
||||
(_('Banners'), ('BANNER_LOGIN', 'BANNER_MAINTENANCE', 'BANNER_TOP', 'BANNER_BOTTOM')),
|
||||
(_('Pagination'), ('PAGINATE_COUNT', 'MAX_PAGE_SIZE')),
|
||||
(_('Validation'), ('CUSTOM_VALIDATORS', 'PROTECTION_RULES')),
|
||||
(_('User Preferences'), ('DEFAULT_USER_PREFERENCES',)),
|
||||
(_('Miscellaneous'), (
|
||||
'MAINTENANCE_MODE', 'GRAPHQL_ENABLED', 'CHANGELOG_RETENTION', 'JOB_RETENTION', 'MAPS_URL',
|
||||
)),
|
||||
(_('Config Revision'), ('comment',))
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ConfigRevision
|
||||
fields = '__all__'
|
||||
widgets = {
|
||||
'BANNER_LOGIN': forms.Textarea(attrs={'class': 'font-monospace'}),
|
||||
'BANNER_MAINTENANCE': forms.Textarea(attrs={'class': 'font-monospace'}),
|
||||
'BANNER_TOP': forms.Textarea(attrs={'class': 'font-monospace'}),
|
||||
'BANNER_BOTTOM': forms.Textarea(attrs={'class': 'font-monospace'}),
|
||||
'CUSTOM_VALIDATORS': forms.Textarea(attrs={'class': 'font-monospace'}),
|
||||
'PROTECTION_RULES': forms.Textarea(attrs={'class': 'font-monospace'}),
|
||||
'comment': forms.Textarea(),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Append current parameter values to form field help texts and check for static configurations
|
||||
config = get_config()
|
||||
for param in PARAMS:
|
||||
value = getattr(config, param.name)
|
||||
|
||||
# Set the field's initial value, if it can be serialized. (This may not be the case e.g. for
|
||||
# CUSTOM_VALIDATORS, which may reference Python objects.)
|
||||
try:
|
||||
json.dumps(value)
|
||||
if type(value) in (tuple, list):
|
||||
self.fields[param.name].initial = ', '.join(value)
|
||||
else:
|
||||
self.fields[param.name].initial = value
|
||||
except TypeError:
|
||||
pass
|
||||
|
||||
# Check whether this parameter is statically configured (e.g. in configuration.py)
|
||||
if hasattr(settings, param.name):
|
||||
self.fields[param.name].disabled = True
|
||||
self.fields[param.name].help_text = _(
|
||||
'This parameter has been defined statically and cannot be modified.'
|
||||
)
|
||||
continue
|
||||
|
||||
# Set the field's help text
|
||||
help_text = self.fields[param.name].help_text
|
||||
if help_text:
|
||||
help_text += '<br />' # Line break
|
||||
help_text += _('Current value: <strong>{value}</strong>').format(value=value or '—')
|
||||
if value == param.default:
|
||||
help_text += _(' (default)')
|
||||
self.fields[param.name].help_text = help_text
|
||||
|
||||
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
|
||||
|
@ -1,7 +1,7 @@
|
||||
from django.core.cache import cache
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from extras.models import ConfigRevision
|
||||
from core.models import ConfigRevision
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
|
31
netbox/core/migrations/0009_configrevision.py
Normal file
31
netbox/core/migrations/0009_configrevision.py
Normal file
@ -0,0 +1,31 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0008_contenttype_proxy'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.SeparateDatabaseAndState(
|
||||
state_operations=[
|
||||
migrations.CreateModel(
|
||||
name='ConfigRevision',
|
||||
fields=[
|
||||
('id', models.BigAutoField(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)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'config revision',
|
||||
'verbose_name_plural': 'config revisions',
|
||||
'ordering': ['-created'],
|
||||
},
|
||||
),
|
||||
],
|
||||
# Table will be renamed from extras_configrevision in extras/0101_move_configrevision
|
||||
database_operations=[],
|
||||
),
|
||||
]
|
@ -1,3 +1,4 @@
|
||||
from .config import *
|
||||
from .contenttypes import *
|
||||
from .data import *
|
||||
from .files import *
|
||||
|
66
netbox/core/models/config.py
Normal file
66
netbox/core/models/config.py
Normal file
@ -0,0 +1,66 @@
|
||||
from django.core.cache import cache
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext, gettext_lazy as _
|
||||
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
|
||||
__all__ = (
|
||||
'ConfigRevision',
|
||||
)
|
||||
|
||||
|
||||
class ConfigRevision(models.Model):
|
||||
"""
|
||||
An atomic revision of NetBox's configuration.
|
||||
"""
|
||||
created = models.DateTimeField(
|
||||
verbose_name=_('created'),
|
||||
auto_now_add=True
|
||||
)
|
||||
comment = models.CharField(
|
||||
verbose_name=_('comment'),
|
||||
max_length=200,
|
||||
blank=True
|
||||
)
|
||||
data = models.JSONField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_('configuration data')
|
||||
)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created']
|
||||
verbose_name = _('config revision')
|
||||
verbose_name_plural = _('config revisions')
|
||||
|
||||
def __str__(self):
|
||||
if not self.pk:
|
||||
return gettext('Default configuration')
|
||||
if self.is_active:
|
||||
return gettext('Current configuration')
|
||||
return gettext('Config revision #{id}').format(id=self.pk)
|
||||
|
||||
def __getattr__(self, item):
|
||||
if item in self.data:
|
||||
return self.data[item]
|
||||
return super().__getattribute__(item)
|
||||
|
||||
def get_absolute_url(self):
|
||||
if not self.pk:
|
||||
return reverse('core:config') # Default config view
|
||||
return reverse('core:configrevision', args=[self.pk])
|
||||
|
||||
def activate(self):
|
||||
"""
|
||||
Cache the configuration data.
|
||||
"""
|
||||
cache.set('config', self.data, None)
|
||||
cache.set('config_version', self.pk, None)
|
||||
activate.alters_data = True
|
||||
|
||||
@property
|
||||
def is_active(self):
|
||||
return cache.get('config_version') == self.pk
|
@ -1,5 +1,8 @@
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import Signal, receiver
|
||||
|
||||
from .models import ConfigRevision
|
||||
|
||||
__all__ = (
|
||||
'post_sync',
|
||||
'pre_sync',
|
||||
@ -19,3 +22,11 @@ def auto_sync(instance, **kwargs):
|
||||
|
||||
for autosync in AutoSyncRecord.objects.filter(datafile__source=instance).prefetch_related('object'):
|
||||
autosync.object.sync(save=True)
|
||||
|
||||
|
||||
@receiver(post_save, sender=ConfigRevision)
|
||||
def update_config(sender, instance, **kwargs):
|
||||
"""
|
||||
Update the cached NetBox configuration when a new ConfigRevision is created.
|
||||
"""
|
||||
instance.activate()
|
||||
|
@ -1,2 +1,3 @@
|
||||
from .config import *
|
||||
from .data import *
|
||||
from .jobs import *
|
||||
|
33
netbox/core/tables/config.py
Normal file
33
netbox/core/tables/config.py
Normal file
@ -0,0 +1,33 @@
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from core.models import ConfigRevision
|
||||
from netbox.tables import NetBoxTable, columns
|
||||
|
||||
__all__ = (
|
||||
'ConfigRevisionTable',
|
||||
)
|
||||
|
||||
REVISION_BUTTONS = """
|
||||
{% if not record.is_active %}
|
||||
<a href="{% url 'core:configrevision_restore' pk=record.pk %}" class="btn btn-sm btn-primary" title="Restore config">
|
||||
<i class="mdi mdi-file-restore"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
|
||||
class ConfigRevisionTable(NetBoxTable):
|
||||
is_active = columns.BooleanColumn(
|
||||
verbose_name=_('Is Active'),
|
||||
)
|
||||
actions = columns.ActionsColumn(
|
||||
actions=('delete',),
|
||||
extra_buttons=REVISION_BUTTONS
|
||||
)
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = ConfigRevision
|
||||
fields = (
|
||||
'pk', 'id', 'is_active', 'created', 'comment',
|
||||
)
|
||||
default_columns = ('pk', 'id', 'is_active', 'created', 'comment')
|
@ -25,6 +25,13 @@ urlpatterns = (
|
||||
path('jobs/<int:pk>/', views.JobView.as_view(), name='job'),
|
||||
path('jobs/<int:pk>/delete/', views.JobDeleteView.as_view(), name='job_delete'),
|
||||
|
||||
# Config revisions
|
||||
path('config-revisions/', views.ConfigRevisionListView.as_view(), name='configrevision_list'),
|
||||
path('config-revisions/add/', views.ConfigRevisionEditView.as_view(), name='configrevision_add'),
|
||||
path('config-revisions/delete/', views.ConfigRevisionBulkDeleteView.as_view(), name='configrevision_bulk_delete'),
|
||||
path('config-revisions/<int:pk>/restore/', views.ConfigRevisionRestoreView.as_view(), name='configrevision_restore'),
|
||||
path('config-revisions/<int:pk>/', include(get_model_urls('core', 'configrevision'))),
|
||||
|
||||
# Configuration
|
||||
path('config/', views.ConfigView.as_view(), name='config'),
|
||||
|
||||
|
@ -1,12 +1,13 @@
|
||||
from django.contrib import messages
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.http import HttpResponseForbidden
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.views.generic import View
|
||||
|
||||
from extras.models import ConfigRevision
|
||||
from netbox.config import get_config
|
||||
from netbox.config import get_config, PARAMS
|
||||
from netbox.views import generic
|
||||
from netbox.views.generic.base import BaseObjectView
|
||||
from utilities.utils import count_related
|
||||
from utilities.views import register_model_view
|
||||
from utilities.views import ContentTypePermissionRequiredMixin, register_model_view
|
||||
from . import filtersets, forms, tables
|
||||
from .models import *
|
||||
|
||||
@ -164,3 +165,67 @@ class ConfigView(generic.ObjectView):
|
||||
return ConfigRevision(
|
||||
data=get_config().defaults
|
||||
)
|
||||
|
||||
|
||||
class ConfigRevisionListView(generic.ObjectListView):
|
||||
queryset = ConfigRevision.objects.all()
|
||||
filterset = filtersets.ConfigRevisionFilterSet
|
||||
filterset_form = forms.ConfigRevisionFilterForm
|
||||
table = tables.ConfigRevisionTable
|
||||
|
||||
|
||||
@register_model_view(ConfigRevision)
|
||||
class ConfigRevisionView(generic.ObjectView):
|
||||
queryset = ConfigRevision.objects.all()
|
||||
|
||||
|
||||
class ConfigRevisionEditView(generic.ObjectEditView):
|
||||
queryset = ConfigRevision.objects.all()
|
||||
form = forms.ConfigRevisionForm
|
||||
|
||||
|
||||
@register_model_view(ConfigRevision, 'delete')
|
||||
class ConfigRevisionDeleteView(generic.ObjectDeleteView):
|
||||
queryset = ConfigRevision.objects.all()
|
||||
|
||||
|
||||
class ConfigRevisionBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = ConfigRevision.objects.all()
|
||||
filterset = filtersets.ConfigRevisionFilterSet
|
||||
table = tables.ConfigRevisionTable
|
||||
|
||||
|
||||
class ConfigRevisionRestoreView(ContentTypePermissionRequiredMixin, View):
|
||||
|
||||
def get_required_permission(self):
|
||||
return 'core.configrevision_edit'
|
||||
|
||||
def get(self, request, pk):
|
||||
candidate_config = get_object_or_404(ConfigRevision, pk=pk)
|
||||
|
||||
# Get the current ConfigRevision
|
||||
config_version = get_config().version
|
||||
current_config = ConfigRevision.objects.filter(pk=config_version).first()
|
||||
|
||||
params = []
|
||||
for param in PARAMS:
|
||||
params.append((
|
||||
param.name,
|
||||
current_config.data.get(param.name, None),
|
||||
candidate_config.data.get(param.name, None)
|
||||
))
|
||||
|
||||
return render(request, 'core/configrevision_restore.html', {
|
||||
'object': candidate_config,
|
||||
'params': params,
|
||||
})
|
||||
|
||||
def post(self, request, pk):
|
||||
if not request.user.has_perm('core.configrevision_edit'):
|
||||
return HttpResponseForbidden()
|
||||
|
||||
candidate_config = get_object_or_404(ConfigRevision, pk=pk)
|
||||
candidate_config.activate()
|
||||
messages.success(request, f"Restored configuration revision #{pk}")
|
||||
|
||||
return redirect(candidate_config.get_absolute_url())
|
||||
|
@ -17,7 +17,6 @@ from .models import *
|
||||
__all__ = (
|
||||
'BookmarkFilterSet',
|
||||
'ConfigContextFilterSet',
|
||||
'ConfigRevisionFilterSet',
|
||||
'ConfigTemplateFilterSet',
|
||||
'ContentTypeFilterSet',
|
||||
'CustomFieldChoiceSetFilterSet',
|
||||
@ -625,27 +624,3 @@ class ContentTypeFilterSet(django_filters.FilterSet):
|
||||
Q(app_label__icontains=value) |
|
||||
Q(model__icontains=value)
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# ConfigRevisions
|
||||
#
|
||||
|
||||
class ConfigRevisionFilterSet(BaseFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label=_('Search'),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ConfigRevision
|
||||
fields = [
|
||||
'id',
|
||||
]
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(comment__icontains=value)
|
||||
)
|
||||
|
@ -18,7 +18,6 @@ from virtualization.models import Cluster, ClusterGroup, ClusterType
|
||||
|
||||
__all__ = (
|
||||
'ConfigContextFilterForm',
|
||||
'ConfigRevisionFilterForm',
|
||||
'ConfigTemplateFilterForm',
|
||||
'CustomFieldChoiceSetFilterForm',
|
||||
'CustomFieldFilterForm',
|
||||
@ -499,9 +498,3 @@ class ObjectChangeFilterForm(SavedFiltersMixin, FilterForm):
|
||||
api_url='/api/extras/content-types/',
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class ConfigRevisionFilterForm(SavedFiltersMixin, FilterForm):
|
||||
fieldsets = (
|
||||
(None, ('q', 'filter_id')),
|
||||
)
|
||||
|
@ -1,7 +1,6 @@
|
||||
import json
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
@ -10,7 +9,6 @@ from core.models import ContentType
|
||||
from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
|
||||
from extras.choices import *
|
||||
from extras.models import *
|
||||
from netbox.config import get_config, PARAMS
|
||||
from netbox.forms import NetBoxModelForm
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
from utilities.forms import BootstrapMixin, add_blank_choice
|
||||
@ -21,11 +19,9 @@ from utilities.forms.fields import (
|
||||
from utilities.forms.widgets import ChoicesWidget
|
||||
from virtualization.models import Cluster, ClusterGroup, ClusterType
|
||||
|
||||
|
||||
__all__ = (
|
||||
'BookmarkForm',
|
||||
'ConfigContextForm',
|
||||
'ConfigRevisionForm',
|
||||
'ConfigTemplateForm',
|
||||
'CustomFieldChoiceSetForm',
|
||||
'CustomFieldForm',
|
||||
@ -445,116 +441,3 @@ class JournalEntryForm(NetBoxModelForm):
|
||||
'assigned_object_type': forms.HiddenInput,
|
||||
'assigned_object_id': forms.HiddenInput,
|
||||
}
|
||||
|
||||
|
||||
EMPTY_VALUES = ('', None, [], ())
|
||||
|
||||
|
||||
class ConfigFormMetaclass(forms.models.ModelFormMetaclass):
|
||||
|
||||
def __new__(mcs, name, bases, attrs):
|
||||
|
||||
# Emulate a declared field for each supported configuration parameter
|
||||
param_fields = {}
|
||||
for param in PARAMS:
|
||||
field_kwargs = {
|
||||
'required': False,
|
||||
'label': param.label,
|
||||
'help_text': param.description,
|
||||
}
|
||||
field_kwargs.update(**param.field_kwargs)
|
||||
param_fields[param.name] = param.field(**field_kwargs)
|
||||
attrs.update(param_fields)
|
||||
|
||||
return super().__new__(mcs, name, bases, attrs)
|
||||
|
||||
|
||||
class ConfigRevisionForm(BootstrapMixin, forms.ModelForm, metaclass=ConfigFormMetaclass):
|
||||
"""
|
||||
Form for creating a new ConfigRevision.
|
||||
"""
|
||||
|
||||
fieldsets = (
|
||||
(_('Rack Elevations'), ('RACK_ELEVATION_DEFAULT_UNIT_HEIGHT', 'RACK_ELEVATION_DEFAULT_UNIT_WIDTH')),
|
||||
(_('Power'), ('POWERFEED_DEFAULT_VOLTAGE', 'POWERFEED_DEFAULT_AMPERAGE', 'POWERFEED_DEFAULT_MAX_UTILIZATION')),
|
||||
(_('IPAM'), ('ENFORCE_GLOBAL_UNIQUE', 'PREFER_IPV4')),
|
||||
(_('Security'), ('ALLOWED_URL_SCHEMES',)),
|
||||
(_('Banners'), ('BANNER_LOGIN', 'BANNER_MAINTENANCE', 'BANNER_TOP', 'BANNER_BOTTOM')),
|
||||
(_('Pagination'), ('PAGINATE_COUNT', 'MAX_PAGE_SIZE')),
|
||||
(_('Validation'), ('CUSTOM_VALIDATORS', 'PROTECTION_RULES')),
|
||||
(_('User Preferences'), ('DEFAULT_USER_PREFERENCES',)),
|
||||
(_('Miscellaneous'), (
|
||||
'MAINTENANCE_MODE', 'GRAPHQL_ENABLED', 'CHANGELOG_RETENTION', 'JOB_RETENTION', 'MAPS_URL',
|
||||
)),
|
||||
(_('Config Revision'), ('comment',))
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ConfigRevision
|
||||
fields = '__all__'
|
||||
widgets = {
|
||||
'BANNER_LOGIN': forms.Textarea(attrs={'class': 'font-monospace'}),
|
||||
'BANNER_MAINTENANCE': forms.Textarea(attrs={'class': 'font-monospace'}),
|
||||
'BANNER_TOP': forms.Textarea(attrs={'class': 'font-monospace'}),
|
||||
'BANNER_BOTTOM': forms.Textarea(attrs={'class': 'font-monospace'}),
|
||||
'CUSTOM_VALIDATORS': forms.Textarea(attrs={'class': 'font-monospace'}),
|
||||
'PROTECTION_RULES': forms.Textarea(attrs={'class': 'font-monospace'}),
|
||||
'comment': forms.Textarea(),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Append current parameter values to form field help texts and check for static configurations
|
||||
config = get_config()
|
||||
for param in PARAMS:
|
||||
value = getattr(config, param.name)
|
||||
|
||||
# Set the field's initial value, if it can be serialized. (This may not be the case e.g. for
|
||||
# CUSTOM_VALIDATORS, which may reference Python objects.)
|
||||
try:
|
||||
json.dumps(value)
|
||||
if type(value) in (tuple, list):
|
||||
self.fields[param.name].initial = ', '.join(value)
|
||||
else:
|
||||
self.fields[param.name].initial = value
|
||||
except TypeError:
|
||||
pass
|
||||
|
||||
# Check whether this parameter is statically configured (e.g. in configuration.py)
|
||||
if hasattr(settings, param.name):
|
||||
self.fields[param.name].disabled = True
|
||||
self.fields[param.name].help_text = _(
|
||||
'This parameter has been defined statically and cannot be modified.'
|
||||
)
|
||||
continue
|
||||
|
||||
# Set the field's help text
|
||||
help_text = self.fields[param.name].help_text
|
||||
if help_text:
|
||||
help_text += '<br />' # Line break
|
||||
help_text += _('Current value: <strong>{value}</strong>').format(value=value or '—')
|
||||
if value == param.default:
|
||||
help_text += _(' (default)')
|
||||
self.fields[param.name].help_text = help_text
|
||||
|
||||
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
|
||||
|
39
netbox/extras/migrations/0101_move_configrevision.py
Normal file
39
netbox/extras/migrations/0101_move_configrevision.py
Normal file
@ -0,0 +1,39 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def update_content_type(apps, schema_editor):
|
||||
ContentType = apps.get_model('contenttypes', 'ContentType')
|
||||
|
||||
# Delete the new ContentType effected by the introduction of core.ConfigRevision
|
||||
ContentType.objects.filter(app_label='core', model='configrevision').delete()
|
||||
|
||||
# Update the app label of the original ContentType for extras.ConfigRevision to ensure any foreign key
|
||||
# references are preserved
|
||||
ContentType.objects.filter(app_label='extras', model='configrevision').update(app_label='core')
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('extras', '0100_customfield_ui_attrs'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.SeparateDatabaseAndState(
|
||||
state_operations=[
|
||||
migrations.DeleteModel(
|
||||
name='ConfigRevision',
|
||||
),
|
||||
],
|
||||
database_operations=[
|
||||
migrations.AlterModelTable(
|
||||
name='ConfigRevision',
|
||||
table='core_configrevision',
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.RunPython(
|
||||
code=update_content_type,
|
||||
reverse_code=migrations.RunPython.noop
|
||||
),
|
||||
]
|
@ -3,14 +3,13 @@ import urllib.parse
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.core.cache import cache
|
||||
from django.core.validators import ValidationError
|
||||
from django.db import models
|
||||
from django.http import HttpResponse
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.translation import gettext, gettext_lazy as _
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework.utils.encoders import JSONEncoder
|
||||
|
||||
from core.models import ContentType
|
||||
@ -28,7 +27,6 @@ from utilities.utils import clean_html, dict_to_querydict, render_jinja2
|
||||
|
||||
__all__ = (
|
||||
'Bookmark',
|
||||
'ConfigRevision',
|
||||
'CustomLink',
|
||||
'ExportTemplate',
|
||||
'ImageAttachment',
|
||||
@ -710,59 +708,3 @@ class Bookmark(models.Model):
|
||||
raise ValidationError(
|
||||
_("Bookmarks cannot be assigned to this object type ({type}).").format(type=self.object_type)
|
||||
)
|
||||
|
||||
|
||||
class ConfigRevision(models.Model):
|
||||
"""
|
||||
An atomic revision of NetBox's configuration.
|
||||
"""
|
||||
created = models.DateTimeField(
|
||||
verbose_name=_('created'),
|
||||
auto_now_add=True
|
||||
)
|
||||
comment = models.CharField(
|
||||
verbose_name=_('comment'),
|
||||
max_length=200,
|
||||
blank=True
|
||||
)
|
||||
data = models.JSONField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_('configuration data')
|
||||
)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created']
|
||||
verbose_name = _('config revision')
|
||||
verbose_name_plural = _('config revisions')
|
||||
|
||||
def __str__(self):
|
||||
if not self.pk:
|
||||
return gettext('Default configuration')
|
||||
if self.is_active:
|
||||
return gettext('Current configuration')
|
||||
return gettext('Config revision #{id}').format(id=self.pk)
|
||||
|
||||
def __getattr__(self, item):
|
||||
if item in self.data:
|
||||
return self.data[item]
|
||||
return super().__getattribute__(item)
|
||||
|
||||
def get_absolute_url(self):
|
||||
if not self.pk:
|
||||
return reverse('core:config') # Default config view
|
||||
return reverse('extras:configrevision', args=[self.pk])
|
||||
|
||||
def activate(self):
|
||||
"""
|
||||
Cache the configuration data.
|
||||
"""
|
||||
cache.set('config', self.data, None)
|
||||
cache.set('config_version', self.pk, None)
|
||||
activate.alters_data = True
|
||||
|
||||
@property
|
||||
def is_active(self):
|
||||
return cache.get('config_version') == self.pk
|
||||
|
@ -14,7 +14,7 @@ from netbox.context import current_request, webhooks_queue
|
||||
from netbox.signals import post_clean
|
||||
from utilities.exceptions import AbortRequest
|
||||
from .choices import ObjectChangeActionChoices
|
||||
from .models import ConfigRevision, CustomField, ObjectChange, TaggedItem
|
||||
from .models import CustomField, ObjectChange, TaggedItem
|
||||
from .webhooks import enqueue_object, get_snapshots, serialize_for_webhook
|
||||
|
||||
#
|
||||
@ -219,18 +219,6 @@ def run_delete_validators(sender, instance, **kwargs):
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Dynamic configuration
|
||||
#
|
||||
|
||||
@receiver(post_save, sender=ConfigRevision)
|
||||
def update_config(sender, instance, **kwargs):
|
||||
"""
|
||||
Update the cached NetBox configuration when a new ConfigRevision is created.
|
||||
"""
|
||||
instance.activate()
|
||||
|
||||
|
||||
#
|
||||
# Tags
|
||||
#
|
||||
|
@ -11,7 +11,6 @@ from .template_code import *
|
||||
__all__ = (
|
||||
'BookmarkTable',
|
||||
'ConfigContextTable',
|
||||
'ConfigRevisionTable',
|
||||
'ConfigTemplateTable',
|
||||
'CustomFieldChoiceSetTable',
|
||||
'CustomFieldTable',
|
||||
@ -34,31 +33,6 @@ IMAGEATTACHMENT_IMAGE = '''
|
||||
{% endif %}
|
||||
'''
|
||||
|
||||
REVISION_BUTTONS = """
|
||||
{% if not record.is_active %}
|
||||
<a href="{% url 'extras:configrevision_restore' pk=record.pk %}" class="btn btn-sm btn-primary" title="Restore config">
|
||||
<i class="mdi mdi-file-restore"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
|
||||
class ConfigRevisionTable(NetBoxTable):
|
||||
is_active = columns.BooleanColumn(
|
||||
verbose_name=_('Is Active'),
|
||||
)
|
||||
actions = columns.ActionsColumn(
|
||||
actions=('delete',),
|
||||
extra_buttons=REVISION_BUTTONS
|
||||
)
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = ConfigRevision
|
||||
fields = (
|
||||
'pk', 'id', 'is_active', 'created', 'comment',
|
||||
)
|
||||
default_columns = ('pk', 'id', 'is_active', 'created', 'comment')
|
||||
|
||||
|
||||
class CustomFieldTable(NetBoxTable):
|
||||
name = tables.Column(
|
||||
|
@ -98,13 +98,6 @@ urlpatterns = [
|
||||
path('journal-entries/import/', views.JournalEntryBulkImportView.as_view(), name='journalentry_import'),
|
||||
path('journal-entries/<int:pk>/', include(get_model_urls('extras', 'journalentry'))),
|
||||
|
||||
# Config revisions
|
||||
path('config-revisions/', views.ConfigRevisionListView.as_view(), name='configrevision_list'),
|
||||
path('config-revisions/add/', views.ConfigRevisionEditView.as_view(), name='configrevision_add'),
|
||||
path('config-revisions/delete/', views.ConfigRevisionBulkDeleteView.as_view(), name='configrevision_bulk_delete'),
|
||||
path('config-revisions/<int:pk>/restore/', views.ConfigRevisionRestoreView.as_view(), name='configrevision_restore'),
|
||||
path('config-revisions/<int:pk>/', include(get_model_urls('extras', 'configrevision'))),
|
||||
|
||||
# Change logging
|
||||
path('changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'),
|
||||
path('changelog/<int:pk>/', include(get_model_urls('extras', 'objectchange'))),
|
||||
|
@ -15,7 +15,6 @@ from core.models import Job
|
||||
from core.tables import JobTable
|
||||
from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm
|
||||
from extras.dashboard.utils import get_widget_class
|
||||
from netbox.config import get_config, PARAMS
|
||||
from netbox.constants import DEFAULT_ACTION_PERMISSIONS
|
||||
from netbox.views import generic
|
||||
from utilities.forms import ConfirmationForm, get_field_value
|
||||
@ -1316,74 +1315,6 @@ class ScriptResultView(ContentTypePermissionRequiredMixin, View):
|
||||
})
|
||||
|
||||
|
||||
#
|
||||
# Config Revisions
|
||||
#
|
||||
|
||||
class ConfigRevisionListView(generic.ObjectListView):
|
||||
queryset = ConfigRevision.objects.all()
|
||||
filterset = filtersets.ConfigRevisionFilterSet
|
||||
filterset_form = forms.ConfigRevisionFilterForm
|
||||
table = tables.ConfigRevisionTable
|
||||
|
||||
|
||||
@register_model_view(ConfigRevision)
|
||||
class ConfigRevisionView(generic.ObjectView):
|
||||
queryset = ConfigRevision.objects.all()
|
||||
|
||||
|
||||
class ConfigRevisionEditView(generic.ObjectEditView):
|
||||
queryset = ConfigRevision.objects.all()
|
||||
form = forms.ConfigRevisionForm
|
||||
|
||||
|
||||
@register_model_view(ConfigRevision, 'delete')
|
||||
class ConfigRevisionDeleteView(generic.ObjectDeleteView):
|
||||
queryset = ConfigRevision.objects.all()
|
||||
|
||||
|
||||
class ConfigRevisionBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = ConfigRevision.objects.all()
|
||||
filterset = filtersets.ConfigRevisionFilterSet
|
||||
table = tables.ConfigRevisionTable
|
||||
|
||||
|
||||
class ConfigRevisionRestoreView(ContentTypePermissionRequiredMixin, View):
|
||||
|
||||
def get_required_permission(self):
|
||||
return 'extras.configrevision_edit'
|
||||
|
||||
def get(self, request, pk):
|
||||
candidate_config = get_object_or_404(ConfigRevision, pk=pk)
|
||||
|
||||
# Get the current ConfigRevision
|
||||
config_version = get_config().version
|
||||
current_config = ConfigRevision.objects.filter(pk=config_version).first()
|
||||
|
||||
params = []
|
||||
for param in PARAMS:
|
||||
params.append((
|
||||
param.name,
|
||||
current_config.data.get(param.name, None),
|
||||
candidate_config.data.get(param.name, None)
|
||||
))
|
||||
|
||||
return render(request, 'extras/configrevision_restore.html', {
|
||||
'object': candidate_config,
|
||||
'params': params,
|
||||
})
|
||||
|
||||
def post(self, request, pk):
|
||||
if not request.user.has_perm('extras.configrevision_edit'):
|
||||
return HttpResponseForbidden()
|
||||
|
||||
candidate_config = get_object_or_404(ConfigRevision, pk=pk)
|
||||
candidate_config.activate()
|
||||
messages.success(request, f"Restored configuration revision #{pk}")
|
||||
|
||||
return redirect(candidate_config.get_absolute_url())
|
||||
|
||||
|
||||
#
|
||||
# Markdown
|
||||
#
|
||||
|
@ -74,7 +74,7 @@ class Config:
|
||||
|
||||
def _populate_from_db(self):
|
||||
"""Cache data from latest ConfigRevision, then populate from cache"""
|
||||
from extras.models import ConfigRevision
|
||||
from core.models import ConfigRevision
|
||||
|
||||
try:
|
||||
revision = ConfigRevision.objects.last()
|
||||
|
@ -424,13 +424,13 @@ ADMIN_MENU = Menu(
|
||||
MenuItem(
|
||||
link='core:config',
|
||||
link_text=_('Current Config'),
|
||||
permissions=['extras.view_configrevision'],
|
||||
permissions=['core.view_configrevision'],
|
||||
staff_only=True
|
||||
),
|
||||
MenuItem(
|
||||
link='extras:configrevision_list',
|
||||
link='core:configrevision_list',
|
||||
link_text=_('Config Revisions'),
|
||||
permissions=['extras.view_configrevision'],
|
||||
permissions=['core.view_configrevision'],
|
||||
staff_only=True
|
||||
),
|
||||
),
|
||||
|
@ -2,7 +2,7 @@ from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.test import override_settings, TestCase
|
||||
|
||||
from extras.models import ConfigRevision
|
||||
from core.models import ConfigRevision
|
||||
from netbox.config import clear_config, get_config
|
||||
|
||||
|
||||
|
@ -14,11 +14,11 @@
|
||||
<div class="controls">
|
||||
<div class="control-group">
|
||||
{% plugin_buttons object %}
|
||||
{% if not object.pk or object.is_active and perms.extras.add_configrevision %}
|
||||
{% url 'extras:configrevision_add' as edit_url %}
|
||||
{% if not object.pk or object.is_active and perms.core.add_configrevision %}
|
||||
{% url 'core:configrevision_add' as edit_url %}
|
||||
{% include "buttons/edit.html" with url=edit_url %}
|
||||
{% endif %}
|
||||
{% if object.pk and not object.is_active and perms.extras.delete_configrevision %}
|
||||
{% if object.pk and not object.is_active and perms.core.delete_configrevision %}
|
||||
{% delete_button object %}
|
||||
{% endif %}
|
||||
</div>
|
@ -18,8 +18,8 @@
|
||||
<div class="col col-md-12">
|
||||
<nav class="breadcrumb-container px-3" aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{% url 'extras:configrevision_list' %}">{% trans "Config revisions" %}</a></li>
|
||||
<li class="breadcrumb-item"><a href="{% url 'extras:configrevision' pk=object.pk %}">{{ object }}</a></li>
|
||||
<li class="breadcrumb-item"><a href="{% url 'core:configrevision_list' %}">{% trans "Config revisions" %}</a></li>
|
||||
<li class="breadcrumb-item"><a href="{% url 'core:configrevision' pk=object.pk %}">{{ object }}</a></li>
|
||||
</ol>
|
||||
</nav>
|
||||
</div>
|
||||
@ -77,7 +77,7 @@
|
||||
<div class="controls">
|
||||
<div class="control-group">
|
||||
<button type="submit" name="restore" class="btn btn-primary">{% trans "Restore" %}</button>
|
||||
<a href="{% url 'extras:configrevision_list' %}" id="cancel" name="cancel" class="btn btn-outline-danger">{% trans "Cancel" %}</a>
|
||||
<a href="{% url 'core:configrevision_list' %}" id="cancel" name="cancel" class="btn btn-outline-danger">{% trans "Cancel" %}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
Loading…
Reference in New Issue
Block a user