Explorar o código

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 %!s(int64=3) %!d(string=hai) anos
pai
achega
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.
 
+### 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.

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

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

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

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

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

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

+ 1 - 0
netbox/extras/constants.py

@@ -8,6 +8,7 @@ EXTRAS_FEATURES = [
     'export_templates',
     'job_results',
     'journaling',
+    'synced_data',
     'tags',
     '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.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():

+ 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.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,

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

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

+ 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 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 - 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.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):
     """

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

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

+ 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/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

+ 6 - 1
netbox/extras/views.py

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

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

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

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

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