Просмотр исходного кода

Closes #19025: Add schema validation for JSON custom fields (#21746)

Jeremy Stretch 4 дней назад
Родитель
Сommit
e5b9e5a279

+ 1 - 0
docs/customization/custom-fields.md

@@ -63,6 +63,7 @@ NetBox supports limited custom validation for custom field values. Following are
 * Text: Regular expression (optional)
 * Integer: Minimum and/or maximum value (optional)
 * Selection: Must exactly match one of the prescribed choices
+* JSON: Must adhere to the defined validation schema (if any)
 
 ### Custom Selection Fields
 

+ 4 - 0
docs/models/extras/customfield.md

@@ -118,3 +118,7 @@ For numeric custom fields only. The maximum valid value (optional).
 ### Validation Regex
 
 For string-based custom fields only. A regular expression used to validate the field's value (optional).
+
+### Validation Schema
+
+For JSON custom fields, users have the option of defining a [validation schema](https://json-schema.org). Any value applied to this custom field on a model will be validated against the provided schema, if any.

+ 5 - 1
netbox/dcim/migrations/0206_load_module_type_profiles.py

@@ -22,17 +22,21 @@ def load_initial_data(apps, schema_editor):
         'power_supply',
         'expansion_card'
     )
+    profile_objects = []
 
     for name in initial_profiles:
         file_path = DATA_FILES_PATH / f'{name}.json'
         with file_path.open('r') as f:
             data = json.load(f)
             try:
-                ModuleTypeProfile.objects.using(db_alias).create(**data)
+                profile = ModuleTypeProfile(**data)
+                profile_objects.append(profile)
             except Exception as e:
                 print(f"Error loading data from {file_path}")
                 raise e
 
+    ModuleTypeProfile.objects.using(db_alias).bulk_create(profile_objects)
+
 
 class Migration(migrations.Migration):
 

+ 2 - 2
netbox/extras/api/serializers_/customfields.py

@@ -65,8 +65,8 @@ class CustomFieldSerializer(OwnerMixin, ChangeLogMessageSerializer, ValidatedMod
             'id', 'url', 'display_url', 'display', 'object_types', 'type', 'related_object_type', 'data_type',
             'name', 'label', 'group_name', 'description', 'required', 'unique', 'search_weight', 'filter_logic',
             'ui_visible', 'ui_editable', 'is_cloneable', 'default', 'related_object_filter', 'weight',
-            'validation_minimum', 'validation_maximum', 'validation_regex', 'choice_set', 'owner', 'comments',
-            'created', 'last_updated',
+            'validation_minimum', 'validation_maximum', 'validation_regex', 'validation_schema', 'choice_set',
+            'owner', 'comments', 'created', 'last_updated',
         ]
         brief_fields = ('id', 'url', 'display', 'name', 'description')
 

+ 10 - 3
netbox/extras/forms/bulk_edit.py

@@ -7,7 +7,7 @@ from netbox.events import get_event_type_choices
 from netbox.forms import NetBoxModelBulkEditForm, PrimaryModelBulkEditForm
 from netbox.forms.mixins import ChangelogMessageMixin, OwnerMixin
 from utilities.forms import BulkEditForm, add_blank_choice
-from utilities.forms.fields import ColorField, CommentField, DynamicModelChoiceField
+from utilities.forms.fields import ColorField, CommentField, DynamicModelChoiceField, JSONField
 from utilities.forms.rendering import FieldSet
 from utilities.forms.widgets import BulkEditNullBooleanSelect
 
@@ -88,14 +88,21 @@ class CustomFieldBulkEditForm(ChangelogMessageMixin, OwnerMixin, BulkEditForm):
         label=_('Validation regex'),
         required=False
     )
