فهرست منبع

12591 config params admin (#12904)

* 12591 initial commit

* 12591 detail view

* 12591 add/edit view

* 12591 edit button

* 12591 base views and forms

* 12591 form cleanup

* 12591 form cleanup

* 12591 form cleanup

* 12591 review changes

* 12591 move check for restrictedqueryset

* 12591 restore view

* 12591 restore page styling

* 12591 remove admin

* Remove edit view for ConfigRevision instances

* Order ConfigRevisions by creation time

* Correct permission name

* Use RestrictedQuerySet for ConfigRevision

* Fix redirect URL

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
Arthur Hanson 2 سال پیش
والد
کامیت
148278a74a

+ 1 - 128
netbox/extras/admin.py

@@ -1,129 +1,2 @@
-from django.contrib import admin
-from django.shortcuts import get_object_or_404, redirect
-from django.template.response import TemplateResponse
-from django.urls import path, reverse
-from django.utils.html import format_html
-
-from netbox.config import get_config, PARAMS
+# TODO: Removing this import triggers an import loop due to how form mixins are currently organized
 from .forms import ConfigRevisionForm
-from .models import ConfigRevision
-
-
-@admin.register(ConfigRevision)
-class ConfigRevisionAdmin(admin.ModelAdmin):
-    fieldsets = [
-        ('Rack Elevations', {
-            'fields': ('RACK_ELEVATION_DEFAULT_UNIT_HEIGHT', 'RACK_ELEVATION_DEFAULT_UNIT_WIDTH'),
-        }),
-        ('Power', {
-            'fields': ('POWERFEED_DEFAULT_VOLTAGE', 'POWERFEED_DEFAULT_AMPERAGE', 'POWERFEED_DEFAULT_MAX_UTILIZATION')
-        }),
-        ('IPAM', {
-            'fields': ('ENFORCE_GLOBAL_UNIQUE', 'PREFER_IPV4'),
-        }),
-        ('Security', {
-            'fields': ('ALLOWED_URL_SCHEMES',),
-        }),
-        ('Banners', {
-            'fields': ('BANNER_LOGIN', 'BANNER_MAINTENANCE', 'BANNER_TOP', 'BANNER_BOTTOM'),
-            'classes': ('monospace',),
-        }),
-        ('Pagination', {
-            'fields': ('PAGINATE_COUNT', 'MAX_PAGE_SIZE'),
-        }),
-        ('Validation', {
-            'fields': ('CUSTOM_VALIDATORS',),
-            'classes': ('monospace',),
-        }),
-        ('User Preferences', {
-            'fields': ('DEFAULT_USER_PREFERENCES',),
-        }),
-        ('Miscellaneous', {
-            'fields': ('MAINTENANCE_MODE', 'GRAPHQL_ENABLED', 'CHANGELOG_RETENTION', 'JOB_RETENTION', 'MAPS_URL'),
-        }),
-        ('Config Revision', {
-            'fields': ('comment',),
-        })
-    ]
-    form = ConfigRevisionForm
-    list_display = ('id', 'is_active', 'created', 'comment', 'restore_link')
-    ordering = ('-id',)
-    readonly_fields = ('data',)
-
-    def get_changeform_initial_data(self, request):
-        """
-        Populate initial form data from the most recent ConfigRevision.
-        """
-        latest_revision = ConfigRevision.objects.last()
-        initial = latest_revision.data if latest_revision else {}
-        initial.update(super().get_changeform_initial_data(request))
-
-        return initial
-
-    # Permissions
-
-    def has_add_permission(self, request):
-        # Only superusers may modify the configuration.
-        return request.user.is_superuser
-
-    def has_change_permission(self, request, obj=None):
-        # ConfigRevisions cannot be modified once created.
-        return False
-
-    def has_delete_permission(self, request, obj=None):
-        # Only inactive ConfigRevisions may be deleted (must be superuser).
-        return request.user.is_superuser and (
-            obj is None or not obj.is_active()
-        )
-
-    # List display methods
-
-    def restore_link(self, obj):
-        if obj.is_active():
-            return ''
-        return format_html(
-            '<a href="{url}" class="button">Restore</a>',
-            url=reverse('admin:extras_configrevision_restore', args=(obj.pk,))
-        )
-    restore_link.short_description = "Actions"
-
-    # URLs
-
-    def get_urls(self):
-        urls = [
-            path('<int:pk>/restore/', self.admin_site.admin_view(self.restore), name='extras_configrevision_restore'),
-        ]
-
-        return urls + super().get_urls()
-
-    # Views
-
-    def restore(self, request, pk):
-        # Get the ConfigRevision being restored
-        candidate_config = get_object_or_404(ConfigRevision, pk=pk)
-
-        if request.method == 'POST':
-            candidate_config.activate()
-            self.message_user(request, f"Restored configuration revision #{pk}")
-
-            return redirect(reverse('admin:extras_configrevision_changelist'))
-
-        # Get the current ConfigRevision
-        config_version = get_config().version
-        current_config = ConfigRevision.objects.filter(pk=config_version).first()
-
-        params = []
-        for param in PARAMS:
-            params.append((
-                param.name,
-                current_config.data.get(param.name, None),
-                candidate_config.data.get(param.name, None)
-            ))
-
-        context = self.admin_site.each_context(request)
-        context.update({
-            'object': candidate_config,
-            'params': params,
-        })
-
-        return TemplateResponse(request, 'admin/extras/configrevision/restore.html', context)

+ 25 - 0
netbox/extras/filtersets.py

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

+ 0 - 1
netbox/extras/forms/__init__.py

@@ -4,5 +4,4 @@ from .bulk_edit import *
 from .bulk_import import *
 from .misc import *
 from .mixins import *
-from .config import *
 from .scripts import *

+ 0 - 82
netbox/extras/forms/config.py

@@ -1,82 +0,0 @@
-from django import forms
-from django.conf import settings
-
-from netbox.config import get_config, PARAMS
-
-__all__ = (
-    'ConfigRevisionForm',
-)
-
-
-EMPTY_VALUES = ('', None, [], ())
-
-
-class FormMetaclass(forms.models.ModelFormMetaclass):
-
-    def __new__(mcs, name, bases, attrs):
-
-        # Emulate a declared field for each supported configuration parameter
-        param_fields = {}
-        for param in PARAMS:
-            field_kwargs = {
-                'required': False,
-                'label': param.label,
-                'help_text': param.description,
-            }
-            field_kwargs.update(**param.field_kwargs)
-            param_fields[param.name] = param.field(**field_kwargs)
-        attrs.update(param_fields)
-
-        return super().__new__(mcs, name, bases, attrs)
-
-
-class ConfigRevisionForm(forms.BaseModelForm, metaclass=FormMetaclass):
-    """
-    Form for creating a new ConfigRevision.
-    """
-    class Meta:
-        widgets = {
-            'comment': forms.Textarea(),
-        }
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        # Append current parameter values to form field help texts and check for static configurations
-        config = get_config()
-        for param in PARAMS:
-            value = getattr(config, param.name)
-            is_static = hasattr(settings, param.name)
-            if value:
-                help_text = self.fields[param.name].help_text
-                if help_text:
-                    help_text += '<br />'  # Line break
-                help_text += f'Current value: <strong>{value}</strong>'
-                if is_static:
-                    help_text += ' (defined statically)'
-                elif value == param.default:
-                    help_text += ' (default)'
-                self.fields[param.name].help_text = help_text
-            if is_static:
-                self.fields[param.name].disabled = True
-
-    def save(self, commit=True):
-        instance = super().save(commit=False)
-
-        # Populate JSON data on the instance
-        instance.data = self.render_json()
-
-        if commit:
-            instance.save()
-
-        return instance
-
-    def render_json(self):
-        json = {}
-
-        # Iterate through each field and populate non-empty values
-        for field_name in self.declared_fields:
-            if field_name in self.cleaned_data and self.cleaned_data[field_name] not in EMPTY_VALUES:
-                json[field_name] = self.cleaned_data[field_name]
-
-        return json

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

@@ -18,6 +18,7 @@ from .mixins import SavedFiltersMixin
 
 __all__ = (
     'ConfigContextFilterForm',
+    'ConfigRevisionFilterForm',
     'ConfigTemplateFilterForm',
     'CustomFieldFilterForm',
     'CustomLinkFilterForm',
@@ -444,3 +445,9 @@ class ObjectChangeFilterForm(SavedFiltersMixin, FilterForm):
             api_url='/api/extras/content-types/',
         )
     )
