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 *
|
from .models import *
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
|
'ConfigRevisionFilterSet',
|
||||||
'DataFileFilterSet',
|
'DataFileFilterSet',
|
||||||
'DataSourceFilterSet',
|
'DataSourceFilterSet',
|
||||||
'JobFilterSet',
|
'JobFilterSet',
|
||||||
@ -123,3 +124,23 @@ class JobFilterSet(BaseFilterSet):
|
|||||||
Q(user__username__icontains=value) |
|
Q(user__username__icontains=value) |
|
||||||
Q(name__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
|
from utilities.forms.widgets import APISelectMultiple, DateTimePicker
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
|
'ConfigRevisionFilterForm',
|
||||||
'DataFileFilterForm',
|
'DataFileFilterForm',
|
||||||
'DataSourceFilterForm',
|
'DataSourceFilterForm',
|
||||||
'JobFilterForm',
|
'JobFilterForm',
|
||||||
@ -123,3 +124,9 @@ class JobFilterForm(SavedFiltersMixin, FilterForm):
|
|||||||
api_url='/api/users/users/',
|
api_url='/api/users/users/',
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigRevisionFilterForm(SavedFiltersMixin, FilterForm):
|
||||||
|
fieldsets = (
|
||||||
|
(None, ('q', 'filter_id')),
|
||||||
|
)
|
||||||
|
@ -1,22 +1,28 @@
|
|||||||
import copy
|
import copy
|
||||||
|
import json
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
|
from django.conf import settings
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from core.forms.mixins import SyncedDataMixin
|
from core.forms.mixins import SyncedDataMixin
|
||||||
from core.models import *
|
from core.models import *
|
||||||
|
from netbox.config import get_config, PARAMS
|
||||||
from netbox.forms import NetBoxModelForm
|
from netbox.forms import NetBoxModelForm
|
||||||
from netbox.registry import registry
|
from netbox.registry import registry
|
||||||
from netbox.utils import get_data_backend_choices
|
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.fields import CommentField
|
||||||
from utilities.forms.widgets import HTMXSelect
|
from utilities.forms.widgets import HTMXSelect
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
|
'ConfigRevisionForm',
|
||||||
'DataSourceForm',
|
'DataSourceForm',
|
||||||
'ManagedFileForm',
|
'ManagedFileForm',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
EMPTY_VALUES = ('', None, [], ())
|
||||||
|
|
||||||
|
|
||||||
class DataSourceForm(NetBoxModelForm):
|
class DataSourceForm(NetBoxModelForm):
|
||||||
type = forms.ChoiceField(
|
type = forms.ChoiceField(
|
||||||
@ -111,3 +117,113 @@ class ManagedFileForm(SyncedDataMixin, NetBoxModelForm):
|
|||||||
new_file.write(self.cleaned_data['upload_file'].read())
|
new_file.write(self.cleaned_data['upload_file'].read())
|
||||||
|
|
||||||
return super().save(*args, **kwargs)
|
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.cache import cache
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
from extras.models import ConfigRevision
|
from core.models import ConfigRevision
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
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 .contenttypes import *
|
||||||
from .data import *
|
from .data import *
|
||||||
from .files 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 django.dispatch import Signal, receiver
|
||||||
|
|
||||||
|
from .models import ConfigRevision
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'post_sync',
|
'post_sync',
|
||||||
'pre_sync',
|
'pre_sync',
|
||||||
@ -19,3 +22,11 @@ def auto_sync(instance, **kwargs):
|
|||||||
|
|
||||||
for autosync in AutoSyncRecord.objects.filter(datafile__source=instance).prefetch_related('object'):
|
for autosync in AutoSyncRecord.objects.filter(datafile__source=instance).prefetch_related('object'):
|
||||||
autosync.object.sync(save=True)
|
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 .data import *
|
||||||
from .jobs 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>/', views.JobView.as_view(), name='job'),
|
||||||
path('jobs/<int:pk>/delete/', views.JobDeleteView.as_view(), name='job_delete'),
|
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
|
# Configuration
|
||||||
path('config/', views.ConfigView.as_view(), name='config'),
|
path('config/', views.ConfigView.as_view(), name='config'),
|
||||||
|
|
||||||
|
@ -1,12 +1,13 @@
|
|||||||
from django.contrib import messages
|
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, PARAMS
|
||||||
from netbox.config import get_config
|
|
||||||
from netbox.views import generic
|
from netbox.views import generic
|
||||||
from netbox.views.generic.base import BaseObjectView
|
from netbox.views.generic.base import BaseObjectView
|
||||||
from utilities.utils import count_related
|
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 . import filtersets, forms, tables
|
||||||
from .models import *
|
from .models import *
|
||||||
|
|
||||||
@ -164,3 +165,67 @@ class ConfigView(generic.ObjectView):
|
|||||||
return ConfigRevision(
|
return ConfigRevision(
|
||||||
data=get_config().defaults
|
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__ = (
|
__all__ = (
|
||||||
'BookmarkFilterSet',
|
'BookmarkFilterSet',
|
||||||
'ConfigContextFilterSet',
|
'ConfigContextFilterSet',
|
||||||
'ConfigRevisionFilterSet',
|
|
||||||
'ConfigTemplateFilterSet',
|
'ConfigTemplateFilterSet',
|
||||||
'ContentTypeFilterSet',
|
'ContentTypeFilterSet',
|
||||||
'CustomFieldChoiceSetFilterSet',
|
'CustomFieldChoiceSetFilterSet',
|
||||||
@ -625,27 +624,3 @@ class ContentTypeFilterSet(django_filters.FilterSet):
|
|||||||
Q(app_label__icontains=value) |
|
Q(app_label__icontains=value) |
|
||||||
Q(model__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__ = (
|
__all__ = (
|
||||||
'ConfigContextFilterForm',
|
'ConfigContextFilterForm',
|
||||||
'ConfigRevisionFilterForm',
|
|
||||||
'ConfigTemplateFilterForm',
|
'ConfigTemplateFilterForm',
|
||||||
'CustomFieldChoiceSetFilterForm',
|
'CustomFieldChoiceSetFilterForm',
|
||||||
'CustomFieldFilterForm',
|
'CustomFieldFilterForm',
|
||||||
@ -499,9 +498,3 @@ class ObjectChangeFilterForm(SavedFiltersMixin, FilterForm):
|
|||||||
api_url='/api/extras/content-types/',
|
api_url='/api/extras/content-types/',
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ConfigRevisionFilterForm(SavedFiltersMixin, FilterForm):
|
|
||||||
fieldsets = (
|
|
||||||
(None, ('q', 'filter_id')),
|
|
||||||
)
|
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.conf import settings
|
|
||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
from django.utils.translation import gettext_lazy as _
|
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 dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
|
||||||
from extras.choices import *
|
from extras.choices import *
|
||||||
from extras.models import *
|
from extras.models import *
|
||||||
from netbox.config import get_config, PARAMS
|
|
||||||
from netbox.forms import NetBoxModelForm
|
from netbox.forms import NetBoxModelForm
|
||||||
from tenancy.models import Tenant, TenantGroup
|
from tenancy.models import Tenant, TenantGroup
|
||||||
from utilities.forms import BootstrapMixin, add_blank_choice
|
from utilities.forms import BootstrapMixin, add_blank_choice
|
||||||
@ -21,11 +19,9 @@ from utilities.forms.fields import (
|
|||||||
from utilities.forms.widgets import ChoicesWidget
|
from utilities.forms.widgets import ChoicesWidget
|
||||||
from virtualization.models import Cluster, ClusterGroup, ClusterType
|
from virtualization.models import Cluster, ClusterGroup, ClusterType
|
||||||
|
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'BookmarkForm',
|
'BookmarkForm',
|
||||||
'ConfigContextForm',
|
'ConfigContextForm',
|
||||||
'ConfigRevisionForm',
|
|
||||||
'ConfigTemplateForm',
|
'ConfigTemplateForm',
|
||||||
'CustomFieldChoiceSetForm',
|
'CustomFieldChoiceSetForm',
|
||||||
'CustomFieldForm',
|
'CustomFieldForm',
|
||||||
@ -445,116 +441,3 @@ class JournalEntryForm(NetBoxModelForm):
|
|||||||
'assigned_object_type': forms.HiddenInput,
|
'assigned_object_type': forms.HiddenInput,
|
||||||
'assigned_object_id': 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.conf import settings
|
||||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||||
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
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.formats import date_format
|
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 rest_framework.utils.encoders import JSONEncoder
|
||||||
|
|
||||||
from core.models import ContentType
|
from core.models import ContentType
|
||||||
@ -28,7 +27,6 @@ from utilities.utils import clean_html, dict_to_querydict, render_jinja2
|
|||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'Bookmark',
|
'Bookmark',
|
||||||
'ConfigRevision',
|
|
||||||
'CustomLink',
|
'CustomLink',
|
||||||
'ExportTemplate',
|
'ExportTemplate',
|
||||||
'ImageAttachment',
|
'ImageAttachment',
|
||||||
@ -710,59 +708,3 @@ class Bookmark(models.Model):
|
|||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
_("Bookmarks cannot be assigned to this object type ({type}).").format(type=self.object_type)
|
_("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 netbox.signals import post_clean
|
||||||
from utilities.exceptions import AbortRequest
|
from utilities.exceptions import AbortRequest
|
||||||
from .choices import ObjectChangeActionChoices
|
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
|
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
|
# Tags
|
||||||
#
|
#
|
||||||
|
@ -11,7 +11,6 @@ from .template_code import *
|
|||||||
__all__ = (
|
__all__ = (
|
||||||
'BookmarkTable',
|
'BookmarkTable',
|
||||||
'ConfigContextTable',
|
'ConfigContextTable',
|
||||||
'ConfigRevisionTable',
|
|
||||||
'ConfigTemplateTable',
|
'ConfigTemplateTable',
|
||||||
'CustomFieldChoiceSetTable',
|
'CustomFieldChoiceSetTable',
|
||||||
'CustomFieldTable',
|
'CustomFieldTable',
|
||||||
@ -34,31 +33,6 @@ IMAGEATTACHMENT_IMAGE = '''
|
|||||||
{% endif %}
|
{% 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):
|
class CustomFieldTable(NetBoxTable):
|
||||||
name = tables.Column(
|
name = tables.Column(
|
||||||
|
@ -98,13 +98,6 @@ urlpatterns = [
|
|||||||
path('journal-entries/import/', views.JournalEntryBulkImportView.as_view(), name='journalentry_import'),
|
path('journal-entries/import/', views.JournalEntryBulkImportView.as_view(), name='journalentry_import'),
|
||||||
path('journal-entries/<int:pk>/', include(get_model_urls('extras', 'journalentry'))),
|
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
|
# Change logging
|
||||||
path('changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'),
|
path('changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'),
|
||||||
path('changelog/<int:pk>/', include(get_model_urls('extras', 'objectchange'))),
|
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 core.tables import JobTable
|
||||||
from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm
|
from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm
|
||||||
from extras.dashboard.utils import get_widget_class
|
from extras.dashboard.utils import get_widget_class
|
||||||
from netbox.config import get_config, PARAMS
|
|
||||||
from netbox.constants import DEFAULT_ACTION_PERMISSIONS
|
from netbox.constants import DEFAULT_ACTION_PERMISSIONS
|
||||||
from netbox.views import generic
|
from netbox.views import generic
|
||||||
from utilities.forms import ConfirmationForm, get_field_value
|
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
|
# Markdown
|
||||||
#
|
#
|
||||||
|
@ -74,7 +74,7 @@ class Config:
|
|||||||
|
|
||||||
def _populate_from_db(self):
|
def _populate_from_db(self):
|
||||||
"""Cache data from latest ConfigRevision, then populate from cache"""
|
"""Cache data from latest ConfigRevision, then populate from cache"""
|
||||||
from extras.models import ConfigRevision
|
from core.models import ConfigRevision
|
||||||
|
|
||||||
try:
|
try:
|
||||||
revision = ConfigRevision.objects.last()
|
revision = ConfigRevision.objects.last()
|
||||||
|
@ -424,13 +424,13 @@ ADMIN_MENU = Menu(
|
|||||||
MenuItem(
|
MenuItem(
|
||||||
link='core:config',
|
link='core:config',
|
||||||
link_text=_('Current Config'),
|
link_text=_('Current Config'),
|
||||||
permissions=['extras.view_configrevision'],
|
permissions=['core.view_configrevision'],
|
||||||
staff_only=True
|
staff_only=True
|
||||||
),
|
),
|
||||||
MenuItem(
|
MenuItem(
|
||||||
link='extras:configrevision_list',
|
link='core:configrevision_list',
|
||||||
link_text=_('Config Revisions'),
|
link_text=_('Config Revisions'),
|
||||||
permissions=['extras.view_configrevision'],
|
permissions=['core.view_configrevision'],
|
||||||
staff_only=True
|
staff_only=True
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -2,7 +2,7 @@ from django.conf import settings
|
|||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.test import override_settings, TestCase
|
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
|
from netbox.config import clear_config, get_config
|
||||||
|
|
||||||
|
|
||||||
|
@ -14,11 +14,11 @@
|
|||||||
<div class="controls">
|
<div class="controls">
|
||||||
<div class="control-group">
|
<div class="control-group">
|
||||||
{% plugin_buttons object %}
|
{% plugin_buttons object %}
|
||||||
{% if not object.pk or object.is_active and perms.extras.add_configrevision %}
|
{% if not object.pk or object.is_active and perms.core.add_configrevision %}
|
||||||
{% url 'extras:configrevision_add' as edit_url %}
|
{% url 'core:configrevision_add' as edit_url %}
|
||||||
{% include "buttons/edit.html" with url=edit_url %}
|
{% include "buttons/edit.html" with url=edit_url %}
|
||||||
{% endif %}
|
{% 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 %}
|
{% delete_button object %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
@ -18,8 +18,8 @@
|
|||||||
<div class="col col-md-12">
|
<div class="col col-md-12">
|
||||||
<nav class="breadcrumb-container px-3" aria-label="breadcrumb">
|
<nav class="breadcrumb-container px-3" aria-label="breadcrumb">
|
||||||
<ol class="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 'core: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' pk=object.pk %}">{{ object }}</a></li>
|
||||||
</ol>
|
</ol>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
@ -77,7 +77,7 @@
|
|||||||
<div class="controls">
|
<div class="controls">
|
||||||
<div class="control-group">
|
<div class="control-group">
|
||||||
<button type="submit" name="restore" class="btn btn-primary">{% trans "Restore" %}</button>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
Loading…
Reference in New Issue
Block a user