diff --git a/docs/customization/custom-fields.md b/docs/customization/custom-fields.md index 4658cc7e6..eae35f3d0 100644 --- a/docs/customization/custom-fields.md +++ b/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 diff --git a/docs/models/extras/customfield.md b/docs/models/extras/customfield.md index a5d083492..615a314b8 100644 --- a/docs/models/extras/customfield.md +++ b/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. diff --git a/netbox/dcim/migrations/0206_load_module_type_profiles.py b/netbox/dcim/migrations/0206_load_module_type_profiles.py index 87661a8ac..1b9873faf 100644 --- a/netbox/dcim/migrations/0206_load_module_type_profiles.py +++ b/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): 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..9fecfdb7a 100644 --- a/netbox/extras/forms/bulk_edit.py +++ b/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): 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') diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py index d661854d6..764166a63 100644 --- a/netbox/extras/tests/test_filtersets.py +++ b/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): diff --git a/netbox/utilities/jsonschema.py b/netbox/utilities/jsonschema.py index db2907199..708e38307 100644 --- a/netbox/utilities/jsonschema.py +++ b/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)