Jelajahi Sumber

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 tahun lalu
induk
melakukan
975a647d9a

+ 21 - 0
netbox/core/filtersets.py

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

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

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

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

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

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

+ 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 .data 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 .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()

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

@@ -1,2 +1,3 @@
+from .config import *
 from .data 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>/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'),
 

+ 69 - 4
netbox/core/views.py

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

+ 0 - 25
netbox/extras/filtersets.py

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

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

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

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

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

+ 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.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

+ 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 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
 #

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

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

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

+ 0 - 69
netbox/extras/views.py

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

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

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

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

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

+ 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.test import override_settings, TestCase
 
-from extras.models import ConfigRevision
+from core.models import ConfigRevision
 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="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>

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

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