Skip to content

Commit

Permalink
Closes #8198: Implement ability to enforce custom field uniqueness
Browse files Browse the repository at this point in the history
  • Loading branch information
jeremystretch committed Jun 19, 2024
1 parent 6819186 commit 7391a47
Show file tree
Hide file tree
Showing 15 changed files with 98 additions and 8 deletions.
4 changes: 4 additions & 0 deletions docs/models/extras/customfield.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
3 changes: 2 additions & 1 deletion netbox/extras/api/customfields.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

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

Expand Down Expand Up @@ -75,7 +76,7 @@ def to_internal_value(self, data):

# 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
):
Expand Down
2 changes: 1 addition & 1 deletion netbox/extras/api/serializers_/customfields.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ class Meta:
'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')

Expand Down
2 changes: 2 additions & 0 deletions netbox/extras/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
EVENT_JOB_START = 'job_start'
EVENT_JOB_END = 'job_end'

# Custom fields
CUSTOMFIELD_EMPTY_VALUES = (None, '', [])

# Webhooks
HTTP_CONTENT_TYPE_JSON = 'application/json'
Expand Down
2 changes: 1 addition & 1 deletion netbox/extras/filtersets.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ class Meta:
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):
Expand Down
5 changes: 5 additions & 0 deletions netbox/extras/forms/bulk_edit.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ class CustomFieldBulkEditForm(BulkEditForm):
required=False,
widget=BulkEditNullBooleanSelect()
)
validation_unique = forms.NullBooleanField(
label=_('Must be unique'),
required=False,
widget=BulkEditNullBooleanSelect()
)
comments = CommentField()

nullable_fields = ('group_name', 'description', 'choice_set')
Expand Down
3 changes: 2 additions & 1 deletion netbox/extras/forms/bulk_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@ class Meta:
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',
)


Expand Down
8 changes: 8 additions & 0 deletions netbox/extras/forms/filtersets.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ 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_unique', name=_('Validation')),
)
related_object_type_id = ContentTypeMultipleChoiceField(
queryset=ObjectType.objects.with_feature('custom_fields'),
Expand Down Expand Up @@ -89,6 +90,13 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
validation_unique = forms.NullBooleanField(
label=_('Must be unique'),
required=False,
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)


class CustomFieldChoiceSetFilterForm(SavedFiltersMixin, FilterForm):
Expand Down
4 changes: 3 additions & 1 deletion netbox/extras/forms/model_forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
16 changes: 16 additions & 0 deletions netbox/extras/migrations/0117_customfield_uniqueness.py
Original file line number Diff line number Diff line change
@@ -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),
),
]
13 changes: 12 additions & 1 deletion netbox/extras/models/customfields.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,11 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
'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(
to='CustomFieldChoiceSet',
on_delete=models.PROTECT,
Expand Down Expand Up @@ -216,7 +221,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:
Expand Down Expand Up @@ -333,6 +338,12 @@ def clean(self):
'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,
Expand Down
5 changes: 4 additions & 1 deletion netbox/extras/tables/tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,13 +71,16 @@ class CustomFieldTable(NetBoxTable):
is_cloneable = columns.BooleanColumn(
verbose_name=_('Is Cloneable'),
)
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_unique', 'comments', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'object_types', 'label', 'group_name', 'type', 'required', 'description')

Expand Down
23 changes: 23 additions & 0 deletions netbox/extras/tests/test_customfields.py
Original file line number Diff line number Diff line change
Expand Up @@ -1140,6 +1140,29 @@ def test_regex_validation(self):
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 = (
Expand Down
12 changes: 11 additions & 1 deletion netbox/netbox/models/features.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
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
Expand Down Expand Up @@ -249,7 +250,7 @@ def get_custom_fields_by_group(self):

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
Expand Down Expand Up @@ -285,6 +286,15 @@ def clean(self):
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:
Expand Down
4 changes: 4 additions & 0 deletions netbox/templates/extras/customfield.html
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,10 @@ <h5 class="card-header">{% trans "Validation Rules" %}</h5>
{% endif %}
</td>
</tr>
<tr>
<th scope="row">{% trans "Must be Unique" %}</th>
<td>{% checkmark object.validation_unique %}</td>
</tr>
</table>
</div>
<div class="card">
Expand Down

0 comments on commit 7391a47

Please sign in to comment.