Skip to content

Commit

Permalink
Closes #14312: Move ConfigRevision to core (#14328)
Browse files Browse the repository at this point in the history
* Move ConfigRevision model & write migrations

* Move ConfigRevision resources from extras to core

* Extend migration to update original content type for ConfigRevision
  • Loading branch information
jeremystretch authored Nov 27, 2023
1 parent 18422e1 commit 975a647
Show file tree
Hide file tree
Showing 26 changed files with 417 additions and 340 deletions.
21 changes: 21 additions & 0 deletions netbox/core/filtersets.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from .models import *

__all__ = (
'ConfigRevisionFilterSet',
'DataFileFilterSet',
'DataSourceFilterSet',
'JobFilterSet',
Expand Down Expand Up @@ -123,3 +124,23 @@ def search(self, queryset, name, value):
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 changes: 7 additions & 0 deletions netbox/core/forms/filtersets.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from utilities.forms.widgets import APISelectMultiple, DateTimePicker

__all__ = (
'ConfigRevisionFilterForm',
'DataFileFilterForm',
'DataSourceFilterForm',
'JobFilterForm',
Expand Down Expand Up @@ -123,3 +124,9 @@ class JobFilterForm(SavedFiltersMixin, FilterForm):
api_url='/api/users/users/',
)
)


class ConfigRevisionFilterForm(SavedFiltersMixin, FilterForm):
fieldsets = (
(None, ('q', 'filter_id')),
)
118 changes: 117 additions & 1 deletion netbox/core/forms/model_forms.py
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -111,3 +117,113 @@ def save(self, *args, **kwargs):
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
2 changes: 1 addition & 1 deletion netbox/core/management/commands/clearcache.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down
31 changes: 31 additions & 0 deletions netbox/core/migrations/0009_configrevision.py
Original file line number Diff line number Diff line change
@@ -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 change: 1 addition & 0 deletions netbox/core/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from .config import *
from .contenttypes import *
from .data import *
from .files import *
Expand Down
66 changes: 66 additions & 0 deletions netbox/core/models/config.py
Original file line number Diff line number Diff line change
@@ -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 changes: 11 additions & 0 deletions netbox/core/signals.py
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -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 change: 1 addition & 0 deletions netbox/core/tables/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
from .config import *
from .data import *
from .jobs import *
33 changes: 33 additions & 0 deletions netbox/core/tables/config.py
Original file line number Diff line number Diff line change
@@ -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 changes: 7 additions & 0 deletions netbox/core/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@
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'),

Expand Down
Loading

0 comments on commit 975a647

Please sign in to comment.