Переглянути джерело

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
Jeremy Stretch 2 роки тому
батько
коміт
975a647d9a

+ 21 - 0
netbox/core/filtersets.py

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

+ 7 - 0
netbox/core/forms/filtersets.py

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

+ 117 - 1
netbox/core/forms/model_forms.py

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

+ 1 - 1
netbox/core/management/commands/clearcache.py

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

+ 31 - 0
netbox/core/migrations/0009_configrevision.py

@@ -0,0 +1,31 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('core', '0008_contenttype_proxy'),
+    ]
+
+    operations = [
+        migrations.SeparateDatabaseAndState(
+            state_operations=[
+                migrations.CreateModel(
+                    name='ConfigRevision',
+                    fields=[
+                        ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
+                        ('created', models.DateTimeField(auto_now_add=True)),
+                        ('comment', models.CharField(blank=True, max_length=200)),
+                        ('data', models.JSONField(blank=True, null=True)),
+                    ],
+                    options={
+                        'verbose_name': 'config revision',
+                        'verbose_name_plural': 'config revisions',
+                        'ordering': ['-created'],
+                    },
+                ),
+            ],
+            # Table will be renamed from extras_configrevision in extras/0101_move_configrevision
+            database_operations=[],
+        ),
+    ]

+ 1 - 0
netbox/core/models/__init__.py

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

+ 66 - 0
netbox/core/models/config.py

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

+ 11 - 0
netbox/core/signals.py

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

+ 1 - 0
netbox/core/tables/__init__.py

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

+ 33 - 0
netbox/core/tables/config.py

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

+ 7 - 0
netbox/core/urls.py

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

+ 69 - 4
netbox/core/views.py

@@ -1,12 +1,13 @@
 from django.contrib import messages
 from django.contrib import messages
-from django.shortcuts import get_object_or_404, redirect
+from django.http import HttpResponseForbidden
+from django.shortcuts import get_object_or_404, redirect, render
+from django.views.generic import View
 
 
-from extras.models import ConfigRevision
-from netbox.config import get_config
+from netbox.config import get_config, PARAMS
 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())

+ 0 - 25
netbox/extras/filtersets.py

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

+ 0 - 7
netbox/extras/forms/filtersets.py

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

+ 0 - 117
netbox/extras/forms/model_forms.py

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

+ 39 - 0
netbox/extras/migrations/0101_move_configrevision.py

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

+ 1 - 59
netbox/extras/models/models.py

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

+ 1 - 13
netbox/extras/signals.py

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

+ 0 - 26
netbox/extras/tables/tables.py

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

+ 0 - 7
netbox/extras/urls.py

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

+ 0 - 69
netbox/extras/views.py

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

+ 1 - 1
netbox/netbox/config/__init__.py

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

+ 3 - 3
netbox/netbox/navigation/menu.py

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

+ 1 - 1
netbox/netbox/tests/test_config.py

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

+ 3 - 3
netbox/templates/extras/configrevision.html → netbox/templates/core/configrevision.html

@@ -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 %}
-        {% 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 %}
         {% 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>

+ 3 - 3
netbox/templates/extras/configrevision_restore.html → netbox/templates/core/configrevision_restore.html

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