diff --git a/docs/models/extras/configcontext.md b/docs/models/extras/configcontext.md index 1e58b9e0179..7c5c597a9a0 100644 --- a/docs/models/extras/configcontext.md +++ b/docs/models/extras/configcontext.md @@ -14,6 +14,10 @@ A unique human-friendly name. A numeric value which influences the order in which context data is merged. Contexts with a lower weight are merged before those with a higher weight. +### Profile + +The [profile](./configcontextprofile.md) to which the config context is assigned (optional). Profiles can be used to enforce structure in their data. + ### Data The context data expressed in JSON format. diff --git a/docs/models/extras/configcontextprofile.md b/docs/models/extras/configcontextprofile.md new file mode 100644 index 00000000000..289cce20ac9 --- /dev/null +++ b/docs/models/extras/configcontextprofile.md @@ -0,0 +1,33 @@ +# Config Context Profiles + +Profiles can be used to organize [configuration contexts](./configcontext.md) and to enforce a desired structure for their data. The later is achieved by defining a [JSON schema](https://json-schema.org/) to which all config context with this profile assigned must comply. + +For example, the following schema defines two keys, `size` and `priority`, of which the former is required: + +```json +{ + "properties": { + "size": { + "type": "integer" + }, + "priority": { + "type": "string", + "enum": ["high", "medium", "low"], + "default": "medium" + } + }, + "required": [ + "size" + ] +} +``` + +## Fields + +### Name + +A unique human-friendly name. + +### Schema + +The JSON schema to be enforced for all assigned config contexts (optional). diff --git a/mkdocs.yml b/mkdocs.yml index d427d3d240b..a2cec0c0c2a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -226,6 +226,7 @@ nav: - Extras: - Bookmark: 'models/extras/bookmark.md' - ConfigContext: 'models/extras/configcontext.md' + - ConfigContextProfile: 'models/extras/configcontextprofile.md' - ConfigTemplate: 'models/extras/configtemplate.md' - CustomField: 'models/extras/customfield.md' - CustomFieldChoiceSet: 'models/extras/customfieldchoiceset.md' diff --git a/netbox/core/graphql/mixins.py b/netbox/core/graphql/mixins.py index 72191e6fd64..6c90423133b 100644 --- a/netbox/core/graphql/mixins.py +++ b/netbox/core/graphql/mixins.py @@ -7,10 +7,12 @@ from core.models import ObjectChange if TYPE_CHECKING: + from core.graphql.types import DataFileType, DataSourceType from netbox.core.graphql.types import ObjectChangeType __all__ = ( 'ChangelogMixin', + 'SyncedDataMixin', ) @@ -25,3 +27,9 @@ def changelog(self, info) -> List[Annotated["ObjectChangeType", strawberry.lazy( changed_object_id=self.pk ) return object_changes.restrict(info.context.request.user, 'view') + + +@strawberry.type +class SyncedDataMixin: + data_source: Annotated["DataSourceType", strawberry.lazy('core.graphql.types')] | None + data_file: Annotated["DataFileType", strawberry.lazy('core.graphql.types')] | None diff --git a/netbox/dcim/migrations/0205_moduletypeprofile.py b/netbox/dcim/migrations/0205_moduletypeprofile.py index 25ab3415b11..8e3ca9f74fe 100644 --- a/netbox/dcim/migrations/0205_moduletypeprofile.py +++ b/netbox/dcim/migrations/0205_moduletypeprofile.py @@ -3,6 +3,7 @@ from django.db import migrations, models import utilities.json +import utilities.jsonschema class Migration(migrations.Migration): @@ -25,7 +26,7 @@ class Migration(migrations.Migration): ('description', models.CharField(blank=True, max_length=200)), ('comments', models.TextField(blank=True)), ('name', models.CharField(max_length=100, unique=True)), - ('schema', models.JSONField(blank=True, null=True)), + ('schema', models.JSONField(blank=True, null=True, validators=[utilities.jsonschema.validate_schema])), ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), ], options={ diff --git a/netbox/dcim/models/modules.py b/netbox/dcim/models/modules.py index f60162fe907..4376f40aa47 100644 --- a/netbox/dcim/models/modules.py +++ b/netbox/dcim/models/modules.py @@ -36,7 +36,8 @@ class ModuleTypeProfile(PrimaryModel): schema = models.JSONField( blank=True, null=True, - verbose_name=_('schema') + validators=[validate_schema], + verbose_name=_('schema'), ) clone_fields = ('schema',) @@ -49,18 +50,6 @@ class Meta: def __str__(self): return self.name - def clean(self): - super().clean() - - # Validate the schema definition - if self.schema is not None: - try: - validate_schema(self.schema) - except ValidationError as e: - raise ValidationError({ - 'schema': e.message, - }) - class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin): """ diff --git a/netbox/extras/api/serializers_/configcontexts.py b/netbox/extras/api/serializers_/configcontexts.py index 4a3f25e2eac..ff85f0fc69c 100644 --- a/netbox/extras/api/serializers_/configcontexts.py +++ b/netbox/extras/api/serializers_/configcontexts.py @@ -6,7 +6,7 @@ from dcim.api.serializers_.roles import DeviceRoleSerializer from dcim.api.serializers_.sites import LocationSerializer, RegionSerializer, SiteSerializer, SiteGroupSerializer from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup -from extras.models import ConfigContext, Tag +from extras.models import ConfigContext, ConfigContextProfile, Tag from netbox.api.fields import SerializedPKRelatedField from netbox.api.serializers import ChangeLogMessageSerializer, ValidatedModelSerializer from tenancy.api.serializers_.tenants import TenantSerializer, TenantGroupSerializer @@ -15,11 +15,43 @@ from virtualization.models import Cluster, ClusterGroup, ClusterType __all__ = ( + 'ConfigContextProfileSerializer', 'ConfigContextSerializer', ) +class ConfigContextProfileSerializer(ChangeLogMessageSerializer, ValidatedModelSerializer): + tags = serializers.SlugRelatedField( + queryset=Tag.objects.all(), + slug_field='slug', + required=False, + many=True + ) + data_source = DataSourceSerializer( + nested=True, + required=False + ) + data_file = DataFileSerializer( + nested=True, + read_only=True + ) + + class Meta: + model = ConfigContextProfile + fields = [ + 'id', 'url', 'display_url', 'display', 'name', 'description', 'schema', 'tags', 'comments', 'data_source', + 'data_path', 'data_file', 'data_synced', 'created', 'last_updated', + ] + brief_fields = ('id', 'url', 'display', 'name', 'description') + + class ConfigContextSerializer(ChangeLogMessageSerializer, ValidatedModelSerializer): + profile = ConfigContextProfileSerializer( + nested=True, + required=False, + allow_null=True, + default=None, + ) regions = SerializedPKRelatedField( queryset=Region.objects.all(), serializer=RegionSerializer, @@ -122,9 +154,9 @@ class ConfigContextSerializer(ChangeLogMessageSerializer, ValidatedModelSerializ class Meta: model = ConfigContext fields = [ - 'id', 'url', 'display_url', 'display', 'name', 'weight', 'description', 'is_active', 'regions', + 'id', 'url', 'display_url', 'display', 'name', 'weight', 'profile', 'description', 'is_active', 'regions', 'site_groups', 'sites', 'locations', 'device_types', 'roles', 'platforms', 'cluster_types', - 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data_source', 'data_path', - 'data_file', 'data_synced', 'data', 'created', 'last_updated', + 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data_source', 'data_path', 'data_file', + 'data_synced', 'data', 'created', 'last_updated', ] brief_fields = ('id', 'url', 'display', 'name', 'description') diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py index bd4a60f6e8b..3757157b43c 100644 --- a/netbox/extras/api/urls.py +++ b/netbox/extras/api/urls.py @@ -25,6 +25,7 @@ router.register('image-attachments', views.ImageAttachmentViewSet) router.register('journal-entries', views.JournalEntryViewSet) router.register('config-contexts', views.ConfigContextViewSet) +router.register('config-context-profiles', views.ConfigContextProfileViewSet) router.register('config-templates', views.ConfigTemplateViewSet) router.register('scripts', views.ScriptViewSet, basename='script') diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 289bf016a1e..f333d5dbf41 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -217,6 +217,12 @@ class JournalEntryViewSet(NetBoxModelViewSet): # Config contexts # +class ConfigContextProfileViewSet(SyncedDataMixin, NetBoxModelViewSet): + queryset = ConfigContextProfile.objects.all() + serializer_class = serializers.ConfigContextProfileSerializer + filterset_class = filtersets.ConfigContextProfileFilterSet + + class ConfigContextViewSet(SyncedDataMixin, NetBoxModelViewSet): queryset = ConfigContext.objects.all() serializer_class = serializers.ConfigContextSerializer diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index f711ddc011d..f34b2137087 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -19,6 +19,7 @@ __all__ = ( 'BookmarkFilterSet', 'ConfigContextFilterSet', + 'ConfigContextProfileFilterSet', 'ConfigTemplateFilterSet', 'CustomFieldChoiceSetFilterSet', 'CustomFieldFilterSet', @@ -588,11 +589,51 @@ def search(self, queryset, name, value): ) +class ConfigContextProfileFilterSet(NetBoxModelFilterSet): + q = django_filters.CharFilter( + method='search', + label=_('Search'), + ) + 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 = ConfigContextProfile + fields = ( + 'id', 'name', 'description', 'auto_sync_enabled', 'data_synced', + ) + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(name__icontains=value) | + Q(description__icontains=value) | + Q(comments__icontains=value) + ) + + class ConfigContextFilterSet(ChangeLoggedModelFilterSet): q = django_filters.CharFilter( method='search', label=_('Search'), ) + profile_id = django_filters.ModelMultipleChoiceFilter( + queryset=ConfigContextProfile.objects.all(), + label=_('Profile (ID)'), + ) + profile = django_filters.ModelMultipleChoiceFilter( + field_name='profile__name', + queryset=ConfigContextProfile.objects.all(), + to_field_name='name', + label=_('Profile (name)'), + ) region_id = django_filters.ModelMultipleChoiceFilter( field_name='regions', queryset=Region.objects.all(), diff --git a/netbox/extras/forms/bulk_edit.py b/netbox/extras/forms/bulk_edit.py index 258910a8c64..82f3d04c41b 100644 --- a/netbox/extras/forms/bulk_edit.py +++ b/netbox/extras/forms/bulk_edit.py @@ -13,6 +13,7 @@ __all__ = ( 'ConfigContextBulkEditForm', + 'ConfigContextProfileBulkEditForm', 'ConfigTemplateBulkEditForm', 'CustomFieldBulkEditForm', 'CustomFieldChoiceSetBulkEditForm', @@ -317,6 +318,25 @@ class TagBulkEditForm(ChangelogMessageMixin, BulkEditForm): nullable_fields = ('description',) +class ConfigContextProfileBulkEditForm(NetBoxModelBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=ConfigContextProfile.objects.all(), + widget=forms.MultipleHiddenInput + ) + description = forms.CharField( + label=_('Description'), + required=False, + max_length=100 + ) + comments = CommentField() + + model = ConfigContextProfile + fieldsets = ( + FieldSet('description',), + ) + nullable_fields = ('description',) + + class ConfigContextBulkEditForm(ChangelogMessageMixin, BulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=ConfigContext.objects.all(), @@ -327,6 +347,10 @@ class ConfigContextBulkEditForm(ChangelogMessageMixin, BulkEditForm): required=False, min_value=0 ) + profile = DynamicModelChoiceField( + queryset=ConfigContextProfile.objects.all(), + required=False + ) is_active = forms.NullBooleanField( label=_('Is active'), required=False, @@ -338,7 +362,10 @@ class ConfigContextBulkEditForm(ChangelogMessageMixin, BulkEditForm): max_length=100 ) - nullable_fields = ('description',) + fieldsets = ( + FieldSet('weight', 'profile', 'is_active', 'description'), + ) + nullable_fields = ('profile', 'description') class ConfigTemplateBulkEditForm(ChangelogMessageMixin, BulkEditForm): diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py index cf15495cacd..4f7c85e8581 100644 --- a/netbox/extras/forms/bulk_import.py +++ b/netbox/extras/forms/bulk_import.py @@ -18,6 +18,7 @@ ) __all__ = ( + 'ConfigContextProfileImportForm', 'ConfigTemplateImportForm', 'CustomFieldChoiceSetImportForm', 'CustomFieldImportForm', @@ -149,6 +150,15 @@ class Meta: ) +class ConfigContextProfileImportForm(NetBoxModelImportForm): + + class Meta: + model = ConfigContextProfile + fields = [ + 'name', 'description', 'schema', 'comments', 'tags', + ] + + class ConfigTemplateImportForm(CSVModelForm): class Meta: diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index 27881f17a95..675315bed08 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -20,6 +20,7 @@ __all__ = ( 'ConfigContextFilterForm', + 'ConfigContextProfileFilterForm', 'ConfigTemplateFilterForm', 'CustomFieldChoiceSetFilterForm', 'CustomFieldFilterForm', @@ -354,16 +355,43 @@ class TagFilterForm(SavedFiltersMixin, FilterForm): ) +class ConfigContextProfileFilterForm(SavedFiltersMixin, FilterForm): + model = ConfigContextProfile + fieldsets = ( + FieldSet('q', 'filter_id'), + FieldSet('data_source_id', 'data_file_id', name=_('Data')), + ) + 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' + } + ) + + class ConfigContextFilterForm(SavedFiltersMixin, FilterForm): model = ConfigContext fieldsets = ( FieldSet('q', 'filter_id', 'tag_id'), + FieldSet('profile', name=_('Config Context')), FieldSet('data_source_id', 'data_file_id', name=_('Data')), FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')), FieldSet('device_type_id', 'platform_id', 'device_role_id', name=_('Device')), FieldSet('cluster_type_id', 'cluster_group_id', 'cluster_id', name=_('Cluster')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')) ) + profile_id = DynamicModelMultipleChoiceField( + queryset=ConfigContextProfile.objects.all(), + required=False, + label=_('Profile') + ) data_source_id = DynamicModelMultipleChoiceField( queryset=DataSource.objects.all(), required=False, diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py index 13499fc2e5a..37ee10604e3 100644 --- a/netbox/extras/forms/model_forms.py +++ b/netbox/extras/forms/model_forms.py @@ -29,6 +29,7 @@ __all__ = ( 'BookmarkForm', 'ConfigContextForm', + 'ConfigContextProfileForm', 'ConfigTemplateForm', 'CustomFieldChoiceSetForm', 'CustomFieldForm', @@ -585,7 +586,36 @@ class Meta: ] +class ConfigContextProfileForm(SyncedDataMixin, NetBoxModelForm): + schema = JSONField( + label=_('Schema'), + required=False, + help_text=_("Enter a valid JSON schema to define supported attributes.") + ) + tags = DynamicModelMultipleChoiceField( + label=_('Tags'), + queryset=Tag.objects.all(), + required=False + ) + + fieldsets = ( + FieldSet('name', 'description', 'schema', 'tags', name=_('Config Context Profile')), + FieldSet('data_source', 'data_file', 'auto_sync_enabled', name=_('Data Source')), + ) + + class Meta: + model = ConfigContextProfile + fields = ( + 'name', 'description', 'schema', 'data_source', 'data_file', 'auto_sync_enabled', 'comments', 'tags', + ) + + class ConfigContextForm(ChangelogMessageMixin, SyncedDataMixin, forms.ModelForm): + profile = DynamicModelChoiceField( + label=_('Profile'), + queryset=ConfigContextProfile.objects.all(), + required=False + ) regions = DynamicModelMultipleChoiceField( label=_('Regions'), queryset=Region.objects.all(), @@ -657,7 +687,7 @@ class ConfigContextForm(ChangelogMessageMixin, SyncedDataMixin, forms.ModelForm) ) fieldsets = ( - FieldSet('name', 'weight', 'description', 'data', 'is_active', name=_('Config Context')), + FieldSet('name', 'weight', 'profile', 'description', 'data', 'is_active', name=_('Config Context')), FieldSet('data_source', 'data_file', 'auto_sync_enabled', name=_('Data Source')), FieldSet( 'regions', 'site_groups', 'sites', 'locations', 'device_types', 'roles', 'platforms', 'cluster_types', @@ -669,9 +699,9 @@ class ConfigContextForm(ChangelogMessageMixin, SyncedDataMixin, forms.ModelForm) class Meta: model = ConfigContext 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', 'data_source', 'data_file', 'auto_sync_enabled', + 'name', 'weight', 'profile', 'description', 'data', 'is_active', 'regions', 'site_groups', 'sites', + 'locations', 'roles', 'device_types', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', + 'tenant_groups', 'tenants', 'tags', 'data_source', 'data_file', 'auto_sync_enabled', ) def __init__(self, *args, initial=None, **kwargs): diff --git a/netbox/extras/graphql/filters.py b/netbox/extras/graphql/filters.py index 1712b705639..dda9d947be9 100644 --- a/netbox/extras/graphql/filters.py +++ b/netbox/extras/graphql/filters.py @@ -8,7 +8,7 @@ from core.graphql.filter_mixins import BaseObjectTypeFilterMixin, ChangeLogFilterMixin from extras import models from extras.graphql.filter_mixins import TagBaseFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin -from netbox.graphql.filter_mixins import SyncedDataFilterMixin +from netbox.graphql.filter_mixins import PrimaryModelFilterMixin, SyncedDataFilterMixin if TYPE_CHECKING: from core.graphql.filters import ContentTypeFilter @@ -24,6 +24,7 @@ __all__ = ( 'ConfigContextFilter', + 'ConfigContextProfileFilter', 'ConfigTemplateFilter', 'CustomFieldFilter', 'CustomFieldChoiceSetFilter', @@ -97,6 +98,13 @@ class ConfigContextFilter(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, Chan ) +@strawberry_django.filter_type(models.ConfigContextProfile, lookups=True) +class ConfigContextProfileFilter(SyncedDataFilterMixin, PrimaryModelFilterMixin): + name: FilterLookup[str] = strawberry_django.filter_field() + description: FilterLookup[str] = strawberry_django.filter_field() + tags: Annotated['TagFilter', strawberry.lazy('extras.graphql.filters')] | None = strawberry_django.filter_field() + + @strawberry_django.filter_type(models.ConfigTemplate, lookups=True) class ConfigTemplateFilter(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, ChangeLogFilterMixin): name: FilterLookup[str] | None = strawberry_django.filter_field() diff --git a/netbox/extras/graphql/schema.py b/netbox/extras/graphql/schema.py index 947ff0b00a2..60d596f01ac 100644 --- a/netbox/extras/graphql/schema.py +++ b/netbox/extras/graphql/schema.py @@ -11,6 +11,9 @@ class ExtrasQuery: config_context: ConfigContextType = strawberry_django.field() config_context_list: List[ConfigContextType] = strawberry_django.field() + config_context_profile: ConfigContextProfileType = strawberry_django.field() + config_context_profile_list: List[ConfigContextProfileType] = strawberry_django.field() + config_template: ConfigTemplateType = strawberry_django.field() config_template_list: List[ConfigTemplateType] = strawberry_django.field() diff --git a/netbox/extras/graphql/types.py b/netbox/extras/graphql/types.py index 4bd836f6ba1..97637684e15 100644 --- a/netbox/extras/graphql/types.py +++ b/netbox/extras/graphql/types.py @@ -3,13 +3,13 @@ import strawberry import strawberry_django +from core.graphql.mixins import SyncedDataMixin from extras import models from extras.graphql.mixins import CustomFieldsMixin, TagsMixin -from netbox.graphql.types import BaseObjectType, ContentTypeType, ObjectType, OrganizationalObjectType +from netbox.graphql.types import BaseObjectType, ContentTypeType, NetBoxObjectType, ObjectType, OrganizationalObjectType from .filters import * if TYPE_CHECKING: - from core.graphql.types import DataFileType, DataSourceType from dcim.graphql.types import ( DeviceRoleType, DeviceType, @@ -25,6 +25,7 @@ from virtualization.graphql.types import ClusterGroupType, ClusterType, ClusterTypeType, VirtualMachineType __all__ = ( + 'ConfigContextProfileType', 'ConfigContextType', 'ConfigTemplateType', 'CustomFieldChoiceSetType', @@ -44,15 +45,24 @@ ) +@strawberry_django.type( + models.ConfigContextProfile, + fields='__all__', + filters=ConfigContextProfileFilter, + pagination=True +) +class ConfigContextProfileType(SyncedDataMixin, NetBoxObjectType): + pass + + @strawberry_django.type( models.ConfigContext, fields='__all__', filters=ConfigContextFilter, pagination=True ) -class ConfigContextType(ObjectType): - data_source: Annotated["DataSourceType", strawberry.lazy('core.graphql.types')] | None - data_file: Annotated["DataFileType", strawberry.lazy('core.graphql.types')] | None +class ConfigContextType(SyncedDataMixin, ObjectType): + profile: ConfigContextProfileType | None roles: List[Annotated["DeviceRoleType", strawberry.lazy('dcim.graphql.types')]] device_types: List[Annotated["DeviceTypeType", strawberry.lazy('dcim.graphql.types')]] tags: List[Annotated["TagType", strawberry.lazy('extras.graphql.types')]] @@ -74,10 +84,7 @@ class ConfigContextType(ObjectType): filters=ConfigTemplateFilter, pagination=True ) -class ConfigTemplateType(TagsMixin, ObjectType): - data_source: Annotated["DataSourceType", strawberry.lazy('core.graphql.types')] | None - data_file: Annotated["DataFileType", strawberry.lazy('core.graphql.types')] | None - +class ConfigTemplateType(SyncedDataMixin, TagsMixin, ObjectType): virtualmachines: List[Annotated["VirtualMachineType", strawberry.lazy('virtualization.graphql.types')]] devices: List[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]] platforms: List[Annotated["PlatformType", strawberry.lazy('dcim.graphql.types')]] @@ -123,9 +130,8 @@ class CustomLinkType(ObjectType): filters=ExportTemplateFilter, pagination=True ) -class ExportTemplateType(ObjectType): - data_source: Annotated["DataSourceType", strawberry.lazy('core.graphql.types')] | None - data_file: Annotated["DataFileType", strawberry.lazy('core.graphql.types')] | None +class ExportTemplateType(SyncedDataMixin, ObjectType): + pass @strawberry_django.type( diff --git a/netbox/extras/migrations/0132_configcontextprofile.py b/netbox/extras/migrations/0132_configcontextprofile.py new file mode 100644 index 00000000000..adf9a9b83b4 --- /dev/null +++ b/netbox/extras/migrations/0132_configcontextprofile.py @@ -0,0 +1,75 @@ +# Generated by Django 5.2.4 on 2025-08-08 16:40 + +import django.db.models.deletion +import netbox.models.deletion +import taggit.managers +import utilities.json +import utilities.jsonschema +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('core', '0018_concrete_objecttype'), + ('extras', '0131_concrete_objecttype'), + ] + + operations = [ + migrations.CreateModel( + name='ConfigContextProfile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ('created', models.DateTimeField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ( + 'custom_field_data', + models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder), + ), + ('data_path', models.CharField(blank=True, editable=False, max_length=1000)), + ('auto_sync_enabled', models.BooleanField(default=False)), + ('data_synced', models.DateTimeField(blank=True, editable=False, null=True)), + ('comments', models.TextField(blank=True)), + ('name', models.CharField(max_length=100, unique=True)), + ('description', models.CharField(blank=True, max_length=200)), + ('schema', models.JSONField(blank=True, null=True, validators=[utilities.jsonschema.validate_schema])), + ( + 'data_file', + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='+', + to='core.datafile', + ), + ), + ( + 'data_source', + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name='+', + to='core.datasource', + ), + ), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ], + options={ + 'verbose_name': 'config context profile', + 'verbose_name_plural': 'config context profiles', + 'ordering': ('name',), + }, + bases=(netbox.models.deletion.DeleteMixin, models.Model), + ), + migrations.AddField( + model_name='configcontext', + name='profile', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name='config_contexts', + to='extras.configcontextprofile', + ), + ), + ] diff --git a/netbox/extras/models/configs.py b/netbox/extras/models/configs.py index f92c6663269..a9d233568e6 100644 --- a/netbox/extras/models/configs.py +++ b/netbox/extras/models/configs.py @@ -1,4 +1,6 @@ +import jsonschema from collections import defaultdict +from jsonschema.exceptions import ValidationError as JSONValidationError from django.conf import settings from django.core.validators import ValidationError @@ -9,13 +11,15 @@ from core.models import ObjectType from extras.models.mixins import RenderTemplateMixin from extras.querysets import ConfigContextQuerySet -from netbox.models import ChangeLoggedModel +from netbox.models import ChangeLoggedModel, PrimaryModel from netbox.models.features import CloningMixin, CustomLinksMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin from utilities.data import deepmerge +from utilities.jsonschema import validate_schema __all__ = ( 'ConfigContext', 'ConfigContextModel', + 'ConfigContextProfile', 'ConfigTemplate', ) @@ -24,6 +28,46 @@ # Config contexts # +class ConfigContextProfile(SyncedDataMixin, PrimaryModel): + """ + A profile which can be used to enforce parameters on a ConfigContext. + """ + name = models.CharField( + verbose_name=_('name'), + max_length=100, + unique=True + ) + description = models.CharField( + verbose_name=_('description'), + max_length=200, + blank=True + ) + schema = models.JSONField( + blank=True, + null=True, + validators=[validate_schema], + verbose_name=_('schema'), + help_text=_('A JSON schema specifying the structure of the context data for this profile') + ) + + clone_fields = ('schema',) + + class Meta: + ordering = ('name',) + verbose_name = _('config context profile') + verbose_name_plural = _('config context profiles') + + def __str__(self): + return self.name + + def sync_data(self): + """ + Synchronize schema from the designated DataFile (if any). + """ + self.schema = self.data_file.get_data() + sync_data.alters_data = True + + class ConfigContext(SyncedDataMixin, CloningMixin, CustomLinksMixin, ChangeLoggedModel): """ A ConfigContext represents a set of arbitrary data available to any Device or VirtualMachine matching its assigned @@ -35,6 +79,13 @@ class ConfigContext(SyncedDataMixin, CloningMixin, CustomLinksMixin, ChangeLogge max_length=100, unique=True ) + profile = models.ForeignKey( + to='extras.ConfigContextProfile', + on_delete=models.PROTECT, + blank=True, + null=True, + related_name='config_contexts', + ) weight = models.PositiveSmallIntegerField( verbose_name=_('weight'), default=1000 @@ -118,9 +169,8 @@ class ConfigContext(SyncedDataMixin, CloningMixin, CustomLinksMixin, ChangeLogge objects = ConfigContextQuerySet.as_manager() clone_fields = ( - 'weight', 'is_active', 'regions', 'site_groups', 'sites', 'locations', 'device_types', - 'roles', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', - 'tenants', 'tags', 'data', + 'weight', 'profile', 'is_active', 'regions', 'site_groups', 'sites', 'locations', 'device_types', 'roles', + 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data', ) class Meta: @@ -147,6 +197,13 @@ def clean(self): {'data': _('JSON data must be in object form. Example:') + ' {"foo": 123}'} ) + # Validate config data against the assigned profile's schema (if any) + if self.profile and self.profile.schema: + try: + jsonschema.validate(self.data, schema=self.profile.schema) + except JSONValidationError as e: + raise ValidationError(_("Data does not conform to profile schema: {error}").format(error=e)) + def sync_data(self): """ Synchronize context data from the designated DataFile (if any). diff --git a/netbox/extras/search.py b/netbox/extras/search.py index 68861869008..67a20d0174b 100644 --- a/netbox/extras/search.py +++ b/netbox/extras/search.py @@ -2,6 +2,17 @@ from . import models +@register_search +class ConfigContextProfileIndex(SearchIndex): + model = models.ConfigContextProfile + fields = ( + ('name', 100), + ('description', 500), + ('comments', 5000), + ) + display_attrs = ('description',) + + @register_search class CustomFieldIndex(SearchIndex): model = models.CustomField diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index 2af89bc9a19..2a8d89a5caf 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -15,6 +15,7 @@ __all__ = ( 'BookmarkTable', + 'ConfigContextProfileTable', 'ConfigContextTable', 'ConfigTemplateTable', 'CustomFieldChoiceSetTable', @@ -546,7 +547,41 @@ class Meta(NetBoxTable.Meta): fields = ('id', 'content_type', 'content_object') +class ConfigContextProfileTable(NetBoxTable): + name = tables.Column( + verbose_name=_('Name'), + linkify=True + ) + data_source = tables.Column( + verbose_name=_('Data Source'), + linkify=True + ) + data_file = tables.Column( + verbose_name=_('Data File'), + linkify=True + ) + is_synced = columns.BooleanColumn( + orderable=False, + verbose_name=_('Synced') + ) + tags = columns.TagColumn( + url_name='extras:configcontextprofile_list' + ) + + class Meta(NetBoxTable.Meta): + model = ConfigContextProfile + fields = ( + 'pk', 'id', 'name', 'description', 'comments', 'data_source', 'data_file', 'is_synced', 'tags', 'created', + 'last_updated', + ) + default_columns = ('pk', 'name', 'is_synced', 'description') + + class ConfigContextTable(NetBoxTable): + profile = tables.Column( + linkify=True, + verbose_name=_('Profile'), + ) data_source = tables.Column( verbose_name=_('Data Source'), linkify=True @@ -573,11 +608,11 @@ class ConfigContextTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = ConfigContext fields = ( - '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', 'tags', 'created', 'last_updated', + 'pk', 'id', 'name', 'weight', 'profile', 'is_active', 'is_synced', 'description', 'regions', 'sites', + 'locations', 'roles', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', + 'tenants', 'data_source', 'data_file', 'data_synced', 'tags', 'created', 'last_updated', ) - default_columns = ('pk', 'name', 'weight', 'is_active', 'is_synced', 'description') + default_columns = ('pk', 'name', 'weight', 'profile', 'is_active', 'is_synced', 'description') class ConfigTemplateTable(NetBoxTable): diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index b5a6cb018b2..d635916e427 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -666,6 +666,70 @@ def setUpTestData(cls): ] +class ConfigContextProfileTest(APIViewTestCases.APIViewTestCase): + model = ConfigContextProfile + brief_fields = ['description', 'display', 'id', 'name', 'url'] + create_data = [ + { + 'name': 'Config Context Profile 4', + }, + { + 'name': 'Config Context Profile 5', + }, + { + 'name': 'Config Context Profile 6', + }, + ] + bulk_update_data = { + 'description': 'New description', + } + + @classmethod + def setUpTestData(cls): + profiles = ( + ConfigContextProfile( + name='Config Context Profile 1', + schema={ + "properties": { + "foo": { + "type": "string" + } + }, + "required": [ + "foo" + ] + } + ), + ConfigContextProfile( + name='Config Context Profile 2', + schema={ + "properties": { + "bar": { + "type": "string" + } + }, + "required": [ + "bar" + ] + } + ), + ConfigContextProfile( + name='Config Context Profile 3', + schema={ + "properties": { + "baz": { + "type": "string" + } + }, + "required": [ + "baz" + ] + } + ), + ) + ConfigContextProfile.objects.bulk_create(profiles) + + class ConfigContextTest(APIViewTestCases.APIViewTestCase): model = ConfigContext brief_fields = ['description', 'display', 'id', 'name', 'url'] diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py index f9147a30c9b..e7f53151fe7 100644 --- a/netbox/extras/tests/test_filtersets.py +++ b/netbox/extras/tests/test_filtersets.py @@ -871,6 +871,39 @@ def test_created(self): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) +class ConfigContextProfileTestCase(TestCase, ChangeLoggedFilterSetTests): + queryset = ConfigContextProfile.objects.all() + filterset = ConfigContextProfileFilterSet + ignore_fields = ('schema', 'data_path') + + @classmethod + def setUpTestData(cls): + profiles = ( + ConfigContextProfile( + name='Config Context Profile 1', + description='foo', + ), + ConfigContextProfile( + name='Config Context Profile 2', + description='bar', + ), + ConfigContextProfile( + name='Config Context Profile 3', + description='baz', + ), + ) + ConfigContextProfile.objects.bulk_create(profiles) + + def test_q(self): + params = {'q': 'foo'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_name(self): + profiles = self.queryset.all()[:2] + params = {'name': [profiles[0].name, profiles[1].name]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = ConfigContext.objects.all() filterset = ConfigContextFilterSet @@ -878,6 +911,12 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests): @classmethod def setUpTestData(cls): + profiles = ( + ConfigContextProfile(name='Config Context Profile 1'), + ConfigContextProfile(name='Config Context Profile 2'), + ConfigContextProfile(name='Config Context Profile 3'), + ) + ConfigContextProfile.objects.bulk_create(profiles) regions = ( Region(name='Region 1', slug='region-1'), @@ -975,6 +1014,7 @@ def setUpTestData(cls): is_active = bool(i % 2) c = ConfigContext.objects.create( name=f"Config Context {i + 1}", + profile=profiles[i], is_active=is_active, data='{"foo": 123}', description=f"foobar{i + 1}" @@ -1011,6 +1051,13 @@ def test_description(self): params = {'description': ['foobar1', 'foobar2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_profile(self): + profiles = ConfigContextProfile.objects.all()[:2] + params = {'profile_id': [profiles[0].pk, profiles[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'profile': [profiles[0].name, profiles[1].name]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_region(self): regions = Region.objects.all()[:2] params = {'region_id': [regions[0].pk, regions[1].pk]} @@ -1184,6 +1231,7 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests): 'cluster', 'clustergroup', 'clustertype', + 'configcontextprofile', 'configtemplate', 'consoleport', 'consoleserverport', diff --git a/netbox/extras/tests/test_models.py b/netbox/extras/tests/test_models.py index 6b718569c49..341920a81a3 100644 --- a/netbox/extras/tests/test_models.py +++ b/netbox/extras/tests/test_models.py @@ -6,7 +6,7 @@ from core.models import DataSource, ObjectType from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Platform, Region, Site, SiteGroup -from extras.models import ConfigContext, ConfigTemplate, Tag +from extras.models import ConfigContext, ConfigContextProfile, ConfigTemplate, Tag from tenancy.models import Tenant, TenantGroup from utilities.exceptions import AbortRequest from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine @@ -159,6 +159,32 @@ def test_name_ordering_after_weight(self): } self.assertEqual(device.get_config_context(), expected_data) + def test_schema_validation(self): + """ + Check that the JSON schema defined by the assigned profile is enforced. + """ + profile = ConfigContextProfile.objects.create( + name="Config context profile 1", + schema={ + "properties": { + "foo": { + "type": "string" + } + }, + "required": [ + "foo" + ] + } + ) + + with self.assertRaises(ValidationError): + # Missing required attribute + ConfigContext(name="CC1", profile=profile, data={}).clean() + with self.assertRaises(ValidationError): + # Invalid attribute type + ConfigContext(name="CC1", profile=profile, data={"foo": 123}).clean() + ConfigContext(name="CC1", profile=profile, data={"foo": "bar"}).clean() + def test_annotation_same_as_get_for_object(self): """ This test incorporates features from all of the above tests cases to ensure diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index fd3ce54534a..767198805c9 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -481,6 +481,78 @@ def setUpTestData(cls): } +class ConfigContextProfileTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = ConfigContextProfile + + @classmethod + def setUpTestData(cls): + profiles = ( + ConfigContextProfile( + name='Config Context Profile 1', + schema={ + "properties": { + "foo": { + "type": "string" + } + }, + "required": [ + "foo" + ] + } + ), + ConfigContextProfile( + name='Config Context Profile 2', + schema={ + "properties": { + "bar": { + "type": "string" + } + }, + "required": [ + "bar" + ] + } + ), + ConfigContextProfile( + name='Config Context Profile 3', + schema={ + "properties": { + "baz": { + "type": "string" + } + }, + "required": [ + "baz" + ] + } + ), + ) + ConfigContextProfile.objects.bulk_create(profiles) + + cls.form_data = { + 'name': 'Config Context Profile X', + 'description': 'A new config context profile', + } + + cls.bulk_edit_data = { + 'description': 'New description', + } + + cls.csv_data = ( + 'name,description', + 'Config context profile 1,Foo', + 'Config context profile 2,Bar', + 'Config context profile 3,Baz', + ) + + cls.csv_update_data = ( + "id,description", + f"{profiles[0].pk},New description", + f"{profiles[1].pk},New description", + f"{profiles[2].pk},New description", + ) + + # TODO: Change base class to PrimaryObjectViewTestCase # Blocked by absence of standard create/edit, bulk create views class ConfigContextTestCase( diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index ca07ba9036c..5635f0c010f 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -47,6 +47,9 @@ path('tags/', include(get_model_urls('extras', 'tag', detail=False))), path('tags//', include(get_model_urls('extras', 'tag'))), + path('config-context-profiles/', include(get_model_urls('extras', 'configcontextprofile', detail=False))), + path('config-context-profiles//', include(get_model_urls('extras', 'configcontextprofile'))), + path('config-contexts/', include(get_model_urls('extras', 'configcontext', detail=False))), path('config-contexts//', include(get_model_urls('extras', 'configcontext'))), diff --git a/netbox/extras/views.py b/netbox/extras/views.py index a2664a2c28f..af7e92c30cb 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -793,6 +793,67 @@ class TagBulkDeleteView(generic.BulkDeleteView): table = tables.TagTable +# +# Config context profiles +# + +@register_model_view(ConfigContextProfile, 'list', path='', detail=False) +class ConfigContextProfileListView(generic.ObjectListView): + queryset = ConfigContextProfile.objects.all() + filterset = filtersets.ConfigContextProfileFilterSet + filterset_form = forms.ConfigContextProfileFilterForm + table = tables.ConfigContextProfileTable + actions = (AddObject, BulkSync, BulkEdit, BulkRename, BulkDelete) + + +@register_model_view(ConfigContextProfile) +class ConfigContextProfileView(generic.ObjectView): + queryset = ConfigContextProfile.objects.all() + + +@register_model_view(ConfigContextProfile, 'add', detail=False) +@register_model_view(ConfigContextProfile, 'edit') +class ConfigContextProfileEditView(generic.ObjectEditView): + queryset = ConfigContextProfile.objects.all() + form = forms.ConfigContextProfileForm + + +@register_model_view(ConfigContextProfile, 'delete') +class ConfigContextProfileDeleteView(generic.ObjectDeleteView): + queryset = ConfigContextProfile.objects.all() + + +@register_model_view(ConfigContextProfile, 'bulk_import', path='import', detail=False) +class ConfigContextProfileBulkImportView(generic.BulkImportView): + queryset = ConfigContextProfile.objects.all() + model_form = forms.ConfigContextProfileImportForm + + +@register_model_view(ConfigContextProfile, 'bulk_edit', path='edit', detail=False) +class ConfigContextProfileBulkEditView(generic.BulkEditView): + queryset = ConfigContextProfile.objects.all() + filterset = filtersets.ConfigContextProfileFilterSet + table = tables.ConfigContextProfileTable + form = forms.ConfigContextProfileBulkEditForm + + +@register_model_view(ConfigContextProfile, 'bulk_rename', path='rename', detail=False) +class ConfigContextProfileBulkRenameView(generic.BulkRenameView): + queryset = ConfigContextProfile.objects.all() + + +@register_model_view(ConfigContextProfile, 'bulk_delete', path='delete', detail=False) +class ConfigContextProfileBulkDeleteView(generic.BulkDeleteView): + queryset = ConfigContextProfile.objects.all() + filterset = filtersets.ConfigContextProfileFilterSet + table = tables.ConfigContextProfileTable + + +@register_model_view(ConfigContextProfile, 'bulk_sync', path='sync', detail=False) +class ConfigContextProfileBulkSyncDataView(generic.BulkSyncDataView): + queryset = ConfigContextProfile.objects.all() + + # # Config contexts # diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index ea8d886c4ad..c4aac5bfef9 100644 --- a/netbox/netbox/navigation/menu.py +++ b/netbox/netbox/navigation/menu.py @@ -331,6 +331,7 @@ label=_('Configurations'), items=( get_model_item('extras', 'configcontext', _('Config Contexts'), actions=['add']), + get_model_item('extras', 'configcontextprofile', _('Config Context Profiles')), get_model_item('extras', 'configtemplate', _('Config Templates'), actions=['add']), ), ), diff --git a/netbox/templates/core/inc/datafile_panel.html b/netbox/templates/core/inc/datafile_panel.html new file mode 100644 index 00000000000..e4d8eca74d0 --- /dev/null +++ b/netbox/templates/core/inc/datafile_panel.html @@ -0,0 +1,36 @@ +{% load i18n %} + +
+

