From 8aeac765d132efad4772e0a1c09a89157923278b Mon Sep 17 00:00:00 2001 From: Arthur Date: Mon, 28 Jul 2025 12:29:31 -0700 Subject: [PATCH 01/21] Create ObjectType and add feature flags when creation CO --- netbox_custom_objects/models.py | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/netbox_custom_objects/models.py b/netbox_custom_objects/models.py index 540b069..3b065dc 100644 --- a/netbox_custom_objects/models.py +++ b/netbox_custom_objects/models.py @@ -10,6 +10,7 @@ # from django.contrib.contenttypes.management import create_contenttypes from django.contrib.contenttypes.models import ContentType +from core.models import ObjectType from django.core.validators import RegexValidator, ValidationError from django.db import connection, models from django.db.models import Q @@ -33,7 +34,9 @@ CustomValidationMixin, EventRulesMixin, ExportTemplatesMixin, + get_model_features, JournalingMixin, + model_is_public, NotificationsMixin, ) from netbox.registry import registry @@ -257,15 +260,15 @@ def content_type(self): def get_or_create_content_type(self): """ - Get or create the ContentType for this CustomObjectType. - This ensures the ContentType is immediately available in the current transaction. + Get or create the ObjectType for this CustomObjectType. + This ensures the ObjectType is immediately available in the current transaction. """ content_type_name = self.get_table_model_name(self.id).lower() try: - return ContentType.objects.get(app_label=APP_LABEL, model=content_type_name) + return ObjectType.objects.get(app_label=APP_LABEL, model=content_type_name) except Exception: - # Create the ContentType and ensure it's immediately available - ct = ContentType.objects.create( + # Create the ObjectType and ensure it's immediately available + ct = ObjectType.objects.create( app_label=APP_LABEL, model=content_type_name ) # Force a refresh to ensure it's available in the current transaction @@ -461,8 +464,16 @@ def create_model(self): model = self.get_model() # Ensure the ContentType exists and is immediately available - self.get_or_create_content_type() + ct = self.get_or_create_content_type() model = self.get_model() + is_public = model_is_public(model) + features = get_model_features(model) + # we need to append tags here as it is added dynamically to the model + # and not through a mixin + features.append("tags") + ct.is_public = is_public + ct.features = features + ct.save() with connection.schema_editor() as schema_editor: schema_editor.create_model(model) @@ -487,7 +498,7 @@ def delete(self, *args, **kwargs): self.clear_model_cache(self.id) model = self.get_model() - ContentType.objects.get( + ObjectType.objects.get( app_label=APP_LABEL, model=self.get_table_model_name(self.id).lower() ).delete() super().delete(*args, **kwargs) From d4f90794fcf611fcb0e06bfcd0a8368df8ad68da Mon Sep 17 00:00:00 2001 From: Arthur Date: Mon, 28 Jul 2025 13:34:51 -0700 Subject: [PATCH 02/21] fix tags in form --- .../customobject_edit.html | 10 +++++++ netbox_custom_objects/views.py | 27 +++++++++++++------ 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/netbox_custom_objects/templates/netbox_custom_objects/customobject_edit.html b/netbox_custom_objects/templates/netbox_custom_objects/customobject_edit.html index e6ca82c..264b727 100644 --- a/netbox_custom_objects/templates/netbox_custom_objects/customobject_edit.html +++ b/netbox_custom_objects/templates/netbox_custom_objects/customobject_edit.html @@ -30,5 +30,15 @@

{{ group }}

