mirror of
https://github.com/netbox-community/netbox.git
synced 2026-03-29 05:42:09 +02:00
Initial work on #19025
This commit is contained in:
@@ -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')
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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."))
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user