@@ -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 )
0 commit comments