diff --git a/netbox_custom_objects/__init__.py b/netbox_custom_objects/__init__.py
index 5515651..288a96b 100644
--- a/netbox_custom_objects/__init__.py
+++ b/netbox_custom_objects/__init__.py
@@ -13,30 +13,5 @@ class CustomObjectsPluginConfig(PluginConfig):
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()
-
config = CustomObjectsPluginConfig
diff --git a/netbox_custom_objects/api/views.py b/netbox_custom_objects/api/views.py
index d366c41..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
@@ -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/field_types.py b/netbox_custom_objects/field_types.py
index d69ce6f..125fdf5 100644
--- a/netbox_custom_objects/field_types.py
+++ b/netbox_custom_objects/field_types.py
@@ -611,16 +611,15 @@ 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"
+
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 +651,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 +695,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 +720,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 +765,24 @@ 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.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
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:
@@ -769,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:
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/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 4052f7a..14b4600 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
@@ -15,17 +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, TagsMixin,
-# )
-from netbox.models.features import CloningMixin, ExportTemplatesMixin, TagsMixin
+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
@@ -35,31 +39,88 @@
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_"
class CustomObject(
- # BookmarksMixin,
- # ChangeLoggingMixin,
- # CloningMixin,
- # CustomLinksMixin,
- # CustomValidationMixin,
- # ExportTemplatesMixin,
- # JournalingMixin,
- # NotificationsMixin,
- TagsMixin,
- # EventRulesMixin,
- models.Model,
+ BookmarksMixin,
+ ChangeLoggingMixin,
+ CloningMixin,
+ CustomLinksMixin,
+ CustomValidationMixin,
+ ExportTemplatesMixin,
+ JournalingMixin,
+ NotificationsMixin,
+ EventRulesMixin,
):
+ """
+ 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()
+ 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)
+ 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
+
+ @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",
+ kwargs={
+ "pk": self.pk,
+ "custom_object_type": self.custom_object_type.name.lower(),
+ },
+ )
+
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)
@@ -83,20 +144,24 @@ 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:
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):
"""
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
"""
@@ -106,12 +171,35 @@ 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
+ """
+ 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):
result = "
"
@@ -135,35 +223,37 @@ 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"
@property
def content_type(self):
- return ContentType.objects.get(
- app_label=APP_LABEL, model=self.get_table_model_name(self.id).lower()
- )
+ try:
+ return self.get_or_create_content_type()
+ except Exception:
+ # If we still can't get it, return None
+ return None
+
+ 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 _fetch_and_generate_field_attrs(
self,
@@ -184,7 +274,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] = {
@@ -199,8 +288,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
@@ -261,7 +348,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)
@@ -275,14 +362,8 @@ 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(
"Meta",
(),
@@ -298,76 +379,83 @@ 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,
- "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)
+ # Create a unique through model for tagging for this CustomObjectType
+
+ through_model_name = f'CustomObjectTaggedItem{self.id}'
+
+ # Create a unique through model for this CustomObjectType
+ through_model = type(
+ through_model_name,
+ (GenericTaggedItemBase,),
+ {
+ '__module__': 'netbox_custom_objects.models',
+ 'tag': models.ForeignKey(
+ to=Tag,
+ related_name=f"custom_objects_{through_model_name.lower()}_items",
+ on_delete=models.CASCADE
+ ),
+ '_netbox_private': True,
+ 'objects': RestrictedQuerySet.as_manager(),
+ 'Meta': type('Meta', (), {
+ 'indexes': [models.Index(fields=["content_type", "object_id"])],
+ 'verbose_name': f'tagged item {self.id}',
+ 'verbose_name_plural': f'tagged items {self.id}',
+ })
+ }
+ )
+
+ attrs['tags'] = TaggableManager(
+ through=through_model,
+ ordering=('weight', 'name'),
+ )
+
# Create the model class.
model = type(
str(model_name),
- (CustomObject,),
+ (CustomObject, models.Model),
attrs,
)
- # patch_meta_get_field(model._meta)
-
if not manytomany_models:
self._after_model_generation(attrs, model)
- # Cache the generated model
+ # Cache the generated model and its through models
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
+
return model
def create_model(self):
+ # Get the model and ensure it's registered
+ model = self.get_model()
+
+ # Ensure the ContentType exists and is immediately available
+ self.get_or_create_content_type()
model = self.get_model()
- apps.register_model(APP_LABEL, model)
- app_config = apps.get_app_config(APP_LABEL)
- create_contenttypes(app_config)
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.pk is None
needs_db_create = self._state.adding
super().save(*args, **kwargs)
if needs_db_create:
@@ -379,33 +467,21 @@ 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()
- # self.content_type.delete()
ContentType.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)
-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"
@@ -1047,7 +1123,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)
- # apps.register_model(APP_LABEL, model)
+
with connection.schema_editor() as schema_editor:
if self._state.adding:
schema_editor.add_field(model, model_field)
@@ -1057,10 +1133,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):
@@ -1068,7 +1144,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)
- # apps.register_model(APP_LABEL, model)
+
with connection.schema_editor() as schema_editor:
if self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
apps = model._meta.apps
@@ -1076,7 +1152,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/templates/netbox_custom_objects/customobject.html b/netbox_custom_objects/templates/netbox_custom_objects/customobject.html
index 6f6d722..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 #}
@@ -32,10 +33,10 @@
{% 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 %}#}
+ {% custom_object_clone_button object %}
{% endif %}
{% if request.user|can_change:object %}
{% custom_object_edit_button object %}
@@ -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 %}
@@ -100,10 +111,10 @@
{% block title %}{{ object }}{% endblock title %}
{% endfor %}
- {# {% include 'inc/panels/tags.html' %} #}
{% plugin_left_page object %}
+ {% include 'inc/panels/tags.html' %}
{% plugin_right_page object %}
{% for field in fields %}
{% if field.many %}
@@ -130,4 +141,4 @@
{% plugin_full_width_page object %}
-{% endblock %}
+{% endblock %}
\ 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 624ff1f..caf49eb 100644
--- a/netbox_custom_objects/templatetags/custom_object_buttons.py
+++ b/netbox_custom_objects/templatetags/custom_object_buttons.py
@@ -32,43 +32,60 @@
@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:
- return {
- "bookmark": bookmark,
- "form_url": form_url,
- "form_data": form_data,
- "return_url": instance.get_absolute_url(),
- }
+ # 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:
+ # 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(),
+ }
+ except Exception:
+ # If we can't get the content type, don't show the bookmark button
+ return {}
@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,
@@ -112,31 +129,44 @@ 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:
+ # 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")
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 5a1fcff..214b7d8 100644
--- a/netbox_custom_objects/views.py
+++ b/netbox_custom_objects/views.py
@@ -278,14 +278,21 @@ def get_extra_context(self, request):
@register_model_view(CustomObject)
class CustomObjectView(generic.ObjectView):
- queryset = CustomObject.objects.all()
+ template_name = "netbox_custom_objects/customobject.html"
+
+ def get_queryset(self, request):
+ 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()
- # kwargs.pop('custom_object_type', None)
- 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")
@@ -345,14 +352,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,),