소스 검색

Closes #9073: Remote data support for config contexts (#11692)

* 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
Jeremy Stretch 3 년 전
부모
커밋
678a7d17df

+ 4 - 0
docs/models/extras/configcontext.md

@@ -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.

+ 1 - 0
docs/release-notes/version-3.5.md

@@ -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

+ 8 - 0
netbox/core/models/data.py

@@ -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

+ 9 - 1
netbox/extras/api/serializers.py

@@ -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',
         ]
         ]
 
 
 
 

+ 4 - 2
netbox/extras/api/views.py

@@ -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

+ 1 - 0
netbox/extras/constants.py

@@ -8,6 +8,7 @@ EXTRAS_FEATURES = [
     'export_templates',
     'export_templates',
     'job_results',
     'job_results',
     'journaling',
     'journaling',
+    'synced_data',
     'tags',
     'tags',
     'webhooks'
     'webhooks'
 ]
 ]

+ 10 - 1
netbox/extras/filtersets.py

@@ -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():

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

@@ -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,

+ 19 - 1
netbox/extras/forms/mixins.py

@@ -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',
+        }
+    )

+ 15 - 3
netbox/extras/forms/model_forms.py

@@ -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 - 0
netbox/extras/migrations/0085_configcontext_synced_data.py

@@ -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),
+        ),
+    ]

+ 10 - 2
netbox/extras/models/configcontexts.py

@@ -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):
     """
     """

+ 13 - 4
netbox/extras/tables/tables.py

@@ -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',
-            '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):
 class ObjectChangeTable(NetBoxTable):

+ 1 - 0
netbox/extras/urls.py

@@ -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

+ 6 - 1
netbox/extras/views.py

@@ -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 - 0
netbox/netbox/api/features.py

@@ -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)

+ 80 - 2
netbox/netbox/models/features.py

@@ -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.core.validators import ValidationError
+from django.db import models
 from django.db.models.signals import class_prepared
 from django.db.models.signals import class_prepared
 from django.dispatch import receiver
 from django.dispatch import receiver
+from django.utils.translation import gettext as _
 
 
-from django.core.validators import ValidationError
-from django.db import models
 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')

+ 54 - 2
netbox/netbox/views/generic/feature_views.py

@@ -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))

+ 98 - 72
netbox/templates/extras/configcontext.html

@@ -3,81 +3,107 @@
 {% load static %}
 {% load static %}
 
 
 {% block content %}
 {% 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>
-        <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 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>
                 </div>
+              {% endif %}
             </div>
             </div>
+          {% endif %}
+            {% include 'extras/inc/configcontext_data.html' with data=object.data format=format %}
         </div>
         </div>
+      </div>
     </div>
     </div>
+  </div>
 {% endblock %}
 {% endblock %}

+ 10 - 0
netbox/templates/extras/configcontext_list.html

@@ -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 %}