mirror of
https://github.com/netbox-community/netbox.git
synced 2024-05-10 07:54:54 +00:00
* WIP * Add bulk sync view for config contexts * Introduce 'sync' permission for synced data models * Docs & cleanup * Remove unused method * Add a REST API endpoint to synchronize config context data
This commit is contained in:
committed by
jeremystretch
parent
664132281e
commit
678a7d17df
@ -18,6 +18,10 @@ A numeric value which influences the order in which context data is merged. Cont
|
||||
|
||||
The context data expressed in JSON format.
|
||||
|
||||
### Data File
|
||||
|
||||
Config context data may optionally be sourced from a remote [data file](../core/datafile.md), which is synchronized from a remote data source. When designating a data file, there is no need to specify local data for the config context: It will be populated automatically from the data file.
|
||||
|
||||
### Is Active
|
||||
|
||||
If not selected, this config context will be excluded from rendering. This can be convenient to temporarily disable a config context.
|
||||
|
@ -4,6 +4,7 @@
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#9073](https://github.com/netbox-community/netbox/issues/9073) - Enable syncing config context data from remote sources
|
||||
* [#11254](https://github.com/netbox-community/netbox/issues/11254) - Introduce the `X-Request-ID` HTTP header to annotate the unique ID of each request for change logging
|
||||
* [#11440](https://github.com/netbox-community/netbox/issues/11440) - Add an `enabled` field for device type interfaces
|
||||
* [#11517](https://github.com/netbox-community/netbox/issues/11517) - Standardize the inclusion of related objects across the entire UI
|
||||
|
@ -1,5 +1,6 @@
|
||||
import logging
|
||||
import os
|
||||
import yaml
|
||||
from fnmatch import fnmatchcase
|
||||
from urllib.parse import urlparse
|
||||
|
||||
@ -283,6 +284,13 @@ class DataFile(ChangeLoggingMixin, models.Model):
|
||||
except UnicodeDecodeError:
|
||||
return None
|
||||
|
||||
def get_data(self):
|
||||
"""
|
||||
Attempt to read the file data as JSON/YAML and return a native Python object.
|
||||
"""
|
||||
# TODO: Something more robust
|
||||
return yaml.safe_load(self.data_as_string)
|
||||
|
||||
def refresh_from_disk(self, source_root):
|
||||
"""
|
||||
Update instance attributes from the file on disk. Returns True if any attribute
|
||||
|
@ -4,6 +4,7 @@ from django.core.exceptions import ObjectDoesNotExist
|
||||
from drf_yasg.utils import swagger_serializer_method
|
||||
from rest_framework import serializers
|
||||
|
||||
from core.api.nested_serializers import NestedDataSourceSerializer, NestedDataFileSerializer
|
||||
from dcim.api.nested_serializers import (
|
||||
NestedDeviceRoleSerializer, NestedDeviceTypeSerializer, NestedLocationSerializer, NestedPlatformSerializer,
|
||||
NestedRegionSerializer, NestedSiteSerializer, NestedSiteGroupSerializer,
|
||||
@ -358,13 +359,20 @@ class ConfigContextSerializer(ValidatedModelSerializer):
|
||||
required=False,
|
||||
many=True
|
||||
)
|
||||
data_source = NestedDataSourceSerializer(
|
||||
required=False
|
||||
)
|
||||
data_file = NestedDataFileSerializer(
|
||||
read_only=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ConfigContext
|
||||
fields = [
|
||||
'id', 'url', 'display', 'name', 'weight', 'description', 'is_active', 'regions', 'site_groups', 'sites',
|
||||
'locations', 'device_types', 'roles', 'platforms', 'cluster_types', 'cluster_groups', 'clusters',
|
||||
'tenant_groups', 'tenants', 'tags', 'data', 'created', 'last_updated',
|
||||
'tenant_groups', 'tenants', 'tags', 'data_source', 'data_path', 'data_file', 'data_synced', 'data',
|
||||
'created', 'last_updated',
|
||||
]
|
||||
|
||||
|
||||
|
@ -17,6 +17,7 @@ from extras.models import CustomField
|
||||
from extras.reports import get_report, get_reports, run_report
|
||||
from extras.scripts import get_script, get_scripts, run_script
|
||||
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
|
||||
from netbox.api.features import SyncedDataMixin
|
||||
from netbox.api.metadata import ContentTypeMetadata
|
||||
from netbox.api.viewsets import NetBoxModelViewSet
|
||||
from utilities.exceptions import RQWorkerNotRunningException
|
||||
@ -147,9 +148,10 @@ class JournalEntryViewSet(NetBoxModelViewSet):
|
||||
# Config contexts
|
||||
#
|
||||
|
||||
class ConfigContextViewSet(NetBoxModelViewSet):
|
||||
class ConfigContextViewSet(SyncedDataMixin, NetBoxModelViewSet):
|
||||
queryset = ConfigContext.objects.prefetch_related(
|
||||
'regions', 'site_groups', 'sites', 'locations', 'roles', 'platforms', 'tenant_groups', 'tenants',
|
||||
'regions', 'site_groups', 'sites', 'locations', 'roles', 'platforms', 'tenant_groups', 'tenants', 'data_source',
|
||||
'data_file',
|
||||
)
|
||||
serializer_class = serializers.ConfigContextSerializer
|
||||
filterset_class = filtersets.ConfigContextFilterSet
|
||||
|
@ -8,6 +8,7 @@ EXTRAS_FEATURES = [
|
||||
'export_templates',
|
||||
'job_results',
|
||||
'journaling',
|
||||
'synced_data',
|
||||
'tags',
|
||||
'webhooks'
|
||||
]
|
||||
|
@ -4,6 +4,7 @@ from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models import Q
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from core.models import DataFile, DataSource
|
||||
from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
|
||||
from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
@ -422,10 +423,18 @@ class ConfigContextFilterSet(ChangeLoggedModelFilterSet):
|
||||
to_field_name='slug',
|
||||
label=_('Tag (slug)'),
|
||||
)
|
||||
data_source_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=DataSource.objects.all(),
|
||||
label=_('Data source (ID)'),
|
||||
)
|
||||
data_file_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=DataSource.objects.all(),
|
||||
label=_('Data file (ID)'),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ConfigContext
|
||||
fields = ['id', 'name', 'is_active']
|
||||
fields = ['id', 'name', 'is_active', 'data_synced']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
|
@ -3,6 +3,7 @@ from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from core.models import DataFile, DataSource
|
||||
from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
|
||||
from extras.choices import *
|
||||
from extras.models import *
|
||||
@ -257,11 +258,25 @@ class TagFilterForm(SavedFiltersMixin, FilterForm):
|
||||
class ConfigContextFilterForm(SavedFiltersMixin, FilterForm):
|
||||
fieldsets = (
|
||||
(None, ('q', 'filter_id', 'tag_id')),
|
||||
('Data', ('data_source_id', 'data_file_id')),
|
||||
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')),
|
||||
('Device', ('device_type_id', 'platform_id', 'role_id')),
|
||||
('Cluster', ('cluster_type_id', 'cluster_group_id', 'cluster_id')),
|
||||
('Tenant', ('tenant_group_id', 'tenant_id'))
|
||||
)
|
||||
data_source_id = DynamicModelMultipleChoiceField(
|
||||
queryset=DataSource.objects.all(),
|
||||
required=False,
|
||||
label=_('Data source')
|
||||
)
|
||||
data_file_id = DynamicModelMultipleChoiceField(
|
||||
queryset=DataFile.objects.all(),
|
||||
required=False,
|
||||
label=_('Data file'),
|
||||
query_params={
|
||||
'source_id': '$data_source_id'
|
||||
}
|
||||
)
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
|
@ -2,13 +2,15 @@ from django.contrib.contenttypes.models import ContentType
|
||||
from django import forms
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from core.models import DataFile, DataSource
|
||||
from extras.models import *
|
||||
from extras.choices import CustomFieldVisibilityChoices
|
||||
from utilities.forms.fields import DynamicModelMultipleChoiceField
|
||||
from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField
|
||||
|
||||
__all__ = (
|
||||
'CustomFieldsMixin',
|
||||
'SavedFiltersMixin',
|
||||
'SyncedDataMixin',
|
||||
)
|
||||
|
||||
|
||||
@ -72,3 +74,19 @@ class SavedFiltersMixin(forms.Form):
|
||||
'usable': True,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class SyncedDataMixin(forms.Form):
|
||||
data_source = DynamicModelChoiceField(
|
||||
queryset=DataSource.objects.all(),
|
||||
required=False,
|
||||
label=_('Data source')
|
||||
)
|
||||
data_file = DynamicModelChoiceField(
|
||||
queryset=DataFile.objects.all(),
|
||||
required=False,
|
||||
label=_('File'),
|
||||
query_params={
|
||||
'source_id': '$data_source',
|
||||
}
|
||||
)
|
||||
|
@ -5,6 +5,7 @@ from django.utils.translation import gettext as _
|
||||
|
||||
from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
|
||||
from extras.choices import *
|
||||
from extras.forms.mixins import SyncedDataMixin
|
||||
from extras.models import *
|
||||
from extras.utils import FeatureQuery
|
||||
from netbox.forms import NetBoxModelForm
|
||||
@ -183,7 +184,7 @@ class TagForm(BootstrapMixin, forms.ModelForm):
|
||||
]
|
||||
|
||||
|
||||
class ConfigContextForm(BootstrapMixin, forms.ModelForm):
|
||||
class ConfigContextForm(BootstrapMixin, SyncedDataMixin, forms.ModelForm):
|
||||
regions = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False
|
||||
@ -236,10 +237,13 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm):
|
||||
queryset=Tag.objects.all(),
|
||||
required=False
|
||||
)
|
||||
data = JSONField()
|
||||
data = JSONField(
|
||||
required=False
|
||||
)
|
||||
|
||||
fieldsets = (
|
||||
('Config Context', ('name', 'weight', 'description', 'data', 'is_active')),
|
||||
('Data Source', ('data_source', 'data_file')),
|
||||
('Assignment', (
|
||||
'regions', 'site_groups', 'sites', 'locations', 'device_types', 'roles', 'platforms', 'cluster_types',
|
||||
'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags',
|
||||
@ -251,9 +255,17 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm):
|
||||
fields = (
|
||||
'name', 'weight', 'description', 'data', 'is_active', 'regions', 'site_groups', 'sites', 'locations',
|
||||
'roles', 'device_types', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups',
|
||||
'tenants', 'tags',
|
||||
'tenants', 'tags', 'data_source', 'data_file',
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
if not self.cleaned_data.get('data') and not self.cleaned_data.get('data_source'):
|
||||
raise forms.ValidationError("Must specify either local data or a data source")
|
||||
|
||||
return self.cleaned_data
|
||||
|
||||
|
||||
class ImageAttachmentForm(BootstrapMixin, forms.ModelForm):
|
||||
|
||||
|
35
netbox/extras/migrations/0085_configcontext_synced_data.py
Normal file
35
netbox/extras/migrations/0085_configcontext_synced_data.py
Normal file
@ -0,0 +1,35 @@
|
||||
# Generated by Django 4.1.6 on 2023-02-06 15:34
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0001_initial'),
|
||||
('extras', '0084_staging'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='configcontext',
|
||||
name='data_file',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='core.datafile'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='configcontext',
|
||||
name='data_path',
|
||||
field=models.CharField(blank=True, editable=False, max_length=1000),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='configcontext',
|
||||
name='data_source',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='core.datasource'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='configcontext',
|
||||
name='data_synced',
|
||||
field=models.DateTimeField(blank=True, editable=False, null=True),
|
||||
),
|
||||
]
|
@ -2,10 +2,11 @@ from django.conf import settings
|
||||
from django.core.validators import ValidationError
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from extras.querysets import ConfigContextQuerySet
|
||||
from netbox.models import ChangeLoggedModel
|
||||
from netbox.models.features import WebhooksMixin
|
||||
from netbox.models.features import SyncedDataMixin, WebhooksMixin
|
||||
from utilities.utils import deepmerge
|
||||
|
||||
|
||||
@ -19,7 +20,7 @@ __all__ = (
|
||||
# Config contexts
|
||||
#
|
||||
|
||||
class ConfigContext(WebhooksMixin, ChangeLoggedModel):
|
||||
class ConfigContext(SyncedDataMixin, WebhooksMixin, ChangeLoggedModel):
|
||||
"""
|
||||
A ConfigContext represents a set of arbitrary data available to any Device or VirtualMachine matching its assigned
|
||||
qualifiers (region, site, etc.). For example, the data stored in a ConfigContext assigned to site A and tenant B
|
||||
@ -130,6 +131,13 @@ class ConfigContext(WebhooksMixin, ChangeLoggedModel):
|
||||
{'data': 'JSON data must be in object form. Example: {"foo": 123}'}
|
||||
)
|
||||
|
||||
def sync_data(self):
|
||||
"""
|
||||
Synchronize context data from the designated DataFile (if any).
|
||||
"""
|
||||
self.data = self.data_file.get_data()
|
||||
self.data_synced = timezone.now()
|
||||
|
||||
|
||||
class ConfigContextModel(models.Model):
|
||||
"""
|
||||
|
@ -188,21 +188,30 @@ class TaggedItemTable(NetBoxTable):
|
||||
|
||||
|
||||
class ConfigContextTable(NetBoxTable):
|
||||
data_source = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
data_file = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
name = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
is_active = columns.BooleanColumn(
|
||||
verbose_name='Active'
|
||||
)
|
||||
is_synced = columns.BooleanColumn(
|
||||
verbose_name='Synced'
|
||||
)
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = ConfigContext
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'weight', 'is_active', 'description', 'regions', 'sites', 'locations', 'roles',
|
||||
'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'created',
|
||||
'last_updated',
|
||||
'pk', 'id', 'name', 'weight', 'is_active', 'is_synced', 'description', 'regions', 'sites', 'locations',
|
||||
'roles', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants',
|
||||
'data_source', 'data_file', 'data_synced', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'weight', 'is_active', 'description')
|
||||
default_columns = ('pk', 'name', 'weight', 'is_active', 'is_synced', 'description')
|
||||
|
||||
|
||||
class ObjectChangeTable(NetBoxTable):
|
||||
|
@ -60,6 +60,7 @@ urlpatterns = [
|
||||
path('config-contexts/add/', views.ConfigContextEditView.as_view(), name='configcontext_add'),
|
||||
path('config-contexts/edit/', views.ConfigContextBulkEditView.as_view(), name='configcontext_bulk_edit'),
|
||||
path('config-contexts/delete/', views.ConfigContextBulkDeleteView.as_view(), name='configcontext_bulk_delete'),
|
||||
path('config-contexts/sync/', views.ConfigContextBulkSyncDataView.as_view(), name='configcontext_bulk_sync'),
|
||||
path('config-contexts/<int:pk>/', include(get_model_urls('extras', 'configcontext'))),
|
||||
|
||||
# Image attachments
|
||||
|
@ -352,7 +352,8 @@ class ConfigContextListView(generic.ObjectListView):
|
||||
filterset = filtersets.ConfigContextFilterSet
|
||||
filterset_form = forms.ConfigContextFilterForm
|
||||
table = tables.ConfigContextTable
|
||||
actions = ('add', 'bulk_edit', 'bulk_delete')
|
||||
template_name = 'extras/configcontext_list.html'
|
||||
actions = ('add', 'bulk_edit', 'bulk_delete', 'bulk_sync')
|
||||
|
||||
|
||||
@register_model_view(ConfigContext)
|
||||
@ -416,6 +417,10 @@ class ConfigContextBulkDeleteView(generic.BulkDeleteView):
|
||||
table = tables.ConfigContextTable
|
||||
|
||||
|
||||
class ConfigContextBulkSyncDataView(generic.BulkSyncDataView):
|
||||
queryset = ConfigContext.objects.all()
|
||||
|
||||
|
||||
class ObjectConfigContextView(generic.ObjectView):
|
||||
base_template = None
|
||||
template_name = 'extras/object_configcontext.html'
|
||||
|
30
netbox/netbox/api/features.py
Normal file
30
netbox/netbox/api/features.py
Normal file
@ -0,0 +1,30 @@
|
||||
from django.shortcuts import get_object_or_404
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework.response import Response
|
||||
|
||||
from utilities.permissions import get_permission_for_model
|
||||
|
||||
__all__ = (
|
||||
'SyncedDataMixin',
|
||||
)
|
||||
|
||||
|
||||
class SyncedDataMixin:
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def sync(self, request, pk):
|
||||
"""
|
||||
Provide a /sync API endpoint to synchronize an object's data from its associated DataFile (if any).
|
||||
"""
|
||||
permission = get_permission_for_model(self.queryset.model, 'sync')
|
||||
if not request.user.has_perm(permission):
|
||||
raise PermissionDenied(f"Missing permission: {permission}")
|
||||
|
||||
obj = get_object_or_404(self.queryset, pk=pk)
|
||||
if obj.data_file:
|
||||
obj.sync_data()
|
||||
obj.save()
|
||||
serializer = self.serializer_class(obj, context={'request': request})
|
||||
|
||||
return Response(serializer.data)
|
@ -2,11 +2,12 @@ from collections import defaultdict
|
||||
from functools import cached_property
|
||||
|
||||
from django.contrib.contenttypes.fields import GenericRelation
|
||||
from django.db.models.signals import class_prepared
|
||||
from django.dispatch import receiver
|
||||
|
||||
from django.core.validators import ValidationError
|
||||
from django.db import models
|
||||
from django.db.models.signals import class_prepared
|
||||
from django.dispatch import receiver
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from taggit.managers import TaggableManager
|
||||
|
||||
from extras.choices import CustomFieldVisibilityChoices, ObjectChangeActionChoices
|
||||
@ -25,6 +26,7 @@ __all__ = (
|
||||
'ExportTemplatesMixin',
|
||||
'JobResultsMixin',
|
||||
'JournalingMixin',
|
||||
'SyncedDataMixin',
|
||||
'TagsMixin',
|
||||
'WebhooksMixin',
|
||||
)
|
||||
@ -317,12 +319,82 @@ class WebhooksMixin(models.Model):
|
||||
abstract = True
|
||||
|
||||
|
||||
class SyncedDataMixin(models.Model):
|
||||
"""
|
||||
Enables population of local data from a DataFile object, synchronized from a remote DatSource.
|
||||
"""
|
||||
data_source = models.ForeignKey(
|
||||
to='core.DataSource',
|
||||
on_delete=models.PROTECT,
|
||||
blank=True,
|
||||
null=True,
|
||||
related_name='+',
|
||||
help_text=_("Remote data source")
|
||||
)
|
||||
data_file = models.ForeignKey(
|
||||
to='core.DataFile',
|
||||
on_delete=models.SET_NULL,
|
||||
blank=True,
|
||||
null=True,
|
||||
related_name='+'
|
||||
)
|
||||
data_path = models.CharField(
|
||||
max_length=1000,
|
||||
blank=True,
|
||||
editable=False,
|
||||
help_text=_("Path to remote file (relative to data source root)")
|
||||
)
|
||||
data_synced = models.DateTimeField(
|
||||
blank=True,
|
||||
null=True,
|
||||
editable=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
@property
|
||||
def is_synced(self):
|
||||
return self.data_file and self.data_synced >= self.data_file.last_updated
|
||||
|
||||
def clean(self):
|
||||
if self.data_file:
|
||||
self.sync_data()
|
||||
self.data_path = self.data_file.path
|
||||
|
||||
if self.data_source and not self.data_file:
|
||||
raise ValidationError({
|
||||
'data_file': _(f"Must specify a data file when designating a data source.")
|
||||
})
|
||||
if self.data_file and not self.data_source:
|
||||
self.data_source = self.data_file.source
|
||||
|
||||
super().clean()
|
||||
|
||||
def resolve_data_file(self):
|
||||
"""
|
||||
Determine the designated DataFile object identified by its parent DataSource and its path. Returns None if
|
||||
either attribute is unset, or if no matching DataFile is found.
|
||||
"""
|
||||
from core.models import DataFile
|
||||
|
||||
if self.data_source and self.data_path:
|
||||
try:
|
||||
return DataFile.objects.get(source=self.data_source, path=self.data_path)
|
||||
except DataFile.DoesNotExist:
|
||||
pass
|
||||
|
||||
def sync_data(self):
|
||||
raise NotImplementedError(f"{self.__class__} must implement a sync_data() method.")
|
||||
|
||||
|
||||
FEATURES_MAP = (
|
||||
('custom_fields', CustomFieldsMixin),
|
||||
('custom_links', CustomLinksMixin),
|
||||
('export_templates', ExportTemplatesMixin),
|
||||
('job_results', JobResultsMixin),
|
||||
('journaling', JournalingMixin),
|
||||
('synced_data', SyncedDataMixin),
|
||||
('tags', TagsMixin),
|
||||
('webhooks', WebhooksMixin),
|
||||
)
|
||||
@ -348,3 +420,9 @@ def _register_features(sender, **kwargs):
|
||||
'changelog',
|
||||
kwargs={'model': sender}
|
||||
)('netbox.views.generic.ObjectChangeLogView')
|
||||
if issubclass(sender, SyncedDataMixin):
|
||||
register_model_view(
|
||||
sender,
|
||||
'sync',
|
||||
kwargs={'model': sender}
|
||||
)('netbox.views.generic.ObjectSyncDataView')
|
||||
|
@ -1,16 +1,22 @@
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.contrib import messages
|
||||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
from django.shortcuts import get_object_or_404, render
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.generic import View
|
||||
|
||||
from extras import forms, tables
|
||||
from extras.models import *
|
||||
from utilities.views import ViewTab
|
||||
from utilities.permissions import get_permission_for_model
|
||||
from utilities.views import GetReturnURLMixin, ViewTab
|
||||
from .base import BaseMultiObjectView
|
||||
|
||||
__all__ = (
|
||||
'BulkSyncDataView',
|
||||
'ObjectChangeLogView',
|
||||
'ObjectJournalView',
|
||||
'ObjectSyncDataView',
|
||||
)
|
||||
|
||||
|
||||
@ -126,3 +132,49 @@ class ObjectJournalView(View):
|
||||
'base_template': self.base_template,
|
||||
'tab': self.tab,
|
||||
})
|
||||
|
||||
|
||||
class ObjectSyncDataView(View):
|
||||
|
||||
def post(self, request, model, **kwargs):
|
||||
"""
|
||||
Synchronize data from the DataFile associated with this object.
|
||||
"""
|
||||
qs = model.objects.all()
|
||||
if hasattr(model.objects, 'restrict'):
|
||||
qs = qs.restrict(request.user, 'sync')
|
||||
obj = get_object_or_404(qs, **kwargs)
|
||||
|
||||
if not obj.data_file:
|
||||
messages.error(request, f"Unable to synchronize data: No data file set.")
|
||||
return redirect(obj.get_absolute_url())
|
||||
|
||||
obj.sync_data()
|
||||
obj.save()
|
||||
messages.success(request, f"Synchronized data for {model._meta.verbose_name} {obj}.")
|
||||
|
||||
return redirect(obj.get_absolute_url())
|
||||
|
||||
|
||||
class BulkSyncDataView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
"""
|
||||
Synchronize multiple instances of a model inheriting from SyncedDataMixin.
|
||||
"""
|
||||
def get_required_permission(self):
|
||||
return get_permission_for_model(self.queryset.model, 'sync')
|
||||
|
||||
def post(self, request):
|
||||
selected_objects = self.queryset.filter(
|
||||
pk__in=request.POST.getlist('pk'),
|
||||
data_file__isnull=False
|
||||
)
|
||||
|
||||
with transaction.atomic():
|
||||
for obj in selected_objects:
|
||||
obj.sync_data()
|
||||
obj.save()
|
||||
|
||||
model_name = self.queryset.model._meta.verbose_name_plural
|
||||
messages.success(request, f"Synced {len(selected_objects)} {model_name}")
|
||||
|
||||
return redirect(self.get_return_url(request))
|
||||
|
@ -3,81 +3,107 @@
|
||||
{% load static %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col col-md-5">
|
||||
<div class="card">
|
||||
<h5 class="card-header">
|
||||
Config Context
|
||||
</h5>
|
||||
<div class="card-body">
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">Name</th>
|
||||
<td>
|
||||
{{ object.name }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Weight</th>
|
||||
<td>
|
||||
{{ object.weight }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Description</th>
|
||||
<td>{{ object.description|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Active</th>
|
||||
<td>
|
||||
{% if object.is_active %}
|
||||
<span class="text-success">
|
||||
<i class="mdi mdi-check-bold"></i>
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-danger">
|
||||
<i class="mdi mdi-close"></i>
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h5 class="card-header">
|
||||
Assignment
|
||||
</h5>
|
||||
<div class="card-body">
|
||||
<table class="table table-hover attr-table">
|
||||
{% for title, objects in assigned_objects %}
|
||||
<tr>
|
||||
<th scope="row">{{ title }}</th>
|
||||
<td>
|
||||
<ul class="list-unstyled mb-0">
|
||||
{% for object in objects %}
|
||||
<li>{{ object|linkify }}</li>
|
||||
{% empty %}
|
||||
<li class="text-muted">None</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col col-md-5">
|
||||
<div class="card">
|
||||
<h5 class="card-header">Config Context</h5>
|
||||
<div class="card-body">
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">Name</th>
|
||||
<td>{{ object.name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Weight</th>
|
||||
<td>{{ object.weight }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Description</th>
|
||||
<td>{{ object.description|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Active</th>
|
||||
<td>{% checkmark object.is_active %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Data Source</th>
|
||||
<td>
|
||||
{% if object.data_source %}
|
||||
<a href="{{ object.data_source.get_absolute_url }}">{{ object.data_source }}</a>
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Data File</th>
|
||||
<td>
|
||||
{% if object.data_file %}
|
||||
<a href="{{ object.data_file.get_absolute_url }}">{{ object.data_file }}</a>
|
||||
{% elif object.data_path %}
|
||||
<div class="float-end text-warning">
|
||||
<i class="mdi mdi-alert" title="The data file associated with this object has been deleted."></i>
|
||||
</div>
|
||||
{{ object.data_path }}
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Data Synced</th>
|
||||
<td>{{ object.data_synced|placeholder }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="col col-md-7">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5>Data</h5>
|
||||
{% include 'extras/inc/configcontext_format.html' %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% include 'extras/inc/configcontext_data.html' with data=object.data format=format %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h5 class="card-header">Assignment</h5>
|
||||
<div class="card-body">
|
||||
<table class="table table-hover attr-table">
|
||||
{% for title, objects in assigned_objects %}
|
||||
<tr>
|
||||
<th scope="row">{{ title }}</th>
|
||||
<td>
|
||||
<ul class="list-unstyled mb-0">
|
||||
{% for object in objects %}
|
||||
<li>{{ object|linkify }}</li>
|
||||
{% empty %}
|
||||
<li class="text-muted">None</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col col-md-7">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5>Data</h5>
|
||||
{% include 'extras/inc/configcontext_format.html' %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if object.data_file and object.data_file.last_updated > object.data_synced %}
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<i class="mdi mdi-alert"></i> Data is out of sync with upstream file (<a href="{{ object.data_file.get_absolute_url }}">{{ object.data_file }}</a>).
|
||||
{% if perms.extras.sync_configcontext %}
|
||||
<div class="float-end">
|
||||
<form action="{% url 'extras:configcontext_sync' pk=object.pk %}" method="post">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-primary btn-sm">
|
||||
<i class="mdi mdi-sync" aria-hidden="true"></i> Sync
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% include 'extras/inc/configcontext_data.html' with data=object.data format=format %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
10
netbox/templates/extras/configcontext_list.html
Normal file
10
netbox/templates/extras/configcontext_list.html
Normal file
@ -0,0 +1,10 @@
|
||||
{% extends 'generic/object_list.html' %}
|
||||
|
||||
{% block bulk_buttons %}
|
||||
{% if perms.extras.sync_configcontext %}
|
||||
<button type="submit" name="_sync" formaction="{% url 'extras:configcontext_bulk_sync' %}" class="btn btn-primary btn-sm">
|
||||
<i class="mdi mdi-sync" aria-hidden="true"></i> Sync Data
|
||||
</button>
|
||||
{% endif %}
|
||||
{{ block.super }}
|
||||
{% endblock %}
|
Reference in New Issue
Block a user