{% trans "Data File" %}

+ + + + + + + + + + + + + +
{% trans "Data Source" %} + {% if object.data_source %} + {{ object.data_source }} + {% else %} + {{ ''|placeholder }} + {% endif %} +
{% trans "Data File" %} + {% if object.data_file %} + {{ object.data_file }} + {% elif object.data_path %} +
+ +
+ {{ object.data_path }} + {% else %} + {{ ''|placeholder }} + {% endif %} +
{% trans "Data Synced" %}{{ object.data_synced|placeholder }}
+
diff --git a/netbox/templates/extras/configcontext.html b/netbox/templates/extras/configcontext.html index b33eddaddb9..7afe7355384 100644 --- a/netbox/templates/extras/configcontext.html +++ b/netbox/templates/extras/configcontext.html @@ -17,6 +17,10 @@

{% trans "Config Context" %}

{% trans "Weight" %} {{ object.weight }} + + {% trans "Profile" %} + {{ object.profile|linkify|placeholder }} + {% trans "Description" %} {{ object.description|placeholder }} @@ -25,37 +29,9 @@

{% trans "Config Context" %}

{% trans "Active" %} {% checkmark object.is_active %} - - {% trans "Data Source" %} - - {% if object.data_source %} - {{ object.data_source }} - {% else %} - {{ ''|placeholder }} - {% endif %} - - - - {% trans "Data File" %} - - {% if object.data_file %} - {{ object.data_file }} - {% elif object.data_path %} -
- -
- {{ object.data_path }} - {% else %} - {{ ''|placeholder }} - {% endif %} - - - - {% trans "Data Synced" %} - {{ object.data_synced|placeholder }} - + {% include 'core/inc/datafile_panel.html' %}

