Closes #19377: Introduce config context profiles

This commit is contained in:
Jeremy Stretch 2025-08-08 13:51:42 -04:00
parent ab8e3ee956
commit 7789147f04
31 changed files with 718 additions and 49 deletions

View File

@ -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.

View 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).

View File

@ -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'

View File

@ -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

View File

@ -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={

View File

@ -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):
"""

View File

@ -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')

View File

@ -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')

View File

@ -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

View File

@ -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(),

View File

@ -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):

View File

@ -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:

View File

@ -20,6 +20,7 @@ from virtualization.models import Cluster, ClusterGroup, ClusterType
__all__ = (
'ConfigContextFilterForm',
'ConfigContextProfileFilterForm',
'ConfigTemplateFilterForm',
'CustomFieldChoiceSetFilterForm',
'CustomFieldFilterForm',
@ -354,16 +355,29 @@ class TagFilterForm(SavedFiltersMixin, FilterForm):
)
class ConfigContextProfileFilterForm(SavedFiltersMixin, FilterForm):
model = ConfigContextProfile
fieldsets = (
FieldSet('q', 'filter_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,

View File

@ -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(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):
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):

View File

@ -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(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)
class ConfigTemplateFilter(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, ChangeLogFilterMixin):
name: FilterLookup[str] | None = strawberry_django.filter_field()

View File

@ -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()

View File

@ -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(

View 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',
),
),
]

View File

@ -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 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):
"""
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).

View File

@ -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

View File

@ -15,6 +15,7 @@ from .columns import NotificationActionsColumn
__all__ = (
'BookmarkTable',
'ConfigContextProfileTable',
'ConfigContextTable',
'ConfigTemplateTable',
'CustomFieldChoiceSetTable',
@ -546,7 +547,28 @@ class TaggedItemTable(NetBoxTable):
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):
profile = tables.Column(
linkify=True,
verbose_name=_('Profile'),
)
data_source = tables.Column(
verbose_name=_('Data Source'),
linkify=True
@ -573,11 +595,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):

View File

@ -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']

View File

@ -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'),
@ -975,6 +1014,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}"
@ -1011,6 +1051,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]}
@ -1184,6 +1231,7 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
'cluster',
'clustergroup',
'clustertype',
'configcontextprofile',
'configtemplate',
'consoleport',
'consoleserverport',

View File

@ -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(

View File

@ -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'))),

View File

@ -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
#

View File

@ -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']),
),
),

View File

@ -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>

View 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 %}

View File

@ -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>

View File

@ -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"))