mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-14 01:41:22 -06: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:
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.
|
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
|
### Is Active
|
||||||
|
|
||||||
If not selected, this config context will be excluded from rendering. This can be convenient to temporarily disable a config context.
|
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
|
### 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
|
* [#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
|
* [#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
|
* [#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 logging
|
||||||
import os
|
import os
|
||||||
|
import yaml
|
||||||
from fnmatch import fnmatchcase
|
from fnmatch import fnmatchcase
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
@ -283,6 +284,13 @@ class DataFile(ChangeLoggingMixin, models.Model):
|
|||||||
except UnicodeDecodeError:
|
except UnicodeDecodeError:
|
||||||
return None
|
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):
|
def refresh_from_disk(self, source_root):
|
||||||
"""
|
"""
|
||||||
Update instance attributes from the file on disk. Returns True if any attribute
|
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 drf_yasg.utils import swagger_serializer_method
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from core.api.nested_serializers import NestedDataSourceSerializer, NestedDataFileSerializer
|
||||||
from dcim.api.nested_serializers import (
|
from dcim.api.nested_serializers import (
|
||||||
NestedDeviceRoleSerializer, NestedDeviceTypeSerializer, NestedLocationSerializer, NestedPlatformSerializer,
|
NestedDeviceRoleSerializer, NestedDeviceTypeSerializer, NestedLocationSerializer, NestedPlatformSerializer,
|
||||||
NestedRegionSerializer, NestedSiteSerializer, NestedSiteGroupSerializer,
|
NestedRegionSerializer, NestedSiteSerializer, NestedSiteGroupSerializer,
|
||||||
@ -358,13 +359,20 @@ class ConfigContextSerializer(ValidatedModelSerializer):
|
|||||||
required=False,
|
required=False,
|
||||||
many=True
|
many=True
|
||||||
)
|
)
|
||||||
|
data_source = NestedDataSourceSerializer(
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
data_file = NestedDataFileSerializer(
|
||||||
|
read_only=True
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ConfigContext
|
model = ConfigContext
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'url', 'display', 'name', 'weight', 'description', 'is_active', 'regions', 'site_groups', 'sites',
|
'id', 'url', 'display', 'name', 'weight', 'description', 'is_active', 'regions', 'site_groups', 'sites',
|
||||||
'locations', 'device_types', 'roles', 'platforms', 'cluster_types', 'cluster_groups', 'clusters',
|
'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.reports import get_report, get_reports, run_report
|
||||||
from extras.scripts import get_script, get_scripts, run_script
|
from extras.scripts import get_script, get_scripts, run_script
|
||||||
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
|
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
|
||||||
|
from netbox.api.features import SyncedDataMixin
|
||||||
from netbox.api.metadata import ContentTypeMetadata
|
from netbox.api.metadata import ContentTypeMetadata
|
||||||
from netbox.api.viewsets import NetBoxModelViewSet
|
from netbox.api.viewsets import NetBoxModelViewSet
|
||||||
from utilities.exceptions import RQWorkerNotRunningException
|
from utilities.exceptions import RQWorkerNotRunningException
|
||||||
@ -147,9 +148,10 @@ class JournalEntryViewSet(NetBoxModelViewSet):
|
|||||||
# Config contexts
|
# Config contexts
|
||||||
#
|
#
|
||||||
|
|
||||||
class ConfigContextViewSet(NetBoxModelViewSet):
|
class ConfigContextViewSet(SyncedDataMixin, NetBoxModelViewSet):
|
||||||
queryset = ConfigContext.objects.prefetch_related(
|
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
|
serializer_class = serializers.ConfigContextSerializer
|
||||||
filterset_class = filtersets.ConfigContextFilterSet
|
filterset_class = filtersets.ConfigContextFilterSet
|
||||||
|
@ -8,6 +8,7 @@ EXTRAS_FEATURES = [
|
|||||||
'export_templates',
|
'export_templates',
|
||||||
'job_results',
|
'job_results',
|
||||||
'journaling',
|
'journaling',
|
||||||
|
'synced_data',
|
||||||
'tags',
|
'tags',
|
||||||
'webhooks'
|
'webhooks'
|
||||||
]
|
]
|
||||||
|
@ -4,6 +4,7 @@ from django.contrib.contenttypes.models import ContentType
|
|||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.utils.translation import gettext as _
|
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 dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
|
||||||
from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet
|
from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet
|
||||||
from tenancy.models import Tenant, TenantGroup
|
from tenancy.models import Tenant, TenantGroup
|
||||||
@ -422,10 +423,18 @@ class ConfigContextFilterSet(ChangeLoggedModelFilterSet):
|
|||||||
to_field_name='slug',
|
to_field_name='slug',
|
||||||
label=_('Tag (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:
|
class Meta:
|
||||||
model = ConfigContext
|
model = ConfigContext
|
||||||
fields = ['id', 'name', 'is_active']
|
fields = ['id', 'name', 'is_active', 'data_synced']
|
||||||
|
|
||||||
def search(self, queryset, name, value):
|
def search(self, queryset, name, value):
|
||||||
if not value.strip():
|
if not value.strip():
|
||||||
|
@ -3,6 +3,7 @@ from django.contrib.auth.models import User
|
|||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.utils.translation import gettext as _
|
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 dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
|
||||||
from extras.choices import *
|
from extras.choices import *
|
||||||
from extras.models import *
|
from extras.models import *
|
||||||
@ -257,11 +258,25 @@ class TagFilterForm(SavedFiltersMixin, FilterForm):
|
|||||||
class ConfigContextFilterForm(SavedFiltersMixin, FilterForm):
|
class ConfigContextFilterForm(SavedFiltersMixin, FilterForm):
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, ('q', 'filter_id', 'tag_id')),
|
(None, ('q', 'filter_id', 'tag_id')),
|
||||||
|
('Data', ('data_source_id', 'data_file_id')),
|
||||||
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')),
|
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')),
|
||||||
('Device', ('device_type_id', 'platform_id', 'role_id')),
|
('Device', ('device_type_id', 'platform_id', 'role_id')),
|
||||||
('Cluster', ('cluster_type_id', 'cluster_group_id', 'cluster_id')),
|
('Cluster', ('cluster_type_id', 'cluster_group_id', 'cluster_id')),
|
||||||
('Tenant', ('tenant_group_id', 'tenant_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(
|
region_id = DynamicModelMultipleChoiceField(
|
||||||
queryset=Region.objects.all(),
|
queryset=Region.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
|
@ -2,13 +2,15 @@ from django.contrib.contenttypes.models import ContentType
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
|
from core.models import DataFile, DataSource
|
||||||
from extras.models import *
|
from extras.models import *
|
||||||
from extras.choices import CustomFieldVisibilityChoices
|
from extras.choices import CustomFieldVisibilityChoices
|
||||||
from utilities.forms.fields import DynamicModelMultipleChoiceField
|
from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'CustomFieldsMixin',
|
'CustomFieldsMixin',
|
||||||
'SavedFiltersMixin',
|
'SavedFiltersMixin',
|
||||||
|
'SyncedDataMixin',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -72,3 +74,19 @@ class SavedFiltersMixin(forms.Form):
|
|||||||
'usable': True,
|
'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 dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
|
||||||
from extras.choices import *
|
from extras.choices import *
|
||||||
|
from extras.forms.mixins import SyncedDataMixin
|
||||||
from extras.models import *
|
from extras.models import *
|
||||||
from extras.utils import FeatureQuery
|
from extras.utils import FeatureQuery
|
||||||
from netbox.forms import NetBoxModelForm
|
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(
|
regions = DynamicModelMultipleChoiceField(
|
||||||
queryset=Region.objects.all(),
|
queryset=Region.objects.all(),
|
||||||
required=False
|
required=False
|
||||||
@ -236,10 +237,13 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm):
|
|||||||
queryset=Tag.objects.all(),
|
queryset=Tag.objects.all(),
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
data = JSONField()
|
data = JSONField(
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
('Config Context', ('name', 'weight', 'description', 'data', 'is_active')),
|
('Config Context', ('name', 'weight', 'description', 'data', 'is_active')),
|
||||||
|
('Data Source', ('data_source', 'data_file')),
|
||||||
('Assignment', (
|
('Assignment', (
|
||||||
'regions', 'site_groups', 'sites', 'locations', 'device_types', 'roles', 'platforms', 'cluster_types',
|
'regions', 'site_groups', 'sites', 'locations', 'device_types', 'roles', 'platforms', 'cluster_types',
|
||||||
'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags',
|
'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags',
|
||||||
@ -251,9 +255,17 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm):
|
|||||||
fields = (
|
fields = (
|
||||||
'name', 'weight', 'description', 'data', 'is_active', 'regions', 'site_groups', 'sites', 'locations',
|
'name', 'weight', 'description', 'data', 'is_active', 'regions', 'site_groups', 'sites', 'locations',
|
||||||
'roles', 'device_types', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups',
|
'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):
|
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.core.validators import ValidationError
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
from extras.querysets import ConfigContextQuerySet
|
from extras.querysets import ConfigContextQuerySet
|
||||||
from netbox.models import ChangeLoggedModel
|
from netbox.models import ChangeLoggedModel
|
||||||
from netbox.models.features import WebhooksMixin
|
from netbox.models.features import SyncedDataMixin, WebhooksMixin
|
||||||
from utilities.utils import deepmerge
|
from utilities.utils import deepmerge
|
||||||
|
|
||||||
|
|
||||||
@ -19,7 +20,7 @@ __all__ = (
|
|||||||
# Config contexts
|
# 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
|
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
|
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}'}
|
{'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):
|
class ConfigContextModel(models.Model):
|
||||||
"""
|
"""
|
||||||
|
@ -188,21 +188,30 @@ class TaggedItemTable(NetBoxTable):
|
|||||||
|
|
||||||
|
|
||||||
class ConfigContextTable(NetBoxTable):
|
class ConfigContextTable(NetBoxTable):
|
||||||
|
data_source = tables.Column(
|
||||||
|
linkify=True
|
||||||
|
)
|
||||||
|
data_file = tables.Column(
|
||||||
|
linkify=True
|
||||||
|
)
|
||||||
name = tables.Column(
|
name = tables.Column(
|
||||||
linkify=True
|
linkify=True
|
||||||
)
|
)
|
||||||
is_active = columns.BooleanColumn(
|
is_active = columns.BooleanColumn(
|
||||||
verbose_name='Active'
|
verbose_name='Active'
|
||||||
)
|
)
|
||||||
|
is_synced = columns.BooleanColumn(
|
||||||
|
verbose_name='Synced'
|
||||||
|
)
|
||||||
|
|
||||||
class Meta(NetBoxTable.Meta):
|
class Meta(NetBoxTable.Meta):
|
||||||
model = ConfigContext
|
model = ConfigContext
|
||||||
fields = (
|
fields = (
|
||||||
'pk', 'id', 'name', 'weight', 'is_active', 'description', 'regions', 'sites', 'locations', 'roles',
|
'pk', 'id', 'name', 'weight', 'is_active', 'is_synced', 'description', 'regions', 'sites', 'locations',
|
||||||
'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'created',
|
'roles', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants',
|
||||||
'last_updated',
|
'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):
|
class ObjectChangeTable(NetBoxTable):
|
||||||
|
@ -60,6 +60,7 @@ urlpatterns = [
|
|||||||
path('config-contexts/add/', views.ConfigContextEditView.as_view(), name='configcontext_add'),
|
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/edit/', views.ConfigContextBulkEditView.as_view(), name='configcontext_bulk_edit'),
|
||||||
path('config-contexts/delete/', views.ConfigContextBulkDeleteView.as_view(), name='configcontext_bulk_delete'),
|
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'))),
|
path('config-contexts/<int:pk>/', include(get_model_urls('extras', 'configcontext'))),
|
||||||
|
|
||||||
# Image attachments
|
# Image attachments
|
||||||
|
@ -352,7 +352,8 @@ class ConfigContextListView(generic.ObjectListView):
|
|||||||
filterset = filtersets.ConfigContextFilterSet
|
filterset = filtersets.ConfigContextFilterSet
|
||||||
filterset_form = forms.ConfigContextFilterForm
|
filterset_form = forms.ConfigContextFilterForm
|
||||||
table = tables.ConfigContextTable
|
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)
|
@register_model_view(ConfigContext)
|
||||||
@ -416,6 +417,10 @@ class ConfigContextBulkDeleteView(generic.BulkDeleteView):
|
|||||||
table = tables.ConfigContextTable
|
table = tables.ConfigContextTable
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigContextBulkSyncDataView(generic.BulkSyncDataView):
|
||||||
|
queryset = ConfigContext.objects.all()
|
||||||
|
|
||||||
|
|
||||||
class ObjectConfigContextView(generic.ObjectView):
|
class ObjectConfigContextView(generic.ObjectView):
|
||||||
base_template = None
|
base_template = None
|
||||||
template_name = 'extras/object_configcontext.html'
|
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 functools import cached_property
|
||||||
|
|
||||||
from django.contrib.contenttypes.fields import GenericRelation
|
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.core.validators import ValidationError
|
||||||
from django.db import models
|
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 taggit.managers import TaggableManager
|
||||||
|
|
||||||
from extras.choices import CustomFieldVisibilityChoices, ObjectChangeActionChoices
|
from extras.choices import CustomFieldVisibilityChoices, ObjectChangeActionChoices
|
||||||
@ -25,6 +26,7 @@ __all__ = (
|
|||||||
'ExportTemplatesMixin',
|
'ExportTemplatesMixin',
|
||||||
'JobResultsMixin',
|
'JobResultsMixin',
|
||||||
'JournalingMixin',
|
'JournalingMixin',
|
||||||
|
'SyncedDataMixin',
|
||||||
'TagsMixin',
|
'TagsMixin',
|
||||||
'WebhooksMixin',
|
'WebhooksMixin',
|
||||||
)
|
)
|
||||||
@ -317,12 +319,82 @@ class WebhooksMixin(models.Model):
|
|||||||
abstract = True
|
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 = (
|
FEATURES_MAP = (
|
||||||
('custom_fields', CustomFieldsMixin),
|
('custom_fields', CustomFieldsMixin),
|
||||||
('custom_links', CustomLinksMixin),
|
('custom_links', CustomLinksMixin),
|
||||||
('export_templates', ExportTemplatesMixin),
|
('export_templates', ExportTemplatesMixin),
|
||||||
('job_results', JobResultsMixin),
|
('job_results', JobResultsMixin),
|
||||||
('journaling', JournalingMixin),
|
('journaling', JournalingMixin),
|
||||||
|
('synced_data', SyncedDataMixin),
|
||||||
('tags', TagsMixin),
|
('tags', TagsMixin),
|
||||||
('webhooks', WebhooksMixin),
|
('webhooks', WebhooksMixin),
|
||||||
)
|
)
|
||||||
@ -348,3 +420,9 @@ def _register_features(sender, **kwargs):
|
|||||||
'changelog',
|
'changelog',
|
||||||
kwargs={'model': sender}
|
kwargs={'model': sender}
|
||||||
)('netbox.views.generic.ObjectChangeLogView')
|
)('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.contenttypes.models import ContentType
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.db import transaction
|
||||||
from django.db.models import Q
|
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.utils.translation import gettext as _
|
||||||
from django.views.generic import View
|
from django.views.generic import View
|
||||||
|
|
||||||
from extras import forms, tables
|
from extras import forms, tables
|
||||||
from extras.models import *
|
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__ = (
|
__all__ = (
|
||||||
|
'BulkSyncDataView',
|
||||||
'ObjectChangeLogView',
|
'ObjectChangeLogView',
|
||||||
'ObjectJournalView',
|
'ObjectJournalView',
|
||||||
|
'ObjectSyncDataView',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -126,3 +132,49 @@ class ObjectJournalView(View):
|
|||||||
'base_template': self.base_template,
|
'base_template': self.base_template,
|
||||||
'tab': self.tab,
|
'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))
|
||||||
|
@ -6,22 +6,16 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col col-md-5">
|
<div class="col col-md-5">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h5 class="card-header">
|
<h5 class="card-header">Config Context</h5>
|
||||||
Config Context
|
|
||||||
</h5>
|
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<table class="table table-hover attr-table">
|
<table class="table table-hover attr-table">
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Name</th>
|
<th scope="row">Name</th>
|
||||||
<td>
|
<td>{{ object.name }}</td>
|
||||||
{{ object.name }}
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Weight</th>
|
<th scope="row">Weight</th>
|
||||||
<td>
|
<td>{{ object.weight }}</td>
|
||||||
{{ object.weight }}
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Description</th>
|
<th scope="row">Description</th>
|
||||||
@ -29,25 +23,42 @@
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Active</th>
|
<th scope="row">Active</th>
|
||||||
|
<td>{% checkmark object.is_active %}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Data Source</th>
|
||||||
<td>
|
<td>
|
||||||
{% if object.is_active %}
|
{% if object.data_source %}
|
||||||
<span class="text-success">
|
<a href="{{ object.data_source.get_absolute_url }}">{{ object.data_source }}</a>
|
||||||
<i class="mdi mdi-check-bold"></i>
|
|
||||||
</span>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-danger">
|
{{ ''|placeholder }}
|
||||||
<i class="mdi mdi-close"></i>
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h5 class="card-header">
|
<h5 class="card-header">Assignment</h5>
|
||||||
Assignment
|
|
||||||
</h5>
|
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<table class="table table-hover attr-table">
|
<table class="table table-hover attr-table">
|
||||||
{% for title, objects in assigned_objects %}
|
{% for title, objects in assigned_objects %}
|
||||||
@ -75,6 +86,21 @@
|
|||||||
{% include 'extras/inc/configcontext_format.html' %}
|
{% include 'extras/inc/configcontext_format.html' %}
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<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 %}
|
{% include 'extras/inc/configcontext_data.html' with data=object.data format=format %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
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 %}
|
Loading…
Reference in New Issue
Block a user