+
+
+class ConfigRevisionFilterForm(SavedFiltersMixin, FilterForm):
+    fieldsets = (
+        (None, ('q', 'filter_id')),
+    )

+ 101 - 1
netbox/extras/forms/model_forms.py

@@ -1,6 +1,7 @@
 import json
 
 from django import forms
+from django.conf import settings
 from django.db.models import Q
 from django.contrib.contenttypes.models import ContentType
 from django.utils.translation import gettext as _
@@ -10,17 +11,20 @@ from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site
 from extras.choices import *
 from extras.models import *
 from extras.utils import FeatureQuery
+from netbox.config import get_config, PARAMS
 from netbox.forms import NetBoxModelForm
 from tenancy.models import Tenant, TenantGroup
-from utilities.forms import BootstrapMixin, add_blank_choice
+from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, BootstrapMixin, add_blank_choice
 from utilities.forms.fields import (
     CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, JSONField,
     SlugField,
 )
 from virtualization.models import Cluster, ClusterGroup, ClusterType
 
+
 __all__ = (
     'ConfigContextForm',
+    'ConfigRevisionForm',
     'ConfigTemplateForm',
     'CustomFieldForm',
     'CustomLinkForm',
@@ -374,3 +378,99 @@ class JournalEntryForm(NetBoxModelForm):
             'assigned_object_type': forms.HiddenInput,
             'assigned_object_id': forms.HiddenInput,
         }
