Closes #14312: Move ConfigRevision to core (#14328)

* 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:
Jeremy Stretch 2023-11-27 16:09:05 -05:00 committed by GitHub
parent 18422e1d26
commit 975a647d9a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 417 additions and 340 deletions

View File

@ -9,6 +9,7 @@ from .choices import *
from .models import *
__all__ = (
'ConfigRevisionFilterSet',
'DataFileFilterSet',
'DataSourceFilterSet',
'JobFilterSet',
@ -123,3 +124,23 @@ class JobFilterSet(BaseFilterSet):
Q(user__username__icontains=value) |
Q(name__icontains=value)
)
class ConfigRevisionFilterSet(BaseFilterSet):
q = django_filters.CharFilter(
method='search',
label=_('Search'),
)
class Meta:
model = ConfigRevision
fields = [
'id',
]
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(comment__icontains=value)
)

View File

@ -12,6 +12,7 @@ from utilities.forms.fields import ContentTypeChoiceField, DynamicModelMultipleC
from utilities.forms.widgets import APISelectMultiple, DateTimePicker
__all__ = (
'ConfigRevisionFilterForm',
'DataFileFilterForm',
'DataSourceFilterForm',
'JobFilterForm',
@ -123,3 +124,9 @@ class JobFilterForm(SavedFiltersMixin, FilterForm):
api_url='/api/users/users/',
)
)
class ConfigRevisionFilterForm(SavedFiltersMixin, FilterForm):
fieldsets = (
(None, ('q', 'filter_id')),
)

View File