{% render_field form|getfield:name %} {% endfor %} {% endfor %} + + {# Render regular model fields (non-custom fields) that are not in any group #} + {% for field in form %} + {% if field.name not in form.custom_object_type_fields %} + {# Only render fields that are meant for user input #} + {% if not field.field.widget.is_hidden and not field.field.disabled and field.field.widget.input_type != 'hidden' %} + {% render_field field %} + {% endif %} + {% endif %} + {% endfor %} {% endblock form %} \ No newline at end of file diff --git a/netbox_custom_objects/views.py b/netbox_custom_objects/views.py index a9e8331..b6d7400 100644 --- a/netbox_custom_objects/views.py +++ b/netbox_custom_objects/views.py @@ -355,6 +355,11 @@ def get_object(self, **kwargs): return get_object_or_404(model.objects.all(), **self.kwargs) def get_form(self, model): + # Get all custom object type field names to identify them later + custom_field_names = set( + self.object.custom_object_type.fields.values_list('name', flat=True) + ) + meta = type( "Meta", (), @@ -372,6 +377,7 @@ def get_form(self, model): "custom_object_type_field_groups": {}, } + # Process custom object type fields (with grouping) for field in self.object.custom_object_type.fields.all().order_by('group_name', 'weight', 'name'): field_type = field_types.FIELD_TYPE_CLASS[field.type]() try: @@ -390,14 +396,10 @@ def get_form(self, model): except NotImplementedError: print(f"get_form: {field.name} field is not supported") - # Create a custom __init__ method to set instance attributes - def custom_init(self, *args, **kwargs): - super(form_class, self).__init__(*args, **kwargs) - # Set the grouping info as instance attributes from the outer scope - self.custom_object_type_fields = attrs["custom_object_type_fields"] - self.custom_object_type_field_groups = attrs["custom_object_type_field_groups"] - - attrs["__init__"] = custom_init + # Note: Regular model fields (non-custom fields) are automatically included + # by the "fields": "__all__" setting in the Meta class, so we don't need + # to manually add them to the form attributes or grouping structure. + # The template will be able to access them directly through the form. form_class = type( f"{model._meta.object_name}Form", @@ -405,6 +407,15 @@ def custom_init(self, *args, **kwargs): attrs, ) + # Create a custom __init__ method to set instance attributes + def custom_init(self, *args, **kwargs): + forms.NetBoxModelForm.__init__(self, *args, **kwargs) + # Set the grouping info as instance attributes from the outer scope + self.custom_object_type_fields = attrs["custom_object_type_fields"] + self.custom_object_type_field_groups = attrs["custom_object_type_field_groups"] + + form_class.__init__ = custom_init + return form_class From 683f8158d45485108b416ed6aaaecea1ec050ee2 Mon Sep 17 00:00:00 2001 From: Arthur Date: Mon, 28 Jul 2025 13:42:31 -0700 Subject: [PATCH 03/21] cleanup --- netbox_custom_objects/views.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/netbox_custom_objects/views.py b/netbox_custom_objects/views.py index b6d7400..e2db1d4 100644 --- a/netbox_custom_objects/views.py +++ b/netbox_custom_objects/views.py @@ -355,11 +355,6 @@ def get_object(self, **kwargs): return get_object_or_404(model.objects.all(), **self.kwargs) def get_form(self, model): - # Get all custom object type field names to identify them later - custom_field_names = set( - self.object.custom_object_type.fields.values_list('name', flat=True) - ) - meta = type( "Meta", (), From c8885708bb650bbe985995e4947eedc158c2d6e5 Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 29 Jul 2025 08:14:31 -0700 Subject: [PATCH 04/21] add get_model get_models --- netbox_custom_objects/__init__.py | 61 +++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/netbox_custom_objects/__init__.py b/netbox_custom_objects/__init__.py index 288a96b..7c0f80c 100644 --- a/netbox_custom_objects/__init__.py +++ b/netbox_custom_objects/__init__.py @@ -1,4 +1,6 @@ from netbox.plugins import PluginConfig +from django.core.exceptions import AppRegistryNotReady +import warnings # Plugin Configuration @@ -13,5 +15,64 @@ class CustomObjectsPluginConfig(PluginConfig): required_settings = [] template_extensions = "template_content.template_extensions" + def get_model(self, model_name, require_ready=True): + try: + # if the model is already loaded, return it + return super().get_model(model_name, require_ready) + except LookupError: + try: + self.apps.check_apps_ready() + except AppRegistryNotReady: + raise + + # only do database calls if we are sure the app is ready to avoid + # Django warnings + if "table" not in model_name.lower() or "model" not in model_name.lower(): + raise LookupError( + "App '%s' doesn't have a '%s' model." % (self.label, model_name) + ) + + from .models import CustomObjectType + custom_object_type_id = int(model_name.replace("table", "").replace("model", "")) + + try: + obj = CustomObjectType.objects.get(pk=custom_object_type_id) + except CustomObjectType.DoesNotExist: + raise LookupError( + "App '%s' doesn't have a '%s' model." % (self.label, model_name) + ) + return obj.get_model() + + def get_models(self, include_auto_created=False, include_swapped=False): + """Return all models for this plugin, including custom object type models.""" + # Get the regular Django models first + models = list(super().get_models(include_auto_created, include_swapped)) + + # Suppress RuntimeWarning and UserWarning about database calls during model loading + # These are read-only operations that are safe to perform - we also need + # to suppress UserWarning as branching plugin will throw that as well. + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=RuntimeWarning, message=".*database.*") + warnings.filterwarnings("ignore", category=UserWarning, message=".*database.*") + + # Add custom object type models + try: + from .models import CustomObjectType + custom_object_types = CustomObjectType.objects.all() + + for custom_type in custom_object_types: + try: + model = custom_type.get_model() + if model: + models.append(model) + except Exception: + # Skip models that can't be loaded + continue + except Exception: + # If we can't load custom object types, just return the regular models + pass + + return models + config = CustomObjectsPluginConfig From e2283357fa1e0429847e9f3bd074d7f4c9bcb0be Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 29 Jul 2025 08:14:55 -0700 Subject: [PATCH 05/21] add get_model get_models --- netbox_custom_objects/__init__.py | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/netbox_custom_objects/__init__.py b/netbox_custom_objects/__init__.py index 7c0f80c..5d3024b 100644 --- a/netbox_custom_objects/__init__.py +++ b/netbox_custom_objects/__init__.py @@ -1,7 +1,8 @@ -from netbox.plugins import PluginConfig -from django.core.exceptions import AppRegistryNotReady import warnings +from django.core.exceptions import AppRegistryNotReady +from netbox.plugins import PluginConfig + # Plugin Configuration class CustomObjectsPluginConfig(PluginConfig): @@ -33,7 +34,10 @@ def get_model(self, model_name, require_ready=True): ) from .models import CustomObjectType - custom_object_type_id = int(model_name.replace("table", "").replace("model", "")) + + custom_object_type_id = int( + model_name.replace("table", "").replace("model", "") + ) try: obj = CustomObjectType.objects.get(pk=custom_object_type_id) @@ -47,19 +51,24 @@ def get_models(self, include_auto_created=False, include_swapped=False): """Return all models for this plugin, including custom object type models.""" # Get the regular Django models first models = list(super().get_models(include_auto_created, include_swapped)) - + # Suppress RuntimeWarning and UserWarning about database calls during model loading # These are read-only operations that are safe to perform - we also need # to suppress UserWarning as branching plugin will throw that as well. with warnings.catch_warnings(): - warnings.filterwarnings("ignore", category=RuntimeWarning, message=".*database.*") - warnings.filterwarnings("ignore", category=UserWarning, message=".*database.*") - + warnings.filterwarnings( + "ignore", category=RuntimeWarning, message=".*database.*" + ) + warnings.filterwarnings( + "ignore", category=UserWarning, message=".*database.*" + ) + # Add custom object type models try: from .models import CustomObjectType + custom_object_types = CustomObjectType.objects.all() - + for custom_type in custom_object_types: try: model = custom_type.get_model() @@ -71,7 +80,7 @@ def get_models(self, include_auto_created=False, include_swapped=False): except Exception: # If we can't load custom object types, just return the regular models pass - + return models From 85f2c62af02a1fd428cc2b2f500468e78753eb1b Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 29 Jul 2025 08:19:50 -0700 Subject: [PATCH 06/21] clear django apps registry cache --- netbox_custom_objects/models.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/netbox_custom_objects/models.py b/netbox_custom_objects/models.py index 3b065dc..ad89c60 100644 --- a/netbox_custom_objects/models.py +++ b/netbox_custom_objects/models.py @@ -177,6 +177,9 @@ def clear_model_cache(cls, custom_object_type_id=None): else: cls._model_cache.clear() cls._through_model_cache.clear() + + # Clear Django apps registry cache to ensure newly created models are recognized + apps.get_models.cache_clear() @classmethod def get_cached_model(cls, custom_object_type_id): From 1e45b91007e6a66756f3a855cad33642258afdbe Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 29 Jul 2025 13:45:08 -0700 Subject: [PATCH 07/21] merge serializer --- netbox_custom_objects/api/serializers.py | 35 +++++++++++++++++++----- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/netbox_custom_objects/api/serializers.py b/netbox_custom_objects/api/serializers.py index 1dda6ce..2364991 100644 --- a/netbox_custom_objects/api/serializers.py +++ b/netbox_custom_objects/api/serializers.py @@ -1,4 +1,6 @@ +import sys from django.contrib.contenttypes.models import ContentType +from core.models import ObjectType from extras.choices import CustomFieldTypeChoices from netbox.api.serializers import NetBoxModelSerializer from rest_framework import serializers @@ -59,10 +61,10 @@ def validate(self, attrs): CustomFieldTypeChoices.TYPE_MULTIOBJECT, ]: try: - attrs["related_object_type"] = ContentType.objects.get( + attrs["related_object_type"] = ObjectType.objects.get( app_label=app_label, model=model ) - except ContentType.DoesNotExist: + except ObjectType.DoesNotExist: raise ValidationError( "Must provide valid app_label and model for object field type." ) @@ -197,17 +199,24 @@ def get_field_data(self, obj): def get_serializer_class(model): model_fields = model.custom_object_type.fields.all() + + # Create field list including all necessary fields + base_fields = ["id", "url", "display", "created", "last_updated", "tags"] + custom_field_names = [field.name for field in model_fields] + all_fields = base_fields + custom_field_names + meta = type( "Meta", (), { "model": model, - "fields": "__all__", + "fields": all_fields, + "brief_fields": ("id", "url", "display"), }, ) def get_url(self, obj): - # Unsaved objects will not yet have a valid URL. + """Generate the API URL for this object""" if hasattr(obj, "pk") and obj.pk in (None, ""): return None @@ -221,11 +230,18 @@ def get_url(self, obj): format = self.context.get("format") return reverse(view_name, kwargs=kwargs, request=request, format=format) + def get_display(self, obj): + """Get display representation of the object""" + return str(obj) + + # Create basic attributes for the serializer attrs = { "Meta": meta, - "__module__": "database.serializers", + "__module__": "netbox_custom_objects.api.serializers", "url": serializers.SerializerMethodField(), "get_url": get_url, + "display": serializers.SerializerMethodField(), + "get_display": get_display, } for field in model_fields: @@ -235,10 +251,15 @@ def get_url(self, obj): except NotImplementedError: print(f"serializer: {field.name} field is not implemented; using a default serializer field") + serializer_name = f"{model._meta.object_name}Serializer" serializer = type( - f"{model._meta.object_name}Serializer", - (serializers.ModelSerializer,), + serializer_name, + (NetBoxModelSerializer,), attrs, ) + + # Register the serializer in the current module so NetBox can find it + current_module = sys.modules[__name__] + setattr(current_module, serializer_name, serializer) return serializer From 0318ab23f17e408980763fc7d938fd4cba434a76 Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 29 Jul 2025 13:51:36 -0700 Subject: [PATCH 08/21] merge model serializer changes --- netbox_custom_objects/models.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/netbox_custom_objects/models.py b/netbox_custom_objects/models.py index ad89c60..e8e8e96 100644 --- a/netbox_custom_objects/models.py +++ b/netbox_custom_objects/models.py @@ -372,7 +372,11 @@ def get_model( # Check if we have a cached model for this CustomObjectType if self.is_model_cached(self.id): - return self.get_cached_model(self.id) + model = self.get_cached_model(self.id) + # Ensure the serializer is registered even for cached models + from netbox_custom_objects.api.serializers import get_serializer_class + get_serializer_class(model) + return model if app_label is None: app_label = str(uuid.uuid4()) + "_database_table" @@ -460,6 +464,12 @@ def get_model( if self.id not in self._through_model_cache: self._through_model_cache[self.id] = {} self._through_model_cache[self.id][through_model_name] = through_model + + # Register the serializer for this model + if not manytomany_models: + from netbox_custom_objects.api.serializers import get_serializer_class + get_serializer_class(model) + return model def create_model(self): From d071480695169e0ab99309a02bff927d259b00b1 Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 29 Jul 2025 14:20:23 -0700 Subject: [PATCH 09/21] working solution --- netbox_custom_objects/__init__.py | 62 ++++++++++++++++--------------- netbox_custom_objects/models.py | 56 +++++----------------------- 2 files changed, 43 insertions(+), 75 deletions(-) diff --git a/netbox_custom_objects/__init__.py b/netbox_custom_objects/__init__.py index 5d3024b..83d5727 100644 --- a/netbox_custom_objects/__init__.py +++ b/netbox_custom_objects/__init__.py @@ -50,38 +50,42 @@ def get_model(self, model_name, require_ready=True): def get_models(self, include_auto_created=False, include_swapped=False): """Return all models for this plugin, including custom object type models.""" # Get the regular Django models first - models = list(super().get_models(include_auto_created, include_swapped)) + for model in super().get_models(include_auto_created, include_swapped): + yield model - # Suppress RuntimeWarning and UserWarning about database calls during model loading - # These are read-only operations that are safe to perform - we also need - # to suppress UserWarning as branching plugin will throw that as well. - with warnings.catch_warnings(): - warnings.filterwarnings( - "ignore", category=RuntimeWarning, message=".*database.*" - ) - warnings.filterwarnings( - "ignore", category=UserWarning, message=".*database.*" - ) - - # Add custom object type models - try: + # Only add dynamic models if we have access to the database + try: + from django.db import connection + connection.ensure_connection() + + # Suppress warnings about database calls during model loading + with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", category=RuntimeWarning, message=".*database.*" + ) + warnings.filterwarnings( + "ignore", category=UserWarning, message=".*database.*" + ) + + # Add custom object type models from .models import CustomObjectType - custom_object_types = CustomObjectType.objects.all() - - for custom_type in custom_object_types: - try: - model = custom_type.get_model() - if model: - models.append(model) - except Exception: - # Skip models that can't be loaded - continue - except Exception: - # If we can't load custom object types, just return the regular models - pass - - return models + try: + custom_object_types = CustomObjectType.objects.all() + for custom_type in custom_object_types: + try: + model = custom_type.get_model() + if model: + yield model + except Exception: + # Skip models that can't be loaded + continue + except Exception: + # Skip if we can't access CustomObjectType (e.g., during migrations) + pass + except Exception: + # Skip dynamic models if database is not available + pass config = CustomObjectsPluginConfig diff --git a/netbox_custom_objects/models.py b/netbox_custom_objects/models.py index e8e8e96..f60de84 100644 --- a/netbox_custom_objects/models.py +++ b/netbox_custom_objects/models.py @@ -415,38 +415,10 @@ def get_model( attrs.update(**field_attrs) - # Create a unique through model for tagging for this CustomObjectType - - through_model_name = f"CustomObjectTaggedItem{self.id}" - - # Create a unique through model for this CustomObjectType - through_model = type( - through_model_name, - (GenericTaggedItemBase,), - { - "__module__": "netbox_custom_objects.models", - "tag": models.ForeignKey( - to=Tag, - related_name=f"custom_objects_{through_model_name.lower()}_items", - on_delete=models.CASCADE, - ), - "_netbox_private": True, - "objects": RestrictedQuerySet.as_manager(), - "Meta": type( - "Meta", - (), - { - "indexes": [models.Index(fields=["content_type", "object_id"])], - "verbose_name": f"tagged item {self.id}", - "verbose_name_plural": f"tagged items {self.id}", - }, - ), - }, - ) - + # Use the standard NetBox tagging system instead of custom through models attrs["tags"] = TaggableManager( - through=through_model, - ordering=("weight", "name"), + through='extras.TaggedItem', + ordering=('weight', 'name'), ) # Create the model class. @@ -456,14 +428,17 @@ def get_model( attrs, ) + # Register the main model with Django's app registry + try: + existing_model = apps.get_model(APP_LABEL, model_name) + except LookupError: + apps.register_model(APP_LABEL, model) + if not manytomany_models: self._after_model_generation(attrs, model) - # Cache the generated model and its through models + # Cache the generated model self._model_cache[self.id] = model - if self.id not in self._through_model_cache: - self._through_model_cache[self.id] = {} - self._through_model_cache[self.id][through_model_name] = through_model # Register the serializer for this model if not manytomany_models: @@ -491,12 +466,6 @@ def create_model(self): with connection.schema_editor() as schema_editor: schema_editor.create_model(model) - # Also create the through model tables for tags and other mixins - if self.id in self._through_model_cache: - through_models = self._through_model_cache[self.id] - for through_model_name, through_model in through_models.items(): - schema_editor.create_model(through_model) - def save(self, *args, **kwargs): needs_db_create = self._state.adding super().save(*args, **kwargs) @@ -516,11 +485,6 @@ def delete(self, *args, **kwargs): ).delete() super().delete(*args, **kwargs) with connection.schema_editor() as schema_editor: - # Delete the through model tables first if they exist - if self.id in self._through_model_cache: - through_models = self._through_model_cache[self.id] - for through_model_name, through_model in through_models.items(): - schema_editor.delete_model(through_model) schema_editor.delete_model(model) From 6930677e60bc7bc4f4e0c21aa9f8aba9ea83c02c Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 29 Jul 2025 14:27:27 -0700 Subject: [PATCH 10/21] cleanup --- netbox_custom_objects/__init__.py | 47 ++++++++++++------------------- 1 file changed, 18 insertions(+), 29 deletions(-) diff --git a/netbox_custom_objects/__init__.py b/netbox_custom_objects/__init__.py index 83d5727..211bbcd 100644 --- a/netbox_custom_objects/__init__.py +++ b/netbox_custom_objects/__init__.py @@ -1,6 +1,7 @@ import warnings from django.core.exceptions import AppRegistryNotReady +from django.db.utils import DatabaseError, OperationalError from netbox.plugins import PluginConfig @@ -53,39 +54,27 @@ def get_models(self, include_auto_created=False, include_swapped=False): for model in super().get_models(include_auto_created, include_swapped): yield model - # Only add dynamic models if we have access to the database - try: - from django.db import connection - connection.ensure_connection() - - # Suppress warnings about database calls during model loading - with warnings.catch_warnings(): - warnings.filterwarnings( - "ignore", category=RuntimeWarning, message=".*database.*" - ) - warnings.filterwarnings( - "ignore", category=UserWarning, message=".*database.*" - ) + # Suppress warnings about database calls during model loading + with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", category=RuntimeWarning, message=".*database.*" + ) + warnings.filterwarnings( + "ignore", category=UserWarning, message=".*database.*" + ) - # Add custom object type models - from .models import CustomObjectType + # Add custom object type models + from .models import CustomObjectType + custom_object_types = CustomObjectType.objects.all() + for custom_type in custom_object_types: try: - custom_object_types = CustomObjectType.objects.all() - for custom_type in custom_object_types: - try: - model = custom_type.get_model() - if model: - yield model - except Exception: - # Skip models that can't be loaded - continue + model = custom_type.get_model() + if model: + yield model except Exception: - # Skip if we can't access CustomObjectType (e.g., during migrations) - pass - except Exception: - # Skip dynamic models if database is not available - pass + # Skip models that can't be loaded + continue config = CustomObjectsPluginConfig From 6a37de4fb4e3223c93e6a672923d3c7a1131cd94 Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 29 Jul 2025 14:41:30 -0700 Subject: [PATCH 11/21] cleanup --- netbox_custom_objects/__init__.py | 1 - netbox_custom_objects/api/serializers.py | 16 +++-- netbox_custom_objects/models.py | 90 ++++++++++++------------ 3 files changed, 56 insertions(+), 51 deletions(-) diff --git a/netbox_custom_objects/__init__.py b/netbox_custom_objects/__init__.py index 211bbcd..8c86e7b 100644 --- a/netbox_custom_objects/__init__.py +++ b/netbox_custom_objects/__init__.py @@ -1,7 +1,6 @@ import warnings from django.core.exceptions import AppRegistryNotReady -from django.db.utils import DatabaseError, OperationalError from netbox.plugins import PluginConfig diff --git a/netbox_custom_objects/api/serializers.py b/netbox_custom_objects/api/serializers.py index 2364991..56d10d3 100644 --- a/netbox_custom_objects/api/serializers.py +++ b/netbox_custom_objects/api/serializers.py @@ -1,6 +1,7 @@ import sys -from django.contrib.contenttypes.models import ContentType + from core.models import ObjectType +from django.contrib.contenttypes.models import ContentType from extras.choices import CustomFieldTypeChoices from netbox.api.serializers import NetBoxModelSerializer from rest_framework import serializers @@ -8,7 +9,8 @@ from rest_framework.reverse import reverse from netbox_custom_objects import field_types -from netbox_custom_objects.models import CustomObject, CustomObjectType, CustomObjectTypeField +from netbox_custom_objects.models import (CustomObject, CustomObjectType, + CustomObjectTypeField) __all__ = ( "CustomObjectTypeSerializer", @@ -199,12 +201,12 @@ def get_field_data(self, obj): def get_serializer_class(model): model_fields = model.custom_object_type.fields.all() - + # Create field list including all necessary fields base_fields = ["id", "url", "display", "created", "last_updated", "tags"] custom_field_names = [field.name for field in model_fields] all_fields = base_fields + custom_field_names - + meta = type( "Meta", (), @@ -249,7 +251,9 @@ def get_display(self, obj): try: attrs[field.name] = field_type.get_serializer_field(field) except NotImplementedError: - print(f"serializer: {field.name} field is not implemented; using a default serializer field") + print( + f"serializer: {field.name} field is not implemented; using a default serializer field" + ) serializer_name = f"{model._meta.object_name}Serializer" serializer = type( @@ -257,7 +261,7 @@ def get_display(self, obj): (NetBoxModelSerializer,), attrs, ) - + # Register the serializer in the current module so NetBox can find it current_module = sys.modules[__name__] setattr(current_module, serializer_name, serializer) diff --git a/netbox_custom_objects/models.py b/netbox_custom_objects/models.py index f60de84..d010396 100644 --- a/netbox_custom_objects/models.py +++ b/netbox_custom_objects/models.py @@ -4,44 +4,32 @@ from datetime import date, datetime import django_filters +from core.models import ObjectType from core.models.contenttypes import ObjectTypeManager from django.apps import apps from django.conf import settings - # from django.contrib.contenttypes.management import create_contenttypes from django.contrib.contenttypes.models import ContentType -from core.models import ObjectType from django.core.validators import RegexValidator, ValidationError from django.db import connection, models from django.db.models import Q from django.db.models.functions import Lower from django.urls import reverse from django.utils.translation import gettext_lazy as _ -from extras.choices import ( - CustomFieldFilterLogicChoices, - CustomFieldTypeChoices, - CustomFieldUIEditableChoices, - CustomFieldUIVisibleChoices, -) +from extras.choices import (CustomFieldFilterLogicChoices, + CustomFieldTypeChoices, + CustomFieldUIEditableChoices, + CustomFieldUIVisibleChoices) from extras.models.customfields import SEARCH_TYPES -from extras.models.tags import Tag from netbox.models import ChangeLoggedModel, PrimaryModel -from netbox.models.features import ( - BookmarksMixin, - ChangeLoggingMixin, - CloningMixin, - CustomLinksMixin, - CustomValidationMixin, - EventRulesMixin, - ExportTemplatesMixin, - get_model_features, - JournalingMixin, - model_is_public, - NotificationsMixin, -) +from netbox.models.features import (BookmarksMixin, ChangeLoggingMixin, + CloningMixin, CustomLinksMixin, + CustomValidationMixin, EventRulesMixin, + ExportTemplatesMixin, JournalingMixin, + NotificationsMixin, get_model_features, + model_is_public) from netbox.registry import registry from taggit.managers import TaggableManager -from taggit.models import GenericTaggedItemBase from utilities import filters from utilities.datetime import datetime_from_timestamp from utilities.object_types import object_type_name @@ -117,7 +105,7 @@ def clone_fields(self): # Get all field names where is_cloneable=True for this custom object type cloneable_fields = self.custom_object_type.fields.filter( is_cloneable=True - ).values_list('name', flat=True) + ).values_list("name", flat=True) return tuple(cloneable_fields) @@ -140,7 +128,9 @@ def get_list_url(self): class CustomObjectType(PrimaryModel): # Class-level cache for generated models _model_cache = {} - _through_model_cache = {} # Now stores {custom_object_type_id: {through_model_name: through_model}} + _through_model_cache = ( + {} + ) # Now stores {custom_object_type_id: {through_model_name: through_model}} name = models.CharField(max_length=100, unique=True) schema = models.JSONField(blank=True, default=dict) verbose_name_plural = models.CharField(max_length=100, blank=True) @@ -177,7 +167,7 @@ def clear_model_cache(cls, custom_object_type_id=None): else: cls._model_cache.clear() cls._through_model_cache.clear() - + # Clear Django apps registry cache to ensure newly created models are recognized apps.get_models.cache_clear() @@ -271,9 +261,7 @@ def get_or_create_content_type(self): return ObjectType.objects.get(app_label=APP_LABEL, model=content_type_name) except Exception: # Create the ObjectType and ensure it's immediately available - ct = ObjectType.objects.create( - app_label=APP_LABEL, model=content_type_name - ) + ct = ObjectType.objects.create(app_label=APP_LABEL, model=content_type_name) # Force a refresh to ensure it's available in the current transaction ct.refresh_from_db() return ct @@ -374,7 +362,9 @@ def get_model( if self.is_model_cached(self.id): model = self.get_cached_model(self.id) # Ensure the serializer is registered even for cached models - from netbox_custom_objects.api.serializers import get_serializer_class + from netbox_custom_objects.api.serializers import \ + get_serializer_class + get_serializer_class(model) return model @@ -417,8 +407,8 @@ def get_model( # Use the standard NetBox tagging system instead of custom through models attrs["tags"] = TaggableManager( - through='extras.TaggedItem', - ordering=('weight', 'name'), + through="extras.TaggedItem", + ordering=("weight", "name"), ) # Create the model class. @@ -430,7 +420,7 @@ def get_model( # Register the main model with Django's app registry try: - existing_model = apps.get_model(APP_LABEL, model_name) + apps.get_model(APP_LABEL, model_name) except LookupError: apps.register_model(APP_LABEL, model) @@ -442,7 +432,9 @@ def get_model( # Register the serializer for this model if not manytomany_models: - from netbox_custom_objects.api.serializers import get_serializer_class + from netbox_custom_objects.api.serializers import \ + get_serializer_class + get_serializer_class(model) return model @@ -1140,8 +1132,10 @@ def save(self, *args, **kwargs): old_field.contribute_to_class(model, self._original_name) # Special handling for MultiObject fields when the name changes - if (self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT and - self.name != self._original_name): + if ( + self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT + and self.name != self._original_name + ): # For renamed MultiObject fields, we just need to rename the through table old_through_table_name = self.original.through_table_name new_through_table_name = self.through_table_name @@ -1170,12 +1164,16 @@ def save(self, *args, **kwargs): "Meta": old_through_meta, "id": models.AutoField(primary_key=True), "source": models.ForeignKey( - model, on_delete=models.CASCADE, - db_column="source_id", related_name="+" + model, + on_delete=models.CASCADE, + db_column="source_id", + related_name="+", ), "target": models.ForeignKey( - model, on_delete=models.CASCADE, - db_column="target_id", related_name="+" + model, + on_delete=models.CASCADE, + db_column="target_id", + related_name="+", ), }, ) @@ -1197,12 +1195,16 @@ def save(self, *args, **kwargs): "Meta": new_through_meta, "id": models.AutoField(primary_key=True), "source": models.ForeignKey( - model, on_delete=models.CASCADE, - db_column="source_id", related_name="+" + model, + on_delete=models.CASCADE, + db_column="source_id", + related_name="+", ), "target": models.ForeignKey( - model, on_delete=models.CASCADE, - db_column="target_id", related_name="+" + model, + on_delete=models.CASCADE, + db_column="target_id", + related_name="+", ), }, ) From 2af08af4ce87b8a7fed68128fa983ffee6746f9f Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 29 Jul 2025 16:56:36 -0700 Subject: [PATCH 12/21] fix tags --- netbox_custom_objects/__init__.py | 46 ++++++++++++++++++++++++++-- netbox_custom_objects/models.py | 50 ++++++++++++++++++++++--------- 2 files changed, 79 insertions(+), 17 deletions(-) diff --git a/netbox_custom_objects/__init__.py b/netbox_custom_objects/__init__.py index 8c86e7b..76fc7b4 100644 --- a/netbox_custom_objects/__init__.py +++ b/netbox_custom_objects/__init__.py @@ -16,6 +16,41 @@ class CustomObjectsPluginConfig(PluginConfig): required_settings = [] template_extensions = "template_content.template_extensions" + ''' + def ready(self): + """Called when the app is ready - ensure all through models are registered.""" + super().ready() + + # Import here to avoid circular imports + try: + from .models import CustomObjectType + + # Ensure all custom object types have their through models registered + # Only if we haven't already done this initialization + if not hasattr(self.__class__, '_initialized'): + custom_object_types = CustomObjectType.objects.all() + for custom_type in custom_object_types: + try: + # This will create and cache the through model if it doesn't exist + custom_type.get_model() + except Exception as e: + # Skip models that can't be loaded during initialization + import logging + logger = logging.getLogger(__name__) + logger.debug(f"Failed to initialize custom object type {custom_type.id}: {e}") + continue + + # Mark as initialized to prevent duplicate runs + self.__class__._initialized = True + + except Exception as e: + # Database might not be ready yet + import logging + logger = logging.getLogger(__name__) + logger.debug(f"Database not ready during app initialization: {e}") + pass + ''' + def get_model(self, model_name, require_ready=True): try: # if the model is already loaded, return it @@ -65,12 +100,17 @@ def get_models(self, include_auto_created=False, include_swapped=False): # Add custom object type models from .models import CustomObjectType + # Only load models that are already cached to avoid creating all models at startup + # This prevents the "two TaggableManagers with same through model" error custom_object_types = CustomObjectType.objects.all() for custom_type in custom_object_types: try: - model = custom_type.get_model() - if model: - yield model + # 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 + # Don't create new models during get_models() - they'll be created on demand except Exception: # Skip models that can't be loaded continue diff --git a/netbox_custom_objects/models.py b/netbox_custom_objects/models.py index d010396..c511632 100644 --- a/netbox_custom_objects/models.py +++ b/netbox_custom_objects/models.py @@ -159,11 +159,8 @@ def clear_model_cache(cls, custom_object_type_id=None): :param custom_object_type_id: ID of the CustomObjectType to clear cache for, or None to clear all """ if custom_object_type_id is not None: - if custom_object_type_id in cls._model_cache: - cls._model_cache.pop(custom_object_type_id, None) - # Also clear through model cache if it exists - if custom_object_type_id in cls._through_model_cache: - cls._through_model_cache.pop(custom_object_type_id, None) + cls._model_cache.pop(custom_object_type_id, None) + cls._through_model_cache.pop(custom_object_type_id, None) else: cls._model_cache.clear() cls._through_model_cache.clear() @@ -319,6 +316,7 @@ def get_collision_safe_order_id_idx_name(self): def get_database_table_name(self): return f"{USER_TABLE_DATABASE_NAME_PREFIX}{self.id}" + @property def title_case_name_plural(self): return title(self.name) + "s" @@ -369,7 +367,7 @@ def get_model( return model if app_label is None: - app_label = str(uuid.uuid4()) + "_database_table" + app_label = APP_LABEL model_name = self.get_table_model_name(self.pk) @@ -405,22 +403,46 @@ def get_model( attrs.update(**field_attrs) - # Use the standard NetBox tagging system instead of custom through models + # Use the standard NetBox tagging system attrs["tags"] = TaggableManager( through="extras.TaggedItem", ordering=("weight", "name"), ) - # Create the model class. - model = type( - str(model_name), - (CustomObject, models.Model), - attrs, - ) + # Create the model class with a workaround for TaggableManager conflicts + # Temporarily disable the through model validation during model creation + from taggit.managers import TaggableManager as TM + original_post_through_setup = TM.post_through_setup + + def patched_post_through_setup(self, model): + # Skip the duplicate through model check for our dynamic models + if hasattr(model, 'custom_object_type_id'): + # Just set up the manager without checking for duplicates + if not hasattr(self.through._meta, 'auto_created'): + setattr(self.through, '_custom_object_managed', True) + return + # Call original method for other models + return original_post_through_setup(self, model) + + TM.post_through_setup = patched_post_through_setup + + try: + model = type( + str(model_name), + (CustomObject, models.Model), + attrs, + ) + finally: + # Restore the original method + TM.post_through_setup = original_post_through_setup # Register the main model with Django's app registry try: - apps.get_model(APP_LABEL, model_name) + existing_model = apps.get_model(APP_LABEL, model_name) + # If model exists but is different, we have a problem + if existing_model is not model: + # Use the existing model to avoid conflicts + model = existing_model except LookupError: apps.register_model(APP_LABEL, model) From 6349892e0b2ecbb7a7d38880c60115834a6c3d12 Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 29 Jul 2025 16:58:36 -0700 Subject: [PATCH 13/21] cleanup --- netbox_custom_objects/__init__.py | 35 --------------------- netbox_custom_objects/models.py | 51 ++++++++++++++++++------------- 2 files changed, 29 insertions(+), 57 deletions(-) diff --git a/netbox_custom_objects/__init__.py b/netbox_custom_objects/__init__.py index 76fc7b4..f2cdcdb 100644 --- a/netbox_custom_objects/__init__.py +++ b/netbox_custom_objects/__init__.py @@ -16,41 +16,6 @@ class CustomObjectsPluginConfig(PluginConfig): required_settings = [] template_extensions = "template_content.template_extensions" - ''' - def ready(self): - """Called when the app is ready - ensure all through models are registered.""" - super().ready() - - # Import here to avoid circular imports - try: - from .models import CustomObjectType - - # Ensure all custom object types have their through models registered - # Only if we haven't already done this initialization - if not hasattr(self.__class__, '_initialized'): - custom_object_types = CustomObjectType.objects.all() - for custom_type in custom_object_types: - try: - # This will create and cache the through model if it doesn't exist - custom_type.get_model() - except Exception as e: - # Skip models that can't be loaded during initialization - import logging - logger = logging.getLogger(__name__) - logger.debug(f"Failed to initialize custom object type {custom_type.id}: {e}") - continue - - # Mark as initialized to prevent duplicate runs - self.__class__._initialized = True - - except Exception as e: - # Database might not be ready yet - import logging - logger = logging.getLogger(__name__) - logger.debug(f"Database not ready during app initialization: {e}") - pass - ''' - def get_model(self, model_name, require_ready=True): try: # if the model is already loaded, return it diff --git a/netbox_custom_objects/models.py b/netbox_custom_objects/models.py index c511632..fb92f9f 100644 --- a/netbox_custom_objects/models.py +++ b/netbox_custom_objects/models.py @@ -1,6 +1,5 @@ import decimal import re -import uuid from datetime import date, datetime import django_filters @@ -8,6 +7,7 @@ from core.models.contenttypes import ObjectTypeManager from django.apps import apps from django.conf import settings + # from django.contrib.contenttypes.management import create_contenttypes from django.contrib.contenttypes.models import ContentType from django.core.validators import RegexValidator, ValidationError @@ -16,18 +16,27 @@ from django.db.models.functions import Lower from django.urls import reverse from django.utils.translation import gettext_lazy as _ -from extras.choices import (CustomFieldFilterLogicChoices, - CustomFieldTypeChoices, - CustomFieldUIEditableChoices, - CustomFieldUIVisibleChoices) +from extras.choices import ( + CustomFieldFilterLogicChoices, + CustomFieldTypeChoices, + CustomFieldUIEditableChoices, + CustomFieldUIVisibleChoices, +) from extras.models.customfields import SEARCH_TYPES from netbox.models import ChangeLoggedModel, PrimaryModel -from netbox.models.features import (BookmarksMixin, ChangeLoggingMixin, - CloningMixin, CustomLinksMixin, - CustomValidationMixin, EventRulesMixin, - ExportTemplatesMixin, JournalingMixin, - NotificationsMixin, get_model_features, - model_is_public) +from netbox.models.features import ( + BookmarksMixin, + ChangeLoggingMixin, + CloningMixin, + CustomLinksMixin, + CustomValidationMixin, + EventRulesMixin, + ExportTemplatesMixin, + JournalingMixin, + NotificationsMixin, + get_model_features, + model_is_public, +) from netbox.registry import registry from taggit.managers import TaggableManager from utilities import filters @@ -316,7 +325,6 @@ def get_collision_safe_order_id_idx_name(self): def get_database_table_name(self): return f"{USER_TABLE_DATABASE_NAME_PREFIX}{self.id}" - @property def title_case_name_plural(self): return title(self.name) + "s" @@ -360,8 +368,7 @@ def get_model( if self.is_model_cached(self.id): model = self.get_cached_model(self.id) # Ensure the serializer is registered even for cached models - from netbox_custom_objects.api.serializers import \ - get_serializer_class + from netbox_custom_objects.api.serializers import get_serializer_class get_serializer_class(model) return model @@ -412,20 +419,21 @@ def get_model( # Create the model class with a workaround for TaggableManager conflicts # Temporarily disable the through model validation during model creation from taggit.managers import TaggableManager as TM + original_post_through_setup = TM.post_through_setup - + def patched_post_through_setup(self, model): # Skip the duplicate through model check for our dynamic models - if hasattr(model, 'custom_object_type_id'): + if hasattr(model, "custom_object_type_id"): # Just set up the manager without checking for duplicates - if not hasattr(self.through._meta, 'auto_created'): - setattr(self.through, '_custom_object_managed', True) + if not hasattr(self.through._meta, "auto_created"): + setattr(self.through, "_custom_object_managed", True) return # Call original method for other models return original_post_through_setup(self, model) - + TM.post_through_setup = patched_post_through_setup - + try: model = type( str(model_name), @@ -454,8 +462,7 @@ def patched_post_through_setup(self, model): # Register the serializer for this model if not manytomany_models: - from netbox_custom_objects.api.serializers import \ - get_serializer_class + from netbox_custom_objects.api.serializers import get_serializer_class get_serializer_class(model) From 6dff7279a969c9abd7f20a91088aa424dfd25131 Mon Sep 17 00:00:00 2001 From: Arthur Date: Wed, 30 Jul 2025 09:35:33 -0700 Subject: [PATCH 14/21] fix migration --- netbox_custom_objects/__init__.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/netbox_custom_objects/__init__.py b/netbox_custom_objects/__init__.py index f2cdcdb..f4e09d3 100644 --- a/netbox_custom_objects/__init__.py +++ b/netbox_custom_objects/__init__.py @@ -1,6 +1,7 @@ import warnings from django.core.exceptions import AppRegistryNotReady +from django.db import utils as django_db_utils from netbox.plugins import PluginConfig @@ -45,6 +46,11 @@ def get_model(self, model_name, require_ready=True): raise LookupError( "App '%s' doesn't have a '%s' model." % (self.label, model_name) ) + except (django_db_utils.ProgrammingError, django_db_utils.OperationalError): + # Handle database errors that occur when the table doesn't exist yet during migrations + raise LookupError( + "App '%s' doesn't have a '%s' model." % (self.label, model_name) + ) return obj.get_model() def get_models(self, include_auto_created=False, include_swapped=False): @@ -67,18 +73,17 @@ def get_models(self, include_auto_created=False, include_swapped=False): # Only load models that are already cached to avoid creating all models at startup # This prevents the "two TaggableManagers with same through model" error - custom_object_types = CustomObjectType.objects.all() - for custom_type in custom_object_types: - try: + try: + 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 - # Don't create new models during get_models() - they'll be created on demand - except Exception: - # Skip models that can't be loaded - continue + except (django_db_utils.ProgrammingError, django_db_utils.OperationalError): + # Handle database errors that occur when the table doesn't exist yet during migrations + pass config = CustomObjectsPluginConfig From ae80f3b75ea9e67ac281ca25a4028e6d038fd0d5 Mon Sep 17 00:00:00 2001 From: Arthur Date: Wed, 30 Jul 2025 09:37:37 -0700 Subject: [PATCH 15/21] missing migration --- .../0005_alter_customobjecttype_description.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 netbox_custom_objects/migrations/0005_alter_customobjecttype_description.py diff --git a/netbox_custom_objects/migrations/0005_alter_customobjecttype_description.py b/netbox_custom_objects/migrations/0005_alter_customobjecttype_description.py new file mode 100644 index 0000000..a8ce3cb --- /dev/null +++ b/netbox_custom_objects/migrations/0005_alter_customobjecttype_description.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.2 on 2025-07-30 16:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("netbox_custom_objects", "0004_customobjecttype_comments"), + ] + + operations = [ + migrations.AlterField( + model_name="customobjecttype", + name="description", + field=models.CharField(blank=True, max_length=200), + ), + ] From 9ed04f4515e8c02a3152de39cb95b0f602736bbc Mon Sep 17 00:00:00 2001 From: Arthur Date: Wed, 30 Jul 2025 14:26:41 -0700 Subject: [PATCH 16/21] fix migration running --- netbox_custom_objects/__init__.py | 41 ++++++++++++++++++------------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/netbox_custom_objects/__init__.py b/netbox_custom_objects/__init__.py index f4e09d3..2c5eb1f 100644 --- a/netbox_custom_objects/__init__.py +++ b/netbox_custom_objects/__init__.py @@ -1,10 +1,21 @@ import warnings +import sys from django.core.exceptions import AppRegistryNotReady -from django.db import utils as django_db_utils from netbox.plugins import PluginConfig +def is_running_migration(): + """ + Check if the code is currently running during a Django migration. + """ + # Check if 'makemigrations' or 'migrate' command is in sys.argv + if any(cmd in sys.argv for cmd in ['makemigrations', 'migrate']): + return True + + return False + + # Plugin Configuration class CustomObjectsPluginConfig(PluginConfig): name = "netbox_custom_objects" @@ -46,11 +57,7 @@ def get_model(self, model_name, require_ready=True): raise LookupError( "App '%s' doesn't have a '%s' model." % (self.label, model_name) ) - except (django_db_utils.ProgrammingError, django_db_utils.OperationalError): - # Handle database errors that occur when the table doesn't exist yet during migrations - raise LookupError( - "App '%s' doesn't have a '%s' model." % (self.label, model_name) - ) + return obj.get_model() def get_models(self, include_auto_created=False, include_swapped=False): @@ -59,6 +66,10 @@ def get_models(self, include_auto_created=False, include_swapped=False): for model in super().get_models(include_auto_created, include_swapped): yield model + # Skip custom object type model loading if running during migration + if is_running_migration(): + return + # Suppress warnings about database calls during model loading with warnings.catch_warnings(): warnings.filterwarnings( @@ -73,17 +84,13 @@ def get_models(self, include_auto_created=False, include_swapped=False): # Only load models that are already cached to avoid creating all models at startup # This prevents the "two TaggableManagers with same through model" error - try: - 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 - except (django_db_utils.ProgrammingError, django_db_utils.OperationalError): - # Handle database errors that occur when the table doesn't exist yet during migrations - pass + 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 config = CustomObjectsPluginConfig From 2a95b41a558eeb31c4e26b2ca18f4745cc6311de Mon Sep 17 00:00:00 2001 From: Arthur Date: Wed, 30 Jul 2025 16:07:26 -0700 Subject: [PATCH 17/21] wrap tags function --- netbox_custom_objects/models.py | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/netbox_custom_objects/models.py b/netbox_custom_objects/models.py index fb92f9f..220ec98 100644 --- a/netbox_custom_objects/models.py +++ b/netbox_custom_objects/models.py @@ -417,22 +417,18 @@ def get_model( ) # Create the model class with a workaround for TaggableManager conflicts - # Temporarily disable the through model validation during model creation + # Wrap the existing post_through_setup method to handle ValueError exceptions from taggit.managers import TaggableManager as TM original_post_through_setup = TM.post_through_setup - def patched_post_through_setup(self, model): - # Skip the duplicate through model check for our dynamic models - if hasattr(model, "custom_object_type_id"): - # Just set up the manager without checking for duplicates - if not hasattr(self.through._meta, "auto_created"): - setattr(self.through, "_custom_object_managed", True) - return - # Call original method for other models - return original_post_through_setup(self, model) - - TM.post_through_setup = patched_post_through_setup + def wrapped_post_through_setup(self, cls): + try: + return original_post_through_setup(self, cls) + except ValueError: + pass + + TM.post_through_setup = wrapped_post_through_setup try: model = type( From 4e5f8226c057748c5847d654bc44dfc835eb0b5c Mon Sep 17 00:00:00 2001 From: Arthur Date: Wed, 30 Jul 2025 16:11:03 -0700 Subject: [PATCH 18/21] wrap tags function --- netbox_custom_objects/models.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/netbox_custom_objects/models.py b/netbox_custom_objects/models.py index 220ec98..57b2d3a 100644 --- a/netbox_custom_objects/models.py +++ b/netbox_custom_objects/models.py @@ -473,9 +473,6 @@ def create_model(self): model = self.get_model() is_public = model_is_public(model) features = get_model_features(model) - # we need to append tags here as it is added dynamically to the model - # and not through a mixin - features.append("tags") ct.is_public = is_public ct.features = features ct.save() From d025984b21c2c9f7186a0d8a0f0bb9d7a9f74941 Mon Sep 17 00:00:00 2001 From: Arthur Date: Wed, 30 Jul 2025 16:16:58 -0700 Subject: [PATCH 19/21] fix tags --- netbox_custom_objects/models.py | 32 ++++---------------------------- 1 file changed, 4 insertions(+), 28 deletions(-) diff --git a/netbox_custom_objects/models.py b/netbox_custom_objects/models.py index 57b2d3a..ed2d585 100644 --- a/netbox_custom_objects/models.py +++ b/netbox_custom_objects/models.py @@ -410,36 +410,12 @@ def get_model( attrs.update(**field_attrs) - # Use the standard NetBox tagging system - attrs["tags"] = TaggableManager( - through="extras.TaggedItem", - ordering=("weight", "name"), + model = type( + str(model_name), + (CustomObject, models.Model), + attrs, ) - # Create the model class with a workaround for TaggableManager conflicts - # Wrap the existing post_through_setup method to handle ValueError exceptions - from taggit.managers import TaggableManager as TM - - original_post_through_setup = TM.post_through_setup - - def wrapped_post_through_setup(self, cls): - try: - return original_post_through_setup(self, cls) - except ValueError: - pass - - TM.post_through_setup = wrapped_post_through_setup - - try: - model = type( - str(model_name), - (CustomObject, models.Model), - attrs, - ) - finally: - # Restore the original method - TM.post_through_setup = original_post_through_setup - # Register the main model with Django's app registry try: existing_model = apps.get_model(APP_LABEL, model_name) From 3f9fa13963012855360d7f0fbb4f27d607ae31a7 Mon Sep 17 00:00:00 2001 From: Arthur Date: Wed, 30 Jul 2025 16:21:21 -0700 Subject: [PATCH 20/21] fix tags --- netbox_custom_objects/models.py | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/netbox_custom_objects/models.py b/netbox_custom_objects/models.py index ed2d585..dbc1b63 100644 --- a/netbox_custom_objects/models.py +++ b/netbox_custom_objects/models.py @@ -36,6 +36,7 @@ NotificationsMixin, get_model_features, model_is_public, + TagsMixin, ) from netbox.registry import registry from taggit.managers import TaggableManager @@ -62,6 +63,7 @@ class CustomObject( JournalingMixin, NotificationsMixin, EventRulesMixin, + TagsMixin, ): """ Base class for dynamically generated custom object models. @@ -410,11 +412,29 @@ def get_model( attrs.update(**field_attrs) - model = type( - str(model_name), - (CustomObject, models.Model), - attrs, - ) + # Create the model class with a workaround for TaggableManager conflicts + # Wrap the existing post_through_setup method to handle ValueError exceptions + from taggit.managers import TaggableManager as TM + + original_post_through_setup = TM.post_through_setup + + def wrapped_post_through_setup(self, cls): + try: + return original_post_through_setup(self, cls) + except ValueError: + pass + + TM.post_through_setup = wrapped_post_through_setup + + try: + model = type( + str(model_name), + (CustomObject, models.Model), + attrs, + ) + finally: + # Restore the original method + TM.post_through_setup = original_post_through_setup # Register the main model with Django's app registry try: From 821ad0281c26db44085dba925358dd51a13fae2e Mon Sep 17 00:00:00 2001 From: Arthur Date: Wed, 30 Jul 2025 16:24:41 -0700 Subject: [PATCH 21/21] fix tags --- netbox_custom_objects/models.py | 1 - 1 file changed, 1 deletion(-) diff --git a/netbox_custom_objects/models.py b/netbox_custom_objects/models.py index dbc1b63..858d793 100644 --- a/netbox_custom_objects/models.py +++ b/netbox_custom_objects/models.py @@ -39,7 +39,6 @@ TagsMixin, ) from netbox.registry import registry -from taggit.managers import TaggableManager from utilities import filters from utilities.datetime import datetime_from_timestamp from utilities.object_types import object_type_name