diff --git a/docs/models/extras/configcontext.md b/docs/models/extras/configcontext.md index 156b2d784..1e58b9e01 100644 --- a/docs/models/extras/configcontext.md +++ b/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. diff --git a/docs/release-notes/version-3.5.md b/docs/release-notes/version-3.5.md index ae2d319b3..985953d47 100644 --- a/docs/release-notes/version-3.5.md +++ b/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 diff --git a/netbox/core/models/data.py b/netbox/core/models/data.py index 54e1dca04..4228c599c 100644 --- a/netbox/core/models/data.py +++ b/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 diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 8b9c6dcb1..54627fbb3 100644 --- a/netbox/extras/api/serializers.py +++ b/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', ] diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 1423824cd..8b97491b1 100644 --- a/netbox/extras/api/views.py +++ b/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 diff --git a/netbox/extras/constants.py b/netbox/extras/constants.py index 123eb0a45..7c7fe331e 100644 --- a/netbox/extras/constants.py +++ b/netbox/extras/constants.py @@ -8,6 +8,7 @@ EXTRAS_FEATURES = [ 'export_templates', 'job_results', 'journaling', + 'synced_data', 'tags', 'webhooks' ] diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index 74b98ccf6..799e79123 100644 --- a/netbox/extras/filtersets.py +++ b/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(): diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index 22c7364db..46b7aa8f6 100644 --- a/netbox/extras/forms/filtersets.py +++ b/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, diff --git a/netbox/extras/forms/mixins.py b/netbox/extras/forms/mixins.py index 640bcc3dc..4e05e3a1e 100644 --- a/netbox/extras/forms/mixins.py +++ b/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', + } + ) diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py index a21cf21e2..429c4140a 100644 --- a/netbox/extras/forms/model_forms.py +++ b/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): diff --git a/netbox/extras/migrations/0085_configcontext_synced_data.py b/netbox/extras/migrations/0085_configcontext_synced_data.py new file mode 100644 index 000000000..f3022665b --- /dev/null +++ b/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), + ), + ] diff --git a/netbox/extras/models/configcontexts.py b/netbox/extras/models/configcontexts.py index d8d3510d7..7b6088324 100644 --- a/netbox/extras/models/configcontexts.py +++ b/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): """ diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index c2b8c9424..51443ad87 100644 --- a/netbox/extras/tables/tables.py +++ b/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): diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index f41a45f5a..6fd178284 100644 --- a/netbox/extras/urls.py +++ b/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//', include(get_model_urls('extras', 'configcontext'))), # Image attachments diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 2d2608ae8..c46890c19 100644 --- a/netbox/extras/views.py +++ b/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' diff --git a/netbox/netbox/api/features.py b/netbox/netbox/api/features.py new file mode 100644 index 000000000..db018ca12 --- /dev/null +++ b/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) diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index f041d016d..2bd0a93d2 100644 --- a/netbox/netbox/models/features.py +++ b/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.db.models.signals import class_prepared -from django.dispatch import receiver - from django.core.validators import ValidationError from django.db import models +from django.db.models.signals import class_prepared +from django.dispatch import receiver +from django.utils.translation import gettext as _ + from taggit.managers import TaggableManager from extras.choices import CustomFieldVisibilityChoices, ObjectChangeActionChoices @@ -25,6 +26,7 @@ __all__ = ( 'ExportTemplatesMixin', 'JobResultsMixin', 'JournalingMixin', + 'SyncedDataMixin', 'TagsMixin', 'WebhooksMixin', ) @@ -317,12 +319,82 @@ class WebhooksMixin(models.Model): abstract = True +class SyncedDataMixin(models.Model): + """ + Enables population of local data from a DataFile object, synchronized from a remote DatSource. + """ + data_source = models.ForeignKey( + to='core.DataSource', + on_delete=models.PROTECT, + blank=True, + null=True, + related_name='+', + help_text=_("Remote data source") + ) + data_file = models.ForeignKey( + to='core.DataFile', + on_delete=models.SET_NULL, + blank=True, + null=True, + related_name='+' + ) + data_path = models.CharField( + max_length=1000, + blank=True, + editable=False, + help_text=_("Path to remote file (relative to data source root)") + ) + data_synced = models.DateTimeField( + blank=True, + null=True, + editable=False + ) + + class Meta: + abstract = True + + @property + def is_synced(self): + return self.data_file and self.data_synced >= self.data_file.last_updated + + def clean(self): + if self.data_file: + self.sync_data() + self.data_path = self.data_file.path + + if self.data_source and not self.data_file: + raise ValidationError({ + 'data_file': _(f"Must specify a data file when designating a data source.") + }) + if self.data_file and not self.data_source: + self.data_source = self.data_file.source + + super().clean() + + def resolve_data_file(self): + """ + Determine the designated DataFile object identified by its parent DataSource and its path. Returns None if + either attribute is unset, or if no matching DataFile is found. + """ + from core.models import DataFile + + if self.data_source and self.data_path: + try: + return DataFile.objects.get(source=self.data_source, path=self.data_path) + except DataFile.DoesNotExist: + pass + + def sync_data(self): + raise NotImplementedError(f"{self.__class__} must implement a sync_data() method.") + + FEATURES_MAP = ( ('custom_fields', CustomFieldsMixin), ('custom_links', CustomLinksMixin), ('export_templates', ExportTemplatesMixin), ('job_results', JobResultsMixin), ('journaling', JournalingMixin), + ('synced_data', SyncedDataMixin), ('tags', TagsMixin), ('webhooks', WebhooksMixin), ) @@ -348,3 +420,9 @@ def _register_features(sender, **kwargs): 'changelog', kwargs={'model': sender} )('netbox.views.generic.ObjectChangeLogView') + if issubclass(sender, SyncedDataMixin): + register_model_view( + sender, + 'sync', + kwargs={'model': sender} + )('netbox.views.generic.ObjectSyncDataView') diff --git a/netbox/netbox/views/generic/feature_views.py b/netbox/netbox/views/generic/feature_views.py index d4d02ee4e..6e310c97a 100644 --- a/netbox/netbox/views/generic/feature_views.py +++ b/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)) diff --git a/netbox/templates/extras/configcontext.html b/netbox/templates/extras/configcontext.html index 56ec52c07..3714b3f1c 100644 --- a/netbox/templates/extras/configcontext.html +++ b/netbox/templates/extras/configcontext.html @@ -3,81 +3,107 @@ {% load static %} {% block content %} -
-
-
-
- Config Context -
-
- - - - - - - - - - - - - - - - - -
Name - {{ object.name }} -
Weight - {{ object.weight }} -
Description{{ object.description|placeholder }}
Active - {% if object.is_active %} - - - - {% else %} - - - - {% endif %} -
-
-
-
-
- Assignment -
-
- - {% for title, objects in assigned_objects %} - - - - - {% endfor %} -
{{ title }} -
    - {% for object in objects %} -
  • {{ object|linkify }}
  • - {% empty %} -
  • None
  • - {% endfor %} -
-
-
-
+
+
+
+
Config Context
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Name{{ object.name }}
Weight{{ object.weight }}
Description{{ object.description|placeholder }}
Active{% checkmark object.is_active %}
Data Source + {% if object.data_source %} + {{ object.data_source }} + {% else %} + {{ ''|placeholder }} + {% endif %} +
Data File + {% if object.data_file %} + {{ object.data_file }} + {% elif object.data_path %} +
+ +
+ {{ object.data_path }} + {% else %} + {{ ''|placeholder }} + {% endif %} +
Data Synced{{ object.data_synced|placeholder }}
-
-
-
-
Data
- {% include 'extras/inc/configcontext_format.html' %} -
-
- {% include 'extras/inc/configcontext_data.html' with data=object.data format=format %} -
-
+
+
+
Assignment
+
+ + {% for title, objects in assigned_objects %} + + + + + {% endfor %} +
{{ title }} +
    + {% for object in objects %} +
  • {{ object|linkify }}
  • + {% empty %} +
  • None
  • + {% endfor %} +
+
+
+
+
+
+
Data
+ {% include 'extras/inc/configcontext_format.html' %} +
+
+ {% if object.data_file and object.data_file.last_updated > object.data_synced %} + + {% endif %} + {% include 'extras/inc/configcontext_data.html' with data=object.data format=format %} +
+
+
+
{% endblock %} diff --git a/netbox/templates/extras/configcontext_list.html b/netbox/templates/extras/configcontext_list.html new file mode 100644 index 000000000..31e7087ad --- /dev/null +++ b/netbox/templates/extras/configcontext_list.html @@ -0,0 +1,10 @@ +{% extends 'generic/object_list.html' %} + +{% block bulk_buttons %} + {% if perms.extras.sync_configcontext %} + + {% endif %} + {{ block.super }} +{% endblock %}