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 ### 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 * [#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 * [#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 * [#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.', 'description': 'A custom field must be assigned to one or more object types.',
'fields': ('content_types',) 'fields': ('content_types',)
}), }),
('Validation Rules', {
'fields': ('validation_minimum', 'validation_maximum', 'validation_regex')
}),
('Choices', { ('Choices', {
'description': 'A selection field must have two or more choices assigned to it.', 'description': 'A selection field must have two or more choices assigned to it.',
'fields': ('choices',) 'fields': ('choices',)

View File

@ -1,3 +1,4 @@
import re
from datetime import datetime from datetime import datetime
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
@ -77,12 +78,21 @@ class CustomFieldsDataField(Field):
# Data validation # Data validation
if value not in [None, '']: 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 # Validate integer
if cf.type == CustomFieldTypeChoices.TYPE_INTEGER: if cf.type == CustomFieldTypeChoices.TYPE_INTEGER:
try: try:
int(value) int(value)
except ValueError: except ValueError:
raise ValidationError(f"Invalid value for integer field {field_name}: {value}") 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 # Validate boolean
if cf.type == CustomFieldTypeChoices.TYPE_BOOLEAN and value not in [True, False, 1, 0]: 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.contrib.postgres.fields
import django.core.validators
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion
import utilities.validators
class Migration(migrations.Migration): class Migration(migrations.Migration):
@ -38,4 +40,20 @@ class Migration(migrations.Migration):
old_name='obj_type', old_name='obj_type',
new_name='content_types', 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.contenttypes.models import ContentType
from django.contrib.postgres.fields import ArrayField from django.contrib.postgres.fields import ArrayField
from django.core.serializers.json import DjangoJSONEncoder 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.db import models
from django.utils.safestring import mark_safe
from utilities.forms import CSVChoiceField, DatePicker, LaxURLField, StaticSelect2, add_blank_choice from utilities.forms import CSVChoiceField, DatePicker, LaxURLField, StaticSelect2, add_blank_choice
from utilities.validators import validate_regex
from extras.choices import * from extras.choices import *
from extras.utils import FeatureQuery from extras.utils import FeatureQuery
@ -101,6 +103,25 @@ class CustomField(models.Model):
default=100, default=100,
help_text='Fields with higher weights appear lower in a form.' 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( choices = ArrayField(
base_field=models.CharField(max_length=100), base_field=models.CharField(max_length=100),
blank=True, blank=True,
@ -128,6 +149,22 @@ class CustomField(models.Model):
obj.save() obj.save()
def clean(self): 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 # Choices can be set only on selection fields
if self.choices and self.type != CustomFieldTypeChoices.TYPE_SELECT: if self.choices and self.type != CustomFieldTypeChoices.TYPE_SELECT:
raise ValidationError({ raise ValidationError({
@ -153,7 +190,12 @@ class CustomField(models.Model):
# Integer # Integer
if self.type == CustomFieldTypeChoices.TYPE_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 # Boolean
elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN: elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
@ -196,6 +238,13 @@ class CustomField(models.Model):
# Text # Text
else: else:
field = forms.CharField(max_length=255, required=required, initial=initial) 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.model = self
field.label = str(self) field.label = str(self)

View File

@ -21,7 +21,6 @@ class CustomFieldTest(TestCase):
]) ])
def test_simple_fields(self): def test_simple_fields(self):
DATA = ( DATA = (
{'field_type': CustomFieldTypeChoices.TYPE_TEXT, 'field_value': 'Foobar!', 'empty_value': ''}, {'field_type': CustomFieldTypeChoices.TYPE_TEXT, 'field_value': 'Foobar!', 'empty_value': ''},
{'field_type': CustomFieldTypeChoices.TYPE_INTEGER, 'field_value': 0, 'empty_value': None}, {'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 = CustomField(type=data['field_type'], name='my_field', required=False)
cf.save() cf.save()
cf.content_types.set([obj_type]) cf.content_types.set([obj_type])
cf.save()
# Assign a value to the first Site # Assign a value to the first Site
site = Site.objects.first() site = Site.objects.first()
@ -61,7 +59,6 @@ class CustomFieldTest(TestCase):
cf.delete() cf.delete()
def test_select_field(self): def test_select_field(self):
obj_type = ContentType.objects.get_for_model(Site) obj_type = ContentType.objects.get_for_model(Site)
# Create a custom field # Create a custom field
@ -73,7 +70,6 @@ class CustomFieldTest(TestCase):
) )
cf.save() cf.save()
cf.content_types.set([obj_type]) cf.content_types.set([obj_type])
cf.save()
# Assign a value to the first Site # Assign a value to the first Site
site = Site.objects.first() 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['url_field'], original_cfvs['url_field'])
self.assertEqual(site.custom_field_data['choice_field'], original_cfvs['choice_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): class CustomFieldImportTest(TestCase):
user_permissions = ( user_permissions = (

View File

@ -1,6 +1,7 @@
import re import re
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.validators import _lazy_re_compile, BaseValidator, URLValidator from django.core.validators import _lazy_re_compile, BaseValidator, URLValidator
@ -29,3 +30,14 @@ class ExclusionValidator(BaseValidator):
def compare(self, a, b): def compare(self, a, b):
return a in 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.")