1
0
mirror of https://github.com/netbox-community/netbox.git synced 2024-05-10 07:54:54 +00:00

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

This commit is contained in:
Jeremy Stretch
2020-10-15 15:06:01 -04:00
parent e7d26ca5dc
commit 8781cf1c57
7 changed files with 135 additions and 7 deletions

View File

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

View File

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

View File

@ -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]:

View File

@ -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]),
),
]

View File

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

View File

@ -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 = (

View File

@ -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.")