Procházet zdrojové kódy

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

Jeremy Stretch před 6 měsíci
rodič
revize
b4c88541da
34 změnil soubory, kde provedl 812 přidání a 109 odebrání
  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.
 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
 ### Data
 
 
 The context data expressed in JSON format.
 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:
         - Extras:
             - Bookmark: 'models/extras/bookmark.md'
             - Bookmark: 'models/extras/bookmark.md'
             - ConfigContext: 'models/extras/configcontext.md'
             - ConfigContext: 'models/extras/configcontext.md'
+            - ConfigContextProfile: 'models/extras/configcontextprofile.md'
             - ConfigTemplate: 'models/extras/configtemplate.md'
             - ConfigTemplate: 'models/extras/configtemplate.md'
             - CustomField: 'models/extras/customfield.md'
             - CustomField: 'models/extras/customfield.md'
             - CustomFieldChoiceSet: 'models/extras/customfieldchoiceset.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
 from core.models import ObjectChange
 
 
 if TYPE_CHECKING:
 if TYPE_CHECKING:
+    from core.graphql.types import DataFileType, DataSourceType
     from netbox.core.graphql.types import ObjectChangeType
     from netbox.core.graphql.types import ObjectChangeType
 
 
 __all__ = (
 __all__ = (
     'ChangelogMixin',
     'ChangelogMixin',
+    'SyncedDataMixin',
 )
 )
 
 
 
 
@@ -25,3 +27,9 @@ class ChangelogMixin:
             changed_object_id=self.pk
             changed_object_id=self.pk
         )
         )
         return object_changes.restrict(info.context.request.user, 'view')
         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
 from django.db import migrations, models
 
 
 import utilities.json
 import utilities.json
+import utilities.jsonschema
 
 
 
 
 class Migration(migrations.Migration):
 class Migration(migrations.Migration):
