1414from django .db import connection , IntegrityError , models , transaction
1515from django .db .models import Q
1616from 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
1819from django .urls import reverse
1920from django .utils .translation import gettext_lazy as _
2021from 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+
719718class 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.
0 commit comments