{% trans "Assignment" %}

diff --git a/netbox/templates/extras/configcontextprofile.html b/netbox/templates/extras/configcontextprofile.html new file mode 100644 index 00000000000..658724196e2 --- /dev/null +++ b/netbox/templates/extras/configcontextprofile.html @@ -0,0 +1,39 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load static %} +{% load i18n %} + +{% block content %} +
+
+
+

{% trans "Config Context Profile" %}

+
+ + + + + + + + +
{% trans "Name" %}{{ object.name }}
{% trans "Description" %}{{ object.description|placeholder }}
+
+ {% include 'core/inc/datafile_panel.html' %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} + +
+
+

+ {% trans "JSON Schema" %} +
+ {% copy_content "schema" %} +
+

+
{{ object.schema|json }}
+
+
+ +{% endblock %} diff --git a/netbox/templates/extras/exporttemplate.html b/netbox/templates/extras/exporttemplate.html index 60e834c3fef..eb8ec91359c 100644 --- a/netbox/templates/extras/exporttemplate.html +++ b/netbox/templates/extras/exporttemplate.html @@ -35,37 +35,9 @@

{% trans "Export Template" %}

{% trans "Attachment" %} {% checkmark object.as_attachment %} - - {% trans "Data Source" %} - - {% if object.data_source %} - {{ object.data_source }} - {% else %} - {{ ''|placeholder }} - {% endif %} - - - - {% trans "Data File" %} - - {% if object.data_file %} - {{ object.data_file }} - {% elif object.data_path %} -
- -
- {{ object.data_path }} - {% else %} - {{ ''|placeholder }} - {% endif %} - - - - {% trans "Data Synced" %} - {{ object.data_synced|placeholder }} - + {% include 'core/inc/datafile_panel.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/extras/inc/configcontext_data.html b/netbox/templates/extras/inc/configcontext_data.html index ef76a170a44..198025ef568 100644 --- a/netbox/templates/extras/inc/configcontext_data.html +++ b/netbox/templates/extras/inc/configcontext_data.html @@ -10,8 +10,4 @@

{% endif %} -
-
-
{% if format == 'json' %}{{ data|json }}{% elif format == 'yaml' %}{{ data|yaml }}{% else %}{{ data }}{% endif %}
-
-
+
{% if format == 'json' %}{{ data|json }}{% elif format == 'yaml' %}{{ data|yaml }}{% else %}{{ data }}{% endif %}
diff --git a/netbox/utilities/jsonschema.py b/netbox/utilities/jsonschema.py index 724253a50a1..db2907199dd 100644 --- a/netbox/utilities/jsonschema.py +++ b/netbox/utilities/jsonschema.py @@ -154,8 +154,11 @@ def validate_schema(schema): """ Check that a minimum JSON schema definition is defined. """ + # Pass on empty values + if schema in (None, ''): + return # Provide some basic sanity checking (not provided by jsonschema) - if not schema or type(schema) is not dict: + if type(schema) is not dict: raise ValidationError(_("Invalid JSON schema definition")) if not schema.get('properties'): raise ValidationError(_("JSON schema must define properties"))