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 = "