+    validation_schema = JSONField(
+        label=_('Validation schema'),
+        required=False
+    )
     comments = CommentField()
 
     fieldsets = (
         FieldSet('group_name', 'description', 'weight', 'required', 'unique', 'choice_set', name=_('Attributes')),
         FieldSet('ui_visible', 'ui_editable', 'is_cloneable', name=_('Behavior')),
-        FieldSet('validation_minimum', 'validation_maximum', 'validation_regex', name=_('Validation')),
+        FieldSet(
+            'validation_minimum', 'validation_maximum', 'validation_regex', 'validation_schema',
+            name=_('Validation')
+        ),
     )
-    nullable_fields = ('group_name', 'description', 'choice_set')
+    nullable_fields = ('group_name', 'description', 'choice_set', 'validation_schema')
 
 
 class CustomFieldChoiceSetBulkEditForm(ChangelogMessageMixin, OwnerMixin, BulkEditForm):

+ 2 - 1
netbox/extras/forms/bulk_import.py

@@ -80,7 +80,8 @@ class CustomFieldImportForm(OwnerCSVMixin, CSVModelForm):
         fields = (
             'name', 'label', 'group_name', 'type', 'object_types', 'related_object_type', 'required', 'unique',
             'description', 'search_weight', 'filter_logic', 'default', 'choice_set', 'weight', 'validation_minimum',
-            'validation_maximum', 'validation_regex', 'ui_visible', 'ui_editable', 'is_cloneable', 'owner', 'comments',
+            'validation_maximum', 'validation_regex', 'validation_schema', 'ui_visible', 'ui_editable',
+            'is_cloneable', 'owner', 'comments',
         )
 
 

+ 15 - 0
netbox/extras/forms/model_forms.py

@@ -76,6 +76,11 @@ class CustomFieldForm(ChangelogMessageMixin, OwnerMixin, forms.ModelForm):
     choice_set = DynamicModelChoiceField(
         queryset=CustomFieldChoiceSet.objects.all()
     )
+    validation_schema = JSONField(
+        label=_('Validation schema'),
+        required=False,
+        help_text=_('A JSON schema definition for validating the custom field value')
+    )
     comments = CommentField()
 
     fieldsets = (
@@ -144,6 +149,16 @@ class CustomFieldForm(ChangelogMessageMixin, OwnerMixin, forms.ModelForm):
             del self.fields['validation_minimum']
             del self.fields['validation_maximum']
 
+        # Adjust for JSON fields
+        if field_type == CustomFieldTypeChoices.TYPE_JSON:
+            self.fieldsets = (
+                self.fieldsets[0],
+                FieldSet('validation_schema', name=_('Validation')),
+                *self.fieldsets[1:]
+            )
+        else:
+            del self.fields['validation_schema']
+
         # Adjust for object & multi-object fields
         if field_type in (
                 CustomFieldTypeChoices.TYPE_OBJECT,

+ 24 - 0
netbox/extras/migrations/0136_customfield_validation_schema.py

@@ -0,0 +1,24 @@
+from django.db import migrations, models
+
+import utilities.jsonschema
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('extras', '0135_configtemplate_debug'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='customfield',
+            name='validation_schema',
+            field=models.JSONField(
+                blank=True,
+                help_text='A JSON schema definition for validating the custom field value',
+                null=True,
+                validators=[utilities.jsonschema.validate_schema],
+                verbose_name='validation schema',
+            ),
+        ),
+    ]

+ 27 - 1
netbox/extras/models/customfields.py

@@ -4,6 +4,7 @@ import re
 from datetime import date, datetime
 
 import django_filters
+import jsonschema
 from django import forms
 from django.conf import settings
 from django.contrib.postgres.fields import ArrayField
@@ -15,6 +16,7 @@ from django.urls import reverse
 from django.utils.html import escape
 from django.utils.safestring import mark_safe
 from django.utils.translation import gettext_lazy as _
+from jsonschema.exceptions import ValidationError as JSONValidationError
 
 from core.models import ObjectType
 from extras.choices import *
@@ -40,6 +42,7 @@ from utilities.forms.fields import (
 )
 from utilities.forms.utils import add_blank_choice
 from utilities.forms.widgets import APISelect, APISelectMultiple, DatePicker, DateTimePicker
+from utilities.jsonschema import validate_schema
 from utilities.querysets import RestrictedQuerySet
 from utilities.templatetags.builtins.filters import render_markdown
 from utilities.validators import validate_regex
@@ -222,6 +225,13 @@ class CustomField(CloningMixin, ExportTemplatesMixin, OwnerMixin, ChangeLoggedMo
             'example, <code>^[A-Z]{3}$</code> will limit values to exactly three uppercase letters.'
         )
     )
