From 3ee76d7cb337d19dcf1984e43e4a99d1fd0d8130 Mon Sep 17 00:00:00 2001 From: Arthur Date: Thu, 4 Sep 2025 09:50:45 -0700 Subject: [PATCH 1/7] 195 add ObjectType field to COT --- .../0003_customobjecttype_object_type.py | 54 +++++++++++++++++++ ...0004_alter_customobjecttype_object_type.py | 25 +++++++++ netbox_custom_objects/models.py | 52 ++++++++---------- .../tests/test_field_types.py | 8 +-- 4 files changed, 106 insertions(+), 33 deletions(-) create mode 100644 netbox_custom_objects/migrations/0003_customobjecttype_object_type.py create mode 100644 netbox_custom_objects/migrations/0004_alter_customobjecttype_object_type.py diff --git a/netbox_custom_objects/migrations/0003_customobjecttype_object_type.py b/netbox_custom_objects/migrations/0003_customobjecttype_object_type.py new file mode 100644 index 0000000..9195756 --- /dev/null +++ b/netbox_custom_objects/migrations/0003_customobjecttype_object_type.py @@ -0,0 +1,54 @@ +# Generated by Django 5.2.5 on 2025-09-04 16:34 + +import django.db.models.deletion +from django.db import migrations, models + + +def populate_object_type_field(apps, schema_editor): + """ + Populate the object_type field for existing CustomObjectType instances. + """ + CustomObjectType = apps.get_model('netbox_custom_objects', 'CustomObjectType') + ObjectType = apps.get_model('core', 'ObjectType') + app_label = CustomObjectType._meta.app_label + + for custom_object_type in CustomObjectType.objects.all(): + content_type_name = f"Table{custom_object_type.id}Model".lower() + try: + object_type = ObjectType.objects.get(app_label=app_label, model=content_type_name) + custom_object_type.object_type = object_type + custom_object_type.save(update_fields=['object_type']) + except ObjectType.DoesNotExist: + # If ObjectType doesn't exist, create it + object_type = ObjectType.objects.create( + app_label=app_label, + model=content_type_name + ) + custom_object_type.object_type = object_type + custom_object_type.save(update_fields=['object_type']) + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0018_concrete_objecttype"), + ("netbox_custom_objects", "0002_customobjecttype_version"), + ] + + operations = [ + migrations.AddField( + model_name="customobjecttype", + name="object_type", + field=models.OneToOneField( + blank=True, + editable=False, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="custom_object_types", + to="core.objecttype", + ), + ), + migrations.RunPython( + populate_object_type_field, + ), + ] diff --git a/netbox_custom_objects/migrations/0004_alter_customobjecttype_object_type.py b/netbox_custom_objects/migrations/0004_alter_customobjecttype_object_type.py new file mode 100644 index 0000000..57c57b0 --- /dev/null +++ b/netbox_custom_objects/migrations/0004_alter_customobjecttype_object_type.py @@ -0,0 +1,25 @@ +# Generated by Django 5.2.5 on 2025-09-04 16:44 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0018_concrete_objecttype"), + ("netbox_custom_objects", "0003_customobjecttype_object_type"), + ] + + operations = [ + migrations.AlterField( + model_name="customobjecttype", + name="object_type", + field=models.OneToOneField( + editable=False, + on_delete=django.db.models.deletion.CASCADE, + related_name="custom_object_types", + to="core.objecttype", + ), + ), + ] diff --git a/netbox_custom_objects/models.py b/netbox_custom_objects/models.py index 7f119c6..a3b3691 100644 --- a/netbox_custom_objects/models.py +++ b/netbox_custom_objects/models.py @@ -187,7 +187,12 @@ class CustomObjectType(PrimaryModel): verbose_name = models.CharField(max_length=100, blank=True) verbose_name_plural = models.CharField(max_length=100, blank=True) slug = models.SlugField(max_length=100, unique=True, db_index=True) - + object_type = models.OneToOneField( + ObjectType, + on_delete=models.CASCADE, + related_name="custom_object_types", + editable=False + ) class Meta: verbose_name = "Custom Object Type" ordering = ("name",) @@ -302,29 +307,6 @@ def get_list_url(self): def get_table_model_name(cls, table_id): return f"Table{table_id}Model" - @property - def content_type(self): - try: - return self.get_or_create_content_type() - except Exception: - # If we still can't get it, return None - return None - - def get_or_create_content_type(self): - """ - Get or create the ObjectType for this CustomObjectType. - This ensures the ObjectType is immediately available in the current transaction. - """ - content_type_name = self.get_table_model_name(self.id).lower() - try: - return ObjectType.objects.get(app_label=APP_LABEL, model=content_type_name) - except Exception: - # Create the ObjectType and ensure it's immediately available - ct = ObjectType.objects.create(app_label=APP_LABEL, model=content_type_name) - # Force a refresh to ensure it's available in the current transaction - ct.refresh_from_db() - return ct - def _fetch_and_generate_field_attrs( self, fields, @@ -672,12 +654,11 @@ def create_model(self): model = self.get_model() # Ensure the ContentType exists and is immediately available - ct = self.get_or_create_content_type() features = get_model_features(model) - ct.features = features + ['branching'] - ct.public = True - ct.features = features - ct.save() + self.object_type.features = features + ['branching'] + self.object_type.public = True + self.object_type.features = features + self.object_type.save() with connection.schema_editor() as schema_editor: schema_editor.create_model(model) @@ -686,6 +667,19 @@ def create_model(self): def save(self, *args, **kwargs): needs_db_create = self._state.adding + + # If creating a new object, get or create the ObjectType + if needs_db_create: + content_type_name = self.get_table_model_name(self.id).lower() + try: + self.object_type = ObjectType.objects.get(app_label=APP_LABEL, model=content_type_name) + except Exception: + # Create the ObjectType and ensure it's immediately available + ct = ObjectType.objects.create(app_label=APP_LABEL, model=content_type_name) + # Force a refresh to ensure it's available in the current transaction + ct.refresh_from_db() + self.object_type = ct + super().save(*args, **kwargs) if needs_db_create: self.create_model() diff --git a/netbox_custom_objects/tests/test_field_types.py b/netbox_custom_objects/tests/test_field_types.py index 8dd8063..f646a77 100644 --- a/netbox_custom_objects/tests/test_field_types.py +++ b/netbox_custom_objects/tests/test_field_types.py @@ -736,7 +736,7 @@ def test_self_referential_object_field(self): name="parent", label="Parent", type="object", - related_object_type=self.custom_object_type.content_type + related_object_type=self.custom_object_type.object_type ) field # To silence ruff error @@ -759,7 +759,7 @@ def test_self_referential_multiobject_field(self): name="children", label="Children", type="multiobject", - related_object_type=self.custom_object_type.content_type + related_object_type=self.custom_object_type.object_type ) field # To silence ruff error @@ -802,7 +802,7 @@ def test_cross_referential_object_field(self): name="related_object", label="Related Object", type="object", - related_object_type=second_type.content_type + related_object_type=second_type.object_type ) field # To silence ruff error @@ -834,7 +834,7 @@ def test_cross_referential_multiobject_field(self): name="related_objects", label="Related Objects", type="multiobject", - related_object_type=second_type.content_type + related_object_type=second_type ) field # To silence ruff error From 622661760e349da9d388f07f1f5e696ff7ceaca8 Mon Sep 17 00:00:00 2001 From: Arthur Date: Thu, 4 Sep 2025 09:54:08 -0700 Subject: [PATCH 2/7] 195 add ObjectType field to COT --- netbox_custom_objects/tests/test_field_types.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox_custom_objects/tests/test_field_types.py b/netbox_custom_objects/tests/test_field_types.py index f646a77..7fe8da6 100644 --- a/netbox_custom_objects/tests/test_field_types.py +++ b/netbox_custom_objects/tests/test_field_types.py @@ -834,7 +834,7 @@ def test_cross_referential_multiobject_field(self): name="related_objects", label="Related Objects", type="multiobject", - related_object_type=second_type + related_object_type=second_type.object_type ) field # To silence ruff error From f8e1918c707f1e559cc483dadd482be7e671715a Mon Sep 17 00:00:00 2001 From: Arthur Date: Thu, 4 Sep 2025 13:40:15 -0700 Subject: [PATCH 3/7] 195 fix COT save --- ...0004_alter_customobjecttype_object_type.py | 25 ------------------- netbox_custom_objects/models.py | 22 ++++++++-------- 2 files changed, 11 insertions(+), 36 deletions(-) delete mode 100644 netbox_custom_objects/migrations/0004_alter_customobjecttype_object_type.py diff --git a/netbox_custom_objects/migrations/0004_alter_customobjecttype_object_type.py b/netbox_custom_objects/migrations/0004_alter_customobjecttype_object_type.py deleted file mode 100644 index 57c57b0..0000000 --- a/netbox_custom_objects/migrations/0004_alter_customobjecttype_object_type.py +++ /dev/null @@ -1,25 +0,0 @@ -# Generated by Django 5.2.5 on 2025-09-04 16:44 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("core", "0018_concrete_objecttype"), - ("netbox_custom_objects", "0003_customobjecttype_object_type"), - ] - - operations = [ - migrations.AlterField( - model_name="customobjecttype", - name="object_type", - field=models.OneToOneField( - editable=False, - on_delete=django.db.models.deletion.CASCADE, - related_name="custom_object_types", - to="core.objecttype", - ), - ), - ] diff --git a/netbox_custom_objects/models.py b/netbox_custom_objects/models.py index a3b3691..a9e455d 100644 --- a/netbox_custom_objects/models.py +++ b/netbox_custom_objects/models.py @@ -191,6 +191,8 @@ class CustomObjectType(PrimaryModel): ObjectType, on_delete=models.CASCADE, related_name="custom_object_types", + null=True, + blank=True, editable=False ) class Meta: @@ -668,20 +670,18 @@ def create_model(self): def save(self, *args, **kwargs): needs_db_create = self._state.adding - # If creating a new object, get or create the ObjectType + super().save(*args, **kwargs) if needs_db_create: + # If creating a new object, get or create the ObjectType content_type_name = self.get_table_model_name(self.id).lower() - try: - self.object_type = ObjectType.objects.get(app_label=APP_LABEL, model=content_type_name) - except Exception: - # Create the ObjectType and ensure it's immediately available - ct = ObjectType.objects.create(app_label=APP_LABEL, model=content_type_name) - # Force a refresh to ensure it's available in the current transaction - ct.refresh_from_db() - self.object_type = ct + ct, created = ObjectType.objects.get_or_create( + app_label=APP_LABEL, + model=content_type_name + ) + # Force a refresh to ensure it's available in the current transaction + # ct.refresh_from_db() + self.object_type = ct - super().save(*args, **kwargs) - if needs_db_create: self.create_model() else: # Clear the model cache when the CustomObjectType is modified From 08ee63b88aa06a0b5b99999c760cb17fe292cb15 Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 5 Sep 2025 09:48:12 -0700 Subject: [PATCH 4/7] fix ruff --- netbox_custom_objects/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox_custom_objects/models.py b/netbox_custom_objects/models.py index 6ddfb95..d650301 100644 --- a/netbox_custom_objects/models.py +++ b/netbox_custom_objects/models.py @@ -675,7 +675,7 @@ def save(self, *args, **kwargs): # If creating a new object, get or create the ObjectType content_type_name = self.get_table_model_name(self.id).lower() ct, created = ObjectType.objects.get_or_create( - app_label=APP_LABEL, + app_label=APP_LABEL, model=content_type_name ) # Force a refresh to ensure it's available in the current transaction From a01f8198d548c28192bf1b92b275ad589275b59a Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 5 Sep 2025 16:39:43 -0700 Subject: [PATCH 5/7] fix save of content type --- netbox_custom_objects/models.py | 106 ++++++++++++++++++++++++++++---- netbox_custom_objects/views.py | 2 +- 2 files changed, 95 insertions(+), 13 deletions(-) diff --git a/netbox_custom_objects/models.py b/netbox_custom_objects/models.py index d650301..410144a 100644 --- a/netbox_custom_objects/models.py +++ b/netbox_custom_objects/models.py @@ -14,7 +14,8 @@ from django.db import connection, IntegrityError, models, transaction from django.db.models import Q from django.db.models.functions import Lower -from django.db.models.signals import pre_delete +from django.db.models.signals import pre_delete, post_save +from django.dispatch import receiver from django.urls import reverse from django.utils.translation import gettext_lazy as _ from core.signals import handle_deleted_object @@ -671,17 +672,8 @@ def save(self, *args, **kwargs): needs_db_create = self._state.adding super().save(*args, **kwargs) - if needs_db_create: - # If creating a new object, get or create the ObjectType - content_type_name = self.get_table_model_name(self.id).lower() - ct, created = ObjectType.objects.get_or_create( - app_label=APP_LABEL, - model=content_type_name - ) - # Force a refresh to ensure it's available in the current transaction - # ct.refresh_from_db() - self.object_type = ct + if needs_db_create: self.create_model() else: # Clear the model cache when the CustomObjectType is modified @@ -694,7 +686,7 @@ def delete(self, *args, **kwargs): model = self.get_model() # Delete all CustomObjectTypeFields that reference this CustomObjectType - for field in CustomObjectTypeField.objects.filter(related_object_type=self.content_type): + for field in CustomObjectTypeField.objects.filter(related_object_type=self.object_type): field.delete() object_type = ObjectType.objects.get_for_model(model) @@ -710,6 +702,19 @@ def delete(self, *args, **kwargs): pre_delete.connect(handle_deleted_object) +@receiver(post_save, sender=CustomObjectType) +def custom_object_type_post_save_handler(sender, instance, created, **kwargs): + if created: + # If creating a new object, get or create the ObjectType + content_type_name = instance.get_table_model_name(instance.id).lower() + ct, created = ObjectType.objects.get_or_create( + app_label=APP_LABEL, + model=content_type_name + ) + instance.object_type = ct + instance.save() + + class CustomObjectTypeField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): custom_object_type = models.ForeignKey( CustomObjectType, on_delete=models.CASCADE, related_name="fields" @@ -1118,6 +1123,83 @@ def clean(self): } ) + # Check for recursion in object and multiobject fields + if (self.type in ( + CustomFieldTypeChoices.TYPE_OBJECT, + CustomFieldTypeChoices.TYPE_MULTIOBJECT, + ) and self.related_object_type_id and + self.related_object_type.app_label == APP_LABEL): + self._check_recursion() + + def _check_recursion(self): + """ + Check for circular references in object and multiobject fields. + Raises ValidationError if recursion is detected. + """ + # Check if this field points to the same custom object type (self-referential) + print(f"related_object_type_id: {self.related_object_type_id}, custom_object_type.object_type_id: {self.custom_object_type.object_type_id}") + breakpoint() + if self.related_object_type_id == self.custom_object_type.object_type_id: + return # Self-referential fields are allowed + + # Get the related custom object type directly from the object_type relationship + try: + related_custom_object_type = CustomObjectType.objects.get(object_type=self.related_object_type) + except CustomObjectType.DoesNotExist: + return # Not a custom object type, no recursion possible + + # Check for circular references by traversing the dependency chain + visited = {self.custom_object_type.id} + if self._has_circular_reference(related_custom_object_type, visited): + raise ValidationError( + { + "related_object_type": _( + "Circular reference detected. This field would create a circular dependency " + "between custom object types." + ) + } + ) + + def _has_circular_reference(self, custom_object_type, visited): + """ + Recursively check if there's a circular reference by following the dependency chain. + + Args: + custom_object_type: The CustomObjectType object to check + visited: Set of custom object type IDs already visited in this traversal + + Returns: + bool: True if a circular reference is detected, False otherwise + """ + # If we've already visited this type, we have a cycle + if custom_object_type.id in visited: + return True + + # Add this type to visited set + visited.add(custom_object_type.id) + + # Check all object and multiobject fields in this custom object type + for field in custom_object_type.fields.filter( + type__in=[ + CustomFieldTypeChoices.TYPE_OBJECT, + CustomFieldTypeChoices.TYPE_MULTIOBJECT, + ], + related_object_type__isnull=False, + related_object_type__app_label=APP_LABEL + ): + + # Get the related custom object type directly from the object_type relationship + try: + next_custom_object_type = CustomObjectType.objects.get(object_type=field.related_object_type) + except CustomObjectType.DoesNotExist: + continue + + # Recursively check this dependency + if self._has_circular_reference(next_custom_object_type, visited): + return True + + return False + def serialize(self, value): """ Prepare a value for storage as JSON data. diff --git a/netbox_custom_objects/views.py b/netbox_custom_objects/views.py index 9e38fda..1532923 100644 --- a/netbox_custom_objects/views.py +++ b/netbox_custom_objects/views.py @@ -203,7 +203,7 @@ def _get_dependent_objects(self, obj): # Find CustomObjectTypeFields that reference this CustomObjectType referencing_fields = CustomObjectTypeField.objects.filter( - related_object_type=obj.content_type + related_object_type=obj.object_type ) # Add the CustomObjectTypeFields that reference this CustomObjectType From 76d3610744308b6c9ec962f28172494bb8d0ab3d Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 5 Sep 2025 16:43:45 -0700 Subject: [PATCH 6/7] remove breakpoint --- netbox_custom_objects/models.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/netbox_custom_objects/models.py b/netbox_custom_objects/models.py index 410144a..1d5cca7 100644 --- a/netbox_custom_objects/models.py +++ b/netbox_custom_objects/models.py @@ -1137,8 +1137,6 @@ def _check_recursion(self): Raises ValidationError if recursion is detected. """ # Check if this field points to the same custom object type (self-referential) - print(f"related_object_type_id: {self.related_object_type_id}, custom_object_type.object_type_id: {self.custom_object_type.object_type_id}") - breakpoint() if self.related_object_type_id == self.custom_object_type.object_type_id: return # Self-referential fields are allowed From 6f43fa447cb915d9f9671fbfeaaeffea2dd8d184 Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 5 Sep 2025 17:16:09 -0700 Subject: [PATCH 7/7] fix ruff check --- netbox_custom_objects/models.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/netbox_custom_objects/models.py b/netbox_custom_objects/models.py index 1d5cca7..2b33d6d 100644 --- a/netbox_custom_objects/models.py +++ b/netbox_custom_objects/models.py @@ -1127,7 +1127,7 @@ def clean(self): if (self.type in ( CustomFieldTypeChoices.TYPE_OBJECT, CustomFieldTypeChoices.TYPE_MULTIOBJECT, - ) and self.related_object_type_id and + ) and self.related_object_type_id and self.related_object_type.app_label == APP_LABEL): self._check_recursion() @@ -1161,11 +1161,11 @@ def _check_recursion(self): def _has_circular_reference(self, custom_object_type, visited): """ Recursively check if there's a circular reference by following the dependency chain. - + Args: custom_object_type: The CustomObjectType object to check visited: Set of custom object type IDs already visited in this traversal - + Returns: bool: True if a circular reference is detected, False otherwise """ @@ -1175,7 +1175,7 @@ def _has_circular_reference(self, custom_object_type, visited): # Add this type to visited set visited.add(custom_object_type.id) - + # Check all object and multiobject fields in this custom object type for field in custom_object_type.fields.filter( type__in=[ @@ -1191,7 +1191,7 @@ def _has_circular_reference(self, custom_object_type, visited): next_custom_object_type = CustomObjectType.objects.get(object_type=field.related_object_type) except CustomObjectType.DoesNotExist: continue - + # Recursively check this dependency if self._has_circular_reference(next_custom_object_type, visited): return True