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

This commit is contained in:
Jeremy Stretch
2026-03-31 13:41:49 -04:00
committed by GitHub
parent 2389feea6b
commit e5b9e5a279
13 changed files with 171 additions and 12 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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