From 820bb32156495d6a8804ffc3efb30b9d1c2accd7 Mon Sep 17 00:00:00 2001 From: Arthur Date: Wed, 9 Jul 2025 10:55:05 -0700 Subject: [PATCH 01/24] NPL-374 Add Bookmark mixin --- netbox_custom_objects/__init__.py | 103 ++++++++++++++++++++++-------- netbox_custom_objects/models.py | 78 +++++++++++++++++----- netbox_custom_objects/signals.py | 53 +++++++++++++++ 3 files changed, 192 insertions(+), 42 deletions(-) create mode 100644 netbox_custom_objects/signals.py diff --git a/netbox_custom_objects/__init__.py b/netbox_custom_objects/__init__.py index 5515651..1009173 100644 --- a/netbox_custom_objects/__init__.py +++ b/netbox_custom_objects/__init__.py @@ -1,42 +1,93 @@ from netbox.plugins import PluginConfig - +from django.apps import apps # Plugin Configuration class CustomObjectsPluginConfig(PluginConfig): name = "netbox_custom_objects" verbose_name = "Custom Objects" description = "A plugin to manage custom objects in NetBox" - version = "0.1.0" + version = "0.1" base_url = "custom-objects" min_version = "4.2.0" + # max_version = "3.5.0" default_settings = {} required_settings = [] template_extensions = "template_content.template_extensions" - # def get_model(self, model_name, require_ready=True): - # if require_ready: - # self.apps.check_models_ready() - # else: - # self.apps.check_apps_ready() - # - # if model_name.lower() in self.models: - # return self.models[model_name.lower()] - # - # from .models import CustomObjectType - # 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) - # ) - # - # 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_model(self, model_name, require_ready=True): + if require_ready: + self.apps.check_models_ready() + else: + self.apps.check_apps_ready() + + if model_name.lower() in self.models: + return self.models[model_name.lower()] + + from .models import CustomObjectType + 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) + ) + + 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 ready(self): + import netbox_custom_objects.signals + + # Import Django models only after apps are ready + # This prevents "AppRegistryNotReady" errors during module import + from django.contrib.contenttypes.models import ContentType + from django.contrib.contenttypes.management import create_contenttypes + + # Ensure all dynamic models are created and registered during startup + # This prevents ContentType race conditions with Bookmark operations + try: + from .models import CustomObjectType + from .constants import APP_LABEL + + # Only run this after the database is ready + if apps.is_installed('django.contrib.contenttypes'): + for custom_object_type in CustomObjectType.objects.all(): + try: + # Get or create the model + model = custom_object_type.get_model() + + # Ensure the model is registered + try: + apps.get_model(APP_LABEL, model._meta.model_name) + except LookupError: + apps.register_model(APP_LABEL, model) + + # Ensure ContentType exists + content_type_name = custom_object_type.get_table_model_name(custom_object_type.id).lower() + try: + ContentType.objects.get( + app_label=APP_LABEL, + model=content_type_name + ) + except ContentType.DoesNotExist: + # Create the ContentType + app_config = apps.get_app_config(APP_LABEL) + create_contenttypes(app_config) + + except Exception as e: + # Log but don't fail startup + print(f"Warning: Could not initialize model for CustomObjectType {custom_object_type.id}: {e}") + except Exception as e: + # Don't fail plugin startup if there are issues + print(f"Warning: Could not initialize custom object models: {e}") + + super().ready() config = CustomObjectsPluginConfig diff --git a/netbox_custom_objects/models.py b/netbox_custom_objects/models.py index c5336bf..8a3fd1e 100644 --- a/netbox_custom_objects/models.py +++ b/netbox_custom_objects/models.py @@ -20,10 +20,10 @@ ) from extras.models.customfields import SEARCH_TYPES from netbox.models import ChangeLoggedModel, NetBoxModel -# from netbox.models.features import ( -# BookmarksMixin, ChangeLoggingMixin, CloningMixin, CustomLinksMixin, CustomValidationMixin, EventRulesMixin, -# ExportTemplatesMixin, JournalingMixin, NotificationsMixin, TagsMixin, -# ) +from netbox.models.features import ( + BookmarksMixin, ChangeLoggingMixin, CloningMixin, CustomLinksMixin, CustomValidationMixin, EventRulesMixin, + ExportTemplatesMixin, JournalingMixin, NotificationsMixin, TagsMixin, +) from netbox.models.features import CloningMixin, ExportTemplatesMixin, TagsMixin from netbox.registry import registry from utilities import filters @@ -41,7 +41,7 @@ class CustomObject( - # BookmarksMixin, + BookmarksMixin, # ChangeLoggingMixin, # CloningMixin, # CustomLinksMixin, @@ -125,9 +125,34 @@ def get_table_model_name(cls, table_id): @property def content_type(self): - return ContentType.objects.get( - app_label=APP_LABEL, model=self.get_table_model_name(self.id).lower() - ) + try: + return ContentType.objects.get( + app_label=APP_LABEL, model=self.get_table_model_name(self.id).lower() + ) + except Exception: + # If ContentType doesn't exist, try to create it + try: + self.create_model() + return ContentType.objects.get( + app_label=APP_LABEL, model=self.get_table_model_name(self.id).lower() + ) + except Exception: + # If we still can't get it, return None + return None + + def ensure_content_type_exists(self): + """ + Ensure that the ContentType for this CustomObjectType exists. + This is useful for preventing race conditions with Bookmark operations. + """ + try: + content_type_name = self.get_table_model_name(self.id).lower() + ContentType.objects.get( + app_label=APP_LABEL, model=content_type_name + ) + except Exception: + # Create the model and ContentType + self.create_model() def _fetch_and_generate_field_attrs( self, @@ -235,12 +260,7 @@ def get_model( fields = [] # TODO: Add other fields with "index" specified - indexes = [ - models.Index( - fields=["id"], - name=self.get_collision_safe_order_id_idx_name(), - ) - ] + indexes = [] apps = AppsProxy(manytomany_models, app_label) meta = type( @@ -316,8 +336,34 @@ def get_absolute_url(self): def create_model(self): model = self.get_model() - apps.register_model(APP_LABEL, model) - app_config = apps.get_app_config(APP_LABEL) + + # Ensure the model is registered with Django's app registry + try: + apps.get_model(APP_LABEL, model._meta.model_name) + except LookupError: + apps.register_model(APP_LABEL, model) + + # Ensure the app is registered + try: + app_config = apps.get_app_config(APP_LABEL) + except LookupError: + # If app config doesn't exist, we'll create ContentTypes manually + # This is a fallback for when the app isn't properly registered + from django.contrib.contenttypes.models import ContentType + content_type_name = self.get_table_model_name(self.id).lower() + try: + ContentType.objects.get( + app_label=APP_LABEL, model=content_type_name + ) + except Exception: + # Create the ContentType manually + ContentType.objects.create( + app_label=APP_LABEL, + model=content_type_name + ) + return + + # Create ContentType for this model create_contenttypes(app_config) with connection.schema_editor() as schema_editor: diff --git a/netbox_custom_objects/signals.py b/netbox_custom_objects/signals.py new file mode 100644 index 0000000..13e4464 --- /dev/null +++ b/netbox_custom_objects/signals.py @@ -0,0 +1,53 @@ +from django.db.models.signals import post_save, post_delete +from django.dispatch import receiver +from django.contrib.contenttypes.models import ContentType +from django.contrib.contenttypes.management import create_contenttypes +from django.apps import apps +from django.core.exceptions import ObjectDoesNotExist + +from .models import CustomObjectType +from .constants import APP_LABEL + + +@receiver(post_save, sender=CustomObjectType) +def ensure_content_type_exists(sender, instance, created, **kwargs): + """ + Ensure ContentType exists for the custom object type after it's saved. + This signal runs after the database transaction is committed. + """ + if created: + try: + # Get the model name for this custom object type + content_type_name = instance.get_table_model_name(instance.id).lower() + + # Check if ContentType already exists + try: + ContentType.objects.get( + app_label=APP_LABEL, + model=content_type_name + ) + except ObjectDoesNotExist: + # Create the ContentType + ContentType.objects.create( + app_label=APP_LABEL, + model=content_type_name + ) + except Exception as e: + # Log the error but don't fail the save operation + print(f"Warning: Could not create ContentType for CustomObjectType {instance.id}: {e}") + + +@receiver(post_delete, sender=CustomObjectType) +def cleanup_content_type(sender, instance, **kwargs): + """ + Clean up the ContentType when a CustomObjectType is deleted. + """ + try: + content_type_name = instance.get_table_model_name(instance.id).lower() + ContentType.objects.filter( + app_label=APP_LABEL, + model=content_type_name + ).delete() + except Exception as e: + # Log the error but don't fail the delete operation + print(f"Warning: Could not delete ContentType for CustomObjectType {instance.id}: {e}") \ No newline at end of file From 3e48069e948882259516669f7a5caf29b5d93d5e Mon Sep 17 00:00:00 2001 From: Arthur Date: Wed, 9 Jul 2025 11:07:55 -0700 Subject: [PATCH 02/24] NPL-374 Add Bookmark mixin for add inline --- netbox_custom_objects/field_types.py | 48 ++++++++++++++++++++-------- 1 file changed, 35 insertions(+), 13 deletions(-) diff --git a/netbox_custom_objects/field_types.py b/netbox_custom_objects/field_types.py index d69ce6f..996331c 100644 --- a/netbox_custom_objects/field_types.py +++ b/netbox_custom_objects/field_types.py @@ -611,16 +611,16 @@ def get_through_model(self, field, model=None): and field.custom_object_type.id == custom_object_type_id ) + # Use the actual model if provided, otherwise use string reference + source_model = model if model else "netbox_custom_objects.CustomObject" + target_model = model if is_self_referential else "netbox_custom_objects.CustomObject" + attrs = { "__module__": "netbox_custom_objects.models", "Meta": meta, "id": models.AutoField(primary_key=True), "source": models.ForeignKey( - ( - "self" - if is_self_referential - else (model or "netbox_custom_objects.CustomObject") - ), + source_model, on_delete=models.CASCADE, related_name="+", db_column="source_id", @@ -652,6 +652,8 @@ def get_model_field(self, field, **kwargs): and field.custom_object_type.id == custom_object_type_id ) + # For now, we'll create the through model with string references + # and resolve them later in after_model_generation through = self.get_through_model(field) # For self-referential fields, use 'self' as the target @@ -694,8 +696,14 @@ def after_model_generation(self, instance, model, field_name): if getattr(field, "_is_self_referential", False): field.remote_field.model = model through_model = field.remote_field.through - through_model._meta.get_field("target").remote_field.model = model - through_model._meta.get_field("target").related_model = model + + # Update both source and target fields to point to the same model + source_field = through_model._meta.get_field("source") + target_field = through_model._meta.get_field("target") + source_field.remote_field.model = model + source_field.related_model = model + target_field.remote_field.model = model + target_field.related_model = model return content_type = ContentType.objects.get(pk=instance.related_object_type_id) @@ -713,12 +721,19 @@ def after_model_generation(self, instance, model, field_name): to_ct = f"{content_type.app_label}.{content_type.model}" to_model = apps.get_model(to_ct) - # Update the M2M field's model references + # Update through model's fields field.remote_field.model = to_model # Update through model's target field through_model = field.remote_field.through + source_field = through_model._meta.get_field("source") target_field = through_model._meta.get_field("target") + + # Source field should point to the current model + source_field.remote_field.model = model + source_field.related_model = model + + # Target field should point to the related model target_field.remote_field.model = to_model target_field.related_model = to_model @@ -751,15 +766,22 @@ def create_m2m_table(self, instance, model, field_name): # Create the through model with actual model references through = self.get_through_model(instance, model) - through._meta.get_field("target").remote_field.model = to_model - through._meta.get_field("target").related_model = to_model + + # Update the through model's foreign key references + source_field = through._meta.get_field("source") + target_field = through._meta.get_field("target") + + # Source field should point to the current model + source_field.remote_field.model = model + source_field.related_model = model + + # Target field should point to the related model + target_field.remote_field.model = to_model + target_field.related_model = to_model # Register the model with Django's app registry apps = model._meta.apps - # if app_label is None: - # app_label = str(uuid.uuid4()) + "_database_table" - # apps = AppsProxy(dynamic_models=None, app_label=app_label) try: through_model = apps.get_model(APP_LABEL, instance.through_model_name) except LookupError: From b047f15d31c7a2141554af34cddd77ba235c677b Mon Sep 17 00:00:00 2001 From: Arthur Date: Wed, 9 Jul 2025 13:22:04 -0700 Subject: [PATCH 03/24] NPL-374 Add Bookmark mixin --- netbox_custom_objects/__init__.py | 14 +------- netbox_custom_objects/models.py | 58 ++++++++++++------------------- netbox_custom_objects/signals.py | 3 +- 3 files changed, 25 insertions(+), 50 deletions(-) diff --git a/netbox_custom_objects/__init__.py b/netbox_custom_objects/__init__.py index 1009173..0cd82e4 100644 --- a/netbox_custom_objects/__init__.py +++ b/netbox_custom_objects/__init__.py @@ -67,19 +67,7 @@ def ready(self): apps.get_model(APP_LABEL, model._meta.model_name) except LookupError: apps.register_model(APP_LABEL, model) - - # Ensure ContentType exists - content_type_name = custom_object_type.get_table_model_name(custom_object_type.id).lower() - try: - ContentType.objects.get( - app_label=APP_LABEL, - model=content_type_name - ) - except ContentType.DoesNotExist: - # Create the ContentType - app_config = apps.get_app_config(APP_LABEL) - create_contenttypes(app_config) - + except Exception as e: # Log but don't fail startup print(f"Warning: Could not initialize model for CustomObjectType {custom_object_type.id}: {e}") diff --git a/netbox_custom_objects/models.py b/netbox_custom_objects/models.py index 8a3fd1e..9157a03 100644 --- a/netbox_custom_objects/models.py +++ b/netbox_custom_objects/models.py @@ -35,7 +35,6 @@ from netbox_custom_objects.constants import APP_LABEL from netbox_custom_objects.field_types import FIELD_TYPE_CLASS -from netbox_custom_objects.utilities import AppsProxy USER_TABLE_DATABASE_NAME_PREFIX = "custom_objects_" @@ -43,12 +42,12 @@ class CustomObject( BookmarksMixin, # ChangeLoggingMixin, - # CloningMixin, - # CustomLinksMixin, - # CustomValidationMixin, - # ExportTemplatesMixin, - # JournalingMixin, - # NotificationsMixin, + CloningMixin, + CustomLinksMixin, + CustomValidationMixin, + ExportTemplatesMixin, + JournalingMixin, + NotificationsMixin, TagsMixin, # EventRulesMixin, models.Model, @@ -262,7 +261,6 @@ def get_model( # TODO: Add other fields with "index" specified indexes = [] - apps = AppsProxy(manytomany_models, app_label) meta = type( "Meta", (), @@ -305,18 +303,14 @@ def get_absolute_url(self): "_generated_table_model": True, "custom_object_type": self, "custom_object_type_id": self.id, - "dynamic_models": apps.dynamic_models, # We are using our own table model manager to implement some queryset # helpers. - # "objects": models.Manager(), "objects": RestrictedQuerySet.as_manager(), - # "objects_and_trash": TableModelTrashAndObjectsManager(), "__str__": __str__, "get_absolute_url": get_absolute_url, } field_attrs = self._fetch_and_generate_field_attrs(fields) - # field_attrs["name"] = models.CharField(max_length=100, unique=True) attrs.update(**field_attrs) @@ -339,38 +333,31 @@ def create_model(self): # Ensure the model is registered with Django's app registry try: - apps.get_model(APP_LABEL, model._meta.model_name) + model = apps.get_model(APP_LABEL, model._meta.model_name) + print("--------------------------------") + print(f"model: {model._meta.model_name}") + print(f"model: {model}") except LookupError: apps.register_model(APP_LABEL, model) + print("--------------------------------") + print(f"register model: {model}") - # Ensure the app is registered + # Create the content type for the model + content_type_name = self.get_table_model_name(self.id).lower() try: - app_config = apps.get_app_config(APP_LABEL) - except LookupError: - # If app config doesn't exist, we'll create ContentTypes manually - # This is a fallback for when the app isn't properly registered - from django.contrib.contenttypes.models import ContentType - content_type_name = self.get_table_model_name(self.id).lower() - try: - ContentType.objects.get( - app_label=APP_LABEL, model=content_type_name - ) - except Exception: - # Create the ContentType manually - ContentType.objects.create( - app_label=APP_LABEL, - model=content_type_name - ) - return + ct = ContentType.objects.get( + app_label=APP_LABEL, model=content_type_name + ) + except Exception: + ContentType.objects.create( + app_label=APP_LABEL, + model=content_type_name + ) - # Create ContentType for this model - create_contenttypes(app_config) - with connection.schema_editor() as schema_editor: schema_editor.create_model(model) def save(self, *args, **kwargs): - # needs_db_create = self.pk is None needs_db_create = self._state.adding super().save(*args, **kwargs) if needs_db_create: @@ -378,7 +365,6 @@ def save(self, *args, **kwargs): def delete(self, *args, **kwargs): model = self.get_model() - # self.content_type.delete() ContentType.objects.get( app_label=APP_LABEL, model=self.get_table_model_name(self.id).lower() ).delete() diff --git a/netbox_custom_objects/signals.py b/netbox_custom_objects/signals.py index 13e4464..4f5ddc5 100644 --- a/netbox_custom_objects/signals.py +++ b/netbox_custom_objects/signals.py @@ -50,4 +50,5 @@ def cleanup_content_type(sender, instance, **kwargs): ).delete() except Exception as e: # Log the error but don't fail the delete operation - print(f"Warning: Could not delete ContentType for CustomObjectType {instance.id}: {e}") \ No newline at end of file + print(f"Warning: Could not delete ContentType for CustomObjectType {instance.id}: {e}") + From 40d0a1e31ef7954b222e5294683c5b2a5fe1d5c9 Mon Sep 17 00:00:00 2001 From: Arthur Date: Mon, 14 Jul 2025 09:17:43 -0700 Subject: [PATCH 04/24] NPL-374 Add Bookmark mixin --- netbox_custom_objects/__init__.py | 4 +- netbox_custom_objects/models.py | 109 ++++++++------ .../templatetags/custom_object_buttons.py | 134 +++++++++++------- 3 files changed, 158 insertions(+), 89 deletions(-) diff --git a/netbox_custom_objects/__init__.py b/netbox_custom_objects/__init__.py index 0cd82e4..afd1e35 100644 --- a/netbox_custom_objects/__init__.py +++ b/netbox_custom_objects/__init__.py @@ -40,7 +40,8 @@ def get_model(self, model_name, require_ready=True): ) return obj.get_model() ''' - + + ''' def ready(self): import netbox_custom_objects.signals @@ -76,6 +77,7 @@ def ready(self): print(f"Warning: Could not initialize custom object models: {e}") super().ready() + ''' config = CustomObjectsPluginConfig diff --git a/netbox_custom_objects/models.py b/netbox_custom_objects/models.py index 9157a03..4d5e466 100644 --- a/netbox_custom_objects/models.py +++ b/netbox_custom_objects/models.py @@ -125,19 +125,10 @@ def get_table_model_name(cls, table_id): @property def content_type(self): try: - return ContentType.objects.get( - app_label=APP_LABEL, model=self.get_table_model_name(self.id).lower() - ) + return self.get_or_create_content_type() except Exception: - # If ContentType doesn't exist, try to create it - try: - self.create_model() - return ContentType.objects.get( - app_label=APP_LABEL, model=self.get_table_model_name(self.id).lower() - ) - except Exception: - # If we still can't get it, return None - return None + # If we still can't get it, return None + return None def ensure_content_type_exists(self): """ @@ -153,6 +144,46 @@ def ensure_content_type_exists(self): # Create the model and ContentType self.create_model() + 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. + """ + content_type_name = self.get_table_model_name(self.id).lower() + try: + return ContentType.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 + ) + # Force a refresh to ensure it's available in the current transaction + ct.refresh_from_db() + return ct + + def ensure_model_registered(self): + """ + Ensure that the model is properly registered with Django's app registry. + This is useful for ensuring the model is accessible after creation. + """ + try: + model = self.get_model() + model_name = model._meta.model_name + + # Try to get the model from the registry + try: + apps.get_model(APP_LABEL, model_name) + except LookupError: + # Model not registered, register it + apps.register_model(APP_LABEL, model) + print(f"Registered model: {model_name}") + + except Exception as e: + print(f"Warning: Could not ensure model registration: {e}") + def _fetch_and_generate_field_attrs( self, fields, @@ -231,6 +262,7 @@ def get_model( fields=None, manytomany_models=None, app_label=None, + ensure_registered=True, ): """ Generates a temporary Django model based on available fields that belong to @@ -326,33 +358,28 @@ def get_absolute_url(self): if not manytomany_models: self._after_model_generation(attrs, model) + # Ensure the model is registered if requested + if ensure_registered: + try: + apps.get_model(APP_LABEL, model._meta.model_name) + except LookupError: + apps.register_model(APP_LABEL, model) + return model + def get_registered_model(self): + """ + Get the model and ensure it's registered with Django's app registry. + This is a convenience method for getting a model that's guaranteed to be registered. + """ + return self.get_model(ensure_registered=True) + def create_model(self): - model = self.get_model() + # Get the model and ensure it's registered + model = self.get_registered_model() - # Ensure the model is registered with Django's app registry - try: - model = apps.get_model(APP_LABEL, model._meta.model_name) - print("--------------------------------") - print(f"model: {model._meta.model_name}") - print(f"model: {model}") - except LookupError: - apps.register_model(APP_LABEL, model) - print("--------------------------------") - print(f"register model: {model}") - - # Create the content type for the model - content_type_name = self.get_table_model_name(self.id).lower() - try: - ct = ContentType.objects.get( - app_label=APP_LABEL, model=content_type_name - ) - except Exception: - ContentType.objects.create( - app_label=APP_LABEL, - model=content_type_name - ) + # Ensure the ContentType exists and is immediately available + self.get_or_create_content_type() with connection.schema_editor() as schema_editor: schema_editor.create_model(model) @@ -362,9 +389,11 @@ def save(self, *args, **kwargs): super().save(*args, **kwargs) if needs_db_create: self.create_model() + # Ensure the ContentType is immediately available after creation + self.get_or_create_content_type() def delete(self, *args, **kwargs): - model = self.get_model() + model = self.get_registered_model() ContentType.objects.get( app_label=APP_LABEL, model=self.get_table_model_name(self.id).lower() ).delete() @@ -1028,9 +1057,9 @@ def through_model_name(self): def save(self, *args, **kwargs): field_type = FIELD_TYPE_CLASS[self.type]() model_field = field_type.get_model_field(self) - model = self.custom_object_type.get_model() + model = self.custom_object_type.get_registered_model() model_field.contribute_to_class(model, self.name) - # apps.register_model(APP_LABEL, model) + with connection.schema_editor() as schema_editor: if self._state.adding: schema_editor.add_field(model, model_field) @@ -1045,9 +1074,9 @@ def save(self, *args, **kwargs): def delete(self, *args, **kwargs): field_type = FIELD_TYPE_CLASS[self.type]() model_field = field_type.get_model_field(self) - model = self.custom_object_type.get_model() + model = self.custom_object_type.get_registered_model() model_field.contribute_to_class(model, self.name) - # apps.register_model(APP_LABEL, model) + with connection.schema_editor() as schema_editor: if self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT: apps = model._meta.apps diff --git a/netbox_custom_objects/templatetags/custom_object_buttons.py b/netbox_custom_objects/templatetags/custom_object_buttons.py index 624ff1f..82691cf 100644 --- a/netbox_custom_objects/templatetags/custom_object_buttons.py +++ b/netbox_custom_objects/templatetags/custom_object_buttons.py @@ -32,31 +32,50 @@ @register.inclusion_tag("buttons/bookmark.html", takes_context=True) def custom_object_bookmark_button(context, instance): - # Check if this user has already bookmarked the object - content_type = ContentType.objects.get_for_model(instance) - bookmark = Bookmark.objects.filter( - object_type=content_type, object_id=instance.pk, user=context["request"].user - ).first() - - # Compile form URL & data - if bookmark: - form_url = reverse("extras:bookmark_delete", kwargs={"pk": bookmark.pk}) - form_data = { - "confirm": "true", - } - else: - form_url = reverse("extras:bookmark_add") - form_data = { - "object_type": content_type.pk, - "object_id": instance.pk, + try: + # For custom objects, ensure the model is ready for bookmarks + if hasattr(instance, 'custom_object_type'): + if not instance.custom_object_type.ensure_model_ready_for_bookmarks(): + # If the model isn't ready, don't show the bookmark button + return {} + + # Check if this user has already bookmarked the object + content_type = ContentType.objects.get_for_model(instance) + + # Verify that the ContentType is properly accessible + try: + # This will test if the ContentType can be used to retrieve the model + content_type.model_class() + except Exception: + # If we can't get the model class, don't show the bookmark button + return {} + + bookmark = Bookmark.objects.filter( + object_type=content_type, object_id=instance.pk, user=context["request"].user + ).first() + + # Compile form URL & data + if bookmark: + form_url = reverse("extras:bookmark_delete", kwargs={"pk": bookmark.pk}) + form_data = { + "confirm": "true", + } + else: + form_url = reverse("extras:bookmark_add") + form_data = { + "object_type": content_type.pk, + "object_id": instance.pk, + } + + return { + "bookmark": bookmark, + "form_url": form_url, + "form_data": form_data, + "return_url": instance.get_absolute_url(), } - - return { - "bookmark": bookmark, - "form_url": form_url, - "form_data": form_data, - "return_url": instance.get_absolute_url(), - } + except Exception: + # If we can't get the content type, don't show the bookmark button + return {} @register.inclusion_tag("buttons/clone.html") @@ -112,31 +131,50 @@ def custom_object_subscribe_button(context, instance): if not (issubclass(instance.__class__, NotificationsMixin)): return {} - # Check if this user has already subscribed to the object - content_type = ContentType.objects.get_for_model(instance) - subscription = Subscription.objects.filter( - object_type=content_type, object_id=instance.pk, user=context["request"].user - ).first() - - # Compile form URL & data - if subscription: - form_url = reverse("extras:subscription_delete", kwargs={"pk": subscription.pk}) - form_data = { - "confirm": "true", - } - else: - form_url = reverse("extras:subscription_add") - form_data = { - "object_type": content_type.pk, - "object_id": instance.pk, + try: + # For custom objects, ensure the model is ready for subscriptions + if hasattr(instance, 'custom_object_type'): + if not instance.custom_object_type.ensure_model_ready_for_bookmarks(): + # If the model isn't ready, don't show the subscribe button + return {} + + # Check if this user has already subscribed to the object + content_type = ContentType.objects.get_for_model(instance) + + # Verify that the ContentType is properly accessible + try: + # This will test if the ContentType can be used to retrieve the model + content_type.model_class() + except Exception: + # If we can't get the model class, don't show the subscribe button + return {} + + subscription = Subscription.objects.filter( + object_type=content_type, object_id=instance.pk, user=context["request"].user + ).first() + + # Compile form URL & data + if subscription: + form_url = reverse("extras:subscription_delete", kwargs={"pk": subscription.pk}) + form_data = { + "confirm": "true", + } + else: + form_url = reverse("extras:subscription_add") + form_data = { + "object_type": content_type.pk, + "object_id": instance.pk, + } + + return { + "subscription": subscription, + "form_url": form_url, + "form_data": form_data, + "return_url": instance.get_absolute_url(), } - - return { - "subscription": subscription, - "form_url": form_url, - "form_data": form_data, - "return_url": instance.get_absolute_url(), - } + except Exception: + # If we can't get the content type, don't show the subscribe button + return {} @register.inclusion_tag("buttons/sync.html") From 7128a0f2f223d4f29f7e9b3006db04b14ebbcd8a Mon Sep 17 00:00:00 2001 From: Arthur Date: Mon, 14 Jul 2025 10:19:22 -0700 Subject: [PATCH 05/24] NPL-374 Add tags --- netbox_custom_objects/__init__.py | 2 -- .../templates/netbox_custom_objects/customobject.html | 2 +- netbox_custom_objects/views.py | 8 -------- 3 files changed, 1 insertion(+), 11 deletions(-) diff --git a/netbox_custom_objects/__init__.py b/netbox_custom_objects/__init__.py index afd1e35..503acef 100644 --- a/netbox_custom_objects/__init__.py +++ b/netbox_custom_objects/__init__.py @@ -41,7 +41,6 @@ def get_model(self, model_name, require_ready=True): return obj.get_model() ''' - ''' def ready(self): import netbox_custom_objects.signals @@ -77,7 +76,6 @@ def ready(self): print(f"Warning: Could not initialize custom object models: {e}") super().ready() - ''' config = CustomObjectsPluginConfig diff --git a/netbox_custom_objects/templates/netbox_custom_objects/customobject.html b/netbox_custom_objects/templates/netbox_custom_objects/customobject.html index 075771e..060f4af 100644 --- a/netbox_custom_objects/templates/netbox_custom_objects/customobject.html +++ b/netbox_custom_objects/templates/netbox_custom_objects/customobject.html @@ -100,7 +100,7 @@

