diff --git a/docs/release-notes/version-2.10.md b/docs/release-notes/version-2.10.md index d1564e5e1..f8288cd6a 100644 --- a/docs/release-notes/version-2.10.md +++ b/docs/release-notes/version-2.10.md @@ -42,6 +42,7 @@ All end-to-end cable paths are now cached using the new CablePath model. This al ### Enhancements +* [#609](https://github.com/netbox-community/netbox/issues/609) - Add min/max value and regex validation for custom fields * [#1503](https://github.com/netbox-community/netbox/issues/1503) - Allow assigment of secrets to virtual machines * [#1692](https://github.com/netbox-community/netbox/issues/1692) - Allow assigment of inventory items to parent items in web UI * [#2179](https://github.com/netbox-community/netbox/issues/2179) - Support the assignment of multiple port numbers for services diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index 1b2fe3d12..e1ffc044a 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -108,6 +108,9 @@ class CustomFieldAdmin(admin.ModelAdmin): 'description': 'A custom field must be assigned to one or more object types.', 'fields': ('content_types',) }), + ('Validation Rules', { + 'fields': ('validation_minimum', 'validation_maximum', 'validation_regex') + }), ('Choices', { 'description': 'A selection field must have two or more choices assigned to it.', 'fields': ('choices',) diff --git a/netbox/extras/api/customfields.py b/netbox/extras/api/customfields.py index d17090fc2..8cb7dd5ba 100644 --- a/netbox/extras/api/customfields.py +++ b/netbox/extras/api/customfields.py @@ -1,3 +1,4 @@ +import re from datetime import datetime from django.contrib.contenttypes.models import ContentType @@ -77,12 +78,21 @@ class CustomFieldsDataField(Field): # Data validation if value not in [None, '']: + # Validate text field + if cf.type == CustomFieldTypeChoices.TYPE_TEXT and cf.validation_regex: + if not re.match(cf.validation_regex, value): + raise ValidationError(f"{field_name}: Value must match regex {cf.validation_regex}") + # Validate integer if cf.type == CustomFieldTypeChoices.TYPE_INTEGER: try: int(value) except ValueError: raise ValidationError(f"Invalid value for integer field {field_name}: {value}") + if cf.validation_minimum is not None and value < cf.validation_minimum: + raise ValidationError(f"{field_name}: Value must be at least {cf.validation_minimum}") + if cf.validation_maximum is not None and value > cf.validation_maximum: + raise ValidationError(f"{field_name}: Value must not exceed {cf.validation_maximum}") # Validate boolean if cf.type == CustomFieldTypeChoices.TYPE_BOOLEAN and value not in [True, False, 1, 0]: diff --git a/netbox/extras/migrations/0050_customfield_changes.py b/netbox/extras/migrations/0050_customfield_changes.py index 923b5dbc4..7d0a4c575 100644 --- a/netbox/extras/migrations/0050_customfield_changes.py +++ b/netbox/extras/migrations/0050_customfield_changes.py @@ -1,6 +1,8 @@ import django.contrib.postgres.fields +import django.core.validators from django.db import migrations, models -import django.db.models.deletion + +import utilities.validators class Migration(migrations.Migration): @@ -38,4 +40,20 @@ class Migration(migrations.Migration): old_name='obj_type', new_name='content_types', ), + # Add validation fields + migrations.AddField( + model_name='customfield', + name='validation_maximum', + field=models.PositiveIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='customfield', + name='validation_minimum', + field=models.PositiveIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='customfield', + name='validation_regex', + field=models.CharField(blank=True, max_length=500, validators=[utilities.validators.validate_regex]), + ), ] diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 62912ee2c..5c403128c 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -4,10 +4,12 @@ from django import forms from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.fields import ArrayField from django.core.serializers.json import DjangoJSONEncoder -from django.core.validators import ValidationError +from django.core.validators import RegexValidator, ValidationError from django.db import models +from django.utils.safestring import mark_safe from utilities.forms import CSVChoiceField, DatePicker, LaxURLField, StaticSelect2, add_blank_choice +from utilities.validators import validate_regex from extras.choices import * from extras.utils import FeatureQuery @@ -101,6 +103,25 @@ class CustomField(models.Model): default=100, help_text='Fields with higher weights appear lower in a form.' ) + validation_minimum = models.PositiveIntegerField( + blank=True, + null=True, + verbose_name='Minimum value', + help_text='Minimum allowed value (for numeric fields)' + ) + validation_maximum = models.PositiveIntegerField( + blank=True, + null=True, + verbose_name='Maximum value', + help_text='Maximum allowed value (for numeric fields)' + ) + validation_regex = models.CharField( + blank=True, + validators=[validate_regex], + max_length=500, + verbose_name='Validation regex', + help_text='Regular expression to enforce on text field values' + ) choices = ArrayField( base_field=models.CharField(max_length=100), blank=True, @@ -128,6 +149,22 @@ class CustomField(models.Model): obj.save() def clean(self): + # Minimum/maximum values can be set only for numeric fields + if self.validation_minimum is not None and self.type != CustomFieldTypeChoices.TYPE_INTEGER: + raise ValidationError({ + 'validation_minimum': "A minimum value may be set only for numeric fields" + }) + if self.validation_maximum is not None and self.type != CustomFieldTypeChoices.TYPE_INTEGER: + raise ValidationError({ + 'validation_maximum': "A maximum value may be set only for numeric fields" + }) + + # Regex validation can be set only for text fields + if self.validation_regex and self.type != CustomFieldTypeChoices.TYPE_TEXT: + raise ValidationError({ + 'validation_regex': "Regular expression validation is supported only for text and URL fields" + }) + # Choices can be set only on selection fields if self.choices and self.type != CustomFieldTypeChoices.TYPE_SELECT: raise ValidationError({ @@ -153,7 +190,12 @@ class CustomField(models.Model): # Integer if self.type == CustomFieldTypeChoices.TYPE_INTEGER: - field = forms.IntegerField(required=required, initial=initial) + field = forms.IntegerField( + required=required, + initial=initial, + min_value=self.validation_minimum, + max_value=self.validation_maximum + ) # Boolean elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN: @@ -196,6 +238,13 @@ class CustomField(models.Model): # Text else: field = forms.CharField(max_length=255, required=required, initial=initial) + if self.validation_regex: + field.validators = [ + RegexValidator( + regex=self.validation_regex, + message=mark_safe(f"Values must match this regex: {self.validation_regex}") + ) + ] field.model = self field.label = str(self) diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index 7ebb7701e..ca33c4697 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -21,7 +21,6 @@ class CustomFieldTest(TestCase): ]) def test_simple_fields(self): - DATA = ( {'field_type': CustomFieldTypeChoices.TYPE_TEXT, 'field_value': 'Foobar!', 'empty_value': ''}, {'field_type': CustomFieldTypeChoices.TYPE_INTEGER, 'field_value': 0, 'empty_value': None}, @@ -40,7 +39,6 @@ class CustomFieldTest(TestCase): cf = CustomField(type=data['field_type'], name='my_field', required=False) cf.save() cf.content_types.set([obj_type]) - cf.save() # Assign a value to the first Site site = Site.objects.first() @@ -61,7 +59,6 @@ class CustomFieldTest(TestCase): cf.delete() def test_select_field(self): - obj_type = ContentType.objects.get_for_model(Site) # Create a custom field @@ -73,7 +70,6 @@ class CustomFieldTest(TestCase): ) cf.save() cf.content_types.set([obj_type]) - cf.save() # Assign a value to the first Site site = Site.objects.first() @@ -409,6 +405,45 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(site.custom_field_data['url_field'], original_cfvs['url_field']) self.assertEqual(site.custom_field_data['choice_field'], original_cfvs['choice_field']) + def test_minimum_maximum_values_validation(self): + url = reverse('dcim-api:site-detail', kwargs={'pk': self.sites[1].pk}) + self.add_permissions('dcim.change_site') + + self.cf_integer.validation_minimum = 10 + self.cf_integer.validation_maximum = 20 + self.cf_integer.save() + + data = {'custom_fields': {'number_field': 9}} + response = self.client.patch(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + + data = {'custom_fields': {'number_field': 21}} + response = self.client.patch(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + + data = {'custom_fields': {'number_field': 15}} + response = self.client.patch(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + + def test_regex_validation(self): + url = reverse('dcim-api:site-detail', kwargs={'pk': self.sites[1].pk}) + self.add_permissions('dcim.change_site') + + self.cf_text.validation_regex = r'^[A-Z]{3}$' # Three uppercase letters + self.cf_text.save() + + data = {'custom_fields': {'text_field': 'ABC123'}} + response = self.client.patch(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + + data = {'custom_fields': {'text_field': 'abc'}} + response = self.client.patch(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + + data = {'custom_fields': {'text_field': 'ABC'}} + response = self.client.patch(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + class CustomFieldImportTest(TestCase): user_permissions = ( diff --git a/netbox/utilities/validators.py b/netbox/utilities/validators.py index 517a567a9..b087b0867 100644 --- a/netbox/utilities/validators.py +++ b/netbox/utilities/validators.py @@ -1,6 +1,7 @@ import re from django.conf import settings +from django.core.exceptions import ValidationError from django.core.validators import _lazy_re_compile, BaseValidator, URLValidator @@ -29,3 +30,14 @@ class ExclusionValidator(BaseValidator): def compare(self, a, b): return a in b + + +def validate_regex(value): + """ + Checks that the value is a valid regular expression. (Don't confuse this with RegexValidator, which *uses* a regex + to validate a value.) + """ + try: + re.compile(value) + except re.error: + raise ValidationError(f"{value} is not a valid regular expression.")