+    validation_schema = models.JSONField(
+        blank=True,
+        null=True,
+        validators=[validate_schema],
+        verbose_name=_('validation schema'),
+        help_text=_('A JSON schema definition for validating the custom field value')
+    )
     choice_set = models.ForeignKey(
         to='CustomFieldChoiceSet',
         on_delete=models.PROTECT,
@@ -259,7 +269,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, OwnerMixin, ChangeLoggedMo
     clone_fields = (
         'object_types', 'type', 'related_object_type', 'group_name', 'description', 'required', 'unique',
         'search_weight', 'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum',
-        'validation_regex', 'choice_set', 'ui_visible', 'ui_editable', 'is_cloneable',
+        'validation_regex', 'validation_schema', 'choice_set', 'ui_visible', 'ui_editable', 'is_cloneable',
     )
 
     class Meta:
@@ -389,6 +399,12 @@ class CustomField(CloningMixin, ExportTemplatesMixin, OwnerMixin, ChangeLoggedMo
                 'validation_regex': _("Regular expression validation is supported only for text and URL fields")
             })
 
+        # Schema validation can be set only for JSON fields
+        if self.validation_schema and self.type != CustomFieldTypeChoices.TYPE_JSON:
+            raise ValidationError({
+                'validation_schema': _("JSON schema validation is supported only for JSON fields")
+            })
+
         # Uniqueness can not be enforced for boolean fields
         if self.unique and self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
             raise ValidationError({
@@ -815,6 +831,16 @@ class CustomField(CloningMixin, ExportTemplatesMixin, OwnerMixin, ChangeLoggedMo
                     if type(id) is not int:
                         raise ValidationError(_("Found invalid object ID: {id}").format(id=id))
 
+            # Validate JSON against schema (if defined)
+            elif self.type == CustomFieldTypeChoices.TYPE_JSON:
+                if self.validation_schema:
+                    try:
+                        jsonschema.validate(value, schema=self.validation_schema)
+                    except JSONValidationError as e:
+                        raise ValidationError(
+                            _("Value does not conform to the assigned schema: {error}").format(error=e.message)
+                        )
+
         elif self.required:
             raise ValidationError(_("Required field cannot be empty."))
 

+ 5 - 1
netbox/extras/tables/tables.py

@@ -121,6 +121,10 @@ class CustomFieldTable(NetBoxTable):
     validation_regex = tables.Column(
         verbose_name=_('Validation Regex'),
     )
+    validation_schema = columns.BooleanColumn(
+        verbose_name=_('Validation Schema'),
+        false_mark=None,
+    )
     owner = tables.Column(
         linkify=True,
         verbose_name=_('Owner')
@@ -132,7 +136,7 @@ class CustomFieldTable(NetBoxTable):
             'pk', 'id', 'name', 'object_types', 'label', 'type', 'related_object_type', 'group_name', 'required',
             'unique', 'default', 'description', 'search_weight', 'filter_logic', 'ui_visible', 'ui_editable',
             'is_cloneable', 'weight', 'choice_set', 'choices', 'validation_minimum', 'validation_maximum',
-            'validation_regex', 'comments', 'created', 'last_updated',
+            'validation_regex', 'validation_schema', 'comments', 'created', 'last_updated',
         )
         default_columns = (
             'pk', 'name', 'object_types', 'label', 'group_name', 'type', 'required', 'unique', 'description',

+ 75 - 0
netbox/extras/tests/test_customfields.py

@@ -655,6 +655,45 @@ class CustomFieldTest(TestCase):
                 default=["xxx"]
             ).full_clean()
 
+    def test_validation_schema_only_for_json_type(self):
+        schema = {
+            'type': 'object',
+            'properties': {
+                'name': {'type': 'string'},
+            },
+        }
+
+        # Valid: schema on a JSON field
+        CustomField(name='test', type=CustomFieldTypeChoices.TYPE_JSON, validation_schema=schema).full_clean()
+
+        # Invalid: schema on a non-JSON field
+        with self.assertRaises(ValidationError):
+            CustomField(name='test', type=CustomFieldTypeChoices.TYPE_TEXT, validation_schema=schema).full_clean()
+        with self.assertRaises(ValidationError):
+            CustomField(name='test', type=CustomFieldTypeChoices.TYPE_INTEGER, validation_schema=schema).full_clean()
+
+    def test_json_schema_default_validation(self):
+        schema = {
+            'type': 'object',
+            'properties': {
+                'name': {'type': 'string'},
+            },
+            'required': ['name'],
+        }
+
+        # Valid default
+        CustomField(
+            name='test', type=CustomFieldTypeChoices.TYPE_JSON,
+            validation_schema=schema, default={'name': 'test'}
+        ).full_clean()
+
+        # Invalid default (missing required 'name')
+        with self.assertRaises(ValidationError):
+            CustomField(
+                name='test', type=CustomFieldTypeChoices.TYPE_JSON,
+                validation_schema=schema, default={'age': 25}
+            ).full_clean()
+
 
 class CustomFieldManagerTest(TestCase):
 
@@ -1322,6 +1361,42 @@ class CustomFieldAPITest(APITestCase):
         response = self.client.patch(url, data, format='json', **self.header)
         self.assertHttpStatus(response, status.HTTP_200_OK)
 
+    def test_json_schema_validation(self):
+        site2 = Site.objects.get(name='Site 2')
+        url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
+        self.add_permissions('dcim.change_site')
+
+        cf_json = CustomField.objects.get(name='json_field')
+        cf_json.validation_schema = {
+            'type': 'object',
+            'properties': {
+                'name': {'type': 'string'},
+                'age': {'type': 'integer'},
+            },
+            'required': ['name'],
+        }
+        cf_json.save()
+
+        # Invalid: missing required 'name' property
+        data = {'custom_fields': {'json_field': {'age': 25}}}
+        response = self.client.patch(url, data, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+
+        # Invalid: 'age' is not an integer
+        data = {'custom_fields': {'json_field': {'name': 'test', 'age': 'not_an_int'}}}
+        response = self.client.patch(url, data, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+
+        # Valid: conforms to schema
+        data = {'custom_fields': {'json_field': {'name': 'test', 'age': 25}}}
+        response = self.client.patch(url, data, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+
+        # Valid: null value (schema not enforced on empty)
+        data = {'custom_fields': {'json_field': None}}
+        response = self.client.patch(url, data, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+
     def test_uniqueness_validation(self):
         # Create a unique custom field
         cf_text = CustomField.objects.get(name='text_field')

+ 1 - 1
netbox/extras/tests/test_filtersets.py

@@ -22,7 +22,7 @@ from virtualization.models import Cluster, ClusterGroup, ClusterType
 class CustomFieldTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = CustomField.objects.all()
     filterset = CustomFieldFilterSet
-    ignore_fields = ('default', 'related_object_filter')
+    ignore_fields = ('default', 'related_object_filter', 'validation_schema')
 
     @classmethod
     def setUpTestData(cls):

+ 0 - 2
netbox/utilities/jsonschema.py

@@ -160,8 +160,6 @@ def validate_schema(schema):
     # Provide some basic sanity checking (not provided by jsonschema)
     if type(schema) is not dict:
         raise ValidationError(_("Invalid JSON schema definition"))
-    if not schema.get('properties'):
-        raise ValidationError(_("JSON schema must define properties"))
     try:
         ValidatorClass = validator_for(schema)
         ValidatorClass.check_schema(schema)