Jelajahi Sumber

Closes #19377: Introduce config context profiles (#20058)

Jeremy Stretch 6 bulan lalu
induk
melakukan
b4c88541da
34 mengubah file dengan 812 tambahan dan 109 penghapusan
  1. 4 0
      docs/models/extras/configcontext.md
  2. 33 0
      docs/models/extras/configcontextprofile.md
  3. 1 0
      mkdocs.yml
  4. 8 0
      netbox/core/graphql/mixins.py
  5. 2 1
      netbox/dcim/migrations/0205_moduletypeprofile.py
  6. 2 13
      netbox/dcim/models/modules.py
  7. 36 4
      netbox/extras/api/serializers_/configcontexts.py
  8. 1 0
      netbox/extras/api/urls.py
  9. 6 0
      netbox/extras/api/views.py
  10. 41 0
      netbox/extras/filtersets.py
  11. 28 1
      netbox/extras/forms/bulk_edit.py
  12. 10 0
      netbox/extras/forms/bulk_import.py
  13. 28 0
      netbox/extras/forms/filtersets.py
  14. 34 4
      netbox/extras/forms/model_forms.py
  15. 9 1
      netbox/extras/graphql/filters.py
  16. 3 0
      netbox/extras/graphql/schema.py
  17. 18 12
      netbox/extras/graphql/types.py
  18. 75 0
      netbox/extras/migrations/0132_configcontextprofile.py
  19. 61 4
      netbox/extras/models/configs.py
  20. 11 0
      netbox/extras/search.py
  21. 39 4
      netbox/extras/tables/tables.py
  22. 64 0
      netbox/extras/tests/test_api.py
  23. 48 0
      netbox/extras/tests/test_filtersets.py
  24. 27 1
      netbox/extras/tests/test_models.py
  25. 72 0
      netbox/extras/tests/test_views.py
  26. 3 0
      netbox/extras/urls.py
  27. 61 0
      netbox/extras/views.py
  28. 1 0
      netbox/netbox/navigation/menu.py
  29. 36 0
      netbox/templates/core/inc/datafile_panel.html
  30. 5 29
      netbox/templates/extras/configcontext.html
  31. 39 0
      netbox/templates/extras/configcontextprofile.html
  32. 1 29
      netbox/templates/extras/exporttemplate.html
  33. 1 5
      netbox/templates/extras/inc/configcontext_data.html
  34. 4 1
      netbox/utilities/jsonschema.py

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

+ 33 - 0
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).

+ 1 - 0
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'

+ 8 - 0
netbox/core/graphql/mixins.py

@@ -7,10 +7,12 @@ from django.contrib.contenttypes.models import ContentType
 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 @@ class ChangelogMixin:
             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

+ 2 - 1
netbox/dcim/migrations/0205_moduletypeprofile.py

@@ -3,6 +3,7 @@ import taggit.managers
 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={

+ 2 - 13
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 ModuleTypeProfile(PrimaryModel):
     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):
     """

+ 36 - 4
netbox/extras/api/serializers_/configcontexts.py

@@ -6,7 +6,7 @@ from dcim.api.serializers_.platforms import PlatformSerializer
 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.api.serializers_.clusters import ClusterSerializer, ClusterG
 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')

+ 1 - 0
netbox/extras/api/urls.py

@@ -25,6 +25,7 @@ router.register('tagged-objects', views.TaggedItemViewSet)
 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')
 

+ 6 - 0
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

+ 41 - 0
netbox/extras/filtersets.py

@@ -19,6 +19,7 @@ from .models import *
 __all__ = (
     'BookmarkFilterSet',
     'ConfigContextFilterSet',
+    'ConfigContextProfileFilterSet',
     'ConfigTemplateFilterSet',
     'CustomFieldChoiceSetFilterSet',
     'CustomFieldFilterSet',
@@ -588,11 +589,51 @@ class TaggedItemFilterSet(BaseFilterSet):
         )
 
 
+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(),

+ 28 - 1
netbox/extras/forms/bulk_edit.py

@@ -13,6 +13,7 @@ from utilities.forms.widgets import BulkEditNullBooleanSelect
 
 __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):

+ 10 - 0
netbox/extras/forms/bulk_import.py

@@ -18,6 +18,7 @@ from utilities.forms.fields import (
 )
 
 __all__ = (
+    'ConfigContextProfileImportForm',
     'ConfigTemplateImportForm',
     'CustomFieldChoiceSetImportForm',
     'CustomFieldImportForm',
@@ -149,6 +150,15 @@ class ExportTemplateImportForm(CSVModelForm):
         )
 
 
+class ConfigContextProfileImportForm(NetBoxModelImportForm):
+
+    class Meta:
+        model = ConfigContextProfile
+        fields = [
+            'name', 'description', 'schema', 'comments', 'tags',
+        ]
+
+
 class ConfigTemplateImportForm(CSVModelForm):
 
     class Meta:

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

@@ -20,6 +20,7 @@ from virtualization.models import Cluster, ClusterGroup, ClusterType
 
 __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,

+ 34 - 4
netbox/extras/forms/model_forms.py

@@ -29,6 +29,7 @@ from virtualization.models import Cluster, ClusterGroup, ClusterType
 __all__ = (
     'BookmarkForm',
     'ConfigContextForm',
+    'ConfigContextProfileForm',
     'ConfigTemplateForm',
     'CustomFieldChoiceSetForm',
     'CustomFieldForm',
@@ -585,7 +586,36 @@ class TagForm(ChangelogMessageMixin, forms.ModelForm):
         ]
 
 
+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):

+ 9 - 1
netbox/extras/graphql/filters.py

@@ -8,7 +8,7 @@ from strawberry_django import FilterLookup
 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 @@ if TYPE_CHECKING:
 
 __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()

+ 3 - 0
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()
 

+ 18 - 12
netbox/extras/graphql/types.py

@@ -3,13 +3,13 @@ from typing import Annotated, List, TYPE_CHECKING
 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 @@ if TYPE_CHECKING:
     from virtualization.graphql.types import ClusterGroupType, ClusterType, ClusterTypeType, VirtualMachineType
 
 __all__ = (
+    'ConfigContextProfileType',
     'ConfigContextType',
     'ConfigTemplateType',
     'CustomFieldChoiceSetType',
@@ -44,15 +45,24 @@ __all__ = (
 )
 
 
+@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(

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

+ 61 - 4
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 django.utils.translation import gettext_lazy as _
 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 @@ __all__ = (
 # 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 @@ class ConfigContext(SyncedDataMixin, CloningMixin, CustomLinksMixin, ChangeLogge
                 {'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).

+ 11 - 0
netbox/extras/search.py

@@ -2,6 +2,17 @@ from netbox.search import SearchIndex, register_search
 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

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

@@ -15,6 +15,7 @@ from .columns import NotificationActionsColumn
 
 __all__ = (
     'BookmarkTable',
+    'ConfigContextProfileTable',
     'ConfigContextTable',
     'ConfigTemplateTable',
     'CustomFieldChoiceSetTable',
@@ -546,7 +547,41 @@ class TaggedItemTable(NetBoxTable):
         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):

+ 64 - 0
netbox/extras/tests/test_api.py

@@ -666,6 +666,70 @@ class JournalEntryTest(APIViewTestCases.APIViewTestCase):
         ]
 
 
+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']

+ 48 - 0
netbox/extras/tests/test_filtersets.py

@@ -871,6 +871,39 @@ class JournalEntryTestCase(TestCase, ChangeLoggedFilterSetTests):
         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'),
@@ -976,6 +1015,7 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
             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}"
@@ -1012,6 +1052,13 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
         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]}
@@ -1185,6 +1232,7 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
         'cluster',
         'clustergroup',
         'clustertype',
+        'configcontextprofile',
         'configtemplate',
         'consoleport',
         'consoleserverport',

+ 27 - 1
netbox/extras/tests/test_models.py

@@ -6,7 +6,7 @@ from django.test import tag, TestCase
 
 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 @@ class ConfigContextTest(TestCase):
         }
         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

+ 72 - 0
netbox/extras/tests/test_views.py

@@ -481,6 +481,78 @@ class TagTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
         }
 
 
+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(

+ 3 - 0
netbox/extras/urls.py

@@ -47,6 +47,9 @@ urlpatterns = [
     path('tags/', include(get_model_urls('extras', 'tag', detail=False))),
     path('tags/<int:pk>/', include(get_model_urls('extras', 'tag'))),
 
+    path('config-context-profiles/', include(get_model_urls('extras', 'configcontextprofile', detail=False))),
+    path('config-context-profiles/<int:pk>/', include(get_model_urls('extras', 'configcontextprofile'))),
+
     path('config-contexts/', include(get_model_urls('extras', 'configcontext', detail=False))),
     path('config-contexts/<int:pk>/', include(get_model_urls('extras', 'configcontext'))),
 

+ 61 - 0
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
 #

+ 1 - 0
netbox/netbox/navigation/menu.py

@@ -331,6 +331,7 @@ PROVISIONING_MENU = Menu(
             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']),
             ),
         ),

+ 36 - 0
netbox/templates/core/inc/datafile_panel.html

@@ -0,0 +1,36 @@
+{% load i18n %}
+
+<div class="card">
+  <h2 class="card-header">{% trans "Data File" %}</h2>
+  <table class="table table-hover attr-table">
+    <tr>
+      <th scope="row">{% trans "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">{% trans "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="{% trans "The data file associated with this object has been deleted" %}."></i>
+          </div>
+          {{ object.data_path }}
+        {% else %}
+          {{ ''|placeholder }}
+        {% endif %}
+      </td>
+    </tr>
+    <tr>
+      <th scope="row">{% trans "Data Synced" %}</th>
+      <td>{{ object.data_synced|placeholder }}</td>
+    </tr>
+  </table>
+</div>

+ 5 - 29
netbox/templates/extras/configcontext.html

@@ -17,6 +17,10 @@
             <th scope="row">{% trans "Weight" %}</th>
             <td>{{ object.weight }}</td>
           </tr>
+          <tr>
+            <th scope="row">{% trans "Profile" %}</th>
+            <td>{{ object.profile|linkify|placeholder }}</td>
+          </tr>
           <tr>
             <th scope="row">{% trans "Description" %}</th>
             <td>{{ object.description|placeholder }}</td>
@@ -25,37 +29,9 @@
             <th scope="row">{% trans "Active" %}</th>
             <td>{% checkmark object.is_active %}</td>
           </tr>
-          <tr>
-            <th scope="row">{% trans "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">{% trans "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="{% trans "The data file associated with this object has been deleted" %}."></i>
-                </div>
-                {{ object.data_path }}
-              {% else %}
-                {{ ''|placeholder }}
-              {% endif %}
-            </td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Data Synced" %}</th>
-            <td>{{ object.data_synced|placeholder }}</td>
-          </tr>
         </table>
       </div>
+      {% include 'core/inc/datafile_panel.html' %}
       <div class="card">
         <h2 class="card-header">{% trans "Assignment" %}</h2>
         <table class="table table-hover attr-table">

+ 39 - 0
netbox/templates/extras/configcontextprofile.html

@@ -0,0 +1,39 @@
+{% extends 'generic/object.html' %}
+{% load helpers %}
+{% load static %}
+{% load i18n %}
+
+{% block content %}
+  <div class="row">
+    <div class="col col-6">
+      <div class="card">
+        <h2 class="card-header">{% trans "Config Context Profile" %}</h2>
+        <table class="table table-hover attr-table">
+          <tr>
+            <th scope="row">{% trans "Name" %}</th>
+            <td>{{ object.name }}</td>
+          </tr>
+          <tr>
+            <th scope="row">{% trans "Description" %}</th>
+            <td>{{ object.description|placeholder }}</td>
+          </tr>
+        </table>
+      </div>
+      {% include 'core/inc/datafile_panel.html' %}
+      {% include 'inc/panels/custom_fields.html' %}
+      {% include 'inc/panels/tags.html' %}
+      {% include 'inc/panels/comments.html' %}
+    </div>
+    <div class="col col-6">
+      <div class="card">
+        <h2 class="card-header d-flex justify-content-between">
+          {% trans "JSON Schema" %}
+          <div>
+            {% copy_content "schema" %}
+          </div>
+        </h2>
+        <pre class="card-body rendered-context-data m-0" id="schema">{{ object.schema|json }}</pre>
+      </div>
+    </div>
+  </div>
+{% endblock %}

+ 1 - 29
netbox/templates/extras/exporttemplate.html

@@ -35,37 +35,9 @@
             <th scope="row">{% trans "Attachment" %}</th>
             <td>{% checkmark object.as_attachment %}</td>
           </tr>
-            <tr>
-              <th scope="row">{% trans "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">{% trans "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="{% trans "The data file associated with this object has been deleted" %}."></i>
-                  </div>
-                  {{ object.data_path }}
-                {% else %}
-                  {{ ''|placeholder }}
-                {% endif %}
-              </td>
-            </tr>
-            <tr>
-              <th scope="row">{% trans "Data Synced" %}</th>
-              <td>{{ object.data_synced|placeholder }}</td>
-            </tr>
         </table>
       </div>
+      {% include 'core/inc/datafile_panel.html' %}
       {% plugin_left_page object %}
     </div>
     <div class="col col-md-6">

+ 1 - 5
netbox/templates/extras/inc/configcontext_data.html

@@ -10,8 +10,4 @@
     </div>
   </h2>
 {% endif %}
-<div class="card-body">
-  <div class="rendered-context-data mt-1">
-    <pre class="block" {% if copyid %}id="{{ copyid }}{% endif %}">{% if format == 'json' %}{{ data|json }}{% elif format == 'yaml' %}{{ data|yaml }}{% else %}{{ data }}{% endif %}</pre>
-  </div>
-</div>
+<pre class="card-body rendered-context-data m-0" {% if copyid %}id="{{ copyid }}{% endif %}">{% if format == 'json' %}{{ data|json }}{% elif format == 'yaml' %}{{ data|yaml }}{% else %}{{ data }}{% endif %}</pre>

+ 4 - 1
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"))