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 %}
+