Move ConfigRevision resources from extras to core

This commit is contained in:
Jeremy Stretch 2023-11-22 12:44:42 -05:00
parent c4d87efdeb
commit b567dfd09e
19 changed files with 279 additions and 283 deletions

View File

@ -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)
)

View File

@ -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')),
)

View File

@ -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 '&mdash;')
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

View File

@ -51,7 +51,7 @@ class ConfigRevision(models.Model):
def get_absolute_url(self): def get_absolute_url(self):
if not self.pk: if not self.pk:
return reverse('core:config') # Default config view return reverse('core:config') # Default config view
return reverse('extras:configrevision', args=[self.pk]) return reverse('core:configrevision', args=[self.pk])
def activate(self): def activate(self):
""" """

View File

@ -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()

View File

@ -1,2 +1,3 @@
from .config import *
from .data import * from .data import *
from .jobs import * from .jobs import *

View 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')

View File

@ -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'),

View File

@ -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 core.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())

View File

@ -4,7 +4,7 @@ from django.contrib.contenttypes.models import ContentType
from django.db.models import Q from django.db.models import Q
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from core.models import ConfigRevision, DataSource from core.models import DataSource
from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
@ -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)
)

View File

@ -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')),
)

View File

@ -1,16 +1,14 @@
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 _
from core.forms.mixins import SyncedDataMixin from core.forms.mixins import SyncedDataMixin
from core.models import ConfigRevision, ContentType 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 '&mdash;')
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

View File

@ -8,7 +8,6 @@ from django.dispatch import receiver, Signal
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django_prometheus.models import model_deletes, model_inserts, model_updates from django_prometheus.models import model_deletes, model_inserts, model_updates
from core.models import ConfigRevision
from extras.validators import CustomValidator from extras.validators import CustomValidator
from netbox.config import get_config from netbox.config import get_config
from netbox.context import current_request, webhooks_queue from netbox.context import current_request, webhooks_queue
@ -220,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
# #

View File

@ -4,7 +4,6 @@ import django_tables2 as tables
from django.conf import settings from django.conf import settings
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from core.models import ConfigRevision
from extras.models import * from extras.models import *
from netbox.tables import NetBoxTable, columns from netbox.tables import NetBoxTable, columns
from .template_code import * from .template_code import *
@ -12,7 +11,6 @@ from .template_code import *
__all__ = ( __all__ = (
'BookmarkTable', 'BookmarkTable',
'ConfigContextTable', 'ConfigContextTable',
'ConfigRevisionTable',
'ConfigTemplateTable', 'ConfigTemplateTable',
'CustomFieldChoiceSetTable', 'CustomFieldChoiceSetTable',
'CustomFieldTable', 'CustomFieldTable',
@ -35,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(

View File

@ -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'))),

View File

@ -11,11 +11,10 @@ from django.views.generic import View
from core.choices import JobStatusChoices, ManagedFileRootPathChoices from core.choices import JobStatusChoices, ManagedFileRootPathChoices
from core.forms import ManagedFileForm from core.forms import ManagedFileForm
from core.models import ConfigRevision, Job 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
# #

View File

@ -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
), ),
), ),

View File

@ -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>

View File

@ -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>