Sfoglia il codice sorgente

Closes #609: Add min/max value and regex validation for custom fields

Jeremy Stretch 5 anni fa
parent
commit
8781cf1c57

+ 1 - 0
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

+ 3 - 0
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',)

+ 10 - 0
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]:

+ 19 - 1
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]),
+        ),
     ]

+ 51 - 2
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: <code>{self.validation_regex}</code>")
+                    )
+                ]
 
         field.model = self
         field.label = str(self)

+ 39 - 4
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 = (

+ 12 - 0
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.")