+
+
+EMPTY_VALUES = ('', None, [], ())
+
+
+class ConfigFormMetaclass(forms.models.ModelFormMetaclass):
+
+    def __new__(mcs, name, bases, attrs):
+
+        # Emulate a declared field for each supported configuration parameter
+        param_fields = {}
+        for param in PARAMS:
+            field_kwargs = {
+                'required': False,
+                'label': param.label,
+                'help_text': param.description,
+            }
+            field_kwargs.update(**param.field_kwargs)
+            param_fields[param.name] = param.field(**field_kwargs)
+        attrs.update(param_fields)
+
+        return super().__new__(mcs, name, bases, attrs)
+
+
+class ConfigRevisionForm(BootstrapMixin, forms.ModelForm, metaclass=ConfigFormMetaclass):
+    """
+    Form for creating a new ConfigRevision.
+    """
+
+    fieldsets = (
+        ('Rack Elevations', ('RACK_ELEVATION_DEFAULT_UNIT_HEIGHT', 'RACK_ELEVATION_DEFAULT_UNIT_WIDTH')),
+        ('Power', ('POWERFEED_DEFAULT_VOLTAGE', 'POWERFEED_DEFAULT_AMPERAGE', 'POWERFEED_DEFAULT_MAX_UTILIZATION')),
+        ('IPAM', ('ENFORCE_GLOBAL_UNIQUE', 'PREFER_IPV4')),
+        ('Security', ('ALLOWED_URL_SCHEMES',)),
+        ('Banners', ('BANNER_LOGIN', 'BANNER_MAINTENANCE', 'BANNER_TOP', 'BANNER_BOTTOM')),
+        ('Pagination', ('PAGINATE_COUNT', 'MAX_PAGE_SIZE')),
+        ('Validation', ('CUSTOM_VALIDATORS',)),
+        ('User Preferences', ('DEFAULT_USER_PREFERENCES',)),
+        ('Miscellaneous', ('MAINTENANCE_MODE', 'GRAPHQL_ENABLED', 'CHANGELOG_RETENTION', 'JOB_RETENTION', 'MAPS_URL')),
+        ('Config Revision', ('comment',))
+    )
+
+    class Meta:
+        model = ConfigRevision
+        fields = '__all__'
+        widgets = {
+            'BANNER_LOGIN': forms.Textarea(attrs={'class': 'font-monospace'}),
+            'BANNER_MAINTENANCE': forms.Textarea(attrs={'class': 'font-monospace'}),
+            'BANNER_TOP': forms.Textarea(attrs={'class': 'font-monospace'}),
+            'BANNER_BOTTOM': forms.Textarea(attrs={'class': 'font-monospace'}),
+            'CUSTOM_VALIDATORS': forms.Textarea(attrs={'class': 'font-monospace'}),
+            'comment': forms.Textarea(),
+        }
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Append current parameter values to form field help texts and check for static configurations
+        config = get_config()
+        for param in PARAMS:
+            value = getattr(config, param.name)
+            is_static = hasattr(settings, param.name)
+            if value:
+                help_text = self.fields[param.name].help_text
+                if help_text:
+                    help_text += '<br />'  # Line break
+                help_text += f'Current value: <strong>{value}</strong>'
+                if is_static:
+                    help_text += ' (defined statically)'
+                elif value == param.default:
+                    help_text += ' (default)'
+                self.fields[param.name].help_text = help_text
+                self.fields[param.name].initial = value
+            if is_static:
+                self.fields[param.name].disabled = True
+
+    def save(self, commit=True):
+        instance = super().save(commit=False)
+
+        # Populate JSON data on the instance
+        instance.data = self.render_json()
+
+        if commit:
+            instance.save()
+
+        return instance
+
+    def render_json(self):
+        json = {}
+
+        # Iterate through each field and populate non-empty values
+        for field_name in self.declared_fields:
+            if field_name in self.cleaned_data and self.cleaned_data[field_name] not in EMPTY_VALUES:
+                json[field_name] = self.cleaned_data[field_name]
+
+        return json

