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:
@ -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
|
||||||
|
@ -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',)
|
||||||
|
@ -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]:
|
||||||
|
@ -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]),
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
@ -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)
|
||||||
|
@ -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 = (
|
||||||
|
@ -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.")
|
||||||
|
Reference in New Issue
Block a user