diff --git a/netbox_custom_objects/models.py b/netbox_custom_objects/models.py index 9ec2d4c..11e1711 100644 --- a/netbox_custom_objects/models.py +++ b/netbox_custom_objects/models.py @@ -12,7 +12,7 @@ # from django.contrib.contenttypes.management import create_contenttypes from django.contrib.contenttypes.models import ContentType from django.core.validators import RegexValidator, ValidationError -from django.db import connection, models +from django.db import connection, models, IntegrityError from django.db.models import Q from django.db.models.functions import Lower from django.db.models.signals import pre_delete @@ -52,6 +52,13 @@ from netbox_custom_objects.constants import APP_LABEL from netbox_custom_objects.field_types import FIELD_TYPE_CLASS + +class UniquenessConstraintTestError(Exception): + """Custom exception used to signal successful uniqueness constraint test.""" + + pass + + USER_TABLE_DATABASE_NAME_PREFIX = "custom_objects_" @@ -310,7 +317,10 @@ def _fetch_and_generate_field_attrs( for field in fields: field_type = FIELD_TYPE_CLASS[field.type]() if skip_object_fields: - if field.type in [CustomFieldTypeChoices.TYPE_OBJECT, CustomFieldTypeChoices.TYPE_MULTIOBJECT]: + if field.type in [ + CustomFieldTypeChoices.TYPE_OBJECT, + CustomFieldTypeChoices.TYPE_MULTIOBJECT, + ]: continue field_name = field.name @@ -453,7 +463,9 @@ def get_model( "custom_object_type_id": self.id, } - field_attrs = self._fetch_and_generate_field_attrs(fields, skip_object_fields=skip_object_fields) + field_attrs = self._fetch_and_generate_field_attrs( + fields, skip_object_fields=skip_object_fields + ) attrs.update(**field_attrs) @@ -587,7 +599,7 @@ class CustomObjectTypeField(CloningMixin, ExportTemplatesMixin, ChangeLoggedMode name = models.CharField( verbose_name=_("name"), max_length=50, - help_text=_("Internal field name, e.g. \"vendor_label\""), + help_text=_('Internal field name, e.g. "vendor_label"'), validators=( RegexValidator( regex=r"^[a-z0-9_]+$", @@ -616,7 +628,9 @@ class CustomObjectTypeField(CloningMixin, ExportTemplatesMixin, ChangeLoggedMode verbose_name=_("group name"), max_length=50, blank=True, - help_text=_("Custom object fields within the same group will be displayed together"), + help_text=_( + "Custom object fields within the same group will be displayed together" + ), ) description = models.CharField( verbose_name=_("description"), max_length=200, blank=True @@ -862,6 +876,40 @@ def clean(self): {"unique": _("Uniqueness cannot be enforced for boolean fields")} ) + # Check if uniqueness constraint can be applied when changing from non-unique to unique + if ( + self.pk + and self.unique + and not self.original.unique + and not self._state.adding + ): + field_type = FIELD_TYPE_CLASS[self.type]() + model_field = field_type.get_model_field(self) + model = self.custom_object_type.get_model() + model_field.contribute_to_class(model, self.name) + + old_field = field_type.get_model_field(self.original) + old_field.contribute_to_class(model, self._original_name) + + try: + with connection.schema_editor() as test_schema_editor: + test_schema_editor.alter_field(model, old_field, model_field) + # If we get here, the constraint was applied successfully + # Now raise a custom exception to rollback the test transaction + raise UniquenessConstraintTestError() + except UniquenessConstraintTestError: + # The constraint can be applied, validation passes + pass + except IntegrityError: + # The constraint cannot be applied due to existing non-unique values + raise ValidationError( + { + "unique": _( + "Custom objects with non-unique values already exist so this action isn't permitted" + ) + } + ) + # Choice set must be set on selection fields, and *only* on selection fields if self.type in ( CustomFieldTypeChoices.TYPE_SELECT,