mirror of
https://github.com/netbox-community/netbox.git
synced 2025-09-06 14:23:36 -06:00
Closes #19377: Introduce config context profiles (#20058)
Some checks failed
CodeQL / Analyze (${{ matrix.language }}) (none, python) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, actions) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Has been cancelled
CI / build (20.x, 3.10) (push) Has been cancelled
CI / build (20.x, 3.11) (push) Has been cancelled
CI / build (20.x, 3.12) (push) Has been cancelled
Some checks failed
CodeQL / Analyze (${{ matrix.language }}) (none, python) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, actions) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Has been cancelled
CI / build (20.x, 3.10) (push) Has been cancelled
CI / build (20.x, 3.11) (push) Has been cancelled
CI / build (20.x, 3.12) (push) Has been cancelled
This commit is contained in:
parent
a7247f8815
commit
b4c88541da
@ -14,6 +14,10 @@ A unique human-friendly name.
|
||||
|
||||
A numeric value which influences the order in which context data is merged. Contexts with a lower weight are merged before those with a higher weight.
|
||||
|
||||
### Profile
|
||||
|
||||
The [profile](./configcontextprofile.md) to which the config context is assigned (optional). Profiles can be used to enforce structure in their data.
|
||||
|
||||
### Data
|
||||
|
||||
The context data expressed in JSON format.
|
||||
|
33
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:
|
||||
- Bookmark: 'models/extras/bookmark.md'
|
||||
- ConfigContext: 'models/extras/configcontext.md'
|
||||
- ConfigContextProfile: 'models/extras/configcontextprofile.md'
|
||||
- ConfigTemplate: 'models/extras/configtemplate.md'
|
||||
- CustomField: 'models/extras/customfield.md'
|
||||
- CustomFieldChoiceSet: 'models/extras/customfieldchoiceset.md'
|
||||
|
@ -7,10 +7,12 @@ from django.contrib.contenttypes.models import ContentType
|
||||
from core.models import ObjectChange
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from core.graphql.types import DataFileType, DataSourceType
|
||||
from netbox.core.graphql.types import ObjectChangeType
|
||||
|
||||
__all__ = (
|
||||
'ChangelogMixin',
|
||||
'SyncedDataMixin',
|
||||
)
|
||||
|
||||
|
||||
@ -25,3 +27,9 @@ class ChangelogMixin:
|
||||
changed_object_id=self.pk
|
||||
)
|
||||
return object_changes.restrict(info.context.request.user, 'view')
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class SyncedDataMixin:
|
||||
data_source: Annotated["DataSourceType", strawberry.lazy('core.graphql.types')] | None
|
||||
data_file: Annotated["DataFileType", strawberry.lazy('core.graphql.types')] | None
|
||||
|
@ -3,6 +3,7 @@ import taggit.managers
|
||||
from django.db import migrations, models
|
||||
|
||||
import utilities.json
|
||||
import utilities.jsonschema
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
@ -25,7 +26,7 @@ class Migration(migrations.Migration):
|
||||
('description', models.CharField(blank=True, max_length=200)),
|
||||
('comments', models.TextField(blank=True)),
|
||||
('name', models.CharField(max_length=100, unique=True)),
|
||||
('schema', models.JSONField(blank=True, null=True)),
|
||||
('schema', models.JSONField(blank=True, null=True, validators=[utilities.jsonschema.validate_schema])),
|
||||
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
|
||||
],
|
||||
options={
|
||||
|
@ -36,7 +36,8 @@ class ModuleTypeProfile(PrimaryModel):
|
||||
schema = models.JSONField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_('schema')
|
||||
validators=[validate_schema],
|
||||
verbose_name=_('schema'),
|
||||
)
|
||||
|
||||
clone_fields = ('schema',)
|
||||
@ -49,18 +50,6 @@ class ModuleTypeProfile(PrimaryModel):
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Validate the schema definition
|
||||
if self.schema is not None:
|
||||
try:
|
||||
validate_schema(self.schema)
|
||||
except ValidationError as e:
|
||||
raise ValidationError({
|
||||
'schema': e.message,
|
||||
})
|
||||
|
||||
|
||||
class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
|
||||
"""
|
||||
|
@ -6,7 +6,7 @@ from dcim.api.serializers_.platforms import PlatformSerializer
|
||||
from dcim.api.serializers_.roles import DeviceRoleSerializer
|
||||
from dcim.api.serializers_.sites import LocationSerializer, RegionSerializer, SiteSerializer, SiteGroupSerializer
|
||||
from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
|
||||
from extras.models import ConfigContext, Tag
|
||||
from extras.models import ConfigContext, ConfigContextProfile, Tag
|
||||
from netbox.api.fields import SerializedPKRelatedField
|
||||
from netbox.api.serializers import ChangeLogMessageSerializer, ValidatedModelSerializer
|
||||
from tenancy.api.serializers_.tenants import TenantSerializer, TenantGroupSerializer
|
||||
@ -15,11 +15,43 @@ from virtualization.api.serializers_.clusters import ClusterSerializer, ClusterG
|
||||
from virtualization.models import Cluster, ClusterGroup, ClusterType
|
||||
|
||||
__all__ = (
|
||||
'ConfigContextProfileSerializer',
|
||||
'ConfigContextSerializer',
|
||||
)
|
||||
|
||||
|
||||
class ConfigContextProfileSerializer(ChangeLogMessageSerializer, ValidatedModelSerializer):
|
||||
tags = serializers.SlugRelatedField(
|
||||
queryset=Tag.objects.all(),
|
||||
slug_field='slug',
|
||||
required=False,
|
||||
many=True
|
||||
)
|
||||
data_source = DataSourceSerializer(
|
||||
nested=True,
|
||||
required=False
|
||||
)
|
||||
data_file = DataFileSerializer(
|
||||
nested=True,
|
||||
read_only=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ConfigContextProfile
|
||||
fields = [
|
||||
'id', 'url', 'display_url', 'display', 'name', 'description', 'schema', 'tags', 'comments', 'data_source',
|
||||
'data_path', 'data_file', 'data_synced', 'created', 'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
||||
|
||||
|
||||
class ConfigContextSerializer(ChangeLogMessageSerializer, ValidatedModelSerializer):
|
||||
profile = ConfigContextProfileSerializer(
|
||||
nested=True,
|
||||
required=False,
|
||||
allow_null=True,
|
||||
default=None,
|
||||
)
|
||||
regions = SerializedPKRelatedField(
|
||||
queryset=Region.objects.all(),
|
||||
serializer=RegionSerializer,
|
||||
@ -122,9 +154,9 @@ class ConfigContextSerializer(ChangeLogMessageSerializer, ValidatedModelSerializ
|
||||
class Meta:
|
||||
model = ConfigContext
|
||||
fields = [
|
||||
'id', 'url', 'display_url', 'display', 'name', 'weight', 'description', 'is_active', 'regions',
|
||||
'id', 'url', 'display_url', 'display', 'name', 'weight', 'profile', 'description', 'is_active', 'regions',
|
||||
'site_groups', 'sites', 'locations', 'device_types', 'roles', 'platforms', 'cluster_types',
|
||||
'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data_source', 'data_path',
|
||||
'data_file', 'data_synced', 'data', 'created', 'last_updated',
|
||||
'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data_source', 'data_path', 'data_file',
|
||||
'data_synced', 'data', 'created', 'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
||||
|
@ -25,6 +25,7 @@ router.register('tagged-objects', views.TaggedItemViewSet)
|
||||
router.register('image-attachments', views.ImageAttachmentViewSet)
|
||||
router.register('journal-entries', views.JournalEntryViewSet)
|
||||
router.register('config-contexts', views.ConfigContextViewSet)
|
||||
router.register('config-context-profiles', views.ConfigContextProfileViewSet)
|
||||
router.register('config-templates', views.ConfigTemplateViewSet)
|
||||
router.register('scripts', views.ScriptViewSet, basename='script')
|
||||
|
||||
|
@ -217,6 +217,12 @@ class JournalEntryViewSet(NetBoxModelViewSet):
|
||||
# Config contexts
|
||||
#
|
||||
|
||||
class ConfigContextProfileViewSet(SyncedDataMixin, NetBoxModelViewSet):
|
||||
queryset = ConfigContextProfile.objects.all()
|
||||
serializer_class = serializers.ConfigContextProfileSerializer
|
||||
filterset_class = filtersets.ConfigContextProfileFilterSet
|
||||
|
||||
|
||||
class ConfigContextViewSet(SyncedDataMixin, NetBoxModelViewSet):
|
||||
queryset = ConfigContext.objects.all()
|
||||
serializer_class = serializers.ConfigContextSerializer
|
||||
|
@ -19,6 +19,7 @@ from .models import *
|
||||
__all__ = (
|
||||
'BookmarkFilterSet',
|
||||
'ConfigContextFilterSet',
|
||||
'ConfigContextProfileFilterSet',
|
||||
'ConfigTemplateFilterSet',
|
||||
'CustomFieldChoiceSetFilterSet',
|
||||
'CustomFieldFilterSet',
|
||||
@ -588,11 +589,51 @@ class TaggedItemFilterSet(BaseFilterSet):
|
||||
)
|
||||
|
||||
|
||||
class ConfigContextProfileFilterSet(NetBoxModelFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label=_('Search'),
|
||||
)
|
||||
data_source_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=DataSource.objects.all(),
|
||||
label=_('Data source (ID)'),
|
||||
)
|
||||
data_file_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=DataSource.objects.all(),
|
||||
label=_('Data file (ID)'),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ConfigContextProfile
|
||||
fields = (
|
||||
'id', 'name', 'description', 'auto_sync_enabled', 'data_synced',
|
||||
)
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(name__icontains=value) |
|
||||
Q(description__icontains=value) |
|
||||
Q(comments__icontains=value)
|
||||
)
|
||||
|
||||
|
||||
class ConfigContextFilterSet(ChangeLoggedModelFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label=_('Search'),
|
||||
)
|
||||
profile_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=ConfigContextProfile.objects.all(),
|
||||
label=_('Profile (ID)'),
|
||||
)
|
||||
profile = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='profile__name',
|
||||
queryset=ConfigContextProfile.objects.all(),
|
||||
to_field_name='name',
|
||||
label=_('Profile (name)'),
|
||||
)
|
||||
region_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='regions',
|
||||
queryset=Region.objects.all(),
|
||||
|
@ -13,6 +13,7 @@ from utilities.forms.widgets import BulkEditNullBooleanSelect
|
||||
|
||||
__all__ = (
|
||||
'ConfigContextBulkEditForm',
|
||||
'ConfigContextProfileBulkEditForm',
|
||||
'ConfigTemplateBulkEditForm',
|
||||
'CustomFieldBulkEditForm',
|
||||
'CustomFieldChoiceSetBulkEditForm',
|
||||
@ -317,6 +318,25 @@ class TagBulkEditForm(ChangelogMessageMixin, BulkEditForm):
|
||||
nullable_fields = ('description',)
|
||||
|
||||
|
||||
class ConfigContextProfileBulkEditForm(NetBoxModelBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=ConfigContextProfile.objects.all(),
|
||||
widget=forms.MultipleHiddenInput
|
||||
)
|
||||
description = forms.CharField(
|
||||
label=_('Description'),
|
||||
required=False,
|
||||
max_length=100
|
||||
)
|
||||
comments = CommentField()
|
||||
|
||||
model = ConfigContextProfile
|
||||
fieldsets = (
|
||||
FieldSet('description',),
|
||||
)
|
||||
nullable_fields = ('description',)
|
||||
|
||||
|
||||
class ConfigContextBulkEditForm(ChangelogMessageMixin, BulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=ConfigContext.objects.all(),
|
||||
@ -327,6 +347,10 @@ class ConfigContextBulkEditForm(ChangelogMessageMixin, BulkEditForm):
|
||||
required=False,
|
||||
min_value=0
|
||||
)
|
||||
profile = DynamicModelChoiceField(
|
||||
queryset=ConfigContextProfile.objects.all(),
|
||||
required=False
|
||||
)
|
||||
is_active = forms.NullBooleanField(
|
||||
label=_('Is active'),
|
||||
required=False,
|
||||
@ -338,7 +362,10 @@ class ConfigContextBulkEditForm(ChangelogMessageMixin, BulkEditForm):
|
||||
max_length=100
|
||||
)
|
||||
|
||||
nullable_fields = ('description',)
|
||||
fieldsets = (
|
||||
FieldSet('weight', 'profile', 'is_active', 'description'),
|
||||
)
|
||||
nullable_fields = ('profile', 'description')
|
||||
|
||||
|
||||
class ConfigTemplateBulkEditForm(ChangelogMessageMixin, BulkEditForm):
|
||||
|
@ -18,6 +18,7 @@ from utilities.forms.fields import (
|
||||
)
|
||||
|
||||
__all__ = (
|
||||
'ConfigContextProfileImportForm',
|
||||
'ConfigTemplateImportForm',
|
||||
'CustomFieldChoiceSetImportForm',
|
||||
'CustomFieldImportForm',
|
||||
@ -149,6 +150,15 @@ class ExportTemplateImportForm(CSVModelForm):
|
||||
)
|
||||
|
||||
|
||||
class ConfigContextProfileImportForm(NetBoxModelImportForm):
|
||||
|
||||
class Meta:
|
||||
model = ConfigContextProfile
|
||||
fields = [
|
||||
'name', 'description', 'schema', 'comments', 'tags',
|
||||
]
|
||||
|
||||
|
||||
class ConfigTemplateImportForm(CSVModelForm):
|
||||
|
||||
class Meta:
|
||||
|
@ -20,6 +20,7 @@ from virtualization.models import Cluster, ClusterGroup, ClusterType
|
||||
|
||||
__all__ = (
|
||||
'ConfigContextFilterForm',
|
||||
'ConfigContextProfileFilterForm',
|
||||
'ConfigTemplateFilterForm',
|
||||
'CustomFieldChoiceSetFilterForm',
|
||||
'CustomFieldFilterForm',
|
||||
@ -354,16 +355,43 @@ class TagFilterForm(SavedFiltersMixin, FilterForm):
|
||||
)
|
||||
|
||||
|
||||
class ConfigContextProfileFilterForm(SavedFiltersMixin, FilterForm):
|
||||
model = ConfigContextProfile
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id'),
|
||||
FieldSet('data_source_id', 'data_file_id', name=_('Data')),
|
||||
)
|
||||
data_source_id = DynamicModelMultipleChoiceField(
|
||||
queryset=DataSource.objects.all(),
|
||||
required=False,
|
||||
label=_('Data source')
|
||||
)
|
||||
data_file_id = DynamicModelMultipleChoiceField(
|
||||
queryset=DataFile.objects.all(),
|
||||
required=False,
|
||||
label=_('Data file'),
|
||||
query_params={
|
||||
'source_id': '$data_source_id'
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class ConfigContextFilterForm(SavedFiltersMixin, FilterForm):
|
||||
model = ConfigContext
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag_id'),
|
||||
FieldSet('profile', name=_('Config Context')),
|
||||
FieldSet('data_source_id', 'data_file_id', name=_('Data')),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')),
|
||||
FieldSet('device_type_id', 'platform_id', 'device_role_id', name=_('Device')),
|
||||
FieldSet('cluster_type_id', 'cluster_group_id', 'cluster_id', name=_('Cluster')),
|
||||
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant'))
|
||||
)
|
||||
profile_id = DynamicModelMultipleChoiceField(
|
||||
queryset=ConfigContextProfile.objects.all(),
|
||||
required=False,
|
||||
label=_('Profile')
|
||||
)
|
||||
data_source_id = DynamicModelMultipleChoiceField(
|
||||
queryset=DataSource.objects.all(),
|
||||
required=False,
|
||||
|
@ -29,6 +29,7 @@ from virtualization.models import Cluster, ClusterGroup, ClusterType
|
||||
__all__ = (
|
||||
'BookmarkForm',
|
||||
'ConfigContextForm',
|
||||
'ConfigContextProfileForm',
|
||||
'ConfigTemplateForm',
|
||||
'CustomFieldChoiceSetForm',
|
||||
'CustomFieldForm',
|
||||
@ -585,7 +586,36 @@ class TagForm(ChangelogMessageMixin, forms.ModelForm):
|
||||
]
|
||||
|
||||
|
||||
class ConfigContextProfileForm(SyncedDataMixin, NetBoxModelForm):
|
||||
schema = JSONField(
|
||||
label=_('Schema'),
|
||||
required=False,
|
||||
help_text=_("Enter a valid JSON schema to define supported attributes.")
|
||||
)
|
||||
tags = DynamicModelMultipleChoiceField(
|
||||
label=_('Tags'),
|
||||
queryset=Tag.objects.all(),
|
||||
required=False
|
||||
)
|
||||
|
||||
fieldsets = (
|
||||
FieldSet('name', 'description', 'schema', 'tags', name=_('Config Context Profile')),
|
||||
FieldSet('data_source', 'data_file', 'auto_sync_enabled', name=_('Data Source')),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ConfigContextProfile
|
||||
fields = (
|
||||
'name', 'description', 'schema', 'data_source', 'data_file', 'auto_sync_enabled', 'comments', 'tags',
|
||||
)
|
||||
|
||||
|
||||
class ConfigContextForm(ChangelogMessageMixin, SyncedDataMixin, forms.ModelForm):
|
||||
profile = DynamicModelChoiceField(
|
||||
label=_('Profile'),
|
||||
queryset=ConfigContextProfile.objects.all(),
|
||||
required=False
|
||||
)
|
||||
regions = DynamicModelMultipleChoiceField(
|
||||
label=_('Regions'),
|
||||
queryset=Region.objects.all(),
|
||||
@ -657,7 +687,7 @@ class ConfigContextForm(ChangelogMessageMixin, SyncedDataMixin, forms.ModelForm)
|
||||
)
|
||||
|
||||
fieldsets = (
|
||||
FieldSet('name', 'weight', 'description', 'data', 'is_active', name=_('Config Context')),
|
||||
FieldSet('name', 'weight', 'profile', 'description', 'data', 'is_active', name=_('Config Context')),
|
||||
FieldSet('data_source', 'data_file', 'auto_sync_enabled', name=_('Data Source')),
|
||||
FieldSet(
|
||||
'regions', 'site_groups', 'sites', 'locations', 'device_types', 'roles', 'platforms', 'cluster_types',
|
||||
@ -669,9 +699,9 @@ class ConfigContextForm(ChangelogMessageMixin, SyncedDataMixin, forms.ModelForm)
|
||||
class Meta:
|
||||
model = ConfigContext
|
||||
fields = (
|
||||
'name', 'weight', 'description', 'data', 'is_active', 'regions', 'site_groups', 'sites', 'locations',
|
||||
'roles', 'device_types', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups',
|
||||
'tenants', 'tags', 'data_source', 'data_file', 'auto_sync_enabled',
|
||||
'name', 'weight', 'profile', 'description', 'data', 'is_active', 'regions', 'site_groups', 'sites',
|
||||
'locations', 'roles', 'device_types', 'platforms', 'cluster_types', 'cluster_groups', 'clusters',
|
||||
'tenant_groups', 'tenants', 'tags', 'data_source', 'data_file', 'auto_sync_enabled',
|
||||
)
|
||||
|
||||
def __init__(self, *args, initial=None, **kwargs):
|
||||
|
@ -8,7 +8,7 @@ from strawberry_django import FilterLookup
|
||||
from core.graphql.filter_mixins import BaseObjectTypeFilterMixin, ChangeLogFilterMixin
|
||||
from extras import models
|
||||
from extras.graphql.filter_mixins import TagBaseFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin
|
||||
from netbox.graphql.filter_mixins import SyncedDataFilterMixin
|
||||
from netbox.graphql.filter_mixins import PrimaryModelFilterMixin, SyncedDataFilterMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from core.graphql.filters import ContentTypeFilter
|
||||
@ -24,6 +24,7 @@ if TYPE_CHECKING:
|
||||
|
||||
__all__ = (
|
||||
'ConfigContextFilter',
|
||||
'ConfigContextProfileFilter',
|
||||
'ConfigTemplateFilter',
|
||||
'CustomFieldFilter',
|
||||
'CustomFieldChoiceSetFilter',
|
||||
@ -97,6 +98,13 @@ class ConfigContextFilter(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, Chan
|
||||
)
|
||||
|
||||
|
||||
@strawberry_django.filter_type(models.ConfigContextProfile, lookups=True)
|
||||
class ConfigContextProfileFilter(SyncedDataFilterMixin, PrimaryModelFilterMixin):
|
||||
name: FilterLookup[str] = strawberry_django.filter_field()
|
||||
description: FilterLookup[str] = strawberry_django.filter_field()
|
||||
tags: Annotated['TagFilter', strawberry.lazy('extras.graphql.filters')] | None = strawberry_django.filter_field()
|
||||
|
||||
|
||||
@strawberry_django.filter_type(models.ConfigTemplate, lookups=True)
|
||||
class ConfigTemplateFilter(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, ChangeLogFilterMixin):
|
||||
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
|
@ -11,6 +11,9 @@ class ExtrasQuery:
|
||||
config_context: ConfigContextType = strawberry_django.field()
|
||||
config_context_list: List[ConfigContextType] = strawberry_django.field()
|
||||
|
||||
config_context_profile: ConfigContextProfileType = strawberry_django.field()
|
||||
config_context_profile_list: List[ConfigContextProfileType] = strawberry_django.field()
|
||||
|
||||
config_template: ConfigTemplateType = strawberry_django.field()
|
||||
config_template_list: List[ConfigTemplateType] = strawberry_django.field()
|
||||
|
||||
|
@ -3,13 +3,13 @@ from typing import Annotated, List, TYPE_CHECKING
|
||||
import strawberry
|
||||
import strawberry_django
|
||||
|
||||
from core.graphql.mixins import SyncedDataMixin
|
||||
from extras import models
|
||||
from extras.graphql.mixins import CustomFieldsMixin, TagsMixin
|
||||
from netbox.graphql.types import BaseObjectType, ContentTypeType, ObjectType, OrganizationalObjectType
|
||||
from netbox.graphql.types import BaseObjectType, ContentTypeType, NetBoxObjectType, ObjectType, OrganizationalObjectType
|
||||
from .filters import *
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from core.graphql.types import DataFileType, DataSourceType
|
||||
from dcim.graphql.types import (
|
||||
DeviceRoleType,
|
||||
DeviceType,
|
||||
@ -25,6 +25,7 @@ if TYPE_CHECKING:
|
||||
from virtualization.graphql.types import ClusterGroupType, ClusterType, ClusterTypeType, VirtualMachineType
|
||||
|
||||
__all__ = (
|
||||
'ConfigContextProfileType',
|
||||
'ConfigContextType',
|
||||
'ConfigTemplateType',
|
||||
'CustomFieldChoiceSetType',
|
||||
@ -44,15 +45,24 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
@strawberry_django.type(
|
||||
models.ConfigContextProfile,
|
||||
fields='__all__',
|
||||
filters=ConfigContextProfileFilter,
|
||||
pagination=True
|
||||
)
|
||||
class ConfigContextProfileType(SyncedDataMixin, NetBoxObjectType):
|
||||
pass
|
||||
|
||||
|
||||
@strawberry_django.type(
|
||||
models.ConfigContext,
|
||||
fields='__all__',
|
||||
filters=ConfigContextFilter,
|
||||
pagination=True
|
||||
)
|
||||
class ConfigContextType(ObjectType):
|
||||
data_source: Annotated["DataSourceType", strawberry.lazy('core.graphql.types')] | None
|
||||
data_file: Annotated["DataFileType", strawberry.lazy('core.graphql.types')] | None
|
||||
class ConfigContextType(SyncedDataMixin, ObjectType):
|
||||
profile: ConfigContextProfileType | None
|
||||
roles: List[Annotated["DeviceRoleType", strawberry.lazy('dcim.graphql.types')]]
|
||||
device_types: List[Annotated["DeviceTypeType", strawberry.lazy('dcim.graphql.types')]]
|
||||
tags: List[Annotated["TagType", strawberry.lazy('extras.graphql.types')]]
|
||||
@ -74,10 +84,7 @@ class ConfigContextType(ObjectType):
|
||||
filters=ConfigTemplateFilter,
|
||||
pagination=True
|
||||
)
|
||||
class ConfigTemplateType(TagsMixin, ObjectType):
|
||||
data_source: Annotated["DataSourceType", strawberry.lazy('core.graphql.types')] | None
|
||||
data_file: Annotated["DataFileType", strawberry.lazy('core.graphql.types')] | None
|
||||
|
||||
class ConfigTemplateType(SyncedDataMixin, TagsMixin, ObjectType):
|
||||
virtualmachines: List[Annotated["VirtualMachineType", strawberry.lazy('virtualization.graphql.types')]]
|
||||
devices: List[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]]
|
||||
platforms: List[Annotated["PlatformType", strawberry.lazy('dcim.graphql.types')]]
|
||||
@ -123,9 +130,8 @@ class CustomLinkType(ObjectType):
|
||||
filters=ExportTemplateFilter,
|
||||
pagination=True
|
||||
)
|
||||
class ExportTemplateType(ObjectType):
|
||||
data_source: Annotated["DataSourceType", strawberry.lazy('core.graphql.types')] | None
|
||||
data_file: Annotated["DataFileType", strawberry.lazy('core.graphql.types')] | None
|
||||
class ExportTemplateType(SyncedDataMixin, ObjectType):
|
||||
pass
|
||||
|
||||
|
||||
@strawberry_django.type(
|
||||
|
75
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 jsonschema.exceptions import ValidationError as JSONValidationError
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.validators import ValidationError
|
||||
@ -9,13 +11,15 @@ from django.utils.translation import gettext_lazy as _
|
||||
from core.models import ObjectType
|
||||
from extras.models.mixins import RenderTemplateMixin
|
||||
from extras.querysets import ConfigContextQuerySet
|
||||
from netbox.models import ChangeLoggedModel
|
||||
from netbox.models import ChangeLoggedModel, PrimaryModel
|
||||
from netbox.models.features import CloningMixin, CustomLinksMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin
|
||||
from utilities.data import deepmerge
|
||||
from utilities.jsonschema import validate_schema
|
||||
|
||||
__all__ = (
|
||||
'ConfigContext',
|
||||
'ConfigContextModel',
|
||||
'ConfigContextProfile',
|
||||
'ConfigTemplate',
|
||||
)
|
||||
|
||||
@ -24,6 +28,46 @@ __all__ = (
|
||||
# Config contexts
|
||||
#
|
||||
|
||||
class ConfigContextProfile(SyncedDataMixin, PrimaryModel):
|
||||
"""
|
||||
A profile which can be used to enforce parameters on a ConfigContext.
|
||||
"""
|
||||
name = models.CharField(
|
||||
verbose_name=_('name'),
|
||||
max_length=100,
|
||||
unique=True
|
||||
)
|
||||
description = models.CharField(
|
||||
verbose_name=_('description'),
|
||||
max_length=200,
|
||||
blank=True
|
||||
)
|
||||
schema = models.JSONField(
|
||||
blank=True,
|
||||
null=True,
|
||||
validators=[validate_schema],
|
||||
verbose_name=_('schema'),
|
||||
help_text=_('A JSON schema specifying the structure of the context data for this profile')
|
||||
)
|
||||
|
||||
clone_fields = ('schema',)
|
||||
|
||||
class Meta:
|
||||
ordering = ('name',)
|
||||
verbose_name = _('config context profile')
|
||||
verbose_name_plural = _('config context profiles')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def sync_data(self):
|
||||
"""
|
||||
Synchronize schema from the designated DataFile (if any).
|
||||
"""
|
||||
self.schema = self.data_file.get_data()
|
||||
sync_data.alters_data = True
|
||||
|
||||
|
||||
class ConfigContext(SyncedDataMixin, CloningMixin, CustomLinksMixin, ChangeLoggedModel):
|
||||
"""
|
||||
A ConfigContext represents a set of arbitrary data available to any Device or VirtualMachine matching its assigned
|
||||
@ -35,6 +79,13 @@ class ConfigContext(SyncedDataMixin, CloningMixin, CustomLinksMixin, ChangeLogge
|
||||
max_length=100,
|
||||
unique=True
|
||||
)
|
||||
profile = models.ForeignKey(
|
||||
to='extras.ConfigContextProfile',
|
||||
on_delete=models.PROTECT,
|
||||
blank=True,
|
||||
null=True,
|
||||
related_name='config_contexts',
|
||||
)
|
||||
weight = models.PositiveSmallIntegerField(
|
||||
verbose_name=_('weight'),
|
||||
default=1000
|
||||
@ -118,9 +169,8 @@ class ConfigContext(SyncedDataMixin, CloningMixin, CustomLinksMixin, ChangeLogge
|
||||
objects = ConfigContextQuerySet.as_manager()
|
||||
|
||||
clone_fields = (
|
||||
'weight', 'is_active', 'regions', 'site_groups', 'sites', 'locations', 'device_types',
|
||||
'roles', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups',
|
||||
'tenants', 'tags', 'data',
|
||||
'weight', 'profile', 'is_active', 'regions', 'site_groups', 'sites', 'locations', 'device_types', 'roles',
|
||||
'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
@ -147,6 +197,13 @@ class ConfigContext(SyncedDataMixin, CloningMixin, CustomLinksMixin, ChangeLogge
|
||||
{'data': _('JSON data must be in object form. Example:') + ' {"foo": 123}'}
|
||||
)
|
||||
|
||||
# Validate config data against the assigned profile's schema (if any)
|
||||
if self.profile and self.profile.schema:
|
||||
try:
|
||||
jsonschema.validate(self.data, schema=self.profile.schema)
|
||||
except JSONValidationError as e:
|
||||
raise ValidationError(_("Data does not conform to profile schema: {error}").format(error=e))
|
||||
|
||||
def sync_data(self):
|
||||
"""
|
||||
Synchronize context data from the designated DataFile (if any).
|
||||
|
@ -2,6 +2,17 @@ from netbox.search import SearchIndex, register_search
|
||||
from . import models
|
||||
|
||||
|
||||
@register_search
|
||||
class ConfigContextProfileIndex(SearchIndex):
|
||||
model = models.ConfigContextProfile
|
||||
fields = (
|
||||
('name', 100),
|
||||
('description', 500),
|
||||
('comments', 5000),
|
||||
)
|
||||
display_attrs = ('description',)
|
||||
|
||||
|
||||
@register_search
|
||||
class CustomFieldIndex(SearchIndex):
|
||||
model = models.CustomField
|
||||
|
@ -15,6 +15,7 @@ from .columns import NotificationActionsColumn
|
||||
|
||||
__all__ = (
|
||||
'BookmarkTable',
|
||||
'ConfigContextProfileTable',
|
||||
'ConfigContextTable',
|
||||
'ConfigTemplateTable',
|
||||
'CustomFieldChoiceSetTable',
|
||||
@ -546,7 +547,41 @@ class TaggedItemTable(NetBoxTable):
|
||||
fields = ('id', 'content_type', 'content_object')
|
||||
|
||||
|
||||
class ConfigContextProfileTable(NetBoxTable):
|
||||
name = tables.Column(
|
||||
verbose_name=_('Name'),
|
||||
linkify=True
|
||||
)
|
||||
data_source = tables.Column(
|
||||
verbose_name=_('Data Source'),
|
||||
linkify=True
|
||||
)
|
||||
data_file = tables.Column(
|
||||
verbose_name=_('Data File'),
|
||||
linkify=True
|
||||
)
|
||||
is_synced = columns.BooleanColumn(
|
||||
orderable=False,
|
||||
verbose_name=_('Synced')
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
url_name='extras:configcontextprofile_list'
|
||||
)
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = ConfigContextProfile
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'description', 'comments', 'data_source', 'data_file', 'is_synced', 'tags', 'created',
|
||||
'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'is_synced', 'description')
|
||||
|
||||
|
||||
class ConfigContextTable(NetBoxTable):
|
||||
profile = tables.Column(
|
||||
linkify=True,
|
||||
verbose_name=_('Profile'),
|
||||
)
|
||||
data_source = tables.Column(
|
||||
verbose_name=_('Data Source'),
|
||||
linkify=True
|
||||
@ -573,11 +608,11 @@ class ConfigContextTable(NetBoxTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = ConfigContext
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'weight', 'is_active', 'is_synced', 'description', 'regions', 'sites', 'locations',
|
||||
'roles', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants',
|
||||
'data_source', 'data_file', 'data_synced', 'tags', 'created', 'last_updated',
|
||||
'pk', 'id', 'name', 'weight', 'profile', 'is_active', 'is_synced', 'description', 'regions', 'sites',
|
||||
'locations', 'roles', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups',
|
||||
'tenants', 'data_source', 'data_file', 'data_synced', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'weight', 'is_active', 'is_synced', 'description')
|
||||
default_columns = ('pk', 'name', 'weight', 'profile', 'is_active', 'is_synced', 'description')
|
||||
|
||||
|
||||
class ConfigTemplateTable(NetBoxTable):
|
||||
|
@ -666,6 +666,70 @@ class JournalEntryTest(APIViewTestCases.APIViewTestCase):
|
||||
]
|
||||
|
||||
|
||||
class ConfigContextProfileTest(APIViewTestCases.APIViewTestCase):
|
||||
model = ConfigContextProfile
|
||||
brief_fields = ['description', 'display', 'id', 'name', 'url']
|
||||
create_data = [
|
||||
{
|
||||
'name': 'Config Context Profile 4',
|
||||
},
|
||||
{
|
||||
'name': 'Config Context Profile 5',
|
||||
},
|
||||
{
|
||||
'name': 'Config Context Profile 6',
|
||||
},
|
||||
]
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
profiles = (
|
||||
ConfigContextProfile(
|
||||
name='Config Context Profile 1',
|
||||
schema={
|
||||
"properties": {
|
||||
"foo": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"foo"
|
||||
]
|
||||
}
|
||||
),
|
||||
ConfigContextProfile(
|
||||
name='Config Context Profile 2',
|
||||
schema={
|
||||
"properties": {
|
||||
"bar": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"bar"
|
||||
]
|
||||
}
|
||||
),
|
||||
ConfigContextProfile(
|
||||
name='Config Context Profile 3',
|
||||
schema={
|
||||
"properties": {
|
||||
"baz": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"baz"
|
||||
]
|
||||
}
|
||||
),
|
||||
)
|
||||
ConfigContextProfile.objects.bulk_create(profiles)
|
||||
|
||||
|
||||
class ConfigContextTest(APIViewTestCases.APIViewTestCase):
|
||||
model = ConfigContext
|
||||
brief_fields = ['description', 'display', 'id', 'name', 'url']
|
||||
|
@ -871,6 +871,39 @@ class JournalEntryTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
class ConfigContextProfileTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
queryset = ConfigContextProfile.objects.all()
|
||||
filterset = ConfigContextProfileFilterSet
|
||||
ignore_fields = ('schema', 'data_path')
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
profiles = (
|
||||
ConfigContextProfile(
|
||||
name='Config Context Profile 1',
|
||||
description='foo',
|
||||
),
|
||||
ConfigContextProfile(
|
||||
name='Config Context Profile 2',
|
||||
description='bar',
|
||||
),
|
||||
ConfigContextProfile(
|
||||
name='Config Context Profile 3',
|
||||
description='baz',
|
||||
),
|
||||
)
|
||||
ConfigContextProfile.objects.bulk_create(profiles)
|
||||
|
||||
def test_q(self):
|
||||
params = {'q': 'foo'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_name(self):
|
||||
profiles = self.queryset.all()[:2]
|
||||
params = {'name': [profiles[0].name, profiles[1].name]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
queryset = ConfigContext.objects.all()
|
||||
filterset = ConfigContextFilterSet
|
||||
@ -878,6 +911,12 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
profiles = (
|
||||
ConfigContextProfile(name='Config Context Profile 1'),
|
||||
ConfigContextProfile(name='Config Context Profile 2'),
|
||||
ConfigContextProfile(name='Config Context Profile 3'),
|
||||
)
|
||||
ConfigContextProfile.objects.bulk_create(profiles)
|
||||
|
||||
regions = (
|
||||
Region(name='Region 1', slug='region-1'),
|
||||
@ -976,6 +1015,7 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
is_active = bool(i % 2)
|
||||
c = ConfigContext.objects.create(
|
||||
name=f"Config Context {i + 1}",
|
||||
profile=profiles[i],
|
||||
is_active=is_active,
|
||||
data='{"foo": 123}',
|
||||
description=f"foobar{i + 1}"
|
||||
@ -1012,6 +1052,13 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'description': ['foobar1', 'foobar2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_profile(self):
|
||||
profiles = ConfigContextProfile.objects.all()[:2]
|
||||
params = {'profile_id': [profiles[0].pk, profiles[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'profile': [profiles[0].name, profiles[1].name]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_region(self):
|
||||
regions = Region.objects.all()[:2]
|
||||
params = {'region_id': [regions[0].pk, regions[1].pk]}
|
||||
@ -1185,6 +1232,7 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
'cluster',
|
||||
'clustergroup',
|
||||
'clustertype',
|
||||
'configcontextprofile',
|
||||
'configtemplate',
|
||||
'consoleport',
|
||||
'consoleserverport',
|
||||
|
@ -6,7 +6,7 @@ from django.test import tag, TestCase
|
||||
|
||||
from core.models import DataSource, ObjectType
|
||||
from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Platform, Region, Site, SiteGroup
|
||||
from extras.models import ConfigContext, ConfigTemplate, Tag
|
||||
from extras.models import ConfigContext, ConfigContextProfile, ConfigTemplate, Tag
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
from utilities.exceptions import AbortRequest
|
||||
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
|
||||
@ -159,6 +159,32 @@ class ConfigContextTest(TestCase):
|
||||
}
|
||||
self.assertEqual(device.get_config_context(), expected_data)
|
||||
|
||||
def test_schema_validation(self):
|
||||
"""
|
||||
Check that the JSON schema defined by the assigned profile is enforced.
|
||||
"""
|
||||
profile = ConfigContextProfile.objects.create(
|
||||
name="Config context profile 1",
|
||||
schema={
|
||||
"properties": {
|
||||
"foo": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"foo"
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
# Missing required attribute
|
||||
ConfigContext(name="CC1", profile=profile, data={}).clean()
|
||||
with self.assertRaises(ValidationError):
|
||||
# Invalid attribute type
|
||||
ConfigContext(name="CC1", profile=profile, data={"foo": 123}).clean()
|
||||
ConfigContext(name="CC1", profile=profile, data={"foo": "bar"}).clean()
|
||||
|
||||
def test_annotation_same_as_get_for_object(self):
|
||||
"""
|
||||
This test incorporates features from all of the above tests cases to ensure
|
||||
|
@ -481,6 +481,78 @@ class TagTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
|
||||
}
|
||||
|
||||
|
||||
class ConfigContextProfileTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
model = ConfigContextProfile
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
profiles = (
|
||||
ConfigContextProfile(
|
||||
name='Config Context Profile 1',
|
||||
schema={
|
||||
"properties": {
|
||||
"foo": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"foo"
|
||||
]
|
||||
}
|
||||
),
|
||||
ConfigContextProfile(
|
||||
name='Config Context Profile 2',
|
||||
schema={
|
||||
"properties": {
|
||||
"bar": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"bar"
|
||||
]
|
||||
}
|
||||
),
|
||||
ConfigContextProfile(
|
||||
name='Config Context Profile 3',
|
||||
schema={
|
||||
"properties": {
|
||||
"baz": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"baz"
|
||||
]
|
||||
}
|
||||
),
|
||||
)
|
||||
ConfigContextProfile.objects.bulk_create(profiles)
|
||||
|
||||
cls.form_data = {
|
||||
'name': 'Config Context Profile X',
|
||||
'description': 'A new config context profile',
|
||||
}
|
||||
|
||||
cls.bulk_edit_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
||||
cls.csv_data = (
|
||||
'name,description',
|
||||
'Config context profile 1,Foo',
|
||||
'Config context profile 2,Bar',
|
||||
'Config context profile 3,Baz',
|
||||
)
|
||||
|
||||
cls.csv_update_data = (
|
||||
"id,description",
|
||||
f"{profiles[0].pk},New description",
|
||||
f"{profiles[1].pk},New description",
|
||||
f"{profiles[2].pk},New description",
|
||||
)
|
||||
|
||||
|
||||
# TODO: Change base class to PrimaryObjectViewTestCase
|
||||
# Blocked by absence of standard create/edit, bulk create views
|
||||
class ConfigContextTestCase(
|
||||
|
@ -47,6 +47,9 @@ urlpatterns = [
|
||||
path('tags/', include(get_model_urls('extras', 'tag', detail=False))),
|
||||
path('tags/<int:pk>/', include(get_model_urls('extras', 'tag'))),
|
||||
|
||||
path('config-context-profiles/', include(get_model_urls('extras', 'configcontextprofile', detail=False))),
|
||||
path('config-context-profiles/<int:pk>/', include(get_model_urls('extras', 'configcontextprofile'))),
|
||||
|
||||
path('config-contexts/', include(get_model_urls('extras', 'configcontext', detail=False))),
|
||||
path('config-contexts/<int:pk>/', include(get_model_urls('extras', 'configcontext'))),
|
||||
|
||||
|
@ -793,6 +793,67 @@ class TagBulkDeleteView(generic.BulkDeleteView):
|
||||
table = tables.TagTable
|
||||
|
||||
|
||||
#
|
||||
# Config context profiles
|
||||
#
|
||||
|
||||
@register_model_view(ConfigContextProfile, 'list', path='', detail=False)
|
||||
class ConfigContextProfileListView(generic.ObjectListView):
|
||||
queryset = ConfigContextProfile.objects.all()
|
||||
filterset = filtersets.ConfigContextProfileFilterSet
|
||||
filterset_form = forms.ConfigContextProfileFilterForm
|
||||
table = tables.ConfigContextProfileTable
|
||||
actions = (AddObject, BulkSync, BulkEdit, BulkRename, BulkDelete)
|
||||
|
||||
|
||||
@register_model_view(ConfigContextProfile)
|
||||
class ConfigContextProfileView(generic.ObjectView):
|
||||
queryset = ConfigContextProfile.objects.all()
|
||||
|
||||
|
||||
@register_model_view(ConfigContextProfile, 'add', detail=False)
|
||||
@register_model_view(ConfigContextProfile, 'edit')
|
||||
class ConfigContextProfileEditView(generic.ObjectEditView):
|
||||
queryset = ConfigContextProfile.objects.all()
|
||||
form = forms.ConfigContextProfileForm
|
||||
|
||||
|
||||
@register_model_view(ConfigContextProfile, 'delete')
|
||||
class ConfigContextProfileDeleteView(generic.ObjectDeleteView):
|
||||
queryset = ConfigContextProfile.objects.all()
|
||||
|
||||
|
||||
@register_model_view(ConfigContextProfile, 'bulk_import', path='import', detail=False)
|
||||
class ConfigContextProfileBulkImportView(generic.BulkImportView):
|
||||
queryset = ConfigContextProfile.objects.all()
|
||||
model_form = forms.ConfigContextProfileImportForm
|
||||
|
||||
|
||||
@register_model_view(ConfigContextProfile, 'bulk_edit', path='edit', detail=False)
|
||||
class ConfigContextProfileBulkEditView(generic.BulkEditView):
|
||||
queryset = ConfigContextProfile.objects.all()
|
||||
filterset = filtersets.ConfigContextProfileFilterSet
|
||||
table = tables.ConfigContextProfileTable
|
||||
form = forms.ConfigContextProfileBulkEditForm
|
||||
|
||||
|
||||
@register_model_view(ConfigContextProfile, 'bulk_rename', path='rename', detail=False)
|
||||
class ConfigContextProfileBulkRenameView(generic.BulkRenameView):
|
||||
queryset = ConfigContextProfile.objects.all()
|
||||
|
||||
|
||||
@register_model_view(ConfigContextProfile, 'bulk_delete', path='delete', detail=False)
|
||||
class ConfigContextProfileBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = ConfigContextProfile.objects.all()
|
||||
filterset = filtersets.ConfigContextProfileFilterSet
|
||||
table = tables.ConfigContextProfileTable
|
||||
|
||||
|
||||
@register_model_view(ConfigContextProfile, 'bulk_sync', path='sync', detail=False)
|
||||
class ConfigContextProfileBulkSyncDataView(generic.BulkSyncDataView):
|
||||
queryset = ConfigContextProfile.objects.all()
|
||||
|
||||
|
||||
#
|
||||
# Config contexts
|
||||
#
|
||||
|
@ -331,6 +331,7 @@ PROVISIONING_MENU = Menu(
|
||||
label=_('Configurations'),
|
||||
items=(
|
||||
get_model_item('extras', 'configcontext', _('Config Contexts'), actions=['add']),
|
||||
get_model_item('extras', 'configcontextprofile', _('Config Context Profiles')),
|
||||
get_model_item('extras', 'configtemplate', _('Config Templates'), actions=['add']),
|
||||
),
|
||||
),
|
||||
|
36
netbox/templates/core/inc/datafile_panel.html
Normal file
36
netbox/templates/core/inc/datafile_panel.html
Normal file
@ -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>
|
@ -17,6 +17,10 @@
|
||||
<th scope="row">{% trans "Weight" %}</th>
|
||||
<td>{{ object.weight }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Profile" %}</th>
|
||||
<td>{{ object.profile|linkify|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Description" %}</th>
|
||||
<td>{{ object.description|placeholder }}</td>
|
||||
@ -25,37 +29,9 @@
|
||||
<th scope="row">{% trans "Active" %}</th>
|
||||
<td>{% checkmark object.is_active %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Data Source" %}</th>
|
||||
<td>
|
||||
{% if object.data_source %}
|
||||
<a href="{{ object.data_source.get_absolute_url }}">{{ object.data_source }}</a>
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Data File" %}</th>
|
||||
<td>
|
||||
{% if object.data_file %}
|
||||
<a href="{{ object.data_file.get_absolute_url }}">{{ object.data_file }}</a>
|
||||
{% elif object.data_path %}
|
||||
<div class="float-end text-warning">
|
||||
<i class="mdi mdi-alert" title="{% trans "The data file associated with this object has been deleted" %}."></i>
|
||||
</div>
|
||||
{{ object.data_path }}
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Data Synced" %}</th>
|
||||
<td>{{ object.data_synced|placeholder }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{% include 'core/inc/datafile_panel.html' %}
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Assignment" %}</h2>
|
||||
<table class="table table-hover attr-table">
|
||||
|
39
netbox/templates/extras/configcontextprofile.html
Normal file
39
netbox/templates/extras/configcontextprofile.html
Normal file
@ -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 %}
|
@ -35,37 +35,9 @@
|
||||
<th scope="row">{% trans "Attachment" %}</th>
|
||||
<td>{% checkmark object.as_attachment %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Data Source" %}</th>
|
||||
<td>
|
||||
{% if object.data_source %}
|
||||
<a href="{{ object.data_source.get_absolute_url }}">{{ object.data_source }}</a>
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Data File" %}</th>
|
||||
<td>
|
||||
{% if object.data_file %}
|
||||
<a href="{{ object.data_file.get_absolute_url }}">{{ object.data_file }}</a>
|
||||
{% elif object.data_path %}
|
||||
<div class="float-end text-warning">
|
||||
<i class="mdi mdi-alert" title="{% trans "The data file associated with this object has been deleted" %}."></i>
|
||||
</div>
|
||||
{{ object.data_path }}
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Data Synced" %}</th>
|
||||
<td>{{ object.data_synced|placeholder }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{% include 'core/inc/datafile_panel.html' %}
|
||||
{% plugin_left_page object %}
|
||||
</div>
|
||||
<div class="col col-md-6">
|
||||
|
@ -10,8 +10,4 @@
|
||||
</div>
|
||||
</h2>
|
||||
{% endif %}
|
||||
<div class="card-body">
|
||||
<div class="rendered-context-data mt-1">
|
||||
<pre class="block" {% if copyid %}id="{{ copyid }}{% endif %}">{% if format == 'json' %}{{ data|json }}{% elif format == 'yaml' %}{{ data|yaml }}{% else %}{{ data }}{% endif %}</pre>
|
||||
</div>
|
||||
</div>
|
||||
<pre class="card-body rendered-context-data m-0" {% if copyid %}id="{{ copyid }}{% endif %}">{% if format == 'json' %}{{ data|json }}{% elif format == 'yaml' %}{{ data|yaml }}{% else %}{{ data }}{% endif %}</pre>
|
||||
|
@ -154,8 +154,11 @@ def validate_schema(schema):
|
||||
"""
|
||||
Check that a minimum JSON schema definition is defined.
|
||||
"""
|
||||
# Pass on empty values
|
||||
if schema in (None, ''):
|
||||
return
|
||||
# Provide some basic sanity checking (not provided by jsonschema)
|
||||
if not schema or type(schema) is not dict:
|
||||
if type(schema) is not dict:
|
||||
raise ValidationError(_("Invalid JSON schema definition"))
|
||||
if not schema.get('properties'):
|
||||
raise ValidationError(_("JSON schema must define properties"))
|
||||
|
Loading…
Reference in New Issue
Block a user