Skip to content

Commit 4cf36b9

Browse files
authored
Merge pull request #307 from netboxlabs/283-cot-delete
#203 make sure cascade DELETE is applied
2 parents 1dc6949 + 3f46072 commit 4cf36b9

File tree

3 files changed

+169
-7
lines changed

3 files changed

+169
-7
lines changed
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
from django.db import migrations
2+
3+
4+
def ensure_existing_fk_constraints(apps, schema_editor):
5+
"""
6+
Go through all existing CustomObjectType models and ensure FK constraints
7+
are properly set for any OBJECT type fields.
8+
"""
9+
# Import the actual model class (not the historical version) to access methods
10+
from netbox_custom_objects.models import CustomObjectType
11+
12+
for custom_object_type in CustomObjectType.objects.all():
13+
try:
14+
model = custom_object_type.get_model()
15+
custom_object_type._ensure_all_fk_constraints(model)
16+
except Exception as e:
17+
print(f"Warning: Could not ensure FK constraints for {custom_object_type}: {e}")
18+
19+
20+
class Migration(migrations.Migration):
21+
22+
dependencies = [
23+
('netbox_custom_objects', '0001_initial'),
24+
]
25+
26+
operations = [
27+
migrations.RunPython(
28+
ensure_existing_fk_constraints,
29+
reverse_code=migrations.RunPython.noop
30+
),
31+
]

netbox_custom_objects/models.py

Lines changed: 131 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -469,11 +469,12 @@ def get_model(
469469
"""
470470

471471
# Double-check pattern: check cache again after acquiring lock
472-
if self.is_model_cached(self.id) and not no_cache:
473-
model = self.get_cached_model(self.id)
474-
return model
472+
with self._global_lock:
473+
if self.is_model_cached(self.id) and not no_cache:
474+
model = self.get_cached_model(self.id)
475+
return model
475476

476-
# Generate the model inside the lock to prevent race conditions
477+
# Generate the model outside the lock to avoid holding it during expensive operations
477478
model_name = self.get_table_model_name(self.pk)
478479

479480
# TODO: Add other fields with "index" specified
@@ -546,8 +547,9 @@ def wrapped_post_through_setup(self, cls):
546547

547548
self._after_model_generation(attrs, model)
548549

549-
# Cache the generated model
550-
self._model_cache[self.id] = model
550+
# Cache the generated model (protected by lock for thread safety)
551+
with self._global_lock:
552+
self._model_cache[self.id] = model
551553

552554
# Do the clear cache now that we have it in the cache so there
553555
# is no recursion.
@@ -561,11 +563,76 @@ def wrapped_post_through_setup(self, cls):
561563

562564
def get_model_with_serializer(self):
563565
from netbox_custom_objects.api.serializers import get_serializer_class
564-
model = self.get_model(no_cache=True)
566+
model = self.get_model()
565567
get_serializer_class(model)
566568
self.register_custom_object_search_index(model)
567569
return model
568570

571+
def _ensure_field_fk_constraint(self, model, field_name):
572+
"""
573+
Ensure that a foreign key constraint is properly created at the database level
574+
for a specific OBJECT type field with ON DELETE CASCADE. This is necessary because
575+
models are created with managed=False, which may not properly create FK constraints
576+
with CASCADE behavior.
577+
578+
:param model: The model containing the field
579+
:param field_name: The name of the field to ensure FK constraint for
580+
"""
581+
table_name = self.get_database_table_name()
582+
583+
# Get the model field
584+
try:
585+
model_field = model._meta.get_field(field_name)
586+
except Exception:
587+
return
588+
589+
if not (hasattr(model_field, 'remote_field') and model_field.remote_field):
590+
return
591+
592+
# Get the referenced table
593+
related_model = model_field.remote_field.model
594+
related_table = related_model._meta.db_table
595+
column_name = model_field.column
596+
597+
with connection.cursor() as cursor:
598+
# Drop existing FK constraint if it exists
599+
# Query for existing constraints
600+
cursor.execute("""
601+
SELECT constraint_name
602+
FROM information_schema.table_constraints
603+
WHERE table_name = %s
604+
AND constraint_type = 'FOREIGN KEY'
605+
AND constraint_name LIKE %s
606+
""", [table_name, f"%{column_name}%"])
607+
608+
for row in cursor.fetchall():
609+
constraint_name = row[0]
610+
cursor.execute(f'ALTER TABLE "{table_name}" DROP CONSTRAINT IF EXISTS "{constraint_name}"')
611+
612+
# Create new FK constraint with ON DELETE CASCADE
613+
constraint_name = f"{table_name}_{column_name}_fk_cascade"
614+
cursor.execute(f"""
615+
ALTER TABLE "{table_name}"
616+
ADD CONSTRAINT "{constraint_name}"
617+
FOREIGN KEY ("{column_name}")
618+
REFERENCES "{related_table}" ("id")
619+
ON DELETE CASCADE
620+
DEFERRABLE INITIALLY DEFERRED
621+
""")
622+
623+
def _ensure_all_fk_constraints(self, model):
624+
"""
625+
Ensure that foreign key constraints are properly created at the database level
626+
for ALL OBJECT type fields with ON DELETE CASCADE.
627+
628+
:param model: The model to ensure FK constraints for
629+
"""
630+
# Query all OBJECT type fields for this CustomObjectType
631+
object_fields = self.fields.filter(type=CustomFieldTypeChoices.TYPE_OBJECT)
632+
633+
for field in object_fields:
634+
self._ensure_field_fk_constraint(model, field.name)
635+
569636
def create_model(self):
570637
from netbox_custom_objects.api.serializers import get_serializer_class
571638
# Get the model and ensure it's registered
@@ -819,6 +886,8 @@ def __init__(self, *args, **kwargs):
819886
super().__init__(*args, **kwargs)
820887
self._name = self.__dict__.get("name")
821888
self._original_name = self.name
889+
self._original_type = self.type
890+
self._original_related_object_type_id = self.related_object_type_id
822891

823892
def __str__(self):
824893
return self.label or self.name.replace("_", " ").capitalize()
@@ -1505,11 +1574,35 @@ def save(self, *args, **kwargs):
15051574
# Normal field alteration
15061575
schema_editor.alter_field(model, old_field, model_field)
15071576

1577+
# Ensure FK constraints are properly created for OBJECT fields with CASCADE behavior
1578+
should_ensure_fk = False
1579+
if self.type == CustomFieldTypeChoices.TYPE_OBJECT:
1580+
if self._state.adding:
1581+
should_ensure_fk = True
1582+
else:
1583+
# Existing field - check if type changed to OBJECT or related_object_type changed
1584+
type_changed_to_object = (
1585+
self._original_type != CustomFieldTypeChoices.TYPE_OBJECT
1586+
and self.type == CustomFieldTypeChoices.TYPE_OBJECT
1587+
)
1588+
related_object_changed = (
1589+
self._original_type == CustomFieldTypeChoices.TYPE_OBJECT
1590+
and self.related_object_type_id != self._original_related_object_type_id
1591+
)
1592+
should_ensure_fk = type_changed_to_object or related_object_changed
1593+
15081594
# Clear and refresh the model cache for this CustomObjectType when a field is modified
15091595
self.custom_object_type.clear_model_cache(self.custom_object_type.id)
15101596

15111597
super().save(*args, **kwargs)
15121598

1599+
# Ensure FK constraints AFTER the transaction commits to avoid "pending trigger events" errors
1600+
if should_ensure_fk:
1601+
def ensure_constraint():
1602+
self.custom_object_type._ensure_field_fk_constraint(model, self.name)
1603+
1604+
transaction.on_commit(ensure_constraint)
1605+
15131606
# Reregister SearchIndex with new set of searchable fields
15141607
self.custom_object_type.register_custom_object_search_index(model)
15151608

@@ -1563,3 +1656,34 @@ class CustomObjectObjectType(ObjectType):
15631656

15641657
class Meta:
15651658
proxy = True
1659+
1660+
1661+
# Signal handlers to clear model cache when definitions change
1662+
1663+
1664+
@receiver(post_save, sender=CustomObjectType)
1665+
def clear_cache_on_custom_object_type_save(sender, instance, **kwargs):
1666+
"""
1667+
Clear the model cache when a CustomObjectType is saved.
1668+
"""
1669+
CustomObjectType.clear_model_cache(instance.id)
1670+
1671+
1672+
@receiver(post_save, sender=CustomObjectTypeField)
1673+
def clear_cache_on_field_save(sender, instance, **kwargs):
1674+
"""
1675+
Clear the model cache when a CustomObjectTypeField is saved.
1676+
This ensures the parent CustomObjectType's model is regenerated.
1677+
"""
1678+
if instance.custom_object_type_id:
1679+
CustomObjectType.clear_model_cache(instance.custom_object_type_id)
1680+
1681+
1682+
@receiver(pre_delete, sender=CustomObjectTypeField)
1683+
def clear_cache_on_field_delete(sender, instance, **kwargs):
1684+
"""
1685+
Clear the model cache when a CustomObjectTypeField is deleted.
1686+
This is in addition to the manual clear in the delete() method.
1687+
"""
1688+
if instance.custom_object_type_id:
1689+
CustomObjectType.clear_model_cache(instance.custom_object_type_id)

netbox_custom_objects/tests/base.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,13 @@ def setUp(self):
2323
self.client = Client()
2424
self.client.force_login(self.user)
2525

26+
def tearDown(self):
27+
"""Clean up after each test."""
28+
# Clear the model cache to ensure test isolation
29+
# This prevents cached models with deleted fields from affecting other tests
30+
CustomObjectType.clear_model_cache()
31+
super().tearDown()
32+
2633
@classmethod
2734
def create_custom_object_type(cls, **kwargs):
2835
"""Helper method to create a custom object type."""

0 commit comments

Comments
 (0)