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