@ -1,22 +1,28 @@
import copy
import json
from django import forms
from django.conf import settings
from django.utils.translation import gettext_lazy as _
from core.forms.mixins import SyncedDataMixin
from core.models import *
from netbox.config import get_config, PARAMS
from netbox.forms import NetBoxModelForm
from netbox.registry import registry
from netbox.utils import get_data_backend_choices
from utilities.forms import get_field_value
from utilities.forms import BootstrapMixin, get_field_value
from utilities.forms.fields import CommentField
from utilities.forms.widgets import HTMXSelect
__all__ = (
'ConfigRevisionForm',
'DataSourceForm',
'ManagedFileForm',
)
EMPTY_VALUES = ('', None, [], ())
class DataSourceForm(NetBoxModelForm):
type = forms.ChoiceField(
@ -111,3 +117,113 @@ class ManagedFileForm(SyncedDataMixin, NetBoxModelForm):
new_file.write(self.cleaned_data['upload_file'].read())
return super().save(*args, **kwargs)
class ConfigFormMetaclass(forms.models.ModelFormMetaclass):
def __new__(mcs, name, bases, attrs):
# Emulate a declared field for each supported configuration parameter
param_fields = {}
for param in PARAMS:
field_kwargs = {
'required': False,
'label': param.label,
'help_text': param.description,
}
field_kwargs.update(**param.field_kwargs)
param_fields[param.name] = param.field(**field_kwargs)
attrs.update(param_fields)
return super().__new__(mcs, name, bases, attrs)
class ConfigRevisionForm(BootstrapMixin, forms.ModelForm, metaclass=ConfigFormMetaclass):
"""
Form for creating a new ConfigRevision.
"""
fieldsets = (
(_('Rack Elevations'), ('RACK_ELEVATION_DEFAULT_UNIT_HEIGHT', 'RACK_ELEVATION_DEFAULT_UNIT_WIDTH')),
(_('Power'), ('POWERFEED_DEFAULT_VOLTAGE', 'POWERFEED_DEFAULT_AMPERAGE', 'POWERFEED_DEFAULT_MAX_UTILIZATION')),
(_('IPAM'), ('ENFORCE_GLOBAL_UNIQUE', 'PREFER_IPV4')),
(_('Security'), ('ALLOWED_URL_SCHEMES',)),
(_('Banners'), ('BANNER_LOGIN', 'BANNER_MAINTENANCE', 'BANNER_TOP', 'BANNER_BOTTOM')),
(_('Pagination'), ('PAGINATE_COUNT', 'MAX_PAGE_SIZE')),
(_('Validation'), ('CUSTOM_VALIDATORS', 'PROTECTION_RULES')),
(_('User Preferences'), ('DEFAULT_USER_PREFERENCES',)),
(_('Miscellaneous'), (
'MAINTENANCE_MODE', 'GRAPHQL_ENABLED', 'CHANGELOG_RETENTION', 'JOB_RETENTION', 'MAPS_URL',
)),
(_('Config Revision'), ('comment',))
)
class Meta:
model = ConfigRevision
fields = '__all__'
widgets = {
'BANNER_LOGIN': forms.Textarea(attrs={'class': 'font-monospace'}),
'BANNER_MAINTENANCE': forms.Textarea(attrs={'class': 'font-monospace'}),
'BANNER_TOP': forms.Textarea(attrs={'class': 'font-monospace'}),
'BANNER_BOTTOM': forms.Textarea(attrs={'class': 'font-monospace'}),
'CUSTOM_VALIDATORS': forms.Textarea(attrs={'class': 'font-monospace'}),
'PROTECTION_RULES': forms.Textarea(attrs={'class': 'font-monospace'}),
'comment': forms.Textarea(),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Append current parameter values to form field help texts and check for static configurations
config = get_config()
for param in PARAMS:
value = getattr(config, param.name)
# Set the field's initial value, if it can be serialized. (This may not be the case e.g. for
# CUSTOM_VALIDATORS, which may reference Python objects.)
try:
json.dumps(value)
if type(value) in (tuple, list):
self.fields[param.name].initial = ', '.join(value)
else:
self.fields[param.name].initial = value
except TypeError:
pass
# Check whether this parameter is statically configured (e.g. in configuration.py)
if hasattr(settings, param.name):
self.fields[param.name].disabled = True
self.fields[param.name].help_text = _(
'This parameter has been defined statically and cannot be modified.'
)
continue
# Set the field's help text
help_text = self.fields[param.name].help_text
if help_text:
help_text += '<br />' # Line break
help_text += _('Current value: <strong>{value}</strong>').format(value=value or '&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

@ -1,7 +1,7 @@
from django.core.cache import cache
from django.core.management.base import BaseCommand
from extras.models import ConfigRevision
from core.models import ConfigRevision
class Command(BaseCommand):

View 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=[],
),
]

View File

@ -1,3 +1,4 @@
from .config import *
from .contenttypes import *
from .data import *
from .files import *

View 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

View File

@ -1,5 +1,8 @@
from django.db.models.signals import post_save
from django.dispatch import Signal, receiver
from .models import ConfigRevision
__all__ = (
'post_sync',
'pre_sync',
@ -19,3 +22,11 @@ def auto_sync(instance, **kwargs):
for autosync in AutoSyncRecord.objects.filter(datafile__source=instance).prefetch_related('object'):
autosync.object.sync(save=True)
@receiver(post_save, sender=ConfigRevision)
def update_config(sender, instance, **kwargs):
"""
Update the cached NetBox configuration when a new ConfigRevision is created.
"""
instance.activate()

View File

@ -1,2 +1,3 @@
from .config import *
from .data 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>/delete/', views.JobDeleteView.as_view(), name='job_delete'),
# Config revisions
path('config-revisions/', views.ConfigRevisionListView.as_view(), name='configrevision_list'),
path('config-revisions/add/', views.ConfigRevisionEditView.as_view(), name='configrevision_add'),
path('config-revisions/delete/', views.ConfigRevisionBulkDeleteView.as_view(), name='configrevision_bulk_delete'),
path('config-revisions/<int:pk>/restore/', views.ConfigRevisionRestoreView.as_view(), name='configrevision_restore'),
path('config-revisions/<int:pk>/', include(get_model_urls('core', 'configrevision'))),
# Configuration
path('config/', views.ConfigView.as_view(), name='config'),

View File

@ -1,12 +1,13 @@
from django.contrib import messages
from django.shortcuts import get_object_or_404, redirect
from django.http import HttpResponseForbidden
from django.shortcuts import get_object_or_404, redirect, render
from django.views.generic import View
from extras.models import ConfigRevision
from netbox.config import get_config
from netbox.config import get_config, PARAMS
from netbox.views import generic
from netbox.views.generic.base import BaseObjectView
from utilities.utils import count_related
from utilities.views import register_model_view
from utilities.views import ContentTypePermissionRequiredMixin, register_model_view
from . import filtersets, forms, tables
from .models import *
@ -164,3 +165,67 @@ class ConfigView(generic.ObjectView):
return ConfigRevision(
data=get_config().defaults
)
class ConfigRevisionListView(generic.ObjectListView):
queryset = ConfigRevision.objects.all()
filterset = filtersets.ConfigRevisionFilterSet
filterset_form = forms.ConfigRevisionFilterForm
table = tables.ConfigRevisionTable
@register_model_view(ConfigRevision)
class ConfigRevisionView(generic.ObjectView):
queryset = ConfigRevision.objects.all()
class ConfigRevisionEditView(generic.ObjectEditView):
queryset = ConfigRevision.objects.all()
form = forms.ConfigRevisionForm
@register_model_view(ConfigRevision, 'delete')
class ConfigRevisionDeleteView(generic.ObjectDeleteView):
queryset = ConfigRevision.objects.all()
class ConfigRevisionBulkDeleteView(generic.BulkDeleteView):
queryset = ConfigRevision.objects.all()
filterset = filtersets.ConfigRevisionFilterSet
table = tables.ConfigRevisionTable
class ConfigRevisionRestoreView(ContentTypePermissionRequiredMixin, View):
def get_required_permission(self):
return 'core.configrevision_edit'
def get(self, request, pk):
candidate_config = get_object_or_404(ConfigRevision, pk=pk)
# Get the current ConfigRevision
config_version = get_config().version
current_config = ConfigRevision.objects.filter(pk=config_version).first()
params = []
for param in PARAMS:
params.append((
param.name,
current_config.data.get(param.name, None),
candidate_config.data.get(param.name, None)
))
return render(request, 'core/configrevision_restore.html', {
'object': candidate_config,
'params': params,
})
def post(self, request, pk):
if not request.user.has_perm('core.configrevision_edit'):
return HttpResponseForbidden()
candidate_config = get_object_or_404(ConfigRevision, pk=pk)
candidate_config.activate()
messages.success(request, f"Restored configuration revision #{pk}")
return redirect(candidate_config.get_absolute_url())

View File

@ -17,7 +17,6 @@ from .models import *
__all__ = (
'BookmarkFilterSet',
'ConfigContextFilterSet',
'ConfigRevisionFilterSet',
'ConfigTemplateFilterSet',
'ContentTypeFilterSet',
'CustomFieldChoiceSetFilterSet',
@ -625,27 +624,3 @@ class ContentTypeFilterSet(django_filters.FilterSet):
Q(app_label__icontains=value) |
Q(model__icontains=value)
)
#
# ConfigRevisions
#
class ConfigRevisionFilterSet(BaseFilterSet):
q = django_filters.CharFilter(
method='search',
label=_('Search'),
)
class Meta:
model = ConfigRevision
fields = [
'id',
]
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(comment__icontains=value)
)

View File

@ -18,7 +18,6 @@ from virtualization.models import Cluster, ClusterGroup, ClusterType
__all__ = (
'ConfigContextFilterForm',
'ConfigRevisionFilterForm',
'ConfigTemplateFilterForm',
'CustomFieldChoiceSetFilterForm',
'CustomFieldFilterForm',
@ -499,9 +498,3 @@ class ObjectChangeFilterForm(SavedFiltersMixin, FilterForm):
api_url='/api/extras/content-types/',
)
)
class ConfigRevisionFilterForm(SavedFiltersMixin, FilterForm):
fieldsets = (
(None, ('q', 'filter_id')),
)

