mirror of
https://github.com/netbox-community/netbox.git
synced 2025-09-06 14:23:36 -06:00
Closes #19377: Introduce config context profiles
This commit is contained in:
parent
ab8e3ee956
commit
7789147f04
@ -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
docs/models/extras/configcontextprofile.md
Normal file
33
docs/models/extras/configcontextprofile.md
Normal file
@ -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).
|
@ -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'
|
||||||
|
@ -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
|
||||||
|
@ -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={
|
||||||
|
@ -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):
|
||||||
"""
|
"""
|
||||||
|
@ -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',
|
'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data_source', 'data_path', 'data_file',
|
||||||
'data_file', 'data_synced', 'data', 'created', 'last_updated',
|
'data_synced', 'data', 'created', 'last_updated',
|
||||||
]
|
]
|
||||||
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
||||||
|
@ -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')
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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(),
|
||||||
|
@ -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):
|
||||||
|
@ -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:
|
||||||
|
@ -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,29 @@ class TagFilterForm(SavedFiltersMixin, FilterForm):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigContextProfileFilterForm(SavedFiltersMixin, FilterForm):
|
||||||
|
model = ConfigContextProfile
|
||||||
|
fieldsets = (
|
||||||
|
FieldSet('q', 'filter_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,
|
||||||
|
@ -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(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', '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',
|
'name', 'weight', 'profile', 'description', 'data', 'is_active', 'regions', 'site_groups', 'sites',
|
||||||
'roles', 'device_types', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups',
|
'locations', 'roles', 'device_types', 'platforms', 'cluster_types', 'cluster_groups', 'clusters',
|
||||||
'tenants', 'tags', 'data_source', 'data_file', 'auto_sync_enabled',
|
'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):
|
||||||
|
@ -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(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, ChangeLogFilterMixin):
|
||||||
|
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()
|
||||||
|
@ -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()
|
||||||
|
|
||||||
|
@ -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):
|
class ConfigContextType(SyncedDataMixin, ObjectType):
|
||||||
data_source: Annotated["DataSourceType", strawberry.lazy('core.graphql.types')] | None
|
profile: ConfigContextProfileType | None
|
||||||
data_file: Annotated["DataFileType", strawberry.lazy('core.graphql.types')] | 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):
|
class ConfigTemplateType(SyncedDataMixin, TagsMixin, ObjectType):
|
||||||
data_source: Annotated["DataSourceType", strawberry.lazy('core.graphql.types')] | None
|
|
||||||
data_file: Annotated["DataFileType", strawberry.lazy('core.graphql.types')] | None
|
|
||||||
|
|
||||||
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):
|
class ExportTemplateType(SyncedDataMixin, ObjectType):
|
||||||
data_source: Annotated["DataSourceType", strawberry.lazy('core.graphql.types')] | None
|
pass
|
||||||
data_file: Annotated["DataFileType", strawberry.lazy('core.graphql.types')] | None
|
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.type(
|
@strawberry_django.type(
|
||||||
|
75
netbox/extras/migrations/0132_configcontextprofile.py
Normal file
75
netbox/extras/migrations/0132_configcontextprofile.py
Normal file
@ -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',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
@ -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 get_absolute_url(self):
|
||||||
|
return reverse('extras:configcontextprofile', kwargs={'pk': self.pk})
|
||||||
|
|
||||||
|
@property
|
||||||
|
def docs_url(self):
|
||||||
|
return f'{settings.STATIC_URL}docs/models/extras/configcontextprofile/'
|
||||||
|
|
||||||
|
|
||||||
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',
|
'weight', 'profile', 'is_active', 'regions', 'site_groups', 'sites', 'locations', 'device_types', 'roles',
|
||||||
'roles', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups',
|
'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data',
|
||||||
'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).
|
||||||
|
@ -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
|
||||||
|
@ -15,6 +15,7 @@ from .columns import NotificationActionsColumn
|
|||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'BookmarkTable',
|
'BookmarkTable',
|
||||||
|
'ConfigContextProfileTable',
|
||||||
'ConfigContextTable',
|
'ConfigContextTable',
|
||||||
'ConfigTemplateTable',
|
'ConfigTemplateTable',
|
||||||
'CustomFieldChoiceSetTable',
|
'CustomFieldChoiceSetTable',
|
||||||
@ -546,7 +547,28 @@ 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
|
||||||
|
)
|
||||||
|
tags = columns.TagColumn(
|
||||||
|
url_name='extras:configcontextprofile_list'
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta(NetBoxTable.Meta):
|
||||||
|
model = ConfigContextProfile
|
||||||
|
fields = (
|
||||||
|
'pk', 'id', 'name', 'description', 'tags', 'created', 'last_updated',
|
||||||
|
)
|
||||||
|
default_columns = ('pk', 'name', '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 +595,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',
|
'pk', 'id', 'name', 'weight', 'profile', 'is_active', 'is_synced', 'description', 'regions', 'sites',
|
||||||
'roles', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants',
|
'locations', 'roles', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups',
|
||||||
'data_source', 'data_file', 'data_synced', 'tags', 'created', 'last_updated',
|
'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):
|
||||||
|
@ -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']
|
||||||
|
@ -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'),
|
||||||
@ -975,6 +1014,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}"
|
||||||
@ -1011,6 +1051,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]}
|
||||||
@ -1184,6 +1231,7 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
'cluster',
|
'cluster',
|
||||||
'clustergroup',
|
'clustergroup',
|
||||||
'clustertype',
|
'clustertype',
|
||||||
|
'configcontextprofile',
|
||||||
'configtemplate',
|
'configtemplate',
|
||||||
'consoleport',
|
'consoleport',
|
||||||
'consoleserverport',
|
'consoleserverport',
|
||||||
|
@ -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(
|
||||||
|
@ -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'))),
|
||||||
|
|
||||||
|
@ -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
|
||||||
#
|
#
|
||||||
|
@ -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']),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -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>
|
||||||
|
38
netbox/templates/extras/configcontextprofile.html
Normal file
38
netbox/templates/extras/configcontextprofile.html
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
{% 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 '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 %}
|
@ -10,8 +10,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</h2>
|
</h2>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="card-body">
|
<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>
|
||||||
<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>
|
|
||||||
|
@ -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"))
|
||||||
|
Loading…
Reference in New Issue
Block a user