Initial work on #19025

This commit is contained in:
Jeremy Stretch
2026-03-24 11:17:41 -04:00
parent 296b89ae02
commit 94ca9cc354
8 changed files with 158 additions and 6 deletions

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

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

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