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