Skip to content

Commit 24a17b0

Browse files
authored
Merge pull request #211 from netboxlabs/195-recursion
195 recursion
2 parents d0723a0 + 6f43fa4 commit 24a17b0

File tree

4 files changed

+164
-36
lines changed

4 files changed

+164
-36
lines changed
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# Generated by Django 5.2.5 on 2025-09-04 16:34
2+
3+
import django.db.models.deletion
4+
from django.db import migrations, models
5+
6+
7+
def populate_object_type_field(apps, schema_editor):
8+
"""
9+
Populate the object_type field for existing CustomObjectType instances.
10+
"""
11+
CustomObjectType = apps.get_model('netbox_custom_objects', 'CustomObjectType')
12+
ObjectType = apps.get_model('core', 'ObjectType')
13+
app_label = CustomObjectType._meta.app_label
14+
15+
for custom_object_type in CustomObjectType.objects.all():
16+
content_type_name = f"Table{custom_object_type.id}Model".lower()
17+
try:
18+
object_type = ObjectType.objects.get(app_label=app_label, model=content_type_name)
19+
custom_object_type.object_type = object_type
20+
custom_object_type.save(update_fields=['object_type'])
21+
except ObjectType.DoesNotExist:
22+
# If ObjectType doesn't exist, create it
23+
object_type = ObjectType.objects.create(
24+
app_label=app_label,
25+
model=content_type_name
26+
)
27+
custom_object_type.object_type = object_type
28+
custom_object_type.save(update_fields=['object_type'])
29+
30+
31+
class Migration(migrations.Migration):
32+
33+
dependencies = [
34+
("core", "0018_concrete_objecttype"),
35+
("netbox_custom_objects", "0002_customobjecttype_version"),
36+
]
37+
38+
operations = [
39+
migrations.AddField(
40+
model_name="customobjecttype",
41+
name="object_type",
42+
field=models.OneToOneField(
43+
blank=True,
44+
editable=False,
45+
null=True,
46+
on_delete=django.db.models.deletion.CASCADE,
47+
related_name="custom_object_types",
48+
to="core.objecttype",
49+
),
50+
),
51+
migrations.RunPython(
52+
populate_object_type_field,
53+
),
54+
]

netbox_custom_objects/models.py

Lines changed: 105 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414
from django.db import connection, IntegrityError, models, transaction
1515
from django.db.models import Q
1616
from django.db.models.functions import Lower
17-
from django.db.models.signals import pre_delete
17+
from django.db.models.signals import pre_delete, post_save
18+
from django.dispatch import receiver
1819
from django.urls import reverse
1920
from django.utils.translation import gettext_lazy as _
2021
from core.signals import handle_deleted_object
@@ -187,7 +188,14 @@ class CustomObjectType(PrimaryModel):
187188
verbose_name = models.CharField(max_length=100, blank=True)
188189
verbose_name_plural = models.CharField(max_length=100, blank=True)
189190
slug = models.SlugField(max_length=100, unique=True, db_index=True)
190-
191+
object_type = models.OneToOneField(
192+
ObjectType,
193+
on_delete=models.CASCADE,
194+
related_name="custom_object_types",
195+
null=True,
196+
blank=True,
197+
editable=False
198+
)
191199
class Meta:
192200
verbose_name = "Custom Object Type"
193201
ordering = ("name",)
@@ -302,29 +310,6 @@ def get_list_url(self):
302310
def get_table_model_name(cls, table_id):
303311
return f"Table{table_id}Model"
304312

305-
@property
306-
def content_type(self):
307-
try:
308-
return self.get_or_create_content_type()
309-
except Exception:
310-
# If we still can't get it, return None
311-
return None
312-
313-
def get_or_create_content_type(self):
314-
"""
315-
Get or create the ObjectType for this CustomObjectType.
316-
This ensures the ObjectType is immediately available in the current transaction.
317-
"""
318-
content_type_name = self.get_table_model_name(self.id).lower()
319-
try:
320-
return ObjectType.objects.get(app_label=APP_LABEL, model=content_type_name)
321-
except Exception:
322-
# Create the ObjectType and ensure it's immediately available
323-
ct = ObjectType.objects.create(app_label=APP_LABEL, model=content_type_name)
324-
# Force a refresh to ensure it's available in the current transaction
325-
ct.refresh_from_db()
326-
return ct
327-
328313
def _fetch_and_generate_field_attrs(
329314
self,
330315
fields,
@@ -672,12 +657,11 @@ def create_model(self):
672657
model = self.get_model()
673658

674659
# Ensure the ContentType exists and is immediately available
675-
ct = self.get_or_create_content_type()
676660
features = get_model_features(model)
677-
ct.features = features + ['branching']
678-
ct.public = True
679-
ct.features = features
680-
ct.save()
661+
self.object_type.features = features + ['branching']
662+
self.object_type.public = True
663+
self.object_type.features = features
664+
self.object_type.save()
681665

682666
with connection.schema_editor() as schema_editor:
683667
schema_editor.create_model(model)
@@ -686,7 +670,9 @@ def create_model(self):
686670

687671
def save(self, *args, **kwargs):
688672
needs_db_create = self._state.adding
673+
689674
super().save(*args, **kwargs)
675+
690676
if needs_db_create:
691677
self.create_model()
692678
else:
@@ -700,7 +686,7 @@ def delete(self, *args, **kwargs):
700686
model = self.get_model()
701687

702688
# Delete all CustomObjectTypeFields that reference this CustomObjectType
703-
for field in CustomObjectTypeField.objects.filter(related_object_type=self.content_type):
689+
for field in CustomObjectTypeField.objects.filter(related_object_type=self.object_type):
704690
field.delete()
705691

706692
object_type = ObjectType.objects.get_for_model(model)
@@ -716,6 +702,19 @@ def delete(self, *args, **kwargs):
716702
pre_delete.connect(handle_deleted_object)
717703

718704

705+
@receiver(post_save, sender=CustomObjectType)
706+
def custom_object_type_post_save_handler(sender, instance, created, **kwargs):
707+
if created:
708+
# If creating a new object, get or create the ObjectType
709+
content_type_name = instance.get_table_model_name(instance.id).lower()
710+
ct, created = ObjectType.objects.get_or_create(
711+
app_label=APP_LABEL,
712+
model=content_type_name
713+
)
714+
instance.object_type = ct
715+
instance.save()
716+
717+
719718
class CustomObjectTypeField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
720719
custom_object_type = models.ForeignKey(
721720
CustomObjectType, on_delete=models.CASCADE, related_name="fields"
@@ -1124,6 +1123,81 @@ def clean(self):
11241123
}
11251124
)
11261125