@@ -25,7 +26,7 @@ class Migration(migrations.Migration):
                 ('description', models.CharField(blank=True, max_length=200)),
                 ('description', models.CharField(blank=True, max_length=200)),
                 ('comments', models.TextField(blank=True)),
                 ('comments', models.TextField(blank=True)),
                 ('name', models.CharField(max_length=100, unique=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')),
                 ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
             ],
             ],
             options={
             options={

+ 2 - 13
netbox/dcim/models/modules.py

@@ -36,7 +36,8 @@ class ModuleTypeProfile(PrimaryModel):
     schema = models.JSONField(
     schema = models.JSONField(
         blank=True,
         blank=True,
         null=True,
         null=True,
-        verbose_name=_('schema')
+        validators=[validate_schema],
+        verbose_name=_('schema'),
     )
     )
 
 
     clone_fields = ('schema',)
     clone_fields = ('schema',)
@@ -49,18 +50,6 @@ class ModuleTypeProfile(PrimaryModel):
     def __str__(self):
     def __str__(self):
         return self.name
         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):
 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_.roles import DeviceRoleSerializer
 from dcim.api.serializers_.sites import LocationSerializer, RegionSerializer, SiteSerializer, SiteGroupSerializer
 from dcim.api.serializers_.sites import LocationSerializer, RegionSerializer, SiteSerializer, SiteGroupSerializer
 from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
 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.fields import SerializedPKRelatedField
 from netbox.api.serializers import ChangeLogMessageSerializer, ValidatedModelSerializer
 from netbox.api.serializers import ChangeLogMessageSerializer, ValidatedModelSerializer
 from tenancy.api.serializers_.tenants import TenantSerializer, TenantGroupSerializer
 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
 from virtualization.models import Cluster, ClusterGroup, ClusterType
 
 
 __all__ = (
 __all__ = (
+    'ConfigContextProfileSerializer',
     'ConfigContextSerializer',
     '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):
 class ConfigContextSerializer(ChangeLogMessageSerializer, ValidatedModelSerializer):
+    profile = ConfigContextProfileSerializer(
+        nested=True,
+        required=False,
+        allow_null=True,
+        default=None,
+    )
     regions = SerializedPKRelatedField(
     regions = SerializedPKRelatedField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
         serializer=RegionSerializer,
         serializer=RegionSerializer,
@@ -122,9 +154,9 @@ class ConfigContextSerializer(ChangeLogMessageSerializer, ValidatedModelSerializ
     class Meta:
     class Meta:
         model = ConfigContext
         model = ConfigContext
         fields = [
         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',
             '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')
         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('image-attachments', views.ImageAttachmentViewSet)
 router.register('journal-entries', views.JournalEntryViewSet)
 router.register('journal-entries', views.JournalEntryViewSet)
 router.register('config-contexts', views.ConfigContextViewSet)
 router.register('config-contexts', views.ConfigContextViewSet)
+router.register('config-context-profiles', views.ConfigContextProfileViewSet)
 router.register('config-templates', views.ConfigTemplateViewSet)
 router.register('config-templates', views.ConfigTemplateViewSet)
 router.register('scripts', views.ScriptViewSet, basename='script')
 router.register('scripts', views.ScriptViewSet, basename='script')
 
 

+ 6 - 0
netbox/extras/api/views.py

@@ -217,6 +217,12 @@ class JournalEntryViewSet(NetBoxModelViewSet):
 # Config contexts
 # Config contexts
 #
 #
 
 
+class ConfigContextProfileViewSet(SyncedDataMixin, NetBoxModelViewSet):
+    queryset = ConfigContextProfile.objects.all()
+    serializer_class = serializers.ConfigContextProfileSerializer
+    filterset_class = filtersets.ConfigContextProfileFilterSet
+
+
 class ConfigContextViewSet(SyncedDataMixin, NetBoxModelViewSet):
 class ConfigContextViewSet(SyncedDataMixin, NetBoxModelViewSet):
     queryset = ConfigContext.objects.all()
     queryset = ConfigContext.objects.all()
     serializer_class = serializers.ConfigContextSerializer
     serializer_class = serializers.ConfigContextSerializer

+ 41 - 0
netbox/extras/filtersets.py

@@ -19,6 +19,7 @@ from .models import *
 __all__ = (
 __all__ = (
     'BookmarkFilterSet',
     'BookmarkFilterSet',
     'ConfigContextFilterSet',
     'ConfigContextFilterSet',
+    'ConfigContextProfileFilterSet',
     'ConfigTemplateFilterSet',
     'ConfigTemplateFilterSet',
     'CustomFieldChoiceSetFilterSet',
     'CustomFieldChoiceSetFilterSet',
     'CustomFieldFilterSet',
     '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):
 class ConfigContextFilterSet(ChangeLoggedModelFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',
         label=_('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(
     region_id = django_filters.ModelMultipleChoiceFilter(
         field_name='regions',
         field_name='regions',
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),

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

@@ -13,6 +13,7 @@ from utilities.forms.widgets import BulkEditNullBooleanSelect
 
 
 __all__ = (
 __all__ = (
     'ConfigContextBulkEditForm',
     'ConfigContextBulkEditForm',
+    'ConfigContextProfileBulkEditForm',
     'ConfigTemplateBulkEditForm',
     'ConfigTemplateBulkEditForm',
     'CustomFieldBulkEditForm',
     'CustomFieldBulkEditForm',
     'CustomFieldChoiceSetBulkEditForm',
     'CustomFieldChoiceSetBulkEditForm',
@@ -317,6 +318,25 @@ class TagBulkEditForm(ChangelogMessageMixin, BulkEditForm):
     nullable_fields = ('description',)
     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):
 class ConfigContextBulkEditForm(ChangelogMessageMixin, BulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=ConfigContext.objects.all(),
         queryset=ConfigContext.objects.all(),
@@ -327,6 +347,10 @@ class ConfigContextBulkEditForm(ChangelogMessageMixin, BulkEditForm):
         required=False,
         required=False,
         min_value=0
         min_value=0
     )
     )
+    profile = DynamicModelChoiceField(
+        queryset=ConfigContextProfile.objects.all(),
+        required=False
+    )
     is_active = forms.NullBooleanField(
     is_active = forms.NullBooleanField(
         label=_('Is active'),
         label=_('Is active'),
         required=False,
         required=False,
@@ -338,7 +362,10 @@ class ConfigContextBulkEditForm(ChangelogMessageMixin, BulkEditForm):
         max_length=100
         max_length=100
     )
     )
 
 
-    nullable_fields = ('description',)
+    fieldsets = (
+        FieldSet('weight', 'profile', 'is_active', 'description'),
+    )
+    nullable_fields = ('profile', 'description')
 
 
 
 
 class ConfigTemplateBulkEditForm(ChangelogMessageMixin, BulkEditForm):
 class ConfigTemplateBulkEditForm(ChangelogMessageMixin, BulkEditForm):

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

@@ -18,6 +18,7 @@ from utilities.forms.fields import (
 )
 )
 
 
 __all__ = (
 __all__ = (
+    'ConfigContextProfileImportForm',
     'ConfigTemplateImportForm',
     'ConfigTemplateImportForm',
     'CustomFieldChoiceSetImportForm',
     'CustomFieldChoiceSetImportForm',
     'CustomFieldImportForm',
     '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 ConfigTemplateImportForm(CSVModelForm):
 
 
     class Meta:
     class Meta:

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

@@ -20,6 +20,7 @@ from virtualization.models import Cluster, ClusterGroup, ClusterType
 
 
 __all__ = (
 __all__ = (
     'ConfigContextFilterForm',
     'ConfigContextFilterForm',
+    'ConfigContextProfileFilterForm',
     'ConfigTemplateFilterForm',
     'ConfigTemplateFilterForm',
     'CustomFieldChoiceSetFilterForm',
     'CustomFieldChoiceSetFilterForm',
     'CustomFieldFilterForm',
     '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):
 class ConfigContextFilterForm(SavedFiltersMixin, FilterForm):
     model = ConfigContext
     model = ConfigContext
     fieldsets = (
     fieldsets = (
         FieldSet('q', 'filter_id', 'tag_id'),
         FieldSet('q', 'filter_id', 'tag_id'),
+        FieldSet('profile', name=_('Config Context')),
         FieldSet('data_source_id', 'data_file_id', name=_('Data')),
         FieldSet('data_source_id', 'data_file_id', name=_('Data')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')),
         FieldSet('device_type_id', 'platform_id', 'device_role_id', name=_('Device')),
         FieldSet('device_type_id', 'platform_id', 'device_role_id', name=_('Device')),
         FieldSet('cluster_type_id', 'cluster_group_id', 'cluster_id', name=_('Cluster')),
         FieldSet('cluster_type_id', 'cluster_group_id', 'cluster_id', name=_('Cluster')),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant'))
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant'))
     )
     )
+    profile_id = DynamicModelMultipleChoiceField(
+        queryset=ConfigContextProfile.objects.all(),
+        required=False,
+        label=_('Profile')
+    )
     data_source_id = DynamicModelMultipleChoiceField(
     data_source_id = DynamicModelMultipleChoiceField(
         queryset=DataSource.objects.all(),
         queryset=DataSource.objects.all(),
         required=False,
         required=False,

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

@@ -29,6 +29,7 @@ from virtualization.models import Cluster, ClusterGroup, ClusterType
 __all__ = (
 __all__ = (
     'BookmarkForm',
     'BookmarkForm',
     'ConfigContextForm',
     'ConfigContextForm',
+    'ConfigContextProfileForm',
     'ConfigTemplateForm',
     'ConfigTemplateForm',
     'CustomFieldChoiceSetForm',
     'CustomFieldChoiceSetForm',
     'CustomFieldForm',
     '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):
 class ConfigContextForm(ChangelogMessageMixin, SyncedDataMixin, forms.ModelForm):
+    profile = DynamicModelChoiceField(
+        label=_('Profile'),
+        queryset=ConfigContextProfile.objects.all(),
+        required=False
+    )
     regions = DynamicModelMultipleChoiceField(
     regions = DynamicModelMultipleChoiceField(
         label=_('Regions'),
         label=_('Regions'),
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
@@ -657,7 +687,7 @@ class ConfigContextForm(ChangelogMessageMixin, SyncedDataMixin, forms.ModelForm)
     )
     )
 
 
     fieldsets = (
     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('data_source', 'data_file', 'auto_sync_enabled', name=_('Data Source')),
         FieldSet(
         FieldSet(
             'regions', 'site_groups', 'sites', 'locations', 'device_types', 'roles', 'platforms', 'cluster_types',
             'regions', 'site_groups', 'sites', 'locations', 'device_types', 'roles', 'platforms', 'cluster_types',
@@ -669,9 +699,9 @@ class ConfigContextForm(ChangelogMessageMixin, SyncedDataMixin, forms.ModelForm)
     class Meta:
     class Meta:
         model = ConfigContext
         model = ConfigContext
         fields = (
         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):
     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 core.graphql.filter_mixins import BaseObjectTypeFilterMixin, ChangeLogFilterMixin
 from extras import models
 from extras import models
 from extras.graphql.filter_mixins import TagBaseFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin
 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:
 if TYPE_CHECKING:
     from core.graphql.filters import ContentTypeFilter
     from core.graphql.filters import ContentTypeFilter
@@ -24,6 +24,7 @@ if TYPE_CHECKING:
 
 
 __all__ = (
 __all__ = (
     'ConfigContextFilter',
     'ConfigContextFilter',
+    'ConfigContextProfileFilter',
     'ConfigTemplateFilter',
     'ConfigTemplateFilter',
     'CustomFieldFilter',
     'CustomFieldFilter',
     'CustomFieldChoiceSetFilter',
     '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)
 @strawberry_django.filter_type(models.ConfigTemplate, lookups=True)
 class ConfigTemplateFilter(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, ChangeLogFilterMixin):
 class ConfigTemplateFilter(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, ChangeLogFilterMixin):
     name: FilterLookup[str] | None = strawberry_django.filter_field()
     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: ConfigContextType = strawberry_django.field()
     config_context_list: List[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: ConfigTemplateType = strawberry_django.field()
     config_template_list: List[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
 import strawberry_django
 import strawberry_django
 
 
+from core.graphql.mixins import SyncedDataMixin
 from extras import models
 from extras import models
 from extras.graphql.mixins import CustomFieldsMixin, TagsMixin
 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 *
 from .filters import *
 
 
 if TYPE_CHECKING:
 if TYPE_CHECKING:
-    from core.graphql.types import DataFileType, DataSourceType
     from dcim.graphql.types import (
     from dcim.graphql.types import (
         DeviceRoleType,
         DeviceRoleType,
         DeviceType,
         DeviceType,
@@ -25,6 +25,7 @@ if TYPE_CHECKING:
     from virtualization.graphql.types import ClusterGroupType, ClusterType, ClusterTypeType, VirtualMachineType
     from virtualization.graphql.types import ClusterGroupType, ClusterType, ClusterTypeType, VirtualMachineType
 
 
 __all__ = (
 __all__ = (
+    'ConfigContextProfileType',
     'ConfigContextType',
     'ConfigContextType',
     'ConfigTemplateType',
     'ConfigTemplateType',
     'CustomFieldChoiceSetType',
     '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(
 @strawberry_django.type(
     models.ConfigContext,
     models.ConfigContext,
     fields='__all__',
     fields='__all__',
     filters=ConfigContextFilter,
     filters=ConfigContextFilter,
     pagination=True
     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')]]
     roles: List[Annotated["DeviceRoleType", strawberry.lazy('dcim.graphql.types')]]
     device_types: List[Annotated["DeviceTypeType", strawberry.lazy('dcim.graphql.types')]]
     device_types: List[Annotated["DeviceTypeType", strawberry.lazy('dcim.graphql.types')]]
     tags: List[Annotated["TagType", strawberry.lazy('extras.graphql.types')]]
     tags: List[Annotated["TagType", strawberry.lazy('extras.graphql.types')]]
@@ -74,10 +84,7 @@ class ConfigContextType(ObjectType):
     filters=ConfigTemplateFilter,
     filters=ConfigTemplateFilter,
     pagination=True
     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')]]
     virtualmachines: List[Annotated["VirtualMachineType", strawberry.lazy('virtualization.graphql.types')]]
     devices: List[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]]
     devices: List[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]]
     platforms: List[Annotated["PlatformType", strawberry.lazy('dcim.graphql.types')]]
     platforms: List[Annotated["PlatformType", strawberry.lazy('dcim.graphql.types')]]
@@ -123,9 +130,8 @@ class CustomLinkType(ObjectType):
     filters=ExportTemplateFilter,
     filters=ExportTemplateFilter,
     pagination=True
     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(
 @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 collections import defaultdict
+from jsonschema.exceptions import ValidationError as JSONValidationError
 
 
 from django.conf import settings
 from django.conf import settings
 from django.core.validators import ValidationError
 from django.core.validators import ValidationError
@@ -9,13 +11,15 @@ from django.utils.translation import gettext_lazy as _
 from core.models import ObjectType
 from core.models import ObjectType
 from extras.models.mixins import RenderTemplateMixin
 from extras.models.mixins import RenderTemplateMixin
 from extras.querysets import ConfigContextQuerySet
 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 netbox.models.features import CloningMixin, CustomLinksMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin
 from utilities.data import deepmerge
 from utilities.data import deepmerge
+from utilities.jsonschema import validate_schema
 
 
 __all__ = (
 __all__ = (
     'ConfigContext',
     'ConfigContext',
     'ConfigContextModel',
     'ConfigContextModel',
+    'ConfigContextProfile',
     'ConfigTemplate',
     'ConfigTemplate',
 )
 )
 
 
@@ -24,6 +28,46 @@ __all__ = (
 # Config contexts
 # 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):
 class ConfigContext(SyncedDataMixin, CloningMixin, CustomLinksMixin, ChangeLoggedModel):
     """
     """
     A ConfigContext represents a set of arbitrary data available to any Device or VirtualMachine matching its assigned
     A ConfigContext represents a set of arbitrary data available to any Device or VirtualMachine matching its assigned
@@ -35,6 +79,13 @@ class ConfigContext(SyncedDataMixin, CloningMixin, CustomLinksMixin, ChangeLogge
         max_length=100,
         max_length=100,
         unique=True
         unique=True
     )
     )
+    profile = models.ForeignKey(
+        to='extras.ConfigContextProfile',
+        on_delete=models.PROTECT,
+        blank=True,
+        null=True,
+        related_name='config_contexts',
+    )
     weight = models.PositiveSmallIntegerField(
     weight = models.PositiveSmallIntegerField(
         verbose_name=_('weight'),
         verbose_name=_('weight'),
         default=1000
         default=1000
@@ -118,9 +169,8 @@ class ConfigContext(SyncedDataMixin, CloningMixin, CustomLinksMixin, ChangeLogge
     objects = ConfigContextQuerySet.as_manager()
     objects = ConfigContextQuerySet.as_manager()
 
 
     clone_fields = (
     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:
     class Meta:
@@ -147,6 +197,13 @@ class ConfigContext(SyncedDataMixin, CloningMixin, CustomLinksMixin, ChangeLogge
                 {'data': _('JSON data must be in object form. Example:') + ' {"foo": 123}'}
                 {'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):
     def sync_data(self):
         """
         """
         Synchronize context data from the designated DataFile (if any).
         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
 from . import models
 
 
 
 
+@register_search
+class ConfigContextProfileIndex(SearchIndex):
+    model = models.ConfigContextProfile
+    fields = (
+        ('name', 100),
+        ('description', 500),
+        ('comments', 5000),
+    )
+    display_attrs = ('description',)
+
+
 @register_search
 @register_search
 class CustomFieldIndex(SearchIndex):
 class CustomFieldIndex(SearchIndex):
     model = models.CustomField
     model = models.CustomField

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

@@ -15,6 +15,7 @@ from .columns import NotificationActionsColumn
 
 
 __all__ = (
 __all__ = (
     'BookmarkTable',
     'BookmarkTable',
+    'ConfigContextProfileTable',
     'ConfigContextTable',
     'ConfigContextTable',
     'ConfigTemplateTable',
     'ConfigTemplateTable',
     'CustomFieldChoiceSetTable',
     'CustomFieldChoiceSetTable',
@@ -546,7 +547,41 @@ class TaggedItemTable(NetBoxTable):
         fields = ('id', 'content_type', 'content_object')
         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):
 class ConfigContextTable(NetBoxTable):
+    profile = tables.Column(
+        linkify=True,
+        verbose_name=_('Profile'),
+    )
     data_source = tables.Column(
     data_source = tables.Column(
         verbose_name=_('Data Source'),
         verbose_name=_('Data Source'),
         linkify=True
         linkify=True
@@ -573,11 +608,11 @@ class ConfigContextTable(NetBoxTable):
     class Meta(NetBoxTable.Meta):
     class Meta(NetBoxTable.Meta):
         model = ConfigContext
         model = ConfigContext
         fields = (
         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):
 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):
 class ConfigContextTest(APIViewTestCases.APIViewTestCase):
     model = ConfigContext
     model = ConfigContext
     brief_fields = ['description', 'display', 'id', 'name', 'url']
     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)
         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):
 class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = ConfigContext.objects.all()
     queryset = ConfigContext.objects.all()
     filterset = ConfigContextFilterSet
     filterset = ConfigContextFilterSet
@@ -878,6 +911,12 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     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 = (
         regions = (
             Region(name='Region 1', slug='region-1'),
             Region(name='Region 1', slug='region-1'),
@@ -976,6 +1015,7 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
             is_active = bool(i % 2)
             is_active = bool(i % 2)
             c = ConfigContext.objects.create(
             c = ConfigContext.objects.create(
                 name=f"Config Context {i + 1}",
                 name=f"Config Context {i + 1}",
+                profile=profiles[i],
                 is_active=is_active,
                 is_active=is_active,
                 data='{"foo": 123}',
                 data='{"foo": 123}',
                 description=f"foobar{i + 1}"
                 description=f"foobar{i + 1}"
@@ -1012,6 +1052,13 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'description': ['foobar1', 'foobar2']}
         params = {'description': ['foobar1', 'foobar2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         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):
     def test_region(self):
         regions = Region.objects.all()[:2]
         regions = Region.objects.all()[:2]
         params = {'region_id': [regions[0].pk, regions[1].pk]}
         params = {'region_id': [regions[0].pk, regions[1].pk]}
@@ -1185,6 +1232,7 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
         'cluster',
         'cluster',
         'clustergroup',
         'clustergroup',
         'clustertype',
         'clustertype',
+        'configcontextprofile',
         'configtemplate',
         'configtemplate',
         'consoleport',
         'consoleport',
         'consoleserverport',
         '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 core.models import DataSource, ObjectType
 from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Platform, Region, Site, SiteGroup
 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 tenancy.models import Tenant, TenantGroup
 from utilities.exceptions import AbortRequest
 from utilities.exceptions import AbortRequest
 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
@@ -159,6 +159,32 @@ class ConfigContextTest(TestCase):
         }
         }
         self.assertEqual(device.get_config_context(), expected_data)
         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):
     def test_annotation_same_as_get_for_object(self):
         """
         """
         This test incorporates features from all of the above tests cases to ensure
         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
 # TODO: Change base class to PrimaryObjectViewTestCase
 # Blocked by absence of standard create/edit, bulk create views
 # Blocked by absence of standard create/edit, bulk create views
 class ConfigContextTestCase(
 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/', include(get_model_urls('extras', 'tag', detail=False))),
     path('tags/<int:pk>/', include(get_model_urls('extras', 'tag'))),
     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/', include(get_model_urls('extras', 'configcontext', detail=False))),
     path('config-contexts/<int:pk>/', include(get_model_urls('extras', 'configcontext'))),
     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
     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
 # Config contexts
 #
 #

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

@@ -331,6 +331,7 @@ PROVISIONING_MENU = Menu(
             label=_('Configurations'),
             label=_('Configurations'),
             items=(
             items=(
                 get_model_item('extras', 'configcontext', _('Config Contexts'), actions=['add']),
                 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']),
                 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>
             <th scope="row">{% trans "Weight" %}</th>
             <td>{{ object.weight }}</td>
             <td>{{ object.weight }}</td>
           </tr>
           </tr>
+          <tr>
+            <th scope="row">{% trans "Profile" %}</th>
+            <td>{{ object.profile|linkify|placeholder }}</td>
+          </tr>
           <tr>
           <tr>
             <th scope="row">{% trans "Description" %}</th>
             <th scope="row">{% trans "Description" %}</th>
             <td>{{ object.description|placeholder }}</td>
             <td>{{ object.description|placeholder }}</td>
@@ -25,37 +29,9 @@
             <th scope="row">{% trans "Active" %}</th>
             <th scope="row">{% trans "Active" %}</th>
             <td>{% checkmark object.is_active %}</td>
             <td>{% checkmark object.is_active %}</td>
           </tr>
           </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>
         </table>
       </div>
       </div>
+      {% include 'core/inc/datafile_panel.html' %}
       <div class="card">
       <div class="card">
         <h2 class="card-header">{% trans "Assignment" %}</h2>
         <h2 class="card-header">{% trans "Assignment" %}</h2>
         <table class="table table-hover attr-table">
         <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>
             <th scope="row">{% trans "Attachment" %}</th>
             <td>{% checkmark object.as_attachment %}</td>
             <td>{% checkmark object.as_attachment %}</td>
           </tr>
           </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>
         </table>
       </div>
       </div>
+      {% include 'core/inc/datafile_panel.html' %}
       {% plugin_left_page object %}
       {% plugin_left_page object %}
     </div>
     </div>
     <div class="col col-md-6">
     <div class="col col-md-6">

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

@@ -10,8 +10,4 @@
     </div>
     </div>
   </h2>
   </h2>
 {% endif %}
 {% 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.
     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)
     # 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"))
         raise ValidationError(_("Invalid JSON schema definition"))
     if not schema.get('properties'):
     if not schema.get('properties'):
         raise ValidationError(_("JSON schema must define properties"))
         raise ValidationError(_("JSON schema must define properties"))