View File

@ -1,7 +1,6 @@
import json
from django import forms
from django.conf import settings
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
@ -10,7 +9,6 @@ from core.models import ContentType
from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
from extras.choices import *
from extras.models import *
from netbox.config import get_config, PARAMS
from netbox.forms import NetBoxModelForm
from tenancy.models import Tenant, TenantGroup
from utilities.forms import BootstrapMixin, add_blank_choice
@ -21,11 +19,9 @@ from utilities.forms.fields import (
from utilities.forms.widgets import ChoicesWidget
from virtualization.models import Cluster, ClusterGroup, ClusterType
__all__ = (
'BookmarkForm',
'ConfigContextForm',
'ConfigRevisionForm',
'ConfigTemplateForm',
'CustomFieldChoiceSetForm',
'CustomFieldForm',
@ -445,116 +441,3 @@ class JournalEntryForm(NetBoxModelForm):
'assigned_object_type': forms.HiddenInput,
'assigned_object_id': forms.HiddenInput,
}
EMPTY_VALUES = ('', None, [], ())
class ConfigFormMetaclass(forms.models.ModelFormMetaclass):
def __new__(mcs, name, bases, attrs):
# Emulate a declared field for each supported configuration parameter
param_fields = {}
for param in PARAMS:
field_kwargs = {
'required': False,
'label': param.label,
'help_text': param.description,
}
field_kwargs.update(**param.field_kwargs)
param_fields[param.name] = param.field(**field_kwargs)
attrs.update(param_fields)
return super().__new__(mcs, name, bases, attrs)
class ConfigRevisionForm(BootstrapMixin, forms.ModelForm, metaclass=ConfigFormMetaclass):
"""
Form for creating a new ConfigRevision.
"""
fieldsets = (
(_('Rack Elevations'), ('RACK_ELEVATION_DEFAULT_UNIT_HEIGHT', 'RACK_ELEVATION_DEFAULT_UNIT_WIDTH')),
(_('Power'), ('POWERFEED_DEFAULT_VOLTAGE', 'POWERFEED_DEFAULT_AMPERAGE', 'POWERFEED_DEFAULT_MAX_UTILIZATION')),
(_('IPAM'), ('ENFORCE_GLOBAL_UNIQUE', 'PREFER_IPV4')),
(_('Security'), ('ALLOWED_URL_SCHEMES',)),
(_('Banners'), ('BANNER_LOGIN', 'BANNER_MAINTENANCE', 'BANNER_TOP', 'BANNER_BOTTOM')),
(_('Pagination'), ('PAGINATE_COUNT', 'MAX_PAGE_SIZE')),
(_('Validation'), ('CUSTOM_VALIDATORS', 'PROTECTION_RULES')),
(_('User Preferences'), ('DEFAULT_USER_PREFERENCES',)),
(_('Miscellaneous'), (
'MAINTENANCE_MODE', 'GRAPHQL_ENABLED', 'CHANGELOG_RETENTION', 'JOB_RETENTION', 'MAPS_URL',
)),
(_('Config Revision'), ('comment',))
)
class Meta:
model = ConfigRevision
fields = '__all__'
widgets = {
'BANNER_LOGIN': forms.Textarea(attrs={'class': 'font-monospace'}),
'BANNER_MAINTENANCE': forms.Textarea(attrs={'class': 'font-monospace'}),
'BANNER_TOP': forms.Textarea(attrs={'class': 'font-monospace'}),
'BANNER_BOTTOM': forms.Textarea(attrs={'class': 'font-monospace'}),
'CUSTOM_VALIDATORS': forms.Textarea(attrs={'class': 'font-monospace'}),
'PROTECTION_RULES': forms.Textarea(attrs={'class': 'font-monospace'}),
'comment': forms.Textarea(),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Append current parameter values to form field help texts and check for static configurations
config = get_config()
for param in PARAMS:
value = getattr(config, param.name)
# Set the field's initial value, if it can be serialized. (This may not be the case e.g. for
# CUSTOM_VALIDATORS, which may reference Python objects.)
try:
json.dumps(value)
if type(value) in (tuple, list):
self.fields[param.name].initial = ', '.join(value)
else:
self.fields[param.name].initial = value
except TypeError:
pass
# Check whether this parameter is statically configured (e.g. in configuration.py)
if hasattr(settings, param.name):
self.fields[param.name].disabled = True
self.fields[param.name].help_text = _(
'This parameter has been defined statically and cannot be modified.'
)
continue
# Set the field's help text
help_text = self.fields[param.name].help_text
if help_text:
help_text += '<br />' # Line break
help_text += _('Current value: <strong>{value}</strong>').format(value=value or '&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

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

View File

@ -3,14 +3,13 @@ import urllib.parse
from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey
from django.core.cache import cache
from django.core.validators import ValidationError
from django.db import models
from django.http import HttpResponse
from django.urls import reverse
from django.utils import timezone
from django.utils.formats import date_format
from django.utils.translation import gettext, gettext_lazy as _
from django.utils.translation import gettext_lazy as _
from rest_framework.utils.encoders import JSONEncoder
from core.models import ContentType
@ -28,7 +27,6 @@ from utilities.utils import clean_html, dict_to_querydict, render_jinja2
__all__ = (
'Bookmark',
'ConfigRevision',
'CustomLink',
'ExportTemplate',
'ImageAttachment',
@ -710,59 +708,3 @@ class Bookmark(models.Model):
raise ValidationError(
_("Bookmarks cannot be assigned to this object type ({type}).").format(type=self.object_type)
)
class ConfigRevision(models.Model):
"""
An atomic revision of NetBox's configuration.
"""
created = models.DateTimeField(
verbose_name=_('created'),
auto_now_add=True
)
comment = models.CharField(
verbose_name=_('comment'),
max_length=200,
blank=True
)
data = models.JSONField(
blank=True,
null=True,
verbose_name=_('configuration data')
)
objects = RestrictedQuerySet.as_manager()
class Meta:
ordering = ['-created']
verbose_name = _('config revision')
verbose_name_plural = _('config revisions')
def __str__(self):
if not self.pk:
return gettext('Default configuration')
if self.is_active:
return gettext('Current configuration')
return gettext('Config revision #{id}').format(id=self.pk)
def __getattr__(self, item):
if item in self.data:
return self.data[item]
return super().__getattribute__(item)
def get_absolute_url(self):
if not self.pk:
return reverse('core:config') # Default config view
return reverse('extras:configrevision', args=[self.pk])
def activate(self):
"""
Cache the configuration data.
"""
cache.set('config', self.data, None)
cache.set('config_version', self.pk, None)
activate.alters_data = True
@property
def is_active(self):
return cache.get('config_version') == self.pk

View File

@ -14,7 +14,7 @@ from netbox.context import current_request, webhooks_queue
from netbox.signals import post_clean
from utilities.exceptions import AbortRequest
from .choices import ObjectChangeActionChoices
from .models import ConfigRevision, CustomField, ObjectChange, TaggedItem
from .models import CustomField, ObjectChange, TaggedItem
from .webhooks import enqueue_object, get_snapshots, serialize_for_webhook
#
@ -219,18 +219,6 @@ def run_delete_validators(sender, instance, **kwargs):
)
#
# Dynamic configuration
#
@receiver(post_save, sender=ConfigRevision)
def update_config(sender, instance, **kwargs):
"""
Update the cached NetBox configuration when a new ConfigRevision is created.
"""
instance.activate()
#
# Tags
#

View File

@ -11,7 +11,6 @@ from .template_code import *
__all__ = (
'BookmarkTable',
'ConfigContextTable',
'ConfigRevisionTable',
'ConfigTemplateTable',
'CustomFieldChoiceSetTable',
'CustomFieldTable',
@ -34,31 +33,6 @@ IMAGEATTACHMENT_IMAGE = '''
{% endif %}
'''
REVISION_BUTTONS = """
{% if not record.is_active %}
<a href="{% url 'extras:configrevision_restore' pk=record.pk %}" class="btn btn-sm btn-primary" title="Restore config">
<i class="mdi mdi-file-restore"></i>
</a>
{% endif %}
"""
class ConfigRevisionTable(NetBoxTable):
is_active = columns.BooleanColumn(
verbose_name=_('Is Active'),
)
actions = columns.ActionsColumn(
actions=('delete',),
extra_buttons=REVISION_BUTTONS
)
class Meta(NetBoxTable.Meta):
model = ConfigRevision
fields = (
'pk', 'id', 'is_active', 'created', 'comment',
)
default_columns = ('pk', 'id', 'is_active', 'created', 'comment')
class CustomFieldTable(NetBoxTable):
name = tables.Column(

View File

@ -98,13 +98,6 @@ urlpatterns = [
path('journal-entries/import/', views.JournalEntryBulkImportView.as_view(), name='journalentry_import'),
path('journal-entries/<int:pk>/', include(get_model_urls('extras', 'journalentry'))),
# Config revisions
path('config-revisions/', views.ConfigRevisionListView.as_view(), name='configrevision_list'),
path('config-revisions/add/', views.ConfigRevisionEditView.as_view(), name='configrevision_add'),
path('config-revisions/delete/', views.ConfigRevisionBulkDeleteView.as_view(), name='configrevision_bulk_delete'),
path('config-revisions/<int:pk>/restore/', views.ConfigRevisionRestoreView.as_view(), name='configrevision_restore'),
path('config-revisions/<int:pk>/', include(get_model_urls('extras', 'configrevision'))),
# Change logging
path('changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'),
path('changelog/<int:pk>/', include(get_model_urls('extras', 'objectchange'))),

View File

@ -15,7 +15,6 @@ from core.models import Job
from core.tables import JobTable
from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm
from extras.dashboard.utils import get_widget_class
from netbox.config import get_config, PARAMS
from netbox.constants import DEFAULT_ACTION_PERMISSIONS
from netbox.views import generic
from utilities.forms import ConfirmationForm, get_field_value
@ -1316,74 +1315,6 @@ class ScriptResultView(ContentTypePermissionRequiredMixin, View):
})
#
# Config Revisions
#
class ConfigRevisionListView(generic.ObjectListView):
queryset = ConfigRevision.objects.all()
filterset = filtersets.ConfigRevisionFilterSet
filterset_form = forms.ConfigRevisionFilterForm
table = tables.ConfigRevisionTable
@register_model_view(ConfigRevision)
class ConfigRevisionView(generic.ObjectView):
queryset = ConfigRevision.objects.all()
class ConfigRevisionEditView(generic.ObjectEditView):
queryset = ConfigRevision.objects.all()
form = forms.ConfigRevisionForm
@register_model_view(ConfigRevision, 'delete')
class ConfigRevisionDeleteView(generic.ObjectDeleteView):
queryset = ConfigRevision.objects.all()
class ConfigRevisionBulkDeleteView(generic.BulkDeleteView):
queryset = ConfigRevision.objects.all()
filterset = filtersets.ConfigRevisionFilterSet
table = tables.ConfigRevisionTable
class ConfigRevisionRestoreView(ContentTypePermissionRequiredMixin, View):
def get_required_permission(self):
return 'extras.configrevision_edit'
def get(self, request, pk):
candidate_config = get_object_or_404(ConfigRevision, pk=pk)
# Get the current ConfigRevision
config_version = get_config().version
current_config = ConfigRevision.objects.filter(pk=config_version).first()
params = []
for param in PARAMS:
params.append((
param.name,
current_config.data.get(param.name, None),
candidate_config.data.get(param.name, None)
))
return render(request, 'extras/configrevision_restore.html', {
'object': candidate_config,
'params': params,
})
def post(self, request, pk):
if not request.user.has_perm('extras.configrevision_edit'):
return HttpResponseForbidden()
candidate_config = get_object_or_404(ConfigRevision, pk=pk)
candidate_config.activate()
messages.success(request, f"Restored configuration revision #{pk}")
return redirect(candidate_config.get_absolute_url())
#
# Markdown
#

View File

@ -74,7 +74,7 @@ class Config:
def _populate_from_db(self):
"""Cache data from latest ConfigRevision, then populate from cache"""
from extras.models import ConfigRevision
from core.models import ConfigRevision
try:
revision = ConfigRevision.objects.last()

View File

@ -424,13 +424,13 @@ ADMIN_MENU = Menu(
MenuItem(
link='core:config',
link_text=_('Current Config'),
permissions=['extras.view_configrevision'],
permissions=['core.view_configrevision'],
staff_only=True
),
MenuItem(
link='extras:configrevision_list',
link='core:configrevision_list',
link_text=_('Config Revisions'),
permissions=['extras.view_configrevision'],
permissions=['core.view_configrevision'],
staff_only=True
),
),

View File

@ -2,7 +2,7 @@ from django.conf import settings
from django.core.cache import cache
from django.test import override_settings, TestCase
from extras.models import ConfigRevision
from core.models import ConfigRevision
from netbox.config import clear_config, get_config

View File

@ -14,11 +14,11 @@
<div class="controls">
<div class="control-group">
{% plugin_buttons object %}
{% if not object.pk or object.is_active and perms.extras.add_configrevision %}
{% url 'extras:configrevision_add' as edit_url %}
{% if not object.pk or object.is_active and perms.core.add_configrevision %}
{% url 'core:configrevision_add' as edit_url %}
{% include "buttons/edit.html" with url=edit_url %}
{% endif %}
{% if object.pk and not object.is_active and perms.extras.delete_configrevision %}
{% if object.pk and not object.is_active and perms.core.delete_configrevision %}
{% delete_button object %}
{% endif %}
</div>

View File

@ -18,8 +18,8 @@
<div class="col col-md-12">
<nav class="breadcrumb-container px-3" aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{% url 'extras:configrevision_list' %}">{% trans "Config revisions" %}</a></li>
<li class="breadcrumb-item"><a href="{% url 'extras:configrevision' pk=object.pk %}">{{ object }}</a></li>
<li class="breadcrumb-item"><a href="{% url 'core:configrevision_list' %}">{% trans "Config revisions" %}</a></li>
<li class="breadcrumb-item"><a href="{% url 'core:configrevision' pk=object.pk %}">{{ object }}</a></li>
</ol>
</nav>
</div>
@ -77,7 +77,7 @@
<div class="controls">
<div class="control-group">
<button type="submit" name="restore" class="btn btn-primary">{% trans "Restore" %}</button>
<a href="{% url 'extras:configrevision_list' %}" id="cancel" name="cancel" class="btn btn-outline-danger">{% trans "Cancel" %}</a>
<a href="{% url 'core:configrevision_list' %}" id="cancel" name="cancel" class="btn btn-outline-danger">{% trans "Cancel" %}</a>
</div>
</div>
</div>