mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-16 04:02:52 -06:00
* Closes #8198: Implement ability to enforce custom field uniqueness * Add missing form fields & table columns for validation attributes * Remove obsolete code
This commit is contained in:
parent
08ac5cd52c
commit
2b4577e365
@ -107,3 +107,7 @@ For numeric custom fields only. The maximum valid value (optional).
|
|||||||
### Validation Regex
|
### Validation Regex
|
||||||
|
|
||||||
For string-based custom fields only. A regular expression used to validate the field's value (optional).
|
For string-based custom fields only. A regular expression used to validate the field's value (optional).
|
||||||
|
|
||||||
|
### Uniqueness Validation
|
||||||
|
|
||||||
|
If enabled, each object must have a unique value set for this custom field (per object type).
|
||||||
|
@ -6,6 +6,7 @@ from rest_framework.serializers import ValidationError
|
|||||||
|
|
||||||
from core.models import ObjectType
|
from core.models import ObjectType
|
||||||
from extras.choices import CustomFieldTypeChoices
|
from extras.choices import CustomFieldTypeChoices
|
||||||
|
from extras.constants import CUSTOMFIELD_EMPTY_VALUES
|
||||||
from extras.models import CustomField
|
from extras.models import CustomField
|
||||||
from utilities.api import get_serializer_for_model
|
from utilities.api import get_serializer_for_model
|
||||||
|
|
||||||
@ -75,7 +76,7 @@ class CustomFieldsDataField(Field):
|
|||||||
|
|
||||||
# Serialize object and multi-object values
|
# Serialize object and multi-object values
|
||||||
for cf in self._get_custom_fields():
|
for cf in self._get_custom_fields():
|
||||||
if cf.name in data and data[cf.name] not in (None, []) and cf.type in (
|
if cf.name in data and data[cf.name] not in CUSTOMFIELD_EMPTY_VALUES and cf.type in (
|
||||||
CustomFieldTypeChoices.TYPE_OBJECT,
|
CustomFieldTypeChoices.TYPE_OBJECT,
|
||||||
CustomFieldTypeChoices.TYPE_MULTIOBJECT
|
CustomFieldTypeChoices.TYPE_MULTIOBJECT
|
||||||
):
|
):
|
||||||
|
@ -65,7 +65,7 @@ class CustomFieldSerializer(ValidatedModelSerializer):
|
|||||||
'id', 'url', 'display', 'object_types', 'type', 'related_object_type', 'data_type', 'name', 'label',
|
'id', 'url', 'display', 'object_types', 'type', 'related_object_type', 'data_type', 'name', 'label',
|
||||||
'group_name', 'description', 'required', 'search_weight', 'filter_logic', 'ui_visible', 'ui_editable',
|
'group_name', 'description', 'required', 'search_weight', 'filter_logic', 'ui_visible', 'ui_editable',
|
||||||
'is_cloneable', 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex',
|
'is_cloneable', 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex',
|
||||||
'choice_set', 'comments', 'created', 'last_updated',
|
'validation_unique', 'choice_set', 'comments', 'created', 'last_updated',
|
||||||
]
|
]
|
||||||
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
||||||
|
|
||||||
|
@ -5,6 +5,8 @@ EVENT_DELETE = 'delete'
|
|||||||
EVENT_JOB_START = 'job_start'
|
EVENT_JOB_START = 'job_start'
|
||||||
EVENT_JOB_END = 'job_end'
|
EVENT_JOB_END = 'job_end'
|
||||||
|
|
||||||
|
# Custom fields
|
||||||
|
CUSTOMFIELD_EMPTY_VALUES = (None, '', [])
|
||||||
|
|
||||||
# Webhooks
|
# Webhooks
|
||||||
HTTP_CONTENT_TYPE_JSON = 'application/json'
|
HTTP_CONTENT_TYPE_JSON = 'application/json'
|
||||||
|
@ -154,7 +154,7 @@ class CustomFieldFilterSet(ChangeLoggedModelFilterSet):
|
|||||||
fields = (
|
fields = (
|
||||||
'id', 'name', 'label', 'group_name', 'required', 'search_weight', 'filter_logic', 'ui_visible',
|
'id', 'name', 'label', 'group_name', 'required', 'search_weight', 'filter_logic', 'ui_visible',
|
||||||
'ui_editable', 'weight', 'is_cloneable', 'description', 'validation_minimum', 'validation_maximum',
|
'ui_editable', 'weight', 'is_cloneable', 'description', 'validation_minimum', 'validation_maximum',
|
||||||
'validation_regex',
|
'validation_regex', 'validation_unique',
|
||||||
)
|
)
|
||||||
|
|
||||||
def search(self, queryset, name, value):
|
def search(self, queryset, name, value):
|
||||||
|
@ -6,6 +6,7 @@ from extras.models import *
|
|||||||
from netbox.forms import NetBoxModelBulkEditForm
|
from netbox.forms import NetBoxModelBulkEditForm
|
||||||
from utilities.forms import BulkEditForm, add_blank_choice
|
from utilities.forms import BulkEditForm, add_blank_choice
|
||||||
from utilities.forms.fields import ColorField, CommentField, DynamicModelChoiceField
|
from utilities.forms.fields import ColorField, CommentField, DynamicModelChoiceField
|
||||||
|
from utilities.forms.rendering import FieldSet
|
||||||
from utilities.forms.widgets import BulkEditNullBooleanSelect
|
from utilities.forms.widgets import BulkEditNullBooleanSelect
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
@ -64,8 +65,32 @@ class CustomFieldBulkEditForm(BulkEditForm):
|
|||||||
required=False,
|
required=False,
|
||||||
widget=BulkEditNullBooleanSelect()
|
widget=BulkEditNullBooleanSelect()
|
||||||
)
|
)
|
||||||
|
validation_minimum = forms.IntegerField(
|
||||||
|
label=_('Minimum value'),
|
||||||
|
required=False,
|
||||||
|
)
|
||||||
|
validation_maximum = forms.IntegerField(
|
||||||
|
label=_('Maximum value'),
|
||||||
|
required=False,
|
||||||
|
)
|
||||||
|
validation_regex = forms.CharField(
|
||||||
|
label=_('Validation regex'),
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
validation_unique = forms.NullBooleanField(
|
||||||
|
label=_('Must be unique'),
|
||||||
|
required=False,
|
||||||
|
widget=BulkEditNullBooleanSelect()
|
||||||
|
)
|
||||||
comments = CommentField()
|
comments = CommentField()
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
FieldSet('group_name', 'description', 'weight', 'choice_set', name=_('Attributes')),
|
||||||
|
FieldSet('ui_visible', 'ui_editable', 'is_cloneable', name=_('Behavior')),
|
||||||
|
FieldSet(
|
||||||
|
'validation_minimum', 'validation_maximum', 'validation_regex', 'validation_unique', name=_('Validation')
|
||||||
|
),
|
||||||
|
)
|
||||||
nullable_fields = ('group_name', 'description', 'choice_set')
|
nullable_fields = ('group_name', 'description', 'choice_set')
|
||||||
|
|
||||||
|
|
||||||
|
@ -71,7 +71,8 @@ class CustomFieldImportForm(CSVModelForm):
|
|||||||
fields = (
|
fields = (
|
||||||
'name', 'label', 'group_name', 'type', 'object_types', 'related_object_type', 'required', 'description',
|
'name', 'label', 'group_name', 'type', 'object_types', 'related_object_type', 'required', 'description',
|
||||||
'search_weight', 'filter_logic', 'default', 'choice_set', 'weight', 'validation_minimum',
|
'search_weight', 'filter_logic', 'default', 'choice_set', 'weight', 'validation_minimum',
|
||||||
'validation_maximum', 'validation_regex', 'ui_visible', 'ui_editable', 'is_cloneable', 'comments',
|
'validation_maximum', 'validation_regex', 'validation_unique', 'ui_visible', 'ui_editable', 'is_cloneable',
|
||||||
|
'comments',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -41,6 +41,9 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
|
|||||||
'type', 'related_object_type_id', 'group_name', 'weight', 'required', 'choice_set_id', 'ui_visible',
|
'type', 'related_object_type_id', 'group_name', 'weight', 'required', 'choice_set_id', 'ui_visible',
|
||||||
'ui_editable', 'is_cloneable', name=_('Attributes')
|
'ui_editable', 'is_cloneable', name=_('Attributes')
|
||||||
),
|
),
|
||||||
|
FieldSet(
|
||||||
|
'validation_minimum', 'validation_maximum', 'validation_regex', 'validation_unique', name=_('Validation')
|
||||||
|
),
|
||||||
)
|
)
|
||||||
related_object_type_id = ContentTypeMultipleChoiceField(
|
related_object_type_id = ContentTypeMultipleChoiceField(
|
||||||
queryset=ObjectType.objects.with_feature('custom_fields'),
|
queryset=ObjectType.objects.with_feature('custom_fields'),
|
||||||
@ -89,6 +92,25 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
|
|||||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
validation_minimum = forms.IntegerField(
|
||||||
|
label=_('Minimum value'),
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
validation_maximum = forms.IntegerField(
|
||||||
|
label=_('Maximum value'),
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
validation_regex = forms.CharField(
|
||||||
|
label=_('Validation regex'),
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
validation_unique = forms.NullBooleanField(
|
||||||
|
label=_('Must be unique'),
|
||||||
|
required=False,
|
||||||
|
widget=forms.Select(
|
||||||
|
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class CustomFieldChoiceSetFilterForm(SavedFiltersMixin, FilterForm):
|
class CustomFieldChoiceSetFilterForm(SavedFiltersMixin, FilterForm):
|
||||||
|
@ -64,7 +64,9 @@ class CustomFieldForm(forms.ModelForm):
|
|||||||
'search_weight', 'filter_logic', 'ui_visible', 'ui_editable', 'weight', 'is_cloneable', name=_('Behavior')
|
'search_weight', 'filter_logic', 'ui_visible', 'ui_editable', 'weight', 'is_cloneable', name=_('Behavior')
|
||||||
),
|
),
|
||||||
FieldSet('default', 'choice_set', name=_('Values')),
|
FieldSet('default', 'choice_set', name=_('Values')),
|
||||||
FieldSet('validation_minimum', 'validation_maximum', 'validation_regex', name=_('Validation')),
|
FieldSet(
|
||||||
|
'validation_minimum', 'validation_maximum', 'validation_regex', 'validation_unique', name=_('Validation')
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
16
netbox/extras/migrations/0117_customfield_uniqueness.py
Normal file
16
netbox/extras/migrations/0117_customfield_uniqueness.py
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('extras', '0116_move_objectchange'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='customfield',
|
||||||
|
name='validation_unique',
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
]
|
@ -180,6 +180,11 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
|||||||
'example, <code>^[A-Z]{3}$</code> will limit values to exactly three uppercase letters.'
|
'example, <code>^[A-Z]{3}$</code> will limit values to exactly three uppercase letters.'
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
validation_unique = models.BooleanField(
|
||||||
|
verbose_name=_('must be unique'),
|
||||||
|
default=False,
|
||||||
|
help_text=_('The value of this field must be unique for the assigned object')
|
||||||
|
)
|
||||||
choice_set = models.ForeignKey(
|
choice_set = models.ForeignKey(
|
||||||
to='CustomFieldChoiceSet',
|
to='CustomFieldChoiceSet',
|
||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
@ -217,7 +222,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
|||||||
clone_fields = (
|
clone_fields = (
|
||||||
'object_types', 'type', 'related_object_type', 'group_name', 'description', 'required', 'search_weight',
|
'object_types', 'type', 'related_object_type', 'group_name', 'description', 'required', 'search_weight',
|
||||||
'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex',
|
'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex',
|
||||||
'choice_set', 'ui_visible', 'ui_editable', 'is_cloneable',
|
'validation_unique', 'choice_set', 'ui_visible', 'ui_editable', 'is_cloneable',
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@ -334,6 +339,12 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
|||||||
'validation_regex': _("Regular expression validation is supported only for text and URL fields")
|
'validation_regex': _("Regular expression validation is supported only for text and URL fields")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# Uniqueness can not be enforced for boolean fields
|
||||||
|
if self.validation_unique and self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
|
||||||
|
raise ValidationError({
|
||||||
|
'validation_unique': _("Uniqueness cannot be enforced for boolean fields")
|
||||||
|
})
|
||||||
|
|
||||||
# Choice set must be set on selection fields, and *only* on selection fields
|
# Choice set must be set on selection fields, and *only* on selection fields
|
||||||
if self.type in (
|
if self.type in (
|
||||||
CustomFieldTypeChoices.TYPE_SELECT,
|
CustomFieldTypeChoices.TYPE_SELECT,
|
||||||
|
@ -7,7 +7,6 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
from extras.models import *
|
from extras.models import *
|
||||||
from netbox.constants import EMPTY_TABLE_TEXT
|
from netbox.constants import EMPTY_TABLE_TEXT
|
||||||
from netbox.tables import BaseTable, NetBoxTable, columns
|
from netbox.tables import BaseTable, NetBoxTable, columns
|
||||||
from .template_code import *
|
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'BookmarkTable',
|
'BookmarkTable',
|
||||||
@ -72,13 +71,26 @@ class CustomFieldTable(NetBoxTable):
|
|||||||
is_cloneable = columns.BooleanColumn(
|
is_cloneable = columns.BooleanColumn(
|
||||||
verbose_name=_('Is Cloneable'),
|
verbose_name=_('Is Cloneable'),
|
||||||
)
|
)
|
||||||
|
validation_minimum = tables.Column(
|
||||||
|
verbose_name=_('Minimum Value'),
|
||||||
|
)
|
||||||
|
validation_maximum = tables.Column(
|
||||||
|
verbose_name=_('Maximum Value'),
|
||||||
|
)
|
||||||
|
validation_regex = tables.Column(
|
||||||
|
verbose_name=_('Validation Regex'),
|
||||||
|
)
|
||||||
|
validation_unique = columns.BooleanColumn(
|
||||||
|
verbose_name=_('Validate Uniqueness'),
|
||||||
|
)
|
||||||
|
|
||||||
class Meta(NetBoxTable.Meta):
|
class Meta(NetBoxTable.Meta):
|
||||||
model = CustomField
|
model = CustomField
|
||||||
fields = (
|
fields = (
|
||||||
'pk', 'id', 'name', 'object_types', 'label', 'type', 'related_object_type', 'group_name', 'required',
|
'pk', 'id', 'name', 'object_types', 'label', 'type', 'related_object_type', 'group_name', 'required',
|
||||||
'default', 'description', 'search_weight', 'filter_logic', 'ui_visible', 'ui_editable', 'is_cloneable',
|
'default', 'description', 'search_weight', 'filter_logic', 'ui_visible', 'ui_editable', 'is_cloneable',
|
||||||
'weight', 'choice_set', 'choices', 'comments', 'created', 'last_updated',
|
'weight', 'choice_set', 'choices', 'validation_minimum', 'validation_maximum', 'validation_regex',
|
||||||
|
'validation_unique', 'comments', 'created', 'last_updated',
|
||||||
)
|
)
|
||||||
default_columns = ('pk', 'name', 'object_types', 'label', 'group_name', 'type', 'required', 'description')
|
default_columns = ('pk', 'name', 'object_types', 'label', 'group_name', 'type', 'required', 'description')
|
||||||
|
|
||||||
|
@ -1,8 +0,0 @@
|
|||||||
CONFIGCONTEXT_ACTIONS = """
|
|
||||||
{% if perms.extras.change_configcontext %}
|
|
||||||
<a href="{% url 'extras:configcontext_edit' pk=record.pk %}" class="btn btn-sm btn-warning"><i class="mdi mdi-pencil" aria-hidden="true"></i></a>
|
|
||||||
{% endif %}
|
|
||||||
{% if perms.extras.delete_configcontext %}
|
|
||||||
<a href="{% url 'extras:configcontext_delete' pk=record.pk %}" class="btn btn-sm btn-danger"><i class="mdi mdi-trash-can-outline" aria-hidden="true"></i></a>
|
|
||||||
{% endif %}
|
|
||||||
"""
|
|
@ -1140,6 +1140,29 @@ class CustomFieldAPITest(APITestCase):
|
|||||||
response = self.client.patch(url, data, format='json', **self.header)
|
response = self.client.patch(url, data, format='json', **self.header)
|
||||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||||
|
|
||||||
|
def test_uniqueness_validation(self):
|
||||||
|
# Create a unique custom field
|
||||||
|
cf_text = CustomField.objects.get(name='text_field')
|
||||||
|
cf_text.validation_unique = True
|
||||||
|
cf_text.save()
|
||||||
|
|
||||||
|
# Set a value on site 1
|
||||||
|
site1 = Site.objects.get(name='Site 1')
|
||||||
|
site1.custom_field_data['text_field'] = 'ABC123'
|
||||||
|
site1.save()
|
||||||
|
|
||||||
|
site2 = Site.objects.get(name='Site 2')
|
||||||
|
url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
|
||||||
|
self.add_permissions('dcim.change_site')
|
||||||
|
|
||||||
|
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': 'DEF456'}}
|
||||||
|
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 = (
|
||||||
|
@ -12,6 +12,7 @@ from taggit.managers import TaggableManager
|
|||||||
from core.choices import JobStatusChoices, ObjectChangeActionChoices
|
from core.choices import JobStatusChoices, ObjectChangeActionChoices
|
||||||
from core.models import ObjectType
|
from core.models import ObjectType
|
||||||
from extras.choices import *
|
from extras.choices import *
|
||||||
|
from extras.constants import CUSTOMFIELD_EMPTY_VALUES
|
||||||
from extras.utils import is_taggable
|
from extras.utils import is_taggable
|
||||||
from netbox.config import get_config
|
from netbox.config import get_config
|
||||||
from netbox.registry import registry
|
from netbox.registry import registry
|
||||||
@ -249,7 +250,7 @@ class CustomFieldsMixin(models.Model):
|
|||||||
|
|
||||||
for cf in visible_custom_fields:
|
for cf in visible_custom_fields:
|
||||||
value = self.custom_field_data.get(cf.name)
|
value = self.custom_field_data.get(cf.name)
|
||||||
if value in (None, '', []) and cf.ui_visible == CustomFieldUIVisibleChoices.IF_SET:
|
if value in CUSTOMFIELD_EMPTY_VALUES and cf.ui_visible == CustomFieldUIVisibleChoices.IF_SET:
|
||||||
continue
|
continue
|
||||||
value = cf.deserialize(value)
|
value = cf.deserialize(value)
|
||||||
groups[cf.group_name][cf] = value
|
groups[cf.group_name][cf] = value
|
||||||
@ -285,6 +286,15 @@ class CustomFieldsMixin(models.Model):
|
|||||||
name=field_name, error=e.message
|
name=field_name, error=e.message
|
||||||
))
|
))
|
||||||
|
|
||||||
|
# Validate uniqueness if enforced
|
||||||
|
if custom_fields[field_name].validation_unique and value not in CUSTOMFIELD_EMPTY_VALUES:
|
||||||
|
if self._meta.model.objects.filter(**{
|
||||||
|
f'custom_field_data__{field_name}': value
|
||||||
|
}).exists():
|
||||||
|
raise ValidationError(_("Custom field '{name}' must have a unique value.").format(
|
||||||
|
name=field_name
|
||||||
|
))
|
||||||
|
|
||||||
# Check for missing required values
|
# Check for missing required values
|
||||||
for cf in custom_fields.values():
|
for cf in custom_fields.values():
|
||||||
if cf.required and cf.name not in self.custom_field_data:
|
if cf.required and cf.name not in self.custom_field_data:
|
||||||
|
@ -120,6 +120,10 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Must be Unique" %}</th>
|
||||||
|
<td>{% checkmark object.validation_unique %}</td>
|
||||||
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
Loading…
Reference in New Issue
Block a user