Selaa lähdekoodia

Closes #8198: Custom field uniqueness (#16661)

* Closes #8198: Implement ability to enforce custom field uniqueness

* Add missing form fields & table columns for validation attributes

* Remove obsolete code
Jeremy Stretch 1 vuosi sitten
vanhempi
commit
2b4577e365

+ 4 - 0
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).

+ 2 - 1
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
             ):

+ 1 - 1
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')
 

+ 2 - 0
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'

+ 1 - 1
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):

+ 25 - 0
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')
 
 

+ 2 - 1
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',
         )
 
 

+ 22 - 0
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):

+ 3 - 1
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:

+ 16 - 0
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),
+        ),
+    ]

+ 12 - 1
netbox/extras/models/customfields.py

@@ -180,6 +180,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,
@@ -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,

+ 14 - 2
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')
 

+ 0 - 8
netbox/extras/tables/template_code.py

@@ -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 %}
-"""

+ 23 - 0
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 = (

+ 11 - 1
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:

+ 4 - 0
netbox/templates/extras/customfield.html

@@ -120,6 +120,10 @@
             {% endif %}
           </td>
         </tr>
+        <tr>
+          <th scope="row">{% trans "Must be Unique" %}</th>
+          <td>{% checkmark object.validation_unique %}</td>
+        </tr>
       </table>
     </div>
     <div class="card">