diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index 6d1b143706..6e82ffc754 100644 --- a/netbox/extras/admin.py +++ b/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( - 'Restore', - url=reverse('admin:extras_configrevision_restore', args=(obj.pk,)) - ) - restore_link.short_description = "Actions" - - # URLs - - def get_urls(self): - urls = [ - path('/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) diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index 5253ae7b0e..acdca30ccb 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -16,6 +16,7 @@ __all__ = ( 'ConfigContextFilterSet', + 'ConfigRevisionFilterSet', 'ConfigTemplateFilterSet', 'ContentTypeFilterSet', 'CustomFieldFilterSet', @@ -557,3 +558,27 @@ def search(self, queryset, name, value): 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) + ) diff --git a/netbox/extras/forms/__init__.py b/netbox/extras/forms/__init__.py index 0825c9ca72..e203bee46f 100644 --- a/netbox/extras/forms/__init__.py +++ b/netbox/extras/forms/__init__.py @@ -4,5 +4,4 @@ from .bulk_import import * from .misc import * from .mixins import * -from .config import * from .scripts import * diff --git a/netbox/extras/forms/config.py b/netbox/extras/forms/config.py deleted file mode 100644 index 4a7dba6147..0000000000 --- a/netbox/extras/forms/config.py +++ /dev/null @@ -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 += '
' # Line break - help_text += f'Current value: {value}' - 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 diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index fae15d0415..1ff57eae95 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -18,6 +18,7 @@ __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')), + ) diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py index 2f617b682c..621052c96f 100644 --- a/netbox/extras/forms/model_forms.py +++ b/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 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 Meta: '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 += '
' # Line break + help_text += f'Current value: {value}' + 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 diff --git a/netbox/extras/migrations/0093_configrevision_ordering.py b/netbox/extras/migrations/0093_configrevision_ordering.py new file mode 100644 index 0000000000..a4e875e6dc --- /dev/null +++ b/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']}, + ), + ] diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index e95c0aff3c..c8edba20ae 100644 --- a/netbox/extras/models/models.py +++ b/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 @@ def __getattr__(self, item): 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. diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index 9e49245326..e41bc91264 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -9,6 +9,7 @@ __all__ = ( 'ConfigContextTable', + 'ConfigRevisionTable', 'ConfigTemplateTable', 'CustomFieldTable', 'CustomLinkTable', @@ -30,6 +31,29 @@ {% endif %} ''' +REVISION_BUTTONS = """ +{% if not record.is_active %} + + + +{% 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( diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index c4fc3d938f..b3909391a0 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -85,6 +85,13 @@ path('journal-entries/import/', views.JournalEntryBulkImportView.as_view(), name='journalentry_import'), path('journal-entries//', 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//restore/', views.ConfigRevisionRestoreView.as_view(), name='configrevision_restore'), + path('config-revisions//', include(get_model_urls('extras', 'configrevision'))), + # Change logging path('changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'), path('changelog//', include(get_model_urls('extras', 'objectchange'))), @@ -114,5 +121,5 @@ path('scripts///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"), ] diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 6cbadf09d9..9e02b50193 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -14,6 +14,7 @@ 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 @@ def get(self, request, job_pk): }) +# +# 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 # diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index d139546d99..6b10fe99e7 100644 --- a/netbox/netbox/navigation/menu.py +++ b/netbox/netbox/navigation/menu.py @@ -344,6 +344,22 @@ ), ) +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, @@ -358,6 +374,7 @@ PROVISIONING_MENU, CUSTOMIZATION_MENU, OPERATIONS_MENU, + ADMIN_MENU, ] # diff --git a/netbox/templates/admin/extras/configrevision/restore.html b/netbox/templates/admin/extras/configrevision/restore.html deleted file mode 100644 index 4a0eb81a64..0000000000 --- a/netbox/templates/admin/extras/configrevision/restore.html +++ /dev/null @@ -1,37 +0,0 @@ -{% extends "admin/base_site.html" %} -{% load static %} - -{% block content %} -

Restore configuration #{{ object.pk }} from {{ object.created }}?

- - - - - - - - - - - - {% for param, current, new in params %} - - - - - - - {% endfor %} - -
ParameterCurrent ValueNew Value
{{ param }}{{ current }}{{ new }}{% if current != new %}*{% endif %}
- -
- {% csrf_token %} -
- - Cancel -
-
-{% endblock content %} - - diff --git a/netbox/templates/extras/configrevision.html b/netbox/templates/extras/configrevision.html new file mode 100644 index 0000000000..1c7eeb2dd8 --- /dev/null +++ b/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 %} +
+
+ {% plugin_buttons object %} +
+
+ {% custom_links object %} +
+
+{% endblock controls %} + +{% block content %} +
+
+
+
Rack Elevation
+
+ + + + + + + + + +
Rack elevation default unit height:{{ object.data.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT }}
Rack elevation default unit width:{{ object.data.RACK_ELEVATION_DEFAULT_UNIT_WIDTH }}
+
+
+ +
+
Power
+
+ + + + + + + + + + + + + +
Powerfeed default voltage:{{ object.data.POWERFEED_DEFAULT_VOLTAGE }}
Powerfeed default amperage:{{ object.data.POWERFEED_DEFAULT_AMPERAGE }}
Powerfeed default max utilization:{{ object.data.POWERFEED_DEFAULT_MAX_UTILIZATION }}
+
+
+ +
+
IPAM
+
+ + + + + + + + + +
IPAM enforce global unique:{{ object.data.ENFORCE_GLOBAL_UNIQUE }}
IPAM prefer IPV4:{{ object.data.PREFER_IPV4 }}
+
+
+ +
+
Security
+
+ + + + + +
Allowed URL schemes:{{ object.data.ALLOWED_URL_SCHEMES }}
+
+
+ +
+
Banners
+
+ + + + + + + + + + + + + + + + + +
Login banner:{{ object.data.BANNER_LOGIN }}
Maintenance banner:{{ object.data.BANNER_MAINTENANCE }}
Top banner:{{ object.data.BANNER_TOP }}
Bottom banner:{{ object.data.BANNER_BOTTOM }}
+
+
+ + +
+
+ +
+
Pagination
+
+ + + + + + + + + +
Paginate count:{{ object.data.PAGINATE_COUNT }}
Max page size:{{ object.data.MAX_PAGE_SIZE }}
+
+
+ +
+
Validation
+
+ + + + + +
Custom validators:{{ object.data.CUSTOM_VALIDATORS }}
+
+
+ +
+
User Preferences
+
+ + + + + +
Default user preferences:{{ object.data.DEFAULT_USER_PREFERENCES }}
+
+
+ +
+
Miscellaneous
+
+ + + + + + + + + + + + + + + + + + + + + +
Maintenance mode:{{ object.data.MAINTENANCE_MODE }}
GraphQL enabled:{{ object.data.GRAPHQL_ENABLED }}
Changelog retention:{{ object.data.CHANGELOG_RETENTION }}
Job retention:{{ object.data.JOB_RETENTION }}
Maps URL:{{ object.data.MAPS_URL }}
+
+
+ +
+
Config Revision
+
+ + + + + +
Comment:{{ object.comment }}
+
+
+ +
+
+{% endblock %} diff --git a/netbox/templates/extras/configrevision_restore.html b/netbox/templates/extras/configrevision_restore.html new file mode 100644 index 0000000000..ac22f8cbdd --- /dev/null +++ b/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 %} +
+ Created {{ object.created|annotated_date }} +
+{% endblock %} + +{% block header %} +
+ +
+ {{ block.super }} +{% endblock header %} + +{% block controls %} +
+
+ {% if request.user|can_delete:job %} + {% delete_button job %} + {% endif %} +
+
+{% endblock controls %} + +{% block tabs %} + +{% endblock %} + +{% block content %} +
+
+ + + + + + + + + + + {% for param, current, new in params %} + + + + + + + {% endfor %} + +
ParameterCurrent ValueNew Value
{{ param }}{{ current }}{{ new }}{% if current != new %}*{% endif %}
+
+
+ +
+ {% csrf_token %} +
+
+
+ + Cancel +
+
+
+
+ +{% endblock content %} + +{% block modals %} +{% endblock modals %} diff --git a/netbox/templates/generic/object.html b/netbox/templates/generic/object.html index d3a6174551..ebbeb2dfc1 100644 --- a/netbox/templates/generic/object.html +++ b/netbox/templates/generic/object.html @@ -38,7 +38,7 @@ {{ block.super }} -{% endblock %} +{% endblock header %} {% block title %}{{ object }}{% endblock %} @@ -48,7 +48,7 @@ · Updated {{ object.last_updated|timesince }} ago -{% endblock %} +{% endblock subtitle %} {% block controls %} {# Clone/Edit/Delete Buttons #}