+ 17 - 0
netbox/extras/migrations/0093_configrevision_ordering.py

@@ -0,0 +1,17 @@
+# Generated by Django 4.1.9 on 2023-06-22 14:14
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('extras', '0092_delete_jobresult'),
+    ]
+
+    operations = [
+        migrations.AlterModelOptions(
+            name='configrevision',
+            options={'ordering': ['-created']},
+        ),
+    ]

+ 8 - 0
netbox/extras/models/models.py

@@ -612,6 +612,11 @@ class ConfigRevision(models.Model):
         verbose_name='Configuration data'
     )
 
+    objects = RestrictedQuerySet.as_manager()
+
+    class Meta:
+        ordering = ['-created']
+
     def __str__(self):
         return f'Config revision #{self.pk} ({self.created})'
 
@@ -620,6 +625,9 @@ class ConfigRevision(models.Model):
             return self.data[item]
         return super().__getattribute__(item)
 
+    def get_absolute_url(self):
+        return reverse('extras:configrevision', args=[self.pk])
+
     def activate(self):
         """
         Cache the configuration data.

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

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

+ 8 - 1
netbox/extras/urls.py

@@ -85,6 +85,13 @@ urlpatterns = [
     path('journal-entries/import/', views.JournalEntryBulkImportView.as_view(), name='journalentry_import'),
     path('journal-entries/<int:pk>/', include(get_model_urls('extras', 'journalentry'))),
 
+    # Config revisions
+    path('config-revisions/', views.ConfigRevisionListView.as_view(), name='configrevision_list'),
+    path('config-revisions/add/', views.ConfigRevisionEditView.as_view(), name='configrevision_add'),
+    path('config-revisions/delete/', views.ConfigRevisionBulkDeleteView.as_view(), name='configrevision_bulk_delete'),
+    path('config-revisions/<int:pk>/restore/', views.ConfigRevisionRestoreView.as_view(), name='configrevision_restore'),
+    path('config-revisions/<int:pk>/', include(get_model_urls('extras', 'configrevision'))),
+
     # Change logging
     path('changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'),
     path('changelog/<int:pk>/', include(get_model_urls('extras', 'objectchange'))),
@@ -114,5 +121,5 @@ urlpatterns = [
     path('scripts/<str:module>/<str:name>/jobs/', views.ScriptJobsView.as_view(), name='script_jobs'),
 
     # Markdown
-    path('render/markdown/', views.RenderMarkdownView.as_view(), name="render_markdown")
+    path('render/markdown/', views.RenderMarkdownView.as_view(), name="render_markdown"),
 ]

+ 69 - 0
netbox/extras/views.py

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

+ 17 - 0
netbox/netbox/navigation/menu.py

@@ -346,6 +346,22 @@ OPERATIONS_MENU = Menu(
     ),
 )
 
+ADMIN_MENU = Menu(
+    label=_('Admin'),
+    icon_class='mdi mdi-account-multiple',
+    groups=(
+        MenuGroup(
+            label=_('Configuration'),
+            items=(
+                MenuItem(
+                    link='extras:configrevision_list',
+                    link_text=_('Config Revisions'),
+                    permissions=['extras.view_configrevision']
+                ),
+            ),
+        ),
+    ),
+)
 
 MENUS = [
     ORGANIZATION_MENU,
@@ -360,6 +376,7 @@ MENUS = [
     PROVISIONING_MENU,
     CUSTOMIZATION_MENU,
     OPERATIONS_MENU,
+    ADMIN_MENU,
 ]
 
 #

+ 0 - 37
netbox/templates/admin/extras/configrevision/restore.html

@@ -1,37 +0,0 @@
-{% extends "admin/base_site.html" %}
-{% load static %}
-
-{% block content %}
-  <p>Restore configuration #{{ object.pk }} from <strong>{{ object.created }}</strong>?</p>
-
-  <table>
-    <thead>
-      <tr>
-        <th>Parameter</th>
-        <th>Current Value</th>
-        <th>New Value</th>
-        <th></th>
-      </tr>
-    </thead>
-    <tbody>
-      {% for param, current, new in params %}
-        <tr{% if current != new %} style="color: #d7a50d"{% endif %}>
-          <td>{{ param }}</td>
-          <td>{{ current }}</td>
-          <td>{{ new }}</td>
-          <td>{% if current != new %}<img src="{% static 'admin/img/icon-changelink.svg' %}" alt="*" title="Changed">{% endif %}</td>
-        </tr>
-      {% endfor %}
-    </tbody>
-  </table>
-
-  <form method="post">
-    {% csrf_token %}
-    <div class="submit-row" style="margin-top: 20px">
-      <input type="submit" name="restore" value="Restore" class="default" style="float: left" />
-      <a href="{% url 'admin:extras_configrevision_changelist' %}" style="float: left; margin: 2px 0; padding: 10px 15px">Cancel</a>
-    </div>
-  </form>
-{% endblock content %}
-
-

+ 200 - 0
netbox/templates/extras/configrevision.html

@@ -0,0 +1,200 @@
+{% extends 'generic/object.html' %}
+{% load buttons %}
+{% load custom_links %}
+{% load helpers %}
+{% load perms %}
+{% load plugins %}
+{% load static %}
+
+{% block breadcrumbs %}
+{% endblock %}
+
+{% block controls %}
+  <div class="controls">
+    <div class="control-group">
+      {% plugin_buttons object %}
+    </div>
+    <div class="control-group">
+      {% custom_links object %}
+    </div>
+  </div>
+{% endblock controls %}
+
+{% block content %}
+  <div class="row">
+    <div class="col col-md-6">
+      <div class="card">
+        <h5 class="card-header">Rack Elevation</h5>
+        <div class="card-body">
+          <table class="table table-hover attr-table">
+            <tr>
+              <th scope="row">Rack elevation default unit height:</th>
+              <td>{{ object.data.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT }}</td>
+            </tr>
+            <tr>
+              <th scope="row">Rack elevation default unit width:</th>
+              <td>{{ object.data.RACK_ELEVATION_DEFAULT_UNIT_WIDTH }}</td>
+            </tr>
+          </table>
+        </div>
+      </div>
+
+      <div class="card">
+        <h5 class="card-header">Power</h5>
+        <div class="card-body">
+          <table class="table table-hover attr-table">
+            <tr>
+              <th scope="row">Powerfeed default voltage:</th>
+              <td>{{ object.data.POWERFEED_DEFAULT_VOLTAGE }}</td>
+            </tr>
+            <tr>
+              <th scope="row">Powerfeed default amperage:</th>
+              <td>{{ object.data.POWERFEED_DEFAULT_AMPERAGE }}</td>
+            </tr>
+            <tr>
+              <th scope="row">Powerfeed default max utilization:</th>
+              <td>{{ object.data.POWERFEED_DEFAULT_MAX_UTILIZATION }}</td>
+            </tr>
+          </table>
+        </div>
+      </div>
+
+      <div class="card">
+        <h5 class="card-header">IPAM</h5>
+        <div class="card-body">
+          <table class="table table-hover attr-table">
+            <tr>
+              <th scope="row">IPAM enforce global unique:</th>
+              <td>{{ object.data.ENFORCE_GLOBAL_UNIQUE }}</td>
+            </tr>
+            <tr>
+              <th scope="row">IPAM prefer IPV4:</th>
+              <td>{{ object.data.PREFER_IPV4 }}</td>
+            </tr>
+          </table>
+        </div>
+      </div>
+
+      <div class="card">
+        <h5 class="card-header">Security</h5>
+        <div class="card-body">
+          <table class="table table-hover attr-table">
+            <tr>
+              <th scope="row">Allowed URL schemes:</th>
+              <td>{{ object.data.ALLOWED_URL_SCHEMES }}</td>
+            </tr>
+          </table>
+        </div>
+      </div>
+
+      <div class="card">
+        <h5 class="card-header">Banners</h5>
+        <div class="card-body">
+          <table class="table table-hover attr-table">
+            <tr>
+              <th scope="row">Login banner:</th>
+              <td>{{ object.data.BANNER_LOGIN }}</td>
+            </tr>
+            <tr>
+              <th scope="row">Maintenance banner:</th>
+              <td>{{ object.data.BANNER_MAINTENANCE }}</td>
+            </tr>
+            <tr>
+              <th scope="row">Top banner:</th>
+              <td>{{ object.data.BANNER_TOP }}</td>
+            </tr>
+            <tr>
+              <th scope="row">Bottom banner:</th>
+              <td>{{ object.data.BANNER_BOTTOM }}</td>
+            </tr>
+          </table>
+        </div>
+      </div>
+
+
+    </div>
+    <div class="col col-md-6">
+
+      <div class="card">
+        <h5 class="card-header">Pagination</h5>
+        <div class="card-body">
+          <table class="table table-hover attr-table">
+            <tr>
+              <th scope="row">Paginate count:</th>
+              <td>{{ object.data.PAGINATE_COUNT }}</td>
+            </tr>
+            <tr>
+              <th scope="row">Max page size:</th>
+              <td>{{ object.data.MAX_PAGE_SIZE }}</td>
+            </tr>
+          </table>
+        </div>
+      </div>
+
+      <div class="card">
+        <h5 class="card-header">Validation</h5>
+        <div class="card-body">
+          <table class="table table-hover attr-table">
+            <tr>
+              <th scope="row">Custom validators:</th>
+              <td>{{ object.data.CUSTOM_VALIDATORS }}</td>
+            </tr>
+          </table>
+        </div>
+      </div>
+
+      <div class="card">
+        <h5 class="card-header">User Preferences</h5>
+        <div class="card-body">
+          <table class="table table-hover attr-table">
+            <tr>
+              <th scope="row">Default user preferences:</th>
+              <td>{{ object.data.DEFAULT_USER_PREFERENCES }}</td>
+            </tr>
+          </table>
+        </div>
+      </div>
+
+      <div class="card">
+        <h5 class="card-header">Miscellaneous</h5>
+        <div class="card-body">
+          <table class="table table-hover attr-table">
+            <tr>
+              <th scope="row">Maintenance mode:</th>
+              <td>{{ object.data.MAINTENANCE_MODE }}</td>
+            </tr>
+            <tr>
+              <th scope="row">GraphQL enabled:</th>
+              <td>{{ object.data.GRAPHQL_ENABLED }}</td>
+            </tr>
+            <tr>
+              <th scope="row">Changelog retention:</th>
+              <td>{{ object.data.CHANGELOG_RETENTION }}</td>
+            </tr>
+            <tr>
+              <th scope="row">Job retention:</th>
+              <td>{{ object.data.JOB_RETENTION }}</td>
+            </tr>
+            <tr>
+              <th scope="row">Maps URL:</th>
+              <td>{{ object.data.MAPS_URL }}</td>
+            </tr>
+          </table>
+        </div>
+      </div>
+
+      <div class="card">
+        <h5 class="card-header">Config Revision</h5>
+        <div class="card-body">
+          <table class="table table-hover attr-table">
+            <tr>
+              <th scope="row">Comment:</th>
+              <td>{{ object.comment }}</td>
+            </tr>
+          </table>
+        </div>
+      </div>
+
+    </div>
+  </div>
+{% endblock %}

+ 88 - 0
netbox/templates/extras/configrevision_restore.html

@@ -0,0 +1,88 @@
+{% extends 'base/layout.html' %}
+{% load helpers %}
+{% load buttons %}
+{% load perms %}
+{% load static %}
+
+{% block title %}Restore: {{ object }}{% endblock %}
+
+{% block subtitle %}
+  <div class="object-subtitle">
+    <span>Created {{ object.created|annotated_date }}</span>
+  </div>
+{% endblock %}
+
+{% block header %}
+  <div class="row noprint">
+    <div class="col col-md-12">
+      <nav class="breadcrumb-container px-3" aria-label="breadcrumb">
+        <ol class="breadcrumb">
+          <li class="breadcrumb-item"><a href="{% url 'extras:configrevision_list' %}">Config revisions</a></li>
+          <li class="breadcrumb-item"><a href="{% url 'extras:configrevision' pk=object.pk %}">{{ object }}</a></li>
+        </ol>
+      </nav>
+    </div>
+  </div>
+  {{ block.super }}
+{% endblock header %}
+
+{% block controls %}
+  <div class="controls">
+    <div class="control-group">
+      {% if request.user|can_delete:job %}
+        {% delete_button job %}
+      {% endif %}
+    </div>
+  </div>
+{% endblock controls %}
+
+{% block tabs %}
+  <ul class="nav nav-tabs px-3" role="tablist">
+    <li class="nav-item" role="presentation">
+      <a href="#log" role="tab" data-bs-toggle="tab" class="nav-link active">Restore</a>
+    </li>
+  </ul>
+{% endblock %}
+
+{% block content %}
+  <div class="row">
+    <div class="col-12">
+      <table class="table table-striped table-hover">
+        <thead>
+          <tr>
+            <th scope="col">Parameter</th>
+            <th scope="col">Current Value</th>
+            <th scope="col">New Value</th>
+            <th scope="col"></th>
+          </tr>
+        </thead>
+        <tbody>
+          {% for param, current, new in params %}
+            <tr{% if current != new %} class="table-warning"{% endif %}>
+              <td>{{ param }}</td>
+              <td>{{ current }}</td>
+              <td>{{ new }}</td>
+              <td>{% if current != new %}<img src="{% static 'admin/img/icon-changelink.svg' %}" alt="*" title="Changed">{% endif %}</td>
+            </tr>
+          {% endfor %}
+        </tbody>
+      </table>
+    </div>
+  </div>
+
+  <form method="post">
+    {% csrf_token %}
+    <div class="submit-row" style="margin-top: 20px">
+      <div class="controls">
+        <div class="control-group">
+          <button type="submit" name="restore" class="btn btn-primary">Restore</button>
+          <a href="{% url 'extras:configrevision_list' %}" id="cancel" name="cancel" class="btn btn-outline-danger">Cancel</a>
+        </div>
+      </div>
+    </div>
+  </form>
+
+{% endblock content %}
+
+{% block modals %}
+{% endblock modals %}

+ 2 - 2
netbox/templates/generic/object.html

@@ -38,7 +38,7 @@ Context:
     </div>
   </div>
   {{ block.super }}
-{% endblock %}
+{% endblock header %}
 
 {% block title %}{{ object }}{% endblock %}
 
@@ -48,7 +48,7 @@ Context:
     <span class="separator">&middot;</span>
     <span>Updated <span title="{{ object.last_updated }}">{{ object.last_updated|timesince }}</span> ago</span>
   </div>
-{% endblock %}
+{% endblock subtitle %}
 
 {% block controls %}
   {# Clone/Edit/Delete Buttons #}