{% block title %}{{ object }}{% endblock title %}

{% endfor %} - {# {% include 'inc/panels/tags.html' %} #} + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox_custom_objects/views.py b/netbox_custom_objects/views.py index 5a1fcff..3308689 100644 --- a/netbox_custom_objects/views.py +++ b/netbox_custom_objects/views.py @@ -345,14 +345,6 @@ def get_form(self, model): except NotImplementedError: print(f"get_form: {field.name} field is not supported") - # Add an __init__ method to handle the tags field widget override - def __init__(self, *args, **kwargs): - forms.NetBoxModelForm.__init__(self, *args, **kwargs) - if 'tags' in self.fields: - del self.fields["tags"] - - attrs['__init__'] = __init__ - form = type( f"{model._meta.object_name}Form", (forms.NetBoxModelForm,), From 46fbdb8ced50568e28161b5bd8eb6a7552afcd50 Mon Sep 17 00:00:00 2001 From: Arthur Date: Mon, 14 Jul 2025 10:41:36 -0700 Subject: [PATCH 06/24] NPL-374 fix url routing --- netbox_custom_objects/models.py | 17 +++++++++++++++++ netbox_custom_objects/urls.py | 16 ++++++++++++---- netbox_custom_objects/views.py | 1 - 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/netbox_custom_objects/models.py b/netbox_custom_objects/models.py index 4d5e466..3409c46 100644 --- a/netbox_custom_objects/models.py +++ b/netbox_custom_objects/models.py @@ -184,6 +184,23 @@ def ensure_model_registered(self): except Exception as e: print(f"Warning: Could not ensure model registration: {e}") + def ensure_model_ready_for_bookmarks(self): + """ + Ensure that the model is ready for bookmark operations. + This includes ensuring the model is registered and the ContentType exists. + """ + try: + # Ensure the model is registered + self.ensure_model_registered() + + # Ensure the ContentType exists + self.ensure_content_type_exists() + + return True + except Exception as e: + print(f"Warning: Could not ensure model ready for bookmarks: {e}") + return False + def _fetch_and_generate_field_attrs( self, fields, diff --git a/netbox_custom_objects/urls.py b/netbox_custom_objects/urls.py index 3880e88..0d4aade 100644 --- a/netbox_custom_objects/urls.py +++ b/netbox_custom_objects/urls.py @@ -19,9 +19,6 @@ "custom_object_types//", include(get_model_urls(APP_LABEL, "customobjecttype")), ), - path( - "custom_objects//", include(get_model_urls(APP_LABEL, "customobject")) - ), path( "custom_object_type_fields//", include(get_model_urls(APP_LABEL, "customobjecttypefield")), @@ -58,6 +55,17 @@ ), path( "//", - include(get_model_urls(APP_LABEL, "customobject")), + views.CustomObjectView.as_view(), + name="customobject", + ), + path( + "//edit/", + views.CustomObjectEditView.as_view(), + name="customobject_edit", + ), + path( + "//delete/", + views.CustomObjectDeleteView.as_view(), + name="customobject_delete", ), ] diff --git a/netbox_custom_objects/views.py b/netbox_custom_objects/views.py index 3308689..159f070 100644 --- a/netbox_custom_objects/views.py +++ b/netbox_custom_objects/views.py @@ -284,7 +284,6 @@ def get_object(self, **kwargs): custom_object_type = self.kwargs.pop("custom_object_type", None) object_type = get_object_or_404(CustomObjectType, name__iexact=custom_object_type) model = object_type.get_model() - # kwargs.pop('custom_object_type', None) return get_object_or_404(model.objects.all(), **self.kwargs) def get_extra_context(self, request, instance): From df033c403a9b2d4b472201f0f40048834a08d782 Mon Sep 17 00:00:00 2001 From: Arthur Date: Mon, 14 Jul 2025 15:11:07 -0700 Subject: [PATCH 07/24] NPL-374 fix for both cases of bookmark --- netbox_custom_objects/__init__.py | 18 +---- netbox_custom_objects/models.py | 74 ++----------------- netbox_custom_objects/signals.py | 54 -------------- .../templatetags/custom_object_buttons.py | 12 ++- 4 files changed, 13 insertions(+), 145 deletions(-) delete mode 100644 netbox_custom_objects/signals.py diff --git a/netbox_custom_objects/__init__.py b/netbox_custom_objects/__init__.py index 503acef..e16471e 100644 --- a/netbox_custom_objects/__init__.py +++ b/netbox_custom_objects/__init__.py @@ -16,6 +16,7 @@ class CustomObjectsPluginConfig(PluginConfig): ''' def get_model(self, model_name, require_ready=True): + return super().get_model(model_name, require_ready) if require_ready: self.apps.check_models_ready() else: @@ -42,31 +43,16 @@ def get_model(self, model_name, require_ready=True): ''' def ready(self): - import netbox_custom_objects.signals - - # Import Django models only after apps are ready - # This prevents "AppRegistryNotReady" errors during module import - from django.contrib.contenttypes.models import ContentType - from django.contrib.contenttypes.management import create_contenttypes - # Ensure all dynamic models are created and registered during startup # This prevents ContentType race conditions with Bookmark operations try: from .models import CustomObjectType - from .constants import APP_LABEL # Only run this after the database is ready if apps.is_installed('django.contrib.contenttypes'): for custom_object_type in CustomObjectType.objects.all(): try: - # Get or create the model - model = custom_object_type.get_model() - - # Ensure the model is registered - try: - apps.get_model(APP_LABEL, model._meta.model_name) - except LookupError: - apps.register_model(APP_LABEL, model) + custom_object_type.get_model() except Exception as e: # Log but don't fail startup diff --git a/netbox_custom_objects/models.py b/netbox_custom_objects/models.py index 3409c46..531a286 100644 --- a/netbox_custom_objects/models.py +++ b/netbox_custom_objects/models.py @@ -130,20 +130,6 @@ def content_type(self): # If we still can't get it, return None return None - def ensure_content_type_exists(self): - """ - Ensure that the ContentType for this CustomObjectType exists. - This is useful for preventing race conditions with Bookmark operations. - """ - try: - content_type_name = self.get_table_model_name(self.id).lower() - ContentType.objects.get( - app_label=APP_LABEL, model=content_type_name - ) - except Exception: - # Create the model and ContentType - self.create_model() - def get_or_create_content_type(self): """ Get or create the ContentType for this CustomObjectType. @@ -164,43 +150,6 @@ def get_or_create_content_type(self): ct.refresh_from_db() return ct - def ensure_model_registered(self): - """ - Ensure that the model is properly registered with Django's app registry. - This is useful for ensuring the model is accessible after creation. - """ - try: - model = self.get_model() - model_name = model._meta.model_name - - # Try to get the model from the registry - try: - apps.get_model(APP_LABEL, model_name) - except LookupError: - # Model not registered, register it - apps.register_model(APP_LABEL, model) - print(f"Registered model: {model_name}") - - except Exception as e: - print(f"Warning: Could not ensure model registration: {e}") - - def ensure_model_ready_for_bookmarks(self): - """ - Ensure that the model is ready for bookmark operations. - This includes ensuring the model is registered and the ContentType exists. - """ - try: - # Ensure the model is registered - self.ensure_model_registered() - - # Ensure the ContentType exists - self.ensure_content_type_exists() - - return True - except Exception as e: - print(f"Warning: Could not ensure model ready for bookmarks: {e}") - return False - def _fetch_and_generate_field_attrs( self, fields, @@ -220,7 +169,6 @@ def _fetch_and_generate_field_attrs( for field in fields: field_type = FIELD_TYPE_CLASS[field.type]() - # field_type = field_type_registry.get_by_model(field) field_name = field.name field_attrs["_field_objects"][field.id] = { @@ -235,8 +183,6 @@ def _fetch_and_generate_field_attrs( field_attrs[field.name] = field_type.get_model_field( field, - # db_column=field.db_column, - # verbose_name=field.name, ) return field_attrs @@ -384,20 +330,14 @@ def get_absolute_url(self): return model - def get_registered_model(self): - """ - Get the model and ensure it's registered with Django's app registry. - This is a convenience method for getting a model that's guaranteed to be registered. - """ - return self.get_model(ensure_registered=True) - def create_model(self): # Get the model and ensure it's registered - model = self.get_registered_model() + model = self.get_model() # Ensure the ContentType exists and is immediately available self.get_or_create_content_type() - + model = self.get_model() + with connection.schema_editor() as schema_editor: schema_editor.create_model(model) @@ -406,11 +346,9 @@ def save(self, *args, **kwargs): super().save(*args, **kwargs) if needs_db_create: self.create_model() - # Ensure the ContentType is immediately available after creation - self.get_or_create_content_type() def delete(self, *args, **kwargs): - model = self.get_registered_model() + model = self.get_model() ContentType.objects.get( app_label=APP_LABEL, model=self.get_table_model_name(self.id).lower() ).delete() @@ -1074,7 +1012,7 @@ def through_model_name(self): def save(self, *args, **kwargs): field_type = FIELD_TYPE_CLASS[self.type]() model_field = field_type.get_model_field(self) - model = self.custom_object_type.get_registered_model() + model = self.custom_object_type.get_model() model_field.contribute_to_class(model, self.name) with connection.schema_editor() as schema_editor: @@ -1091,7 +1029,7 @@ def save(self, *args, **kwargs): def delete(self, *args, **kwargs): field_type = FIELD_TYPE_CLASS[self.type]() model_field = field_type.get_model_field(self) - model = self.custom_object_type.get_registered_model() + model = self.custom_object_type.get_model() model_field.contribute_to_class(model, self.name) with connection.schema_editor() as schema_editor: diff --git a/netbox_custom_objects/signals.py b/netbox_custom_objects/signals.py deleted file mode 100644 index 4f5ddc5..0000000 --- a/netbox_custom_objects/signals.py +++ /dev/null @@ -1,54 +0,0 @@ -from django.db.models.signals import post_save, post_delete -from django.dispatch import receiver -from django.contrib.contenttypes.models import ContentType -from django.contrib.contenttypes.management import create_contenttypes -from django.apps import apps -from django.core.exceptions import ObjectDoesNotExist - -from .models import CustomObjectType -from .constants import APP_LABEL - - -@receiver(post_save, sender=CustomObjectType) -def ensure_content_type_exists(sender, instance, created, **kwargs): - """ - Ensure ContentType exists for the custom object type after it's saved. - This signal runs after the database transaction is committed. - """ - if created: - try: - # Get the model name for this custom object type - content_type_name = instance.get_table_model_name(instance.id).lower() - - # Check if ContentType already exists - try: - ContentType.objects.get( - app_label=APP_LABEL, - model=content_type_name - ) - except ObjectDoesNotExist: - # Create the ContentType - ContentType.objects.create( - app_label=APP_LABEL, - model=content_type_name - ) - except Exception as e: - # Log the error but don't fail the save operation - print(f"Warning: Could not create ContentType for CustomObjectType {instance.id}: {e}") - - -@receiver(post_delete, sender=CustomObjectType) -def cleanup_content_type(sender, instance, **kwargs): - """ - Clean up the ContentType when a CustomObjectType is deleted. - """ - try: - content_type_name = instance.get_table_model_name(instance.id).lower() - ContentType.objects.filter( - app_label=APP_LABEL, - model=content_type_name - ).delete() - except Exception as e: - # Log the error but don't fail the delete operation - print(f"Warning: Could not delete ContentType for CustomObjectType {instance.id}: {e}") - diff --git a/netbox_custom_objects/templatetags/custom_object_buttons.py b/netbox_custom_objects/templatetags/custom_object_buttons.py index 82691cf..aa50fc0 100644 --- a/netbox_custom_objects/templatetags/custom_object_buttons.py +++ b/netbox_custom_objects/templatetags/custom_object_buttons.py @@ -10,7 +10,7 @@ __all__ = ( "custom_object_add_button", - "custom_object_bookmark_button", + # "custom_object_bookmark_button", "custom_object_bulk_delete_button", "custom_object_bulk_edit_button", "custom_object_clone_button", @@ -33,20 +33,17 @@ @register.inclusion_tag("buttons/bookmark.html", takes_context=True) def custom_object_bookmark_button(context, instance): try: - # For custom objects, ensure the model is ready for bookmarks - if hasattr(instance, 'custom_object_type'): - if not instance.custom_object_type.ensure_model_ready_for_bookmarks(): - # If the model isn't ready, don't show the bookmark button - return {} # Check if this user has already bookmarked the object content_type = ContentType.objects.get_for_model(instance) - + instance.custom_object_type.get_model() + # Verify that the ContentType is properly accessible try: # This will test if the ContentType can be used to retrieve the model content_type.model_class() except Exception: + print("returning empty") # If we can't get the model class, don't show the bookmark button return {} @@ -75,6 +72,7 @@ def custom_object_bookmark_button(context, instance): } except Exception: # If we can't get the content type, don't show the bookmark button + print("returning empty Exception") return {} From 03d5da58b1652753065e1d509f631a55d7ddc1ca Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 15 Jul 2025 08:13:27 -0700 Subject: [PATCH 08/24] NPL-374 cleanup --- netbox_custom_objects/__init__.py | 57 ++----------------------------- 1 file changed, 3 insertions(+), 54 deletions(-) diff --git a/netbox_custom_objects/__init__.py b/netbox_custom_objects/__init__.py index e16471e..f830dda 100644 --- a/netbox_custom_objects/__init__.py +++ b/netbox_custom_objects/__init__.py @@ -1,67 +1,16 @@ from netbox.plugins import PluginConfig -from django.apps import apps + # Plugin Configuration class CustomObjectsPluginConfig(PluginConfig): name = "netbox_custom_objects" verbose_name = "Custom Objects" description = "A plugin to manage custom objects in NetBox" - version = "0.1" + version = "0.1.0" base_url = "custom-objects" min_version = "4.2.0" - # max_version = "3.5.0" default_settings = {} required_settings = [] template_extensions = "template_content.template_extensions" - ''' - def get_model(self, model_name, require_ready=True): - return super().get_model(model_name, require_ready) - if require_ready: - self.apps.check_models_ready() - else: - self.apps.check_apps_ready() - - if model_name.lower() in self.models: - return self.models[model_name.lower()] - - from .models import CustomObjectType - 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) - ) - - 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 ready(self): - # Ensure all dynamic models are created and registered during startup - # This prevents ContentType race conditions with Bookmark operations - try: - from .models import CustomObjectType - - # Only run this after the database is ready - if apps.is_installed('django.contrib.contenttypes'): - for custom_object_type in CustomObjectType.objects.all(): - try: - custom_object_type.get_model() - - except Exception as e: - # Log but don't fail startup - print(f"Warning: Could not initialize model for CustomObjectType {custom_object_type.id}: {e}") - except Exception as e: - # Don't fail plugin startup if there are issues - print(f"Warning: Could not initialize custom object models: {e}") - - super().ready() - - -config = CustomObjectsPluginConfig +config = CustomObjectsPluginConfig \ No newline at end of file From ec9bda7d3417f60d35dfa58558f6e4f307f41847 Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 15 Jul 2025 08:16:34 -0700 Subject: [PATCH 09/24] NPL-374 cleanup --- netbox_custom_objects/models.py | 8 -------- .../templatetags/custom_object_buttons.py | 2 +- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/netbox_custom_objects/models.py b/netbox_custom_objects/models.py index 531a286..471da97 100644 --- a/netbox_custom_objects/models.py +++ b/netbox_custom_objects/models.py @@ -225,7 +225,6 @@ def get_model( fields=None, manytomany_models=None, app_label=None, - ensure_registered=True, ): """ Generates a temporary Django model based on available fields that belong to @@ -321,13 +320,6 @@ def get_absolute_url(self): if not manytomany_models: self._after_model_generation(attrs, model) - # Ensure the model is registered if requested - if ensure_registered: - try: - apps.get_model(APP_LABEL, model._meta.model_name) - except LookupError: - apps.register_model(APP_LABEL, model) - return model def create_model(self): diff --git a/netbox_custom_objects/templatetags/custom_object_buttons.py b/netbox_custom_objects/templatetags/custom_object_buttons.py index aa50fc0..3b1210e 100644 --- a/netbox_custom_objects/templatetags/custom_object_buttons.py +++ b/netbox_custom_objects/templatetags/custom_object_buttons.py @@ -10,7 +10,7 @@ __all__ = ( "custom_object_add_button", - # "custom_object_bookmark_button", + "custom_object_bookmark_button", "custom_object_bulk_delete_button", "custom_object_bulk_edit_button", "custom_object_clone_button", From 3db27bbf93545b561e538d7974e6c84daea09428 Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 15 Jul 2025 08:32:35 -0700 Subject: [PATCH 10/24] NPL-374 cleanup --- netbox_custom_objects/templatetags/custom_object_buttons.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/netbox_custom_objects/templatetags/custom_object_buttons.py b/netbox_custom_objects/templatetags/custom_object_buttons.py index 3b1210e..451efa4 100644 --- a/netbox_custom_objects/templatetags/custom_object_buttons.py +++ b/netbox_custom_objects/templatetags/custom_object_buttons.py @@ -43,7 +43,6 @@ def custom_object_bookmark_button(context, instance): # This will test if the ContentType can be used to retrieve the model content_type.model_class() except Exception: - print("returning empty") # If we can't get the model class, don't show the bookmark button return {} @@ -72,7 +71,6 @@ def custom_object_bookmark_button(context, instance): } except Exception: # If we can't get the content type, don't show the bookmark button - print("returning empty Exception") return {} From 7deb04c271845b020050c571b67a568027b7fbe9 Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 15 Jul 2025 09:24:35 -0700 Subject: [PATCH 11/24] NPL-374 cleanup --- netbox_custom_objects/models.py | 55 ++++++++++++++++----------------- 1 file changed, 26 insertions(+), 29 deletions(-) diff --git a/netbox_custom_objects/models.py b/netbox_custom_objects/models.py index 471da97..ea37d51 100644 --- a/netbox_custom_objects/models.py +++ b/netbox_custom_objects/models.py @@ -54,6 +54,32 @@ class CustomObject( ): objects = RestrictedQuerySet.as_manager() + def __str__(self): + # Find the field with primary=True and return that field's "name" as the name of the object + primary_field = self._field_objects.get(self._primary_field_id, None) + primary_field_value = None + if primary_field: + field_type = FIELD_TYPE_CLASS[primary_field["field"].type]() + primary_field_value = field_type.get_display_value(self, primary_field["name"]) + if not primary_field_value: + return f"{self.custom_object_type.name} {self.id}" + return str(primary_field_value) or str(self.id) + + @property + def _generated_table_model(self): + # An indication that the model is a generated table model. + return True + + def get_absolute_url(self): + return reverse( + "plugins:netbox_custom_objects:customobject", + kwargs={ + "pk": self.pk, + "custom_object_type": self.custom_object_type.name.lower(), + }, + ) + + class CustomObjectType(NetBoxModel): name = models.CharField(max_length=100, unique=True) @@ -270,38 +296,11 @@ def get_model( }, ) - def __str__(self): - # Find the field with primary=True and return that field's "name" as the name of the object - primary_field = self._field_objects.get(self._primary_field_id, None) - primary_field_value = None - if primary_field: - field_type = FIELD_TYPE_CLASS[primary_field["field"].type]() - primary_field_value = field_type.get_display_value(self, primary_field["name"]) - if not primary_field_value: - return f"{self.custom_object_type.name} {self.id}" - return str(primary_field_value) or str(self.id) - - def get_absolute_url(self): - return reverse( - "plugins:netbox_custom_objects:customobject", - kwargs={ - "pk": self.pk, - "custom_object_type": self.custom_object_type.name.lower(), - }, - ) - attrs = { "Meta": meta, "__module__": "database.models", - # An indication that the model is a generated table model. - "_generated_table_model": True, "custom_object_type": self, "custom_object_type_id": self.id, - # We are using our own table model manager to implement some queryset - # helpers. - "objects": RestrictedQuerySet.as_manager(), - "__str__": __str__, - "get_absolute_url": get_absolute_url, } field_attrs = self._fetch_and_generate_field_attrs(fields) @@ -315,8 +314,6 @@ def get_absolute_url(self): attrs, ) - # patch_meta_get_field(model._meta) - if not manytomany_models: self._after_model_generation(attrs, model) From fed29dcc936a98cbf58a987f5208e3a1b53a7f5f Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 15 Jul 2025 09:27:10 -0700 Subject: [PATCH 12/24] NPL-374 cleanup --- netbox_custom_objects/models.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/netbox_custom_objects/models.py b/netbox_custom_objects/models.py index ea37d51..e7e511d 100644 --- a/netbox_custom_objects/models.py +++ b/netbox_custom_objects/models.py @@ -52,6 +52,20 @@ class CustomObject( # EventRulesMixin, models.Model, ): + """ + Base class for dynamically generated custom object models. + + This abstract model serves as the foundation for all custom object types created + through the CustomObjectType system. When a CustomObjectType is created, a concrete + model class is dynamically generated that inherits from this base class and includes + the specific fields defined in the CustomObjectType's schema. + + This class should not be used directly - instead, use CustomObjectType.get_model() + to create concrete model classes for specific custom object types. + + Attributes: + _generated_table_model (property): Indicates this is a generated table model + """ objects = RestrictedQuerySet.as_manager() def __str__(self): From 1ffc4808c787ca0ad269013c5b037921f98d582e Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 15 Jul 2025 10:06:24 -0700 Subject: [PATCH 13/24] NPL-374 fix subscribe button --- .../templates/netbox_custom_objects/customobject.html | 2 +- netbox_custom_objects/templatetags/custom_object_buttons.py | 6 ------ 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/netbox_custom_objects/templates/netbox_custom_objects/customobject.html b/netbox_custom_objects/templates/netbox_custom_objects/customobject.html index 060f4af..63c2297 100644 --- a/netbox_custom_objects/templates/netbox_custom_objects/customobject.html +++ b/netbox_custom_objects/templates/netbox_custom_objects/customobject.html @@ -32,7 +32,7 @@

{% block title %}{{ object }}{% endblock title %}

{% custom_object_bookmark_button object %} {% endif %} {% if perms.extras.add_subscription and object.subscriptions %} - {# {% subscribe_button object %}#} + {% custom_object_subscribe_button object %} {% endif %} {% if request.user|can_add:object %} {# {% clone_button object %}#} diff --git a/netbox_custom_objects/templatetags/custom_object_buttons.py b/netbox_custom_objects/templatetags/custom_object_buttons.py index 451efa4..052c778 100644 --- a/netbox_custom_objects/templatetags/custom_object_buttons.py +++ b/netbox_custom_objects/templatetags/custom_object_buttons.py @@ -128,12 +128,6 @@ def custom_object_subscribe_button(context, instance): return {} try: - # For custom objects, ensure the model is ready for subscriptions - if hasattr(instance, 'custom_object_type'): - if not instance.custom_object_type.ensure_model_ready_for_bookmarks(): - # If the model isn't ready, don't show the subscribe button - return {} - # Check if this user has already subscribed to the object content_type = ContentType.objects.get_for_model(instance) From 131740eaeb8ba3ae37d7e0b11ddd75d59bd2c4f3 Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 15 Jul 2025 10:09:23 -0700 Subject: [PATCH 14/24] NPL-374 fix clone button --- .../templates/netbox_custom_objects/customobject.html | 2 +- .../templatetags/custom_object_buttons.py | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/netbox_custom_objects/templates/netbox_custom_objects/customobject.html b/netbox_custom_objects/templates/netbox_custom_objects/customobject.html index 63c2297..3ef47c3 100644 --- a/netbox_custom_objects/templates/netbox_custom_objects/customobject.html +++ b/netbox_custom_objects/templates/netbox_custom_objects/customobject.html @@ -35,7 +35,7 @@

{% block title %}{{ object }}{% endblock title %}

{% custom_object_subscribe_button object %} {% endif %} {% if request.user|can_add:object %} - {# {% clone_button object %}#} + {% custom_object_clone_button object %} {% endif %} {% if request.user|can_change:object %} {% custom_object_edit_button object %} diff --git a/netbox_custom_objects/templatetags/custom_object_buttons.py b/netbox_custom_objects/templatetags/custom_object_buttons.py index 052c778..5becc6a 100644 --- a/netbox_custom_objects/templatetags/custom_object_buttons.py +++ b/netbox_custom_objects/templatetags/custom_object_buttons.py @@ -76,14 +76,16 @@ def custom_object_bookmark_button(context, instance): @register.inclusion_tag("buttons/clone.html") def custom_object_clone_button(instance): - url = reverse(get_viewname(instance, "add")) + viewname = get_viewname(instance, "add") + url = reverse( + viewname, + kwargs={"custom_object_type": instance.custom_object_type.name.lower()} + ) # Populate cloned field values param_string = prepare_cloned_fields(instance).urlencode() if param_string: url = f"{url}?{param_string}" - else: - url = None return { "url": url, From c75848aa243d0f532118e5d8829d8db974e597e9 Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 15 Jul 2025 10:58:18 -0700 Subject: [PATCH 15/24] NPL-374 fix eventsrulesmixin button --- netbox_custom_objects/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox_custom_objects/models.py b/netbox_custom_objects/models.py index e7e511d..af3bc7d 100644 --- a/netbox_custom_objects/models.py +++ b/netbox_custom_objects/models.py @@ -49,7 +49,7 @@ class CustomObject( JournalingMixin, NotificationsMixin, TagsMixin, - # EventRulesMixin, + EventRulesMixin, models.Model, ): """ From 60409e16f1fe1b5a157584e315db55a68c4ebf7a Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 15 Jul 2025 11:01:48 -0700 Subject: [PATCH 16/24] NPL-374 changelogging mixin --- ...bject_created_customobject_last_updated.py | 23 +++++++++++++++++++ netbox_custom_objects/models.py | 2 +- 2 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 netbox_custom_objects/migrations/0002_customobject_created_customobject_last_updated.py diff --git a/netbox_custom_objects/migrations/0002_customobject_created_customobject_last_updated.py b/netbox_custom_objects/migrations/0002_customobject_created_customobject_last_updated.py new file mode 100644 index 0000000..0948529 --- /dev/null +++ b/netbox_custom_objects/migrations/0002_customobject_created_customobject_last_updated.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.2 on 2025-07-15 17:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("netbox_custom_objects", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="customobject", + name="created", + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name="customobject", + name="last_updated", + field=models.DateTimeField(auto_now=True, null=True), + ), + ] diff --git a/netbox_custom_objects/models.py b/netbox_custom_objects/models.py index af3bc7d..41fd9ba 100644 --- a/netbox_custom_objects/models.py +++ b/netbox_custom_objects/models.py @@ -41,7 +41,7 @@ class CustomObject( BookmarksMixin, - # ChangeLoggingMixin, + ChangeLoggingMixin, CloningMixin, CustomLinksMixin, CustomValidationMixin, From 21caffb75834a6875bfffadd5064d93d75c0a716 Mon Sep 17 00:00:00 2001 From: Arthur Date: Thu, 17 Jul 2025 14:33:16 -0700 Subject: [PATCH 17/24] NPL-374 move tags --- .../templates/netbox_custom_objects/customobject.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox_custom_objects/templates/netbox_custom_objects/customobject.html b/netbox_custom_objects/templates/netbox_custom_objects/customobject.html index 3ef47c3..22043b3 100644 --- a/netbox_custom_objects/templates/netbox_custom_objects/customobject.html +++ b/netbox_custom_objects/templates/netbox_custom_objects/customobject.html @@ -100,10 +100,10 @@

{% block title %}{{ object }}{% endblock title %}

{% endfor %}
- {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
+ {% include 'inc/panels/tags.html' %} {% include 'inc/panels/comments.html' %} {% plugin_right_page object %} {% for field in fields %} From 5313595b0fbfd40c389d0147c3ba935fe6da1fbb Mon Sep 17 00:00:00 2001 From: Arthur Date: Mon, 21 Jul 2025 09:14:14 -0700 Subject: [PATCH 18/24] NPL-374 fix tagging with ABC --- netbox_custom_objects/api/views.py | 1 - .../migrations/0003_delete_customobject.py | 19 ++++ netbox_custom_objects/models.py | 95 +++++++++++-------- netbox_custom_objects/views.py | 7 +- 4 files changed, 79 insertions(+), 43 deletions(-) create mode 100644 netbox_custom_objects/migrations/0003_delete_customobject.py diff --git a/netbox_custom_objects/api/views.py b/netbox_custom_objects/api/views.py index d366c41..cfdd36e 100644 --- a/netbox_custom_objects/api/views.py +++ b/netbox_custom_objects/api/views.py @@ -18,7 +18,6 @@ class CustomObjectTypeViewSet(ModelViewSet): class CustomObjectViewSet(ModelViewSet): - queryset = CustomObject.objects.all() serializer_class = serializers.CustomObjectSerializer model = None diff --git a/netbox_custom_objects/migrations/0003_delete_customobject.py b/netbox_custom_objects/migrations/0003_delete_customobject.py new file mode 100644 index 0000000..33c6463 --- /dev/null +++ b/netbox_custom_objects/migrations/0003_delete_customobject.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.2 on 2025-07-18 20:24 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "netbox_custom_objects", + "0002_customobject_created_customobject_last_updated", + ), + ] + + operations = [ + migrations.DeleteModel( + name="CustomObject", + ), + ] diff --git a/netbox_custom_objects/models.py b/netbox_custom_objects/models.py index ed00b9b..e3c68e1 100644 --- a/netbox_custom_objects/models.py +++ b/netbox_custom_objects/models.py @@ -24,7 +24,6 @@ BookmarksMixin, ChangeLoggingMixin, CloningMixin, CustomLinksMixin, CustomValidationMixin, EventRulesMixin, ExportTemplatesMixin, JournalingMixin, NotificationsMixin, TagsMixin, ) -from netbox.models.features import CloningMixin, ExportTemplatesMixin, TagsMixin from netbox.registry import registry from utilities import filters from utilities.datetime import datetime_from_timestamp @@ -48,9 +47,7 @@ class CustomObject( ExportTemplatesMixin, JournalingMixin, NotificationsMixin, - TagsMixin, EventRulesMixin, - models.Model, ): """ Base class for dynamically generated custom object models. @@ -68,6 +65,9 @@ class CustomObject( """ objects = RestrictedQuerySet.as_manager() + class Meta: + abstract = True + def __str__(self): # Find the field with primary=True and return that field's "name" as the name of the object primary_field = self._field_objects.get(self._primary_field_id, None) @@ -98,6 +98,7 @@ def get_absolute_url(self): class CustomObjectType(NetBoxModel): # Class-level cache for generated models _model_cache = {} + _through_model_cache = {} name = models.CharField(max_length=100, unique=True) description = models.TextField(blank=True) @@ -128,8 +129,12 @@ def clear_model_cache(cls, custom_object_type_id=None): 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) else: cls._model_cache.clear() + cls._through_model_cache.clear() @classmethod def get_cached_model(cls, custom_object_type_id): @@ -181,26 +186,6 @@ def get_list_url(self): kwargs={"custom_object_type": self.name.lower()}, ) - def create_proxy_model( - self, model_name, base_model, extra_fields=None, meta_options=None - ): - """Creates a dynamic proxy model.""" - name = f"{model_name}Proxy" - - attrs = {"__module__": base_model.__module__} - if extra_fields: - attrs.update(extra_fields) - - meta_attrs = {"proxy": True, "app_label": base_model._meta.app_label} - if meta_options: - meta_attrs.update(meta_options) - - attrs["Meta"] = type("Meta", (), meta_attrs) - attrs["objects"] = ProxyManager(custom_object_type=self) - - proxy_model = type(name, (base_model,), attrs) - return proxy_model - @classmethod def get_table_model_name(cls, table_id): return f"Table{table_id}Model" @@ -368,18 +353,53 @@ def get_model( attrs.update(**field_attrs) + # Create a unique through model for tagging for this CustomObjectType + from taggit.managers import TaggableManager + from taggit.models import GenericTaggedItemBase + from extras.models.tags import Tag + from utilities.querysets import RestrictedQuerySet + + 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}', + }) + } + ) + + attrs['tags'] = TaggableManager( + through=through_model, + ordering=('weight', 'name'), + ) + # Create the model class. model = type( str(model_name), - (CustomObject,), + (CustomObject, models.Model), attrs, ) if not manytomany_models: self._after_model_generation(attrs, model) - # Cache the generated model + # Cache the generated model and its through model self._model_cache[self.id] = model + self._through_model_cache[self.id] = through_model return model @@ -393,6 +413,11 @@ def create_model(self): with connection.schema_editor() as schema_editor: schema_editor.create_model(model) + + # Also create the through model table for tags + if self.id in self._through_model_cache: + through_model = self._through_model_cache[self.id] + schema_editor.create_model(through_model) def save(self, *args, **kwargs): needs_db_create = self._state.adding @@ -413,25 +438,13 @@ def delete(self, *args, **kwargs): ).delete() super().delete(*args, **kwargs) with connection.schema_editor() as schema_editor: + # Delete the through model table first if it exists + if self.id in self._through_model_cache: + through_model = self._through_model_cache[self.id] + schema_editor.delete_model(through_model) schema_editor.delete_model(model) -class ProxyManager(models.Manager): - custom_object_type = None - - def __init__(self, *args, **kwargs): - self.custom_object_type = kwargs.pop("custom_object_type", None) - super().__init__(*args, **kwargs) - - # TODO: make this a RestrictedQuerySet - # def restrict(self, user, action='view'): - # queryset = super().restrict(user, action=action) - # return queryset.filter(custom_object_type=self.custom_object_type) - - def get_queryset(self): - return super().get_queryset().filter(custom_object_type=self.custom_object_type) - - class CustomObjectTypeField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): custom_object_type = models.ForeignKey( CustomObjectType, on_delete=models.CASCADE, related_name="fields" diff --git a/netbox_custom_objects/views.py b/netbox_custom_objects/views.py index 159f070..1f65526 100644 --- a/netbox_custom_objects/views.py +++ b/netbox_custom_objects/views.py @@ -278,7 +278,12 @@ def get_extra_context(self, request): @register_model_view(CustomObject) class CustomObjectView(generic.ObjectView): - queryset = CustomObject.objects.all() + + def get_queryset(self, request): + custom_object_type = self.kwargs.pop("custom_object_type", None) + object_type = get_object_or_404(CustomObjectType, name__iexact=custom_object_type) + model = object_type.get_model() + return model.objects.all() def get_object(self, **kwargs): custom_object_type = self.kwargs.pop("custom_object_type", None) From a301b46fc39a4e024d56a44405084817e7c8aabc Mon Sep 17 00:00:00 2001 From: Arthur Date: Mon, 21 Jul 2025 14:35:02 -0700 Subject: [PATCH 19/24] fix detail view --- netbox_custom_objects/views.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/netbox_custom_objects/views.py b/netbox_custom_objects/views.py index 1f65526..214b7d8 100644 --- a/netbox_custom_objects/views.py +++ b/netbox_custom_objects/views.py @@ -278,18 +278,21 @@ def get_extra_context(self, request): @register_model_view(CustomObject) class CustomObjectView(generic.ObjectView): + template_name = "netbox_custom_objects/customobject.html" def get_queryset(self, request): - custom_object_type = self.kwargs.pop("custom_object_type", None) + custom_object_type = self.kwargs.get("custom_object_type", None) object_type = get_object_or_404(CustomObjectType, name__iexact=custom_object_type) model = object_type.get_model() return model.objects.all() def get_object(self, **kwargs): - custom_object_type = self.kwargs.pop("custom_object_type", None) + custom_object_type = self.kwargs.get("custom_object_type", None) object_type = get_object_or_404(CustomObjectType, name__iexact=custom_object_type) model = object_type.get_model() - return get_object_or_404(model.objects.all(), **self.kwargs) + # Filter out custom_object_type from kwargs for the object lookup + lookup_kwargs = {k: v for k, v in self.kwargs.items() if k != "custom_object_type"} + return get_object_or_404(model.objects.all(), **lookup_kwargs) def get_extra_context(self, request, instance): fields = instance.custom_object_type.fields.all().order_by("weight") From 69a22817893d1e68b5002513ca83078829b4e8e2 Mon Sep 17 00:00:00 2001 From: Arthur Date: Mon, 21 Jul 2025 14:50:01 -0700 Subject: [PATCH 20/24] multiple through models --- netbox_custom_objects/models.py | 45 ++++++++++++++++++++++++++------- 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/netbox_custom_objects/models.py b/netbox_custom_objects/models.py index 74874d0..dbb14d9 100644 --- a/netbox_custom_objects/models.py +++ b/netbox_custom_objects/models.py @@ -98,7 +98,7 @@ def get_absolute_url(self): class CustomObjectType(NetBoxModel): # Class-level cache for generated models _model_cache = {} - _through_model_cache = {} + _through_model_cache = {} # Now stores {custom_object_type_id: {through_model_name: through_model}} name = models.CharField(max_length=100, unique=True) description = models.TextField(blank=True) @@ -155,6 +155,29 @@ def is_model_cached(cls, custom_object_type_id): :return: True if the model is cached, False otherwise """ return custom_object_type_id in cls._model_cache + + @classmethod + def get_cached_through_model(cls, custom_object_type_id, through_model_name): + """ + Get a specific cached through model for a CustomObjectType. + + :param custom_object_type_id: ID of the CustomObjectType + :param through_model_name: Name of the through model to retrieve + :return: The cached through model or None if not found + """ + if custom_object_type_id in cls._through_model_cache: + return cls._through_model_cache[custom_object_type_id].get(through_model_name) + return None + + @classmethod + def get_cached_through_models(cls, custom_object_type_id): + """ + Get all cached through models for a CustomObjectType. + + :param custom_object_type_id: ID of the CustomObjectType + :return: Dict of through models or empty dict if not found + """ + return cls._through_model_cache.get(custom_object_type_id, {}) @property def formatted_schema(self): @@ -390,9 +413,11 @@ def get_model( if not manytomany_models: self._after_model_generation(attrs, model) - # Cache the generated model and its through model + # Cache the generated model and its through models self._model_cache[self.id] = model - self._through_model_cache[self.id] = through_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 return model @@ -407,10 +432,11 @@ def create_model(self): with connection.schema_editor() as schema_editor: schema_editor.create_model(model) - # Also create the through model table for tags + # Also create the through model tables for tags and other mixins if self.id in self._through_model_cache: - through_model = self._through_model_cache[self.id] - schema_editor.create_model(through_model) + 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 @@ -431,10 +457,11 @@ def delete(self, *args, **kwargs): ).delete() super().delete(*args, **kwargs) with connection.schema_editor() as schema_editor: - # Delete the through model table first if it exists + # Delete the through model tables first if they exist if self.id in self._through_model_cache: - through_model = self._through_model_cache[self.id] - schema_editor.delete_model(through_model) + 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 b86c3b1def70122339df6e8e567c4f6ef8bab95e Mon Sep 17 00:00:00 2001 From: Arthur Date: Mon, 21 Jul 2025 15:00:57 -0700 Subject: [PATCH 21/24] fix ruff errors --- netbox_custom_objects/__init__.py | 3 +- netbox_custom_objects/api/views.py | 2 +- netbox_custom_objects/field_types.py | 7 ++- netbox_custom_objects/models.py | 51 +++++++++---------- netbox_custom_objects/navigation.py | 2 +- .../templatetags/custom_object_buttons.py | 8 +-- 6 files changed, 36 insertions(+), 37 deletions(-) diff --git a/netbox_custom_objects/__init__.py b/netbox_custom_objects/__init__.py index f830dda..288a96b 100644 --- a/netbox_custom_objects/__init__.py +++ b/netbox_custom_objects/__init__.py @@ -13,4 +13,5 @@ class CustomObjectsPluginConfig(PluginConfig): required_settings = [] template_extensions = "template_content.template_extensions" -config = CustomObjectsPluginConfig \ No newline at end of file + +config = CustomObjectsPluginConfig diff --git a/netbox_custom_objects/api/views.py b/netbox_custom_objects/api/views.py index cfdd36e..8432ccd 100644 --- a/netbox_custom_objects/api/views.py +++ b/netbox_custom_objects/api/views.py @@ -2,7 +2,7 @@ from rest_framework.routers import APIRootView from rest_framework.viewsets import ModelViewSet -from netbox_custom_objects.models import CustomObject, CustomObjectType, CustomObjectTypeField +from netbox_custom_objects.models import CustomObjectType, CustomObjectTypeField from . import serializers diff --git a/netbox_custom_objects/field_types.py b/netbox_custom_objects/field_types.py index 996331c..b0c1939 100644 --- a/netbox_custom_objects/field_types.py +++ b/netbox_custom_objects/field_types.py @@ -613,7 +613,6 @@ def get_through_model(self, field, model=None): # Use the actual model if provided, otherwise use string reference source_model = model if model else "netbox_custom_objects.CustomObject" - target_model = model if is_self_referential else "netbox_custom_objects.CustomObject" attrs = { "__module__": "netbox_custom_objects.models", @@ -732,7 +731,7 @@ def after_model_generation(self, instance, model, field_name): # Source field should point to the current model source_field.remote_field.model = model source_field.related_model = model - + # Target field should point to the related model target_field.remote_field.model = to_model target_field.related_model = to_model @@ -770,11 +769,11 @@ def create_m2m_table(self, instance, model, field_name): # Update the through model's foreign key references source_field = through._meta.get_field("source") target_field = through._meta.get_field("target") - + # Source field should point to the current model source_field.remote_field.model = model source_field.related_model = model - + # Target field should point to the related model target_field.remote_field.model = to_model target_field.related_model = to_model diff --git a/netbox_custom_objects/models.py b/netbox_custom_objects/models.py index dbb14d9..7112284 100644 --- a/netbox_custom_objects/models.py +++ b/netbox_custom_objects/models.py @@ -7,7 +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.management import create_contenttypes from django.contrib.contenttypes.models import ContentType from django.core.validators import RegexValidator, ValidationError from django.db import connection, models @@ -22,7 +22,7 @@ from netbox.models import ChangeLoggedModel, NetBoxModel from netbox.models.features import ( BookmarksMixin, ChangeLoggingMixin, CloningMixin, CustomLinksMixin, CustomValidationMixin, EventRulesMixin, - ExportTemplatesMixin, JournalingMixin, NotificationsMixin, TagsMixin, + ExportTemplatesMixin, JournalingMixin, NotificationsMixin ) from netbox.registry import registry from utilities import filters @@ -51,15 +51,15 @@ class CustomObject( ): """ Base class for dynamically generated custom object models. - + This abstract model serves as the foundation for all custom object types created through the CustomObjectType system. When a CustomObjectType is created, a concrete model class is dynamically generated that inherits from this base class and includes the specific fields defined in the CustomObjectType's schema. - + This class should not be used directly - instead, use CustomObjectType.get_model() to create concrete model classes for specific custom object types. - + Attributes: _generated_table_model (property): Indicates this is a generated table model """ @@ -94,12 +94,11 @@ def get_absolute_url(self): ) - class CustomObjectType(NetBoxModel): # Class-level cache for generated models _model_cache = {} _through_model_cache = {} # Now stores {custom_object_type_id: {through_model_name: through_model}} - + name = models.CharField(max_length=100, unique=True) description = models.TextField(blank=True) schema = models.JSONField(blank=True, default=dict) @@ -123,7 +122,7 @@ def __str__(self): def clear_model_cache(cls, custom_object_type_id=None): """ Clear the model cache for a specific CustomObjectType or all models. - + :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: @@ -140,7 +139,7 @@ def clear_model_cache(cls, custom_object_type_id=None): def get_cached_model(cls, custom_object_type_id): """ Get a cached model for a specific CustomObjectType if it exists. - + :param custom_object_type_id: ID of the CustomObjectType :return: The cached model or None if not found """ @@ -150,17 +149,17 @@ def get_cached_model(cls, custom_object_type_id): def is_model_cached(cls, custom_object_type_id): """ Check if a model is cached for a specific CustomObjectType. - + :param custom_object_type_id: ID of the CustomObjectType :return: True if the model is cached, False otherwise """ return custom_object_type_id in cls._model_cache - + @classmethod def get_cached_through_model(cls, custom_object_type_id, through_model_name): """ Get a specific cached through model for a CustomObjectType. - + :param custom_object_type_id: ID of the CustomObjectType :param through_model_name: Name of the through model to retrieve :return: The cached through model or None if not found @@ -168,12 +167,12 @@ def get_cached_through_model(cls, custom_object_type_id, through_model_name): if custom_object_type_id in cls._through_model_cache: return cls._through_model_cache[custom_object_type_id].get(through_model_name) return None - + @classmethod def get_cached_through_models(cls, custom_object_type_id): """ Get all cached through models for a CustomObjectType. - + :param custom_object_type_id: ID of the CustomObjectType :return: Dict of through models or empty dict if not found """ @@ -327,7 +326,7 @@ def get_model( :return: The generated model. :rtype: 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) @@ -374,9 +373,9 @@ def get_model( from taggit.models import GenericTaggedItemBase from extras.models.tags import Tag from utilities.querysets import RestrictedQuerySet - + through_model_name = f'CustomObjectTaggedItem{self.id}' - + # Create a unique through model for this CustomObjectType through_model = type( through_model_name, @@ -397,7 +396,7 @@ def get_model( }) } ) - + attrs['tags'] = TaggableManager( through=through_model, ordering=('weight', 'name'), @@ -418,7 +417,7 @@ 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 - + return model def create_model(self): @@ -431,7 +430,7 @@ 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] @@ -450,7 +449,7 @@ def save(self, *args, **kwargs): def delete(self, *args, **kwargs): # Clear the model cache for this CustomObjectType self.clear_model_cache(self.id) - + model = self.get_model() ContentType.objects.get( app_label=APP_LABEL, model=self.get_table_model_name(self.id).lower() @@ -1106,7 +1105,7 @@ def save(self, *args, **kwargs): model_field = field_type.get_model_field(self) model = self.custom_object_type.get_model() model_field.contribute_to_class(model, self.name) - + with connection.schema_editor() as schema_editor: if self._state.adding: schema_editor.add_field(model, model_field) @@ -1116,10 +1115,10 @@ def save(self, *args, **kwargs): old_field = field_type.get_model_field(self.original) old_field.contribute_to_class(model, self._original_name) schema_editor.alter_field(model, old_field, model_field) - + # Clear and refresh the model cache for this CustomObjectType when a field is modified self.custom_object_type.clear_model_cache(self.custom_object_type.id) - + super().save(*args, **kwargs) def delete(self, *args, **kwargs): @@ -1127,7 +1126,7 @@ def delete(self, *args, **kwargs): model_field = field_type.get_model_field(self) model = self.custom_object_type.get_model() model_field.contribute_to_class(model, self.name) - + with connection.schema_editor() as schema_editor: if self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT: apps = model._meta.apps @@ -1135,7 +1134,7 @@ def delete(self, *args, **kwargs): schema_editor.delete_model(through_model) schema_editor.remove_field(model, model_field) - # Clear the model cache for this CustomObjectType when a field is deleted + # Clear the model cache for this CustomObjectType when a field is deleted self.custom_object_type.clear_model_cache(self.custom_object_type.id) super().delete(*args, **kwargs) diff --git a/netbox_custom_objects/navigation.py b/netbox_custom_objects/navigation.py index 97cfd54..8381540 100644 --- a/netbox_custom_objects/navigation.py +++ b/netbox_custom_objects/navigation.py @@ -60,4 +60,4 @@ def __iter__(self): label=_("Custom Objects"), groups=tuple(groups), icon_class="mdi mdi-toy-brick-outline", -) \ No newline at end of file +) diff --git a/netbox_custom_objects/templatetags/custom_object_buttons.py b/netbox_custom_objects/templatetags/custom_object_buttons.py index 5becc6a..caf49eb 100644 --- a/netbox_custom_objects/templatetags/custom_object_buttons.py +++ b/netbox_custom_objects/templatetags/custom_object_buttons.py @@ -33,7 +33,7 @@ @register.inclusion_tag("buttons/bookmark.html", takes_context=True) def custom_object_bookmark_button(context, instance): try: - + # Check if this user has already bookmarked the object content_type = ContentType.objects.get_for_model(instance) instance.custom_object_type.get_model() @@ -45,7 +45,7 @@ def custom_object_bookmark_button(context, instance): except Exception: # If we can't get the model class, don't show the bookmark button return {} - + bookmark = Bookmark.objects.filter( object_type=content_type, object_id=instance.pk, user=context["request"].user ).first() @@ -132,7 +132,7 @@ def custom_object_subscribe_button(context, instance): try: # Check if this user has already subscribed to the object content_type = ContentType.objects.get_for_model(instance) - + # Verify that the ContentType is properly accessible try: # This will test if the ContentType can be used to retrieve the model @@ -140,7 +140,7 @@ def custom_object_subscribe_button(context, instance): except Exception: # If we can't get the model class, don't show the subscribe button return {} - + subscription = Subscription.objects.filter( object_type=content_type, object_id=instance.pk, user=context["request"].user ).first() From 67e6e5212fe62350864337709fdc7cb527673b7f Mon Sep 17 00:00:00 2001 From: Arthur Date: Mon, 21 Jul 2025 15:14:25 -0700 Subject: [PATCH 22/24] clone fields --- netbox_custom_objects/models.py | 40 ++++++++++++++++++++++++--------- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/netbox_custom_objects/models.py b/netbox_custom_objects/models.py index 7112284..14b4600 100644 --- a/netbox_custom_objects/models.py +++ b/netbox_custom_objects/models.py @@ -15,16 +15,21 @@ 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, NetBoxModel -from netbox.models.features import ( - BookmarksMixin, ChangeLoggingMixin, CloningMixin, CustomLinksMixin, CustomValidationMixin, EventRulesMixin, - ExportTemplatesMixin, JournalingMixin, NotificationsMixin -) +from netbox.models.features import (BookmarksMixin, ChangeLoggingMixin, + CloningMixin, CustomLinksMixin, + CustomValidationMixin, EventRulesMixin, + ExportTemplatesMixin, JournalingMixin, + NotificationsMixin) 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 @@ -84,6 +89,23 @@ def _generated_table_model(self): # An indication that the model is a generated table model. return True + @property + def clone_fields(self): + """ + Return a tuple of field names that should be cloned when this object is cloned. + This property dynamically determines which fields to clone based on the + is_cloneable flag on the associated CustomObjectTypeField instances. + """ + if not hasattr(self, 'custom_object_type_id'): + return () + + # 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) + + return tuple(cloneable_fields) + def get_absolute_url(self): return reverse( "plugins:netbox_custom_objects:customobject", @@ -369,10 +391,6 @@ def get_model( attrs.update(**field_attrs) # Create a unique through model for tagging for this CustomObjectType - from taggit.managers import TaggableManager - from taggit.models import GenericTaggedItemBase - from extras.models.tags import Tag - from utilities.querysets import RestrictedQuerySet through_model_name = f'CustomObjectTaggedItem{self.id}' From 465f71e6581514043a031a1f6792b4c78f0fcf07 Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 22 Jul 2025 10:48:25 -0700 Subject: [PATCH 23/24] add custom links --- .../netbox_custom_objects/customobject.html | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/netbox_custom_objects/templates/netbox_custom_objects/customobject.html b/netbox_custom_objects/templates/netbox_custom_objects/customobject.html index f0c5f35..8e9b0fe 100644 --- a/netbox_custom_objects/templates/netbox_custom_objects/customobject.html +++ b/netbox_custom_objects/templates/netbox_custom_objects/customobject.html @@ -1,6 +1,7 @@ {% extends 'generic/object.html' %} {% load static %} {% load custom_object_buttons %} +{% load custom_links %} {% load helpers %} {% load perms %} {% load plugins %} @@ -14,7 +15,7 @@ {% block page-header %}
-
+
{# Title #}
@@ -45,7 +46,17 @@

{% block title %}{{ object }}{% endblock title %}

{% endif %} {% endblock %}
- {% endblock controls %} + + {# Custom links #} +
+
+ {% block custom-links %} + {% custom_links object %} + {% endblock custom-links %} +
+
+ + {% endblock controls %}
@@ -104,7 +115,6 @@

{% block title %}{{ object }}{% endblock title %}

{% include 'inc/panels/tags.html' %} - {% include 'inc/panels/comments.html' %} {% plugin_right_page object %} {% for field in fields %} {% if field.many %} From e18b7ad83a42e67d96d307957f71cc9951c630b4 Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 22 Jul 2025 10:57:33 -0700 Subject: [PATCH 24/24] fix multi-object fields creation --- netbox_custom_objects/field_types.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/netbox_custom_objects/field_types.py b/netbox_custom_objects/field_types.py index b0c1939..125fdf5 100644 --- a/netbox_custom_objects/field_types.py +++ b/netbox_custom_objects/field_types.py @@ -772,10 +772,12 @@ def create_m2m_table(self, instance, model, field_name): # Source field should point to the current model source_field.remote_field.model = model + source_field.remote_field.field_name = model._meta.pk.name source_field.related_model = model # Target field should point to the related model target_field.remote_field.model = to_model + target_field.remote_field.field_name = to_model._meta.pk.name target_field.related_model = to_model # Register the model with Django's app registry @@ -790,6 +792,7 @@ def create_m2m_table(self, instance, model, field_name): # Update the M2M field's through model and target model field.remote_field.through = through_model field.remote_field.model = to_model + field.remote_field.field_name = to_model._meta.pk.name # Create the through table with connection.schema_editor() as schema_editor: