diff --git a/netbox_custom_objects/__init__.py b/netbox_custom_objects/__init__.py index 42fb01b..f5415d0 100644 --- a/netbox_custom_objects/__init__.py +++ b/netbox_custom_objects/__init__.py @@ -6,6 +6,8 @@ from django.db.utils import DatabaseError, OperationalError, ProgrammingError from netbox.plugins import PluginConfig +from .constants import APP_LABEL as APP_LABEL + def is_running_migration(): """ @@ -106,11 +108,14 @@ def get_models(self, include_auto_created=False, include_swapped=False): custom_object_types = CustomObjectType.objects.all() for custom_type in custom_object_types: - # Only yield already cached models during discovery - if CustomObjectType.is_model_cached(custom_type.id): - model = CustomObjectType.get_cached_model(custom_type.id) - if model: - yield model + model = custom_type.get_model() + if model: + yield model + + # If include_auto_created is True, also yield through models + if include_auto_created and hasattr(model, '_through_models'): + for through_model in model._through_models: + yield through_model config = CustomObjectsPluginConfig diff --git a/netbox_custom_objects/field_types.py b/netbox_custom_objects/field_types.py index c390b33..cd46e0d 100644 --- a/netbox_custom_objects/field_types.py +++ b/netbox_custom_objects/field_types.py @@ -30,6 +30,7 @@ from utilities.templatetags.builtins.filters import linkify, render_markdown from netbox_custom_objects.constants import APP_LABEL +from netbox_custom_objects.utilities import generate_model class LazyForeignKey(ForeignKey): @@ -405,11 +406,15 @@ def get_model_field(self, field, **kwargs): if custom_object_type.id == field.custom_object_type.id: # For self-referential fields, use LazyForeignKey to defer resolution model_name = f"{APP_LABEL}.{custom_object_type.get_table_model_name(custom_object_type.id)}" + # Generate a unique related_name to prevent reverse accessor conflicts + table_model_name = field.custom_object_type.get_table_model_name(field.custom_object_type.id).lower() + related_name = f"{table_model_name}_{field.name}_set" f = LazyForeignKey( model_name, null=True, blank=True, on_delete=models.CASCADE, + related_name=related_name, **field_kwargs ) return f @@ -420,11 +425,17 @@ def get_model_field(self, field, **kwargs): # We're in a circular reference, don't call get_model() to prevent recursion # Use a string reference instead model_name = f"{APP_LABEL}.{custom_object_type.get_table_model_name(custom_object_type.id)}" + # Generate a unique related_name to prevent reverse accessor conflicts + table_model_name = field.custom_object_type.get_table_model_name( + field.custom_object_type.id + ).lower() + related_name = f"{table_model_name}_{field.name}_set" f = models.ForeignKey( model_name, null=True, blank=True, on_delete=models.CASCADE, + related_name=related_name, **field_kwargs ) return f @@ -435,8 +446,11 @@ def get_model_field(self, field, **kwargs): to_ct = f"{content_type.app_label}.{to_model}" model = apps.get_model(to_ct) + # Generate a unique related_name to prevent reverse accessor conflicts + table_model_name = field.custom_object_type.get_table_model_name(field.custom_object_type.id).lower() + related_name = f"{table_model_name}_{field.name}_set" f = models.ForeignKey( - model, null=True, blank=True, on_delete=models.CASCADE, **field_kwargs + model, null=True, blank=True, on_delete=models.CASCADE, related_name=related_name, **field_kwargs ) return f @@ -719,7 +733,7 @@ def get_through_model(self, field, model_string): ), } - return type(field.through_model_name, (models.Model,), attrs) + return generate_model(field.through_model_name, (models.Model,), attrs) def get_model_field(self, field, **kwargs): """ diff --git a/netbox_custom_objects/models.py b/netbox_custom_objects/models.py index 595d294..ceb35c5 100644 --- a/netbox_custom_objects/models.py +++ b/netbox_custom_objects/models.py @@ -1,6 +1,5 @@ import decimal import re -import warnings from datetime import date, datetime import django_filters @@ -51,6 +50,7 @@ from netbox_custom_objects.constants import APP_LABEL, RESERVED_FIELD_NAMES from netbox_custom_objects.field_types import FIELD_TYPE_CLASS +from netbox_custom_objects.utilities import generate_model class UniquenessConstraintTestError(Exception): @@ -422,6 +422,9 @@ def _after_model_generation(self, attrs, model): # Get the set of fields that were skipped due to recursion skipped_fields = attrs.get("_skipped_fields", set()) + # Collect through models during after_model_generation + through_models = [] + for field_object in all_field_objects.values(): field_name = field_object["name"] @@ -433,15 +436,28 @@ def _after_model_generation(self, attrs, model): # Fields might be skipped due to recursion prevention if hasattr(model._meta, 'get_field'): try: - model._meta.get_field(field_name) + field = model._meta.get_field(field_name) # Field exists, process it field_object["type"].after_model_generation( field_object["field"], model, field_name ) + + # Collect through models from M2M fields + if hasattr(field, 'remote_field') and hasattr(field.remote_field, 'through'): + through_model = field.remote_field.through + # Only collect custom through models, not auto-created Django ones + if (through_model and through_model not in through_models and + hasattr(through_model._meta, 'app_label') and + through_model._meta.app_label == APP_LABEL): + through_models.append(through_model) + except Exception: # Field doesn't exist (likely skipped due to recursion), skip processing continue + # Store through models on the model for yielding in get_models() + model._through_models = through_models + def get_collision_safe_order_id_idx_name(self): return f"tbl_order_id_{self.id}_idx" @@ -602,22 +618,14 @@ def wrapped_post_through_setup(self, cls): TM.post_through_setup = wrapped_post_through_setup - # Suppress RuntimeWarning about model already being registered - # TODO: Remove this once we have a better way to handle model registration - with warnings.catch_warnings(): - warnings.filterwarnings( - "ignore", category=RuntimeWarning, message=".*was already registered.*" + try: + model = generate_model( + str(model_name), + (CustomObject, models.Model), + attrs, ) - - try: - model = type( - str(model_name), - (CustomObject, models.Model), - attrs, - ) - finally: - # Restore the original method - TM.post_through_setup = original_post_through_setup + finally: + TM.post_through_setup = original_post_through_setup # Register the main model with Django's app registry try: @@ -635,6 +643,9 @@ def wrapped_post_through_setup(self, cls): # Cache the generated model if not no_cache: self._model_cache[self.id] = model + # Do the clear cache now that we have it in the cache so there + # is no recursion. + apps.clear_cache() # Register the serializer for this model if not manytomany_models: @@ -664,6 +675,7 @@ def create_model(self): # 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() @@ -1424,7 +1436,7 @@ def save(self, *args, **kwargs): "managed": True, }, ) - old_through_model = type( + old_through_model = generate_model( f"TempOld{self.original.through_model_name}", (models.Model,), { @@ -1455,7 +1467,7 @@ def save(self, *args, **kwargs): "managed": True, }, ) - new_through_model = type( + new_through_model = generate_model( f"TempNew{self.through_model_name}", (models.Model,), { diff --git a/netbox_custom_objects/utilities.py b/netbox_custom_objects/utilities.py index f7f6b32..45fdac3 100644 --- a/netbox_custom_objects/utilities.py +++ b/netbox_custom_objects/utilities.py @@ -1,9 +1,12 @@ +import warnings + from django.apps import apps from netbox_custom_objects.constants import APP_LABEL __all__ = ( "AppsProxy", + "generate_model", "get_viewname", ) @@ -83,3 +86,25 @@ def get_viewname(model, action=None, rest_api=False): viewname = f"{viewname}_{action}" return viewname + + +def generate_model(*args, **kwargs): + """ + Create a model. + """ + # Monkey patch apps.clear_cache to do nothing + apps.clear_cache = lambda: None + + # Suppress RuntimeWarning about model already being registered + # TODO: Remove this once we have a better way to handle model registration + with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", category=RuntimeWarning, message=".*was already registered.*" + ) + + try: + model = type(*args, **kwargs) + finally: + apps.clear_cache = apps.clear_cache + + return model