From 94ca9cc354a89f8315ada0cea4f5f3b195d5d953 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 24 Mar 2026 11:17:41 -0400 Subject: [PATCH] Initial work on #19025 --- .../extras/api/serializers_/customfields.py | 4 +- netbox/extras/forms/bulk_edit.py | 9 ++- netbox/extras/forms/bulk_import.py | 3 +- netbox/extras/forms/model_forms.py | 15 ++++ .../0136_customfield_validation_schema.py | 24 ++++++ netbox/extras/models/customfields.py | 28 ++++++- netbox/extras/tables/tables.py | 6 +- netbox/extras/tests/test_customfields.py | 75 +++++++++++++++++++ 8 files changed, 158 insertions(+), 6 deletions(-) create mode 100644 netbox/extras/migrations/0136_customfield_validation_schema.py diff --git a/netbox/extras/api/serializers_/customfields.py b/netbox/extras/api/serializers_/customfields.py index b12979439..37eaca4c8 100644 --- a/netbox/extras/api/serializers_/customfields.py +++ b/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') diff --git a/netbox/extras/forms/bulk_edit.py b/netbox/extras/forms/bulk_edit.py index de0f2bb27..98bc248e1 100644 --- a/netbox/extras/forms/bulk_edit.py +++ b/netbox/extras/forms/bulk_edit.py @@ -88,12 +88,19 @@ class CustomFieldBulkEditForm(ChangelogMessageMixin, OwnerMixin, BulkEditForm): label=_('Validation regex'), required=False ) + validation_schema = forms.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') diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py index df329fbcb..2bdb4beb6 100644 --- a/netbox/extras/forms/bulk_import.py +++ b/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', ) diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py index d01e32691..d1c43d2b0 100644 --- a/netbox/extras/forms/model_forms.py +++ b/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, diff --git a/netbox/extras/migrations/0136_customfield_validation_schema.py b/netbox/extras/migrations/0136_customfield_validation_schema.py new file mode 100644 index 000000000..9b4913fdd --- /dev/null +++ b/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', + ), + ), + ] diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index c9cfc4105..848199c20 100644 --- a/netbox/extras/models/customfields.py +++ b/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, ^[A-Z]{3}$ 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.")) diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index 3315e1252..fa58b04cc 100644 --- a/netbox/extras/tables/tables.py +++ b/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', diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index a8b65f119..cb6f0ecae 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/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')