mirror of
https://github.com/netbox-community/netbox.git
synced 2024-05-10 07:54:54 +00:00
12591 config params admin (#12904)
* 12591 initial commit * 12591 detail view * 12591 add/edit view * 12591 edit button * 12591 base views and forms * 12591 form cleanup * 12591 form cleanup * 12591 form cleanup * 12591 review changes * 12591 move check for restrictedqueryset * 12591 restore view * 12591 restore page styling * 12591 remove admin * Remove edit view for ConfigRevision instances * Order ConfigRevisions by creation time * Correct permission name * Use RestrictedQuerySet for ConfigRevision * Fix redirect URL --------- Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
This commit is contained in:
@ -1,129 +1,2 @@
|
||||
from django.contrib import admin
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.template.response import TemplateResponse
|
||||
from django.urls import path, reverse
|
||||
from django.utils.html import format_html
|
||||
|
||||
from netbox.config import get_config, PARAMS
|
||||
# TODO: Removing this import triggers an import loop due to how form mixins are currently organized
|
||||
from .forms import ConfigRevisionForm
|
||||
from .models import ConfigRevision
|
||||
|
||||
|
||||
@admin.register(ConfigRevision)
|
||||
class ConfigRevisionAdmin(admin.ModelAdmin):
|
||||
fieldsets = [
|
||||
('Rack Elevations', {
|
||||
'fields': ('RACK_ELEVATION_DEFAULT_UNIT_HEIGHT', 'RACK_ELEVATION_DEFAULT_UNIT_WIDTH'),
|
||||
}),
|
||||
('Power', {
|
||||
'fields': ('POWERFEED_DEFAULT_VOLTAGE', 'POWERFEED_DEFAULT_AMPERAGE', 'POWERFEED_DEFAULT_MAX_UTILIZATION')
|
||||
}),
|
||||
('IPAM', {
|
||||
'fields': ('ENFORCE_GLOBAL_UNIQUE', 'PREFER_IPV4'),
|
||||
}),
|
||||
('Security', {
|
||||
'fields': ('ALLOWED_URL_SCHEMES',),
|
||||
}),
|
||||
('Banners', {
|
||||
'fields': ('BANNER_LOGIN', 'BANNER_MAINTENANCE', 'BANNER_TOP', 'BANNER_BOTTOM'),
|
||||
'classes': ('monospace',),
|
||||
}),
|
||||
('Pagination', {
|
||||
'fields': ('PAGINATE_COUNT', 'MAX_PAGE_SIZE'),
|
||||
}),
|
||||
('Validation', {
|
||||
'fields': ('CUSTOM_VALIDATORS',),
|
||||
'classes': ('monospace',),
|
||||
}),
|
||||
('User Preferences', {
|
||||
'fields': ('DEFAULT_USER_PREFERENCES',),
|
||||
}),
|
||||
('Miscellaneous', {
|
||||
'fields': ('MAINTENANCE_MODE', 'GRAPHQL_ENABLED', 'CHANGELOG_RETENTION', 'JOB_RETENTION', 'MAPS_URL'),
|
||||
}),
|
||||
('Config Revision', {
|
||||
'fields': ('comment',),
|
||||
})
|
||||
]
|
||||
form = ConfigRevisionForm
|
||||
list_display = ('id', 'is_active', 'created', 'comment', 'restore_link')
|
||||
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
|
||||
|
||||
# Permissions
|
||||
|
||||
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()
|
||||
)
|
||||
|
||||
# List display methods
|
||||
|
||||
def restore_link(self, obj):
|
||||
if obj.is_active():
|
||||
return ''
|
||||
return format_html(
|
||||
'<a href="{url}" class="button">Restore</a>',
|
||||
url=reverse('admin:extras_configrevision_restore', args=(obj.pk,))
|
||||
)
|
||||
restore_link.short_description = "Actions"
|
||||
|
||||
# URLs
|
||||
|
||||
def get_urls(self):
|
||||
urls = [
|
||||
path('<int:pk>/restore/', self.admin_site.admin_view(self.restore), name='extras_configrevision_restore'),
|
||||
]
|
||||
|
||||
return urls + super().get_urls()
|
||||
|
||||
# Views
|
||||
|
||||
def restore(self, request, pk):
|
||||
# Get the ConfigRevision being restored
|
||||
candidate_config = get_object_or_404(ConfigRevision, pk=pk)
|
||||
|
||||
if request.method == 'POST':
|
||||
candidate_config.activate()
|
||||
self.message_user(request, f"Restored configuration revision #{pk}")
|
||||
|
||||
return redirect(reverse('admin:extras_configrevision_changelist'))
|
||||
|
||||
# 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)
|
||||
))
|
||||
|
||||
context = self.admin_site.each_context(request)
|
||||
context.update({
|
||||
'object': candidate_config,
|
||||
'params': params,
|
||||
})
|
||||
|
||||
return TemplateResponse(request, 'admin/extras/configrevision/restore.html', context)
|
||||
|
@ -16,6 +16,7 @@ from .models import *
|
||||
|
||||
__all__ = (
|
||||
'ConfigContextFilterSet',
|
||||
'ConfigRevisionFilterSet',
|
||||
'ConfigTemplateFilterSet',
|
||||
'ContentTypeFilterSet',
|
||||
'CustomFieldFilterSet',
|
||||
@ -557,3 +558,27 @@ 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)
|
||||
)
|
||||
|
@ -4,5 +4,4 @@ from .bulk_edit import *
|
||||
from .bulk_import import *
|
||||
from .misc import *
|
||||
from .mixins import *
|
||||
from .config import *
|
||||
from .scripts import *
|
||||
|
@ -1,82 +0,0 @@
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
|
||||
from netbox.config import get_config, 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:
|
||||
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(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)
|
||||
|
||||
# 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)
|
||||
is_static = hasattr(settings, param.name)
|
||||
if value:
|
||||
help_text = self.fields[param.name].help_text
|
||||
if help_text:
|
||||
help_text += '<br />' # Line break
|
||||
help_text += f'Current value: <strong>{value}</strong>'
|
||||
if is_static:
|
||||
help_text += ' (defined statically)'
|
||||
elif value == param.default:
|
||||
help_text += ' (default)'
|
||||
self.fields[param.name].help_text = help_text
|
||||
if is_static:
|
||||
self.fields[param.name].disabled = True
|
||||
|
||||
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
|
@ -18,6 +18,7 @@ from .mixins import SavedFiltersMixin
|
||||
|
||||
__all__ = (
|
||||
'ConfigContextFilterForm',
|
||||
'ConfigRevisionFilterForm',
|
||||
'ConfigTemplateFilterForm',
|
||||
'CustomFieldFilterForm',
|
||||
'CustomLinkFilterForm',
|
||||
@ -444,3 +445,9 @@ class ObjectChangeFilterForm(SavedFiltersMixin, FilterForm):
|
||||
api_url='/api/extras/content-types/',
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class ConfigRevisionFilterForm(SavedFiltersMixin, FilterForm):
|
||||
fieldsets = (
|
||||
(None, ('q', 'filter_id')),
|
||||
)
|
||||
|
@ -1,6 +1,7 @@
|
||||
import json
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.db.models import Q
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.utils.translation import gettext as _
|
||||
@ -10,17 +11,20 @@ from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site
|
||||
from extras.choices import *
|
||||
from extras.models import *
|
||||
from extras.utils import FeatureQuery
|
||||
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
|
||||
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, BootstrapMixin, add_blank_choice
|
||||
from utilities.forms.fields import (
|
||||
CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, JSONField,
|
||||
SlugField,
|
||||
)
|
||||
from virtualization.models import Cluster, ClusterGroup, ClusterType
|
||||
|
||||
|
||||
__all__ = (
|
||||
'ConfigContextForm',
|
||||
'ConfigRevisionForm',
|
||||
'ConfigTemplateForm',
|
||||
'CustomFieldForm',
|
||||
'CustomLinkForm',
|
||||
@ -374,3 +378,99 @@ 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',)),
|
||||
('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'}),
|
||||
'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)
|
||||
is_static = hasattr(settings, param.name)
|
||||
if value:
|
||||
help_text = self.fields[param.name].help_text
|
||||
if help_text:
|
||||
help_text += '<br />' # Line break
|
||||
help_text += f'Current value: <strong>{value}</strong>'
|
||||
if is_static:
|
||||
help_text += ' (defined statically)'
|
||||
elif value == param.default:
|
||||
help_text += ' (default)'
|
||||
self.fields[param.name].help_text = help_text
|
||||
self.fields[param.name].initial = value
|
||||
if is_static:
|
||||
self.fields[param.name].disabled = True
|
||||
|
||||
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
|
||||
|
17
netbox/extras/migrations/0093_configrevision_ordering.py
Normal file
17
netbox/extras/migrations/0093_configrevision_ordering.py
Normal file
@ -0,0 +1,17 @@
|
||||
# Generated by Django 4.1.9 on 2023-06-22 14:14
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('extras', '0092_delete_jobresult'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='configrevision',
|
||||
options={'ordering': ['-created']},
|
||||
),
|
||||
]
|
@ -612,6 +612,11 @@ class ConfigRevision(models.Model):
|
||||
verbose_name='Configuration data'
|
||||
)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created']
|
||||
|
||||
def __str__(self):
|
||||
return f'Config revision #{self.pk} ({self.created})'
|
||||
|
||||
@ -620,6 +625,9 @@ class ConfigRevision(models.Model):
|
||||
return self.data[item]
|
||||
return super().__getattribute__(item)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('extras:configrevision', args=[self.pk])
|
||||
|
||||
def activate(self):
|
||||
"""
|
||||
Cache the configuration data.
|
||||
|
@ -9,6 +9,7 @@ from .template_code import *
|
||||
|
||||
__all__ = (
|
||||
'ConfigContextTable',
|
||||
'ConfigRevisionTable',
|
||||
'ConfigTemplateTable',
|
||||
'CustomFieldTable',
|
||||
'CustomLinkTable',
|
||||
@ -30,6 +31,29 @@ 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()
|
||||
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(
|
||||
|
@ -85,6 +85,13 @@ 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'))),
|
||||
@ -114,5 +121,5 @@ urlpatterns = [
|
||||
path('scripts/<str:module>/<str:name>/jobs/', views.ScriptJobsView.as_view(), name='script_jobs'),
|
||||
|
||||
# Markdown
|
||||
path('render/markdown/', views.RenderMarkdownView.as_view(), name="render_markdown")
|
||||
path('render/markdown/', views.RenderMarkdownView.as_view(), name="render_markdown"),
|
||||
]
|
||||
|
@ -14,6 +14,7 @@ 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.views import generic
|
||||
from utilities.forms import ConfirmationForm, get_field_value
|
||||
from utilities.htmx import is_htmx
|
||||
@ -1176,6 +1177,74 @@ 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
|
||||
#
|
||||
|
@ -346,6 +346,22 @@ OPERATIONS_MENU = Menu(
|
||||
),
|
||||
)
|
||||
|
||||
ADMIN_MENU = Menu(
|
||||
label=_('Admin'),
|
||||
icon_class='mdi mdi-account-multiple',
|
||||
groups=(
|
||||
MenuGroup(
|
||||
label=_('Configuration'),
|
||||
items=(
|
||||
MenuItem(
|
||||
link='extras:configrevision_list',
|
||||
link_text=_('Config Revisions'),
|
||||
permissions=['extras.view_configrevision']
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
MENUS = [
|
||||
ORGANIZATION_MENU,
|
||||
@ -360,6 +376,7 @@ MENUS = [
|
||||
PROVISIONING_MENU,
|
||||
CUSTOMIZATION_MENU,
|
||||
OPERATIONS_MENU,
|
||||
ADMIN_MENU,
|
||||
]
|
||||
|
||||
#
|
||||
|
@ -1,37 +0,0 @@
|
||||
{% extends "admin/base_site.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block content %}
|
||||
<p>Restore configuration #{{ object.pk }} from <strong>{{ object.created }}</strong>?</p>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Parameter</th>
|
||||
<th>Current Value</th>
|
||||
<th>New Value</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for param, current, new in params %}
|
||||
<tr{% if current != new %} style="color: #d7a50d"{% endif %}>
|
||||
<td>{{ param }}</td>
|
||||
<td>{{ current }}</td>
|
||||
<td>{{ new }}</td>
|
||||
<td>{% if current != new %}<img src="{% static 'admin/img/icon-changelink.svg' %}" alt="*" title="Changed">{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<div class="submit-row" style="margin-top: 20px">
|
||||
<input type="submit" name="restore" value="Restore" class="default" style="float: left" />
|
||||
<a href="{% url 'admin:extras_configrevision_changelist' %}" style="float: left; margin: 2px 0; padding: 10px 15px">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock content %}
|
||||
|
||||
|
200
netbox/templates/extras/configrevision.html
Normal file
200
netbox/templates/extras/configrevision.html
Normal file
@ -0,0 +1,200 @@
|
||||
{% extends 'generic/object.html' %}
|
||||
{% load buttons %}
|
||||
{% load custom_links %}
|
||||
{% load helpers %}
|
||||
{% load perms %}
|
||||
{% load plugins %}
|
||||
{% load static %}
|
||||
|
||||
{% block breadcrumbs %}
|
||||
{% endblock %}
|
||||
|
||||
{% block controls %}
|
||||
<div class="controls">
|
||||
<div class="control-group">
|
||||
{% plugin_buttons object %}
|
||||
</div>
|
||||
<div class="control-group">
|
||||
{% custom_links object %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock controls %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col col-md-6">
|
||||
<div class="card">
|
||||
<h5 class="card-header">Rack Elevation</h5>
|
||||
<div class="card-body">
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">Rack elevation default unit height:</th>
|
||||
<td>{{ object.data.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Rack elevation default unit width:</th>
|
||||
<td>{{ object.data.RACK_ELEVATION_DEFAULT_UNIT_WIDTH }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h5 class="card-header">Power</h5>
|
||||
<div class="card-body">
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">Powerfeed default voltage:</th>
|
||||
<td>{{ object.data.POWERFEED_DEFAULT_VOLTAGE }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Powerfeed default amperage:</th>
|
||||
<td>{{ object.data.POWERFEED_DEFAULT_AMPERAGE }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Powerfeed default max utilization:</th>
|
||||
<td>{{ object.data.POWERFEED_DEFAULT_MAX_UTILIZATION }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h5 class="card-header">IPAM</h5>
|
||||
<div class="card-body">
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">IPAM enforce global unique:</th>
|
||||
<td>{{ object.data.ENFORCE_GLOBAL_UNIQUE }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">IPAM prefer IPV4:</th>
|
||||
<td>{{ object.data.PREFER_IPV4 }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h5 class="card-header">Security</h5>
|
||||
<div class="card-body">
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">Allowed URL schemes:</th>
|
||||
<td>{{ object.data.ALLOWED_URL_SCHEMES }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h5 class="card-header">Banners</h5>
|
||||
<div class="card-body">
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">Login banner:</th>
|
||||
<td>{{ object.data.BANNER_LOGIN }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Maintenance banner:</th>
|
||||
<td>{{ object.data.BANNER_MAINTENANCE }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Top banner:</th>
|
||||
<td>{{ object.data.BANNER_TOP }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Bottom banner:</th>
|
||||
<td>{{ object.data.BANNER_BOTTOM }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
<div class="col col-md-6">
|
||||
|
||||
<div class="card">
|
||||
<h5 class="card-header">Pagination</h5>
|
||||
<div class="card-body">
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">Paginate count:</th>
|
||||
<td>{{ object.data.PAGINATE_COUNT }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Max page size:</th>
|
||||
<td>{{ object.data.MAX_PAGE_SIZE }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h5 class="card-header">Validation</h5>
|
||||
<div class="card-body">
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">Custom validators:</th>
|
||||
<td>{{ object.data.CUSTOM_VALIDATORS }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h5 class="card-header">User Preferences</h5>
|
||||
<div class="card-body">
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">Default user preferences:</th>
|
||||
<td>{{ object.data.DEFAULT_USER_PREFERENCES }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h5 class="card-header">Miscellaneous</h5>
|
||||
<div class="card-body">
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">Maintenance mode:</th>
|
||||
<td>{{ object.data.MAINTENANCE_MODE }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">GraphQL enabled:</th>
|
||||
<td>{{ object.data.GRAPHQL_ENABLED }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Changelog retention:</th>
|
||||
<td>{{ object.data.CHANGELOG_RETENTION }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Job retention:</th>
|
||||
<td>{{ object.data.JOB_RETENTION }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Maps URL:</th>
|
||||
<td>{{ object.data.MAPS_URL }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h5 class="card-header">Config Revision</h5>
|
||||
<div class="card-body">
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">Comment:</th>
|
||||
<td>{{ object.comment }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
88
netbox/templates/extras/configrevision_restore.html
Normal file
88
netbox/templates/extras/configrevision_restore.html
Normal file
@ -0,0 +1,88 @@
|
||||
{% extends 'base/layout.html' %}
|
||||
{% load helpers %}
|
||||
{% load buttons %}
|
||||
{% load perms %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Restore: {{ object }}{% endblock %}
|
||||
|
||||
{% block subtitle %}
|
||||
<div class="object-subtitle">
|
||||
<span>Created {{ object.created|annotated_date }}</span>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block header %}
|
||||
<div class="row noprint">
|
||||
<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' %}">Config revisions</a></li>
|
||||
<li class="breadcrumb-item"><a href="{% url 'extras:configrevision' pk=object.pk %}">{{ object }}</a></li>
|
||||
</ol>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
{{ block.super }}
|
||||
{% endblock header %}
|
||||
|
||||
{% block controls %}
|
||||
<div class="controls">
|
||||
<div class="control-group">
|
||||
{% if request.user|can_delete:job %}
|
||||
{% delete_button job %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock controls %}
|
||||
|
||||
{% block tabs %}
|
||||
<ul class="nav nav-tabs px-3" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<a href="#log" role="tab" data-bs-toggle="tab" class="nav-link active">Restore</a>
|
||||
</li>
|
||||
</ul>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<table class="table table-striped table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Parameter</th>
|
||||
<th scope="col">Current Value</th>
|
||||
<th scope="col">New Value</th>
|
||||
<th scope="col"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for param, current, new in params %}
|
||||
<tr{% if current != new %} class="table-warning"{% endif %}>
|
||||
<td>{{ param }}</td>
|
||||
<td>{{ current }}</td>
|
||||
<td>{{ new }}</td>
|
||||
<td>{% if current != new %}<img src="{% static 'admin/img/icon-changelink.svg' %}" alt="*" title="Changed">{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<div class="submit-row" style="margin-top: 20px">
|
||||
<div class="controls">
|
||||
<div class="control-group">
|
||||
<button type="submit" name="restore" class="btn btn-primary">Restore</button>
|
||||
<a href="{% url 'extras:configrevision_list' %}" id="cancel" name="cancel" class="btn btn-outline-danger">Cancel</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% endblock content %}
|
||||
|
||||
{% block modals %}
|
||||
{% endblock modals %}
|
@ -38,7 +38,7 @@ Context:
|
||||
</div>
|
||||
</div>
|
||||
{{ block.super }}
|
||||
{% endblock %}
|
||||
{% endblock header %}
|
||||
|
||||
{% block title %}{{ object }}{% endblock %}
|
||||
|
||||
@ -48,7 +48,7 @@ Context:
|
||||
<span class="separator">·</span>
|
||||
<span>Updated <span title="{{ object.last_updated }}">{{ object.last_updated|timesince }}</span> ago</span>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% endblock subtitle %}
|
||||
|
||||
{% block controls %}
|
||||
{# Clone/Edit/Delete Buttons #}
|
||||
|
Reference in New Issue
Block a user