diff --git a/docs/models/extras/customfield.md b/docs/models/extras/customfield.md index 495c4e2e8..2353bc2b9 100644 --- a/docs/models/extras/customfield.md +++ b/docs/models/extras/customfield.md @@ -107,3 +107,7 @@ For numeric custom fields only. The maximum valid value (optional). ### Validation Regex 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). diff --git a/netbox/extras/api/customfields.py b/netbox/extras/api/customfields.py index 09f247929..578ab8c4b 100644 --- a/netbox/extras/api/customfields.py +++ b/netbox/extras/api/customfields.py @@ -6,6 +6,7 @@ from rest_framework.serializers import ValidationError from core.models import ObjectType from extras.choices import CustomFieldTypeChoices +from extras.constants import CUSTOMFIELD_EMPTY_VALUES from extras.models import CustomField from utilities.api import get_serializer_for_model @@ -75,7 +76,7 @@ class CustomFieldsDataField(Field): # Serialize object and multi-object values 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_MULTIOBJECT ): diff --git a/netbox/extras/api/serializers_/customfields.py b/netbox/extras/api/serializers_/customfields.py index 082047e94..9625a58b3 100644 --- a/netbox/extras/api/serializers_/customfields.py +++ b/netbox/extras/api/serializers_/customfields.py @@ -65,7 +65,7 @@ class CustomFieldSerializer(ValidatedModelSerializer): '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', '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') diff --git a/netbox/extras/constants.py b/netbox/extras/constants.py index 7e6ca9f84..005f6863d 100644 --- a/netbox/extras/constants.py +++ b/netbox/extras/constants.py @@ -5,6 +5,8 @@ EVENT_DELETE = 'delete' EVENT_JOB_START = 'job_start' EVENT_JOB_END = 'job_end' +# Custom fields +CUSTOMFIELD_EMPTY_VALUES = (None, '', []) # Webhooks HTTP_CONTENT_TYPE_JSON = 'application/json' diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index 12dd8042f..bf0275c2d 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -154,7 +154,7 @@ class CustomFieldFilterSet(ChangeLoggedModelFilterSet): fields = ( 'id', 'name', 'label', 'group_name', 'required', 'search_weight', 'filter_logic', 'ui_visible', 'ui_editable', 'weight', 'is_cloneable', 'description', 'validation_minimum', 'validation_maximum', - 'validation_regex', + 'validation_regex', 'validation_unique', ) def search(self, queryset, name, value): diff --git a/netbox/extras/forms/bulk_edit.py b/netbox/extras/forms/bulk_edit.py index 7e9f452e8..acb564b30 100644 --- a/netbox/extras/forms/bulk_edit.py +++ b/netbox/extras/forms/bulk_edit.py @@ -6,6 +6,7 @@ from extras.models import * from netbox.forms import NetBoxModelBulkEditForm from utilities.forms import BulkEditForm, add_blank_choice from utilities.forms.fields import ColorField, CommentField, DynamicModelChoiceField +from utilities.forms.rendering import FieldSet from utilities.forms.widgets import BulkEditNullBooleanSelect __all__ = ( @@ -64,8 +65,32 @@ class CustomFieldBulkEditForm(BulkEditForm): required=False, 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() + 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') diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py index c09eed3da..cf022ba0e 100644 --- a/netbox/extras/forms/bulk_import.py +++ b/netbox/extras/forms/bulk_import.py @@ -71,7 +71,8 @@ class CustomFieldImportForm(CSVModelForm): fields = ( 'name', 'label', 'group_name', 'type', 'object_types', 'related_object_type', 'required', 'description', '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', ) diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index 658aae83b..e29fd549d 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -41,6 +41,9 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm): 'type', 'related_object_type_id', 'group_name', 'weight', 'required', 'choice_set_id', 'ui_visible', 'ui_editable', 'is_cloneable', name=_('Attributes') ), + FieldSet( + 'validation_minimum', 'validation_maximum', 'validation_regex', 'validation_unique', name=_('Validation') + ), ) related_object_type_id = ContentTypeMultipleChoiceField( queryset=ObjectType.objects.with_feature('custom_fields'), @@ -89,6 +92,25 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm): 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): diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py index ebd6e6c08..19823e9a4 100644 --- a/netbox/extras/forms/model_forms.py +++ b/netbox/extras/forms/model_forms.py @@ -64,7 +64,9 @@ class CustomFieldForm(forms.ModelForm): 'search_weight', 'filter_logic', 'ui_visible', 'ui_editable', 'weight', 'is_cloneable', name=_('Behavior') ), 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: diff --git a/netbox/extras/migrations/0117_customfield_uniqueness.py b/netbox/extras/migrations/0117_customfield_uniqueness.py new file mode 100644 index 000000000..5d633b039 --- /dev/null +++ b/netbox/extras/migrations/0117_customfield_uniqueness.py @@ -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), + ), + ] diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index d2397fee8..cee69d16b 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -180,6 +180,11 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): 'example, ^[A-Z]{3}$ 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( to='CustomFieldChoiceSet', on_delete=models.PROTECT, @@ -217,7 +222,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): clone_fields = ( 'object_types', 'type', 'related_object_type', 'group_name', 'description', 'required', 'search_weight', '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: @@ -334,6 +339,12 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): '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 if self.type in ( CustomFieldTypeChoices.TYPE_SELECT, diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index 6bfaca860..d919ff1d5 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -7,7 +7,6 @@ from django.utils.translation import gettext_lazy as _ from extras.models import * from netbox.constants import EMPTY_TABLE_TEXT from netbox.tables import BaseTable, NetBoxTable, columns -from .template_code import * __all__ = ( 'BookmarkTable', @@ -72,13 +71,26 @@ class CustomFieldTable(NetBoxTable): is_cloneable = columns.BooleanColumn( 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): model = CustomField fields = ( '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', - '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') diff --git a/netbox/extras/tables/template_code.py b/netbox/extras/tables/template_code.py deleted file mode 100644 index fe1a5685d..000000000 --- a/netbox/extras/tables/template_code.py +++ /dev/null @@ -1,8 +0,0 @@ -CONFIGCONTEXT_ACTIONS = """ -{% if perms.extras.change_configcontext %} - -{% endif %} -{% if perms.extras.delete_configcontext %} - -{% endif %} -""" diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index d4917cde9..009ae8798 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -1140,6 +1140,29 @@ class CustomFieldAPITest(APITestCase): response = self.client.patch(url, data, format='json', **self.header) 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): user_permissions = ( diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index ac6be67d9..0393bf25d 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -12,6 +12,7 @@ from taggit.managers import TaggableManager from core.choices import JobStatusChoices, ObjectChangeActionChoices from core.models import ObjectType from extras.choices import * +from extras.constants import CUSTOMFIELD_EMPTY_VALUES from extras.utils import is_taggable from netbox.config import get_config from netbox.registry import registry @@ -249,7 +250,7 @@ class CustomFieldsMixin(models.Model): for cf in visible_custom_fields: 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 value = cf.deserialize(value) groups[cf.group_name][cf] = value @@ -285,6 +286,15 @@ class CustomFieldsMixin(models.Model): 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 for cf in custom_fields.values(): if cf.required and cf.name not in self.custom_field_data: diff --git a/netbox/templates/extras/customfield.html b/netbox/templates/extras/customfield.html index 4efd1e4d0..fa0986778 100644 --- a/netbox/templates/extras/customfield.html +++ b/netbox/templates/extras/customfield.html @@ -120,6 +120,10 @@ {% endif %} + + {% trans "Must be Unique" %} + {% checkmark object.validation_unique %} +