1126+
# Check for recursion in object and multiobject fields
1127+
if (self.type in (
1128+
CustomFieldTypeChoices.TYPE_OBJECT,
1129+
CustomFieldTypeChoices.TYPE_MULTIOBJECT,
1130+
) and self.related_object_type_id and
1131+
self.related_object_type.app_label == APP_LABEL):
1132+
self._check_recursion()
1133+
1134+
def _check_recursion(self):
1135+
"""
1136+
Check for circular references in object and multiobject fields.
1137+
Raises ValidationError if recursion is detected.
1138+
"""
1139+
# Check if this field points to the same custom object type (self-referential)
1140+
if self.related_object_type_id == self.custom_object_type.object_type_id:
1141+
return # Self-referential fields are allowed
1142+
1143+
# Get the related custom object type directly from the object_type relationship
1144+
try:
1145+
related_custom_object_type = CustomObjectType.objects.get(object_type=self.related_object_type)
1146+
except CustomObjectType.DoesNotExist:
1147+
return # Not a custom object type, no recursion possible
1148+
1149+
# Check for circular references by traversing the dependency chain
1150+
visited = {self.custom_object_type.id}
1151+
if self._has_circular_reference(related_custom_object_type, visited):
1152+
raise ValidationError(
1153+
{
1154+
"related_object_type": _(
1155+
"Circular reference detected. This field would create a circular dependency "
1156+
"between custom object types."
1157+
)
1158+
}
1159+
)
1160+
1161+
def _has_circular_reference(self, custom_object_type, visited):
1162+
"""
1163+
Recursively check if there's a circular reference by following the dependency chain.
1164+
1165+
Args:
1166+
custom_object_type: The CustomObjectType object to check
1167+
visited: Set of custom object type IDs already visited in this traversal
1168+
1169+
Returns:
1170+
bool: True if a circular reference is detected, False otherwise
1171+
"""
1172+
# If we've already visited this type, we have a cycle
1173+
if custom_object_type.id in visited:
1174+
return True
1175+
1176+
# Add this type to visited set
1177+
visited.add(custom_object_type.id)
1178+
1179+
# Check all object and multiobject fields in this custom object type
1180+
for field in custom_object_type.fields.filter(
1181+
type__in=[
1182+
CustomFieldTypeChoices.TYPE_OBJECT,
1183+
CustomFieldTypeChoices.TYPE_MULTIOBJECT,
1184+
],
1185+
related_object_type__isnull=False,
1186+
related_object_type__app_label=APP_LABEL
1187+
):
1188+
1189+
# Get the related custom object type directly from the object_type relationship
1190+
try:
1191+
next_custom_object_type = CustomObjectType.objects.get(object_type=field.related_object_type)
1192+
except CustomObjectType.DoesNotExist:
1193+
continue
1194+
1195+
# Recursively check this dependency
1196+
if self._has_circular_reference(next_custom_object_type, visited):
1197+
return True
1198+
1199+
return False
1200+
11271201
def serialize(self, value):
11281202
"""
11291203
Prepare a value for storage as JSON data.

netbox_custom_objects/tests/test_field_types.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -736,7 +736,7 @@ def test_self_referential_object_field(self):
736736
name="parent",
737737
label="Parent",
738738
type="object",
739-
related_object_type=self.custom_object_type.content_type
739+
related_object_type=self.custom_object_type.object_type
740740
)
741741
field # To silence ruff error
742742

@@ -759,7 +759,7 @@ def test_self_referential_multiobject_field(self):
759759
name="children",
760760
label="Children",
761761
type="multiobject",
762-
related_object_type=self.custom_object_type.content_type
762+
related_object_type=self.custom_object_type.object_type
763763
)
764764
field # To silence ruff error
765765

@@ -802,7 +802,7 @@ def test_cross_referential_object_field(self):
802802
name="related_object",
803803
label="Related Object",
804804
type="object",
805-
related_object_type=second_type.content_type
805+
related_object_type=second_type.object_type
806806
)
807807
field # To silence ruff error
808808

@@ -834,7 +834,7 @@ def test_cross_referential_multiobject_field(self):
834834
name="related_objects",
835835
label="Related Objects",
836836
type="multiobject",
837-
related_object_type=second_type.content_type
837+
related_object_type=second_type.object_type
838838
)
839839
field # To silence ruff error
840840

netbox_custom_objects/views.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,7 @@ def _get_dependent_objects(self, obj):
203203

204204
# Find CustomObjectTypeFields that reference this CustomObjectType
205205
referencing_fields = CustomObjectTypeField.objects.filter(
206-
related_object_type=obj.content_type
206+
related_object_type=obj.object_type
207207
)
208208

209209
# Add the CustomObjectTypeFields that reference this CustomObjectType

0 commit comments

Comments
 (0)