diff --git a/netbox_custom_objects/__init__.py b/netbox_custom_objects/__init__.py index 288a96b..2c5eb1f 100644 --- a/netbox_custom_objects/__init__.py +++ b/netbox_custom_objects/__init__.py @@ -1,6 +1,21 @@ +import warnings +import sys + +from django.core.exceptions import AppRegistryNotReady 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" @@ -13,5 +28,69 @@ 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 + 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( + "ignore", category=RuntimeWarning, message=".*database.*" + ) + warnings.filterwarnings( + "ignore", category=UserWarning, message=".*database.*" + ) + + # 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: + # 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 diff --git a/netbox_custom_objects/api/serializers.py b/netbox_custom_objects/api/serializers.py index 1dda6ce..56d10d3 100644 --- a/netbox_custom_objects/api/serializers.py +++ b/netbox_custom_objects/api/serializers.py @@ -1,3 +1,6 @@ +import sys + +from core.models import ObjectType from django.contrib.contenttypes.models import ContentType from extras.choices import CustomFieldTypeChoices from netbox.api.serializers import NetBoxModelSerializer @@ -6,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", @@ -59,10 +63,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 +201,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 +232,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: @@ -233,12 +251,19 @@ def get_url(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( - 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 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), + ), + ] diff --git a/netbox_custom_objects/models.py b/netbox_custom_objects/models.py index 540b069..858d793 100644 --- a/netbox_custom_objects/models.py +++ b/netbox_custom_objects/models.py @@ -1,9 +1,9 @@ import decimal import re -import uuid 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 @@ -23,7 +23,6 @@ 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, @@ -35,10 +34,11 @@ ExportTemplatesMixin, JournalingMixin, NotificationsMixin, + get_model_features, + model_is_public, + TagsMixin, ) 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 @@ -62,6 +62,7 @@ class CustomObject( JournalingMixin, NotificationsMixin, EventRulesMixin, + TagsMixin, ): """ Base class for dynamically generated custom object models. @@ -114,7 +115,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) @@ -137,7 +138,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) @@ -166,15 +169,15 @@ 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() + # 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): """ @@ -257,17 +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( - app_label=APP_LABEL, model=content_type_name - ) + # 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 ct.refresh_from_db() return ct @@ -366,10 +367,15 @@ 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" + app_label = APP_LABEL model_name = self.get_table_model_name(self.pk) @@ -405,55 +411,52 @@ def get_model( attrs.update(**field_attrs) - # Create a unique through model for tagging for this CustomObjectType + # 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 - through_model_name = f"CustomObjectTaggedItem{self.id}" + original_post_through_setup = TM.post_through_setup - # 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}", - }, - ), - }, - ) + def wrapped_post_through_setup(self, cls): + try: + return original_post_through_setup(self, cls) + except ValueError: + pass - attrs["tags"] = TaggableManager( - through=through_model, - ordering=("weight", "name"), - ) + TM.post_through_setup = wrapped_post_through_setup - # Create the model class. - model = type( - 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 + + # Register the main model with Django's app registry + try: + 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) 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: + from netbox_custom_objects.api.serializers import get_serializer_class + + get_serializer_class(model) + return model def create_model(self): @@ -461,18 +464,17 @@ 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) + ct.is_public = is_public + ct.features = features + ct.save() 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) @@ -487,16 +489,11 @@ 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) 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) @@ -1152,8 +1149,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 @@ -1182,12 +1181,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="+", ), }, ) @@ -1209,12 +1212,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="+", ), }, ) 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 @@