From 30dfe5a62ef71dd8b36c68c5bc9bf3bae60a1279 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 22 Jul 2025 10:11:11 -0400 Subject: [PATCH 01/14] Convert ObjectType to a concrete child model of ContentType --- .../migrations/0016_concrete_objecttype.py | 75 +++++++++++++++++++ netbox/core/models/contenttypes.py | 28 +++++-- .../migrations/0130_concrete_objecttype.py | 56 ++++++++++++++ .../migrations/0010_concrete_objecttype.py | 17 +++++ 4 files changed, 171 insertions(+), 5 deletions(-) create mode 100644 netbox/core/migrations/0016_concrete_objecttype.py create mode 100644 netbox/extras/migrations/0130_concrete_objecttype.py create mode 100644 netbox/users/migrations/0010_concrete_objecttype.py diff --git a/netbox/core/migrations/0016_concrete_objecttype.py b/netbox/core/migrations/0016_concrete_objecttype.py new file mode 100644 index 000000000..e17e6f846 --- /dev/null +++ b/netbox/core/migrations/0016_concrete_objecttype.py @@ -0,0 +1,75 @@ +import django.contrib.postgres.fields +import django.db.models.deletion +from django.db import migrations, models + +import core.models.contenttypes + + +def populate_object_types(apps, schema_editor): + """ + Create an ObjectType record for each valid ContentType. + """ + ContentType = apps.get_model('contenttypes', 'ContentType') + ObjectType = apps.get_model('core', 'ObjectType') + + for ct in ContentType.objects.all(): + try: + # Validate ContentType + apps.get_model(ct.app_label, ct.model) + except LookupError: + continue + ObjectType(pk=ct.pk).save_base(raw=True) + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('core', '0015_remove_redundant_indexes'), + ] + + operations = [ + # Delete the proxy model from the migration state + migrations.DeleteModel( + name='ObjectType', + ), + # Create the new concrete model + migrations.CreateModel( + name='ObjectType', + fields=[ + ( + 'contenttype_ptr', + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to='contenttypes.contenttype', + related_name='object_type' + ) + ), + ( + 'features', + django.contrib.postgres.fields.ArrayField( + base_field=models.CharField(max_length=50), + blank=True, + null=True, + size=None + ) + ), + ], + options={ + 'verbose_name': 'object type', + 'verbose_name_plural': 'object types', + }, + bases=('contenttypes.contenttype',), + managers=[ + ('objects', core.models.contenttypes.ObjectTypeManager()), + ], + ), + # Create an ObjectType record for each ContentType + migrations.RunPython( + code=populate_object_types, + reverse_code=migrations.RunPython.noop, + ), + ] diff --git a/netbox/core/models/contenttypes.py b/netbox/core/models/contenttypes.py index a7d5c91af..899e88c31 100644 --- a/netbox/core/models/contenttypes.py +++ b/netbox/core/models/contenttypes.py @@ -1,5 +1,8 @@ from django.contrib.contenttypes.models import ContentType, ContentTypeManager +from django.contrib.postgres.fields import ArrayField +from django.db import models from django.db.models import Q +from django.utils.translation import gettext as _ from netbox.plugins import PluginConfig from netbox.registry import registry @@ -19,8 +22,8 @@ class ObjectTypeManager(ContentTypeManager): in registry['models'] and intended for reference by other objects. """ q = Q() - for app_label, models in registry['models'].items(): - q |= Q(app_label=app_label, model__in=models) + for app_label, model_list in registry['models'].items(): + q |= Q(app_label=app_label, model__in=model_list) return self.get_queryset().filter(q) def with_feature(self, feature): @@ -36,8 +39,8 @@ class ObjectTypeManager(ContentTypeManager): ) q = Q() - for app_label, models in registry['model_features'][feature].items(): - q |= Q(app_label=app_label, model__in=models) + for app_label, model_list in registry['model_features'][feature].items(): + q |= Q(app_label=app_label, model__in=model_list) return self.get_queryset().filter(q) @@ -46,10 +49,25 @@ class ObjectType(ContentType): """ Wrap Django's native ContentType model to use our custom manager. """ + contenttype_ptr = models.OneToOneField( + on_delete=models.CASCADE, + to='contenttypes.ContentType', + parent_link=True, + primary_key=True, + serialize=False, + related_name='object_type' + ) + features = ArrayField( + base_field=models.CharField(max_length=50), + blank=True, + null=True, + ) + objects = ObjectTypeManager() class Meta: - proxy = True + verbose_name = _('object type') + verbose_name_plural = _('object types') @property def app_labeled_name(self): diff --git a/netbox/extras/migrations/0130_concrete_objecttype.py b/netbox/extras/migrations/0130_concrete_objecttype.py new file mode 100644 index 000000000..dcfad9272 --- /dev/null +++ b/netbox/extras/migrations/0130_concrete_objecttype.py @@ -0,0 +1,56 @@ +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('core', '0016_concrete_objecttype'), + ('extras', '0129_fix_script_paths'), + ] + + operations = [ + migrations.AlterField( + model_name='Tag', + name='object_types', + field=models.ManyToManyField(blank=True, related_name='+', to='core.objecttype'), + ), + migrations.AlterField( + model_name='CustomField', + name='related_object_type', + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='core.objecttype' + ), + ), + migrations.AlterField( + model_name='CustomField', + name='object_types', + field=models.ManyToManyField(related_name='custom_fields', to='core.objecttype'), + ), + migrations.AlterField( + model_name='EventRule', + name='object_types', + field=models.ManyToManyField(related_name='event_rules', to='core.objecttype'), + ), + migrations.AlterField( + model_name='CustomLink', + name='object_types', + field=models.ManyToManyField(related_name='custom_links', to='core.objecttype'), + ), + migrations.AlterField( + model_name='ExportTemplate', + name='object_types', + field=models.ManyToManyField(related_name='export_templates', to='core.objecttype'), + ), + migrations.AlterField( + model_name='SavedFilter', + name='object_types', + field=models.ManyToManyField(related_name='saved_filters', to='core.objecttype'), + ), + migrations.AlterField( + model_name='TableConfig', + name='object_type', + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, related_name='table_configs', to='core.objecttype' + ), + ), + ] diff --git a/netbox/users/migrations/0010_concrete_objecttype.py b/netbox/users/migrations/0010_concrete_objecttype.py new file mode 100644 index 000000000..d01d9f30f --- /dev/null +++ b/netbox/users/migrations/0010_concrete_objecttype.py @@ -0,0 +1,17 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0016_concrete_objecttype'), + ('users', '0009_update_group_perms'), + ] + + operations = [ + migrations.AlterField( + model_name='ObjectPermission', + name='object_types', + field=models.ManyToManyField(related_name='object_permissions', to='core.objecttype'), + ), + ] From 1871f6f3d74cc5ea44cfc97eebf53e5fc84ff79e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 22 Jul 2025 10:22:12 -0400 Subject: [PATCH 02/14] Add public flag to ObjectType --- netbox/core/migrations/0016_concrete_objecttype.py | 6 ++++++ netbox/core/models/contenttypes.py | 5 ++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/netbox/core/migrations/0016_concrete_objecttype.py b/netbox/core/migrations/0016_concrete_objecttype.py index e17e6f846..041fe6f7d 100644 --- a/netbox/core/migrations/0016_concrete_objecttype.py +++ b/netbox/core/migrations/0016_concrete_objecttype.py @@ -48,6 +48,12 @@ class Migration(migrations.Migration): related_name='object_type' ) ), + ( + 'public', + models.BooleanField( + default=False + ) + ), ( 'features', django.contrib.postgres.fields.ArrayField( diff --git a/netbox/core/models/contenttypes.py b/netbox/core/models/contenttypes.py index 899e88c31..6b6dd37a4 100644 --- a/netbox/core/models/contenttypes.py +++ b/netbox/core/models/contenttypes.py @@ -55,7 +55,10 @@ class ObjectType(ContentType): parent_link=True, primary_key=True, serialize=False, - related_name='object_type' + related_name='object_type', + ) + public = models.BooleanField( + default=False, ) features = ArrayField( base_field=models.CharField(max_length=50), From 28e5543297df2ee6225f7b75418b752de72f7558 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 22 Jul 2025 10:59:48 -0400 Subject: [PATCH 03/14] Catch post_migrate signal to update ObjectTypes --- netbox/core/models/contenttypes.py | 21 ++++++-------------- netbox/core/signals.py | 32 ++++++++++++++++++++++++++++-- 2 files changed, 36 insertions(+), 17 deletions(-) diff --git a/netbox/core/models/contenttypes.py b/netbox/core/models/contenttypes.py index 6b6dd37a4..a49bb0882 100644 --- a/netbox/core/models/contenttypes.py +++ b/netbox/core/models/contenttypes.py @@ -1,7 +1,6 @@ from django.contrib.contenttypes.models import ContentType, ContentTypeManager from django.contrib.postgres.fields import ArrayField from django.db import models -from django.db.models import Q from django.utils.translation import gettext as _ from netbox.plugins import PluginConfig @@ -18,31 +17,23 @@ class ObjectTypeManager(ContentTypeManager): def public(self): """ - Filter the base queryset to return only ContentTypes corresponding to "public" models; those which are listed - in registry['models'] and intended for reference by other objects. + Filter the base queryset to return only ObjectTypes corresponding to "public" models; those which are intended + for reference by other objects. """ - q = Q() - for app_label, model_list in registry['models'].items(): - q |= Q(app_label=app_label, model__in=model_list) - return self.get_queryset().filter(q) + return self.get_queryset().filter(public=True) def with_feature(self, feature): """ Return the ContentTypes only for models which are registered as supporting the specified feature. For example, - we can find all ContentTypes for models which support webhooks with + we can find all ContentTypes for models which support event rules with: - ContentType.objects.with_feature('event_rules') + ObjectType.objects.with_feature('event_rules') """ if feature not in registry['model_features']: raise KeyError( f"{feature} is not a registered model feature! Valid features are: {registry['model_features'].keys()}" ) - - q = Q() - for app_label, model_list in registry['model_features'][feature].items(): - q |= Q(app_label=app_label, model__in=model_list) - - return self.get_queryset().filter(q) + return self.get_queryset().filter(features__contains=[feature]) class ObjectType(ContentType): diff --git a/netbox/core/signals.py b/netbox/core/signals.py index 8ba8cc244..c64d11bc9 100644 --- a/netbox/core/signals.py +++ b/netbox/core/signals.py @@ -3,18 +3,19 @@ import logging from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.db.models.fields.reverse_related import ManyToManyRel, ManyToOneRel -from django.db.models.signals import m2m_changed, post_save, pre_delete +from django.db.models.signals import m2m_changed, post_migrate, post_save, pre_delete from django.dispatch import receiver, Signal from django.utils.translation import gettext_lazy as _ from django_prometheus.models import model_deletes, model_inserts, model_updates from core.choices import JobStatusChoices, ObjectChangeActionChoices from core.events import * +from core.models import ObjectType from extras.events import enqueue_event from extras.utils import run_validators from netbox.config import get_config from netbox.context import current_request, events_queue -from netbox.models.features import ChangeLoggingMixin +from netbox.models.features import ChangeLoggingMixin, FEATURES_MAP from utilities.exceptions import AbortRequest from .models import ConfigRevision, DataSource, ObjectChange @@ -38,6 +39,33 @@ post_sync = Signal() clear_events = Signal() +# +# Model registration +# + +@receiver(post_migrate) +def update_object_types(sender, **kwargs): + models = sender.get_models() + + for model in models: + app_label, model_name = model._meta.label_lower.split('.') + + # Determine whether model is public + is_public = not getattr(model, '_netbox_private', False) + + # Determine NetBox features supported by the model + features = [ + feature for feature, cls in FEATURES_MAP.items() if issubclass(model, cls) + ] + + # TODO: Update ObjectTypes in bulk + # Update the ObjectType for the model + ObjectType.objects.filter(app_label=app_label, model=model_name).update( + public=is_public, + features=features, + ) + + # # Change logging & event handling # From 68edba8c220816e9f8c6fe4b12899bdb13f66b9a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 22 Jul 2025 11:31:53 -0400 Subject: [PATCH 04/14] Reference ObjectType records instead of registry for feature support --- ...ecttype.py => 0017_concrete_objecttype.py} | 2 +- netbox/extras/events.py | 9 +++++---- ...ecttype.py => 0131_concrete_objecttype.py} | 4 ++-- netbox/extras/models/configs.py | 20 +++++++++---------- netbox/extras/signals.py | 16 ++++++++++----- netbox/netbox/models/features.py | 4 ++++ netbox/netbox/tests/test_plugins.py | 6 ++++-- .../migrations/0010_concrete_objecttype.py | 2 +- 8 files changed, 37 insertions(+), 26 deletions(-) rename netbox/core/migrations/{0016_concrete_objecttype.py => 0017_concrete_objecttype.py} (97%) rename netbox/extras/migrations/{0130_concrete_objecttype.py => 0131_concrete_objecttype.py} (95%) diff --git a/netbox/core/migrations/0016_concrete_objecttype.py b/netbox/core/migrations/0017_concrete_objecttype.py similarity index 97% rename from netbox/core/migrations/0016_concrete_objecttype.py rename to netbox/core/migrations/0017_concrete_objecttype.py index 041fe6f7d..1c3fec669 100644 --- a/netbox/core/migrations/0016_concrete_objecttype.py +++ b/netbox/core/migrations/0017_concrete_objecttype.py @@ -25,7 +25,7 @@ class Migration(migrations.Migration): dependencies = [ ('contenttypes', '0002_remove_content_type_name'), - ('core', '0015_remove_redundant_indexes'), + ('core', '0016_job_log_entries'), ] operations = [ diff --git a/netbox/extras/events.py b/netbox/extras/events.py index d7c642c4e..36fa87661 100644 --- a/netbox/extras/events.py +++ b/netbox/extras/events.py @@ -9,9 +9,9 @@ from django.utils.translation import gettext as _ from django_rq import get_queue from core.events import * +from core.models import ObjectType from netbox.config import get_config from netbox.constants import RQ_QUEUE_DEFAULT -from netbox.registry import registry from users.models import User from utilities.api import get_serializer_for_model from utilities.rqworker import get_rq_retry @@ -55,11 +55,12 @@ def enqueue_event(queue, instance, user, request_id, event_type): Enqueue a serialized representation of a created/updated/deleted object for the processing of events once the request has completed. """ - # Determine whether this type of object supports event rules + # Bail if this type of object does not support event rules + if 'event_rules' not in ObjectType.objects.get_for_model(instance).features: + return + app_label = instance._meta.app_label model_name = instance._meta.model_name - if model_name not in registry['model_features']['event_rules'].get(app_label, []): - return assert instance.pk is not None key = f'{app_label}.{model_name}:{instance.pk}' diff --git a/netbox/extras/migrations/0130_concrete_objecttype.py b/netbox/extras/migrations/0131_concrete_objecttype.py similarity index 95% rename from netbox/extras/migrations/0130_concrete_objecttype.py rename to netbox/extras/migrations/0131_concrete_objecttype.py index dcfad9272..b93bdd882 100644 --- a/netbox/extras/migrations/0130_concrete_objecttype.py +++ b/netbox/extras/migrations/0131_concrete_objecttype.py @@ -4,8 +4,8 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('core', '0016_concrete_objecttype'), - ('extras', '0129_fix_script_paths'), + ('core', '0017_concrete_objecttype'), + ('extras', '0130_imageattachment_description'), ] operations = [ diff --git a/netbox/extras/models/configs.py b/netbox/extras/models/configs.py index 8d6b8d999..f92c66632 100644 --- a/netbox/extras/models/configs.py +++ b/netbox/extras/models/configs.py @@ -1,15 +1,16 @@ -from django.apps import apps +from collections import defaultdict + from django.conf import settings from django.core.validators import ValidationError from django.db import models from django.urls import reverse from django.utils.translation import gettext_lazy as _ +from core.models import ObjectType from extras.models.mixins import RenderTemplateMixin from extras.querysets import ConfigContextQuerySet from netbox.models import ChangeLoggedModel from netbox.models.features import CloningMixin, CustomLinksMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin -from netbox.registry import registry from utilities.data import deepmerge __all__ = ( @@ -239,15 +240,12 @@ class ConfigTemplate( sync_data.alters_data = True def get_context(self, context=None, queryset=None): - _context = dict() - for app, model_names in registry['models'].items(): - _context.setdefault(app, {}) - for model_name in model_names: - try: - model = apps.get_registered_model(app, model_name) - _context[app][model.__name__] = model - except LookupError: - pass + _context = defaultdict(dict) + + # Populate all public models for reference within the template + for object_type in ObjectType.objects.public(): + if model := object_type.model_class(): + _context[object_type.app_label][model.__name__] = model # Apply the provided context data, if any if context is not None: diff --git a/netbox/extras/signals.py b/netbox/extras/signals.py index 10c3f73c5..bee31acc9 100644 --- a/netbox/extras/signals.py +++ b/netbox/extras/signals.py @@ -8,7 +8,6 @@ from core.signals import job_end, job_start from extras.events import process_event_rules from extras.models import EventRule, Notification, Subscription from netbox.config import get_config -from netbox.registry import registry from netbox.signals import post_clean from utilities.exceptions import AbortRequest from .models import CustomField, TaggedItem @@ -150,17 +149,24 @@ def notify_object_changed(sender, instance, **kwargs): event_type = OBJECT_DELETED # Skip unsupported object types - ct = ContentType.objects.get_for_model(instance) - if ct.model not in registry['model_features']['notifications'].get(ct.app_label, []): + object_type = ObjectType.objects.get_for_model(instance) + if 'notifications' not in object_type.features: return # Find all subscribed Users - subscribed_users = Subscription.objects.filter(object_type=ct, object_id=instance.pk).values_list('user', flat=True) + subscribed_users = Subscription.objects.filter( + object_type=object_type, + object_id=instance.pk + ).values_list('user', flat=True) if not subscribed_users: return # Delete any existing Notifications for the object - Notification.objects.filter(object_type=ct, object_id=instance.pk, user__in=subscribed_users).delete() + Notification.objects.filter( + object_type=object_type, + object_id=instance.pk, + user__in=subscribed_users + ).delete() # Create Notifications for Subscribers Notification.objects.bulk_create([ diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index 79145ce70..c81727d68 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -32,6 +32,7 @@ __all__ = ( 'CustomValidationMixin', 'EventRulesMixin', 'ExportTemplatesMixin', + 'FEATURES_MAP', 'ImageAttachmentsMixin', 'JobsMixin', 'JournalingMixin', @@ -633,6 +634,7 @@ FEATURES_MAP = { 'tags': TagsMixin, } +# TODO: Remove in NetBox v4.5 registry['model_features'].update({ feature: defaultdict(set) for feature in FEATURES_MAP.keys() }) @@ -653,10 +655,12 @@ def register_models(*models): for model in models: app_label, model_name = model._meta.label_lower.split('.') + # TODO: Remove in NetBox v4.5 # Register public models if not getattr(model, '_netbox_private', False): registry['models'][app_label].add(model_name) + # TODO: Remove in NetBox v4.5 # Record each applicable feature for the model in the registry features = { feature for feature, cls in FEATURES_MAP.items() if issubclass(model, cls) diff --git a/netbox/netbox/tests/test_plugins.py b/netbox/netbox/tests/test_plugins.py index 264c8e6f9..f1d2b34c8 100644 --- a/netbox/netbox/tests/test_plugins.py +++ b/netbox/netbox/tests/test_plugins.py @@ -6,6 +6,7 @@ from django.test import Client, TestCase, override_settings from django.urls import reverse from core.choices import JobIntervalChoices +from core.models import ObjectType from netbox.tests.dummy_plugin import config as dummy_config from netbox.tests.dummy_plugin.data_backends import DummyBackend from netbox.tests.dummy_plugin.jobs import DummySystemJob @@ -23,8 +24,9 @@ class PluginTest(TestCase): self.assertIn('netbox.tests.dummy_plugin.DummyPluginConfig', settings.INSTALLED_APPS) def test_model_registration(self): - self.assertIn('dummy_plugin', registry['models']) - self.assertIn('dummymodel', registry['models']['dummy_plugin']) + self.assertIsNone( + ObjectType.objects.filter(app_label='dummy_plugin', model='dummymodel') + ) def test_models(self): from netbox.tests.dummy_plugin.models import DummyModel diff --git a/netbox/users/migrations/0010_concrete_objecttype.py b/netbox/users/migrations/0010_concrete_objecttype.py index d01d9f30f..bfd9e20e5 100644 --- a/netbox/users/migrations/0010_concrete_objecttype.py +++ b/netbox/users/migrations/0010_concrete_objecttype.py @@ -4,7 +4,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('core', '0016_concrete_objecttype'), + ('core', '0017_concrete_objecttype'), ('users', '0009_update_group_perms'), ] From e38ba299283fd602413fa6efab2a4e5f91f48fb2 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 23 Jul 2025 13:00:25 -0400 Subject: [PATCH 05/14] Automatically create ObjectTypes --- .../migrations/0017_concrete_objecttype.py | 6 +- netbox/core/models/contenttypes.py | 53 ++++++++++++++++- netbox/core/signals.py | 57 +++++++++++++++---- 3 files changed, 99 insertions(+), 17 deletions(-) diff --git a/netbox/core/migrations/0017_concrete_objecttype.py b/netbox/core/migrations/0017_concrete_objecttype.py index 1c3fec669..42b624376 100644 --- a/netbox/core/migrations/0017_concrete_objecttype.py +++ b/netbox/core/migrations/0017_concrete_objecttype.py @@ -18,7 +18,8 @@ def populate_object_types(apps, schema_editor): apps.get_model(ct.app_label, ct.model) except LookupError: continue - ObjectType(pk=ct.pk).save_base(raw=True) + # TODO assign public/features + ObjectType(pk=ct.pk, features=[]).save_base(raw=True) class Migration(migrations.Migration): @@ -58,8 +59,7 @@ class Migration(migrations.Migration): 'features', django.contrib.postgres.fields.ArrayField( base_field=models.CharField(max_length=50), - blank=True, - null=True, + default=list, size=None ) ), diff --git a/netbox/core/models/contenttypes.py b/netbox/core/models/contenttypes.py index a49bb0882..9418172e1 100644 --- a/netbox/core/models/contenttypes.py +++ b/netbox/core/models/contenttypes.py @@ -1,5 +1,6 @@ from django.contrib.contenttypes.models import ContentType, ContentTypeManager from django.contrib.postgres.fields import ArrayField +from django.core.exceptions import ObjectDoesNotExist from django.db import models from django.utils.translation import gettext as _ @@ -13,8 +14,57 @@ __all__ = ( ) +class ObjectTypeQuerySet(models.QuerySet): + + def create(self, **kwargs): + # If attempting to create a new ObjectType for a given app_label & model, replace those kwargs + # with a reference to the ContentType (if one exists). + if (app_label := kwargs.get('app_label')) and (model := kwargs.get('model')): + try: + kwargs['contenttype_ptr'] = ContentType.objects.get(app_label=app_label, model=model) + kwargs.pop('app_label') + kwargs.pop('model') + except ObjectDoesNotExist: + pass + return super().create(**kwargs) + + class ObjectTypeManager(ContentTypeManager): + def get_queryset(self): + return ObjectTypeQuerySet(self.model, using=self._db) + + def create(self, **kwargs): + return self.get_queryset().create(**kwargs) + + def get_for_model(self, model, for_concrete_model=True): + """ + Return the ContentType object for a given model, creating the + ContentType if necessary. Lookups are cached so that subsequent lookups + for the same model don't hit the database. + """ + opts = self._get_opts(model, for_concrete_model) + try: + return self._get_from_cache(opts) + except KeyError: + pass + + # The ContentType entry was not found in the cache, therefore we + # proceed to load or create it. + try: + # Start with get() and not get_or_create() in order to use + # the db_for_read (see #20401). + ct = self.get(app_label=opts.app_label, model=opts.model_name) + except self.model.DoesNotExist: + # Not found in the database; we proceed to create it. This time + # use get_or_create to take care of any race conditions. + ct, __ = self.get_or_create( + app_label=opts.app_label, + model=opts.model_name, + ) + self._add_to_cache(self.db, ct) + return ct + def public(self): """ Filter the base queryset to return only ObjectTypes corresponding to "public" models; those which are intended @@ -53,8 +103,7 @@ class ObjectType(ContentType): ) features = ArrayField( base_field=models.CharField(max_length=50), - blank=True, - null=True, + default=list, ) objects = ObjectTypeManager() diff --git a/netbox/core/signals.py b/netbox/core/signals.py index c64d11bc9..50a360421 100644 --- a/netbox/core/signals.py +++ b/netbox/core/signals.py @@ -1,7 +1,8 @@ import logging from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ValidationError +from django.core.exceptions import ObjectDoesNotExist, ValidationError +from django.db import ProgrammingError from django.db.models.fields.reverse_related import ManyToManyRel, ManyToOneRel from django.db.models.signals import m2m_changed, post_migrate, post_save, pre_delete from django.dispatch import receiver, Signal @@ -39,8 +40,18 @@ post_sync = Signal() clear_events = Signal() +def model_is_public(model): + return not getattr(model, '_netbox_private', False) + + +def get_model_features(model): + return [ + feature for feature, cls in FEATURES_MAP.items() if issubclass(model, cls) + ] + + # -# Model registration +# Object types # @receiver(post_migrate) @@ -51,19 +62,41 @@ def update_object_types(sender, **kwargs): app_label, model_name = model._meta.label_lower.split('.') # Determine whether model is public - is_public = not getattr(model, '_netbox_private', False) + is_public = model_is_public(model) # Determine NetBox features supported by the model - features = [ - feature for feature, cls in FEATURES_MAP.items() if issubclass(model, cls) - ] + features = get_model_features(model) - # TODO: Update ObjectTypes in bulk - # Update the ObjectType for the model - ObjectType.objects.filter(app_label=app_label, model=model_name).update( - public=is_public, - features=features, - ) + # Create/update the ObjectType for the model + try: + ot = ObjectType.objects.get_by_natural_key(app_label=app_label, model=model_name) + ot.public = is_public + ot.features = features + except ObjectDoesNotExist: + ct = ContentType.objects.get_for_model(model) + ot = ObjectType( + contenttype_ptr=ct, + app_label=app_label, + model=model_name, + public=is_public, + features=features, + ) + ot.save() + + +@receiver(post_save, sender=ContentType) +def create_object_type(sender, instance, created, **kwargs): + if created: + model = instance.model_class() + try: + ObjectType.objects.create( + contenttype_ptr=instance, + public=model_is_public(model), + features=get_model_features(model), + ) + except ProgrammingError: + # Will fail during migrations if ObjectType hasn't been created yet + pass # From 0d8b6c78aea3134fac99ac2ae87e897eb98a022f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 23 Jul 2025 14:40:42 -0400 Subject: [PATCH 06/14] Introduce has_feature() utility function --- netbox/core/models/change_logging.py | 4 ++-- netbox/core/models/jobs.py | 3 ++- netbox/extras/models/models.py | 11 +++++------ netbox/extras/models/notifications.py | 6 +++--- netbox/netbox/models/features.py | 11 +++++++++++ netbox/tenancy/models/contacts.py | 5 ++--- 6 files changed, 25 insertions(+), 15 deletions(-) diff --git a/netbox/core/models/change_logging.py b/netbox/core/models/change_logging.py index 1d1bbc07c..bf9a32f00 100644 --- a/netbox/core/models/change_logging.py +++ b/netbox/core/models/change_logging.py @@ -11,8 +11,8 @@ from mptt.models import MPTTModel from core.choices import ObjectChangeActionChoices from core.querysets import ObjectChangeQuerySet from netbox.models.features import ChangeLoggingMixin +from netbox.models.features import has_feature from utilities.data import shallow_compare_dict -from .contenttypes import ObjectType __all__ = ( 'ObjectChange', @@ -118,7 +118,7 @@ class ObjectChange(models.Model): super().clean() # Validate the assigned object type - if self.changed_object_type not in ObjectType.objects.with_feature('change_logging'): + if not has_feature(self.changed_object_type, 'change_logging'): raise ValidationError( _("Change logging is not supported for this object type ({type}).").format( type=self.changed_object_type diff --git a/netbox/core/models/jobs.py b/netbox/core/models/jobs.py index 050f71921..0377ffbb1 100644 --- a/netbox/core/models/jobs.py +++ b/netbox/core/models/jobs.py @@ -20,6 +20,7 @@ from core.choices import JobStatusChoices from core.dataclasses import JobLogEntry from core.models import ObjectType from core.signals import job_end, job_start +from netbox.models.features import has_feature from utilities.json import JobLogDecoder from utilities.querysets import RestrictedQuerySet from utilities.rqworker import get_queue_for_model @@ -148,7 +149,7 @@ class Job(models.Model): super().clean() # Validate the assigned object type - if self.object_type and self.object_type not in ObjectType.objects.with_feature('jobs'): + if self.object_type and not has_feature(self.object_type, 'jobs'): raise ValidationError( _("Jobs cannot be assigned to this object type ({type}).").format(type=self.object_type) ) diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 2fdc1ffe3..a486ec1ee 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -12,17 +12,16 @@ from django.utils import timezone from django.utils.translation import gettext_lazy as _ from rest_framework.utils.encoders import JSONEncoder -from core.models import ObjectType from extras.choices import * from extras.conditions import ConditionSet, InvalidCondition from extras.constants import * -from extras.utils import image_upload from extras.models.mixins import RenderTemplateMixin +from extras.utils import image_upload from netbox.config import get_config from netbox.events import get_event_type_choices from netbox.models import ChangeLoggedModel from netbox.models.features import ( - CloningMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin + CloningMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin, has_feature ) from utilities.html import clean_html from utilities.jinja2 import render_jinja2 @@ -707,7 +706,7 @@ class ImageAttachment(ChangeLoggedModel): super().clean() # Validate the assigned object type - if self.object_type not in ObjectType.objects.with_feature('image_attachments'): + if not has_feature(self.object_type, 'image_attachments'): raise ValidationError( _("Image attachments cannot be assigned to this object type ({type}).").format(type=self.object_type) ) @@ -807,7 +806,7 @@ class JournalEntry(CustomFieldsMixin, CustomLinksMixin, TagsMixin, ExportTemplat super().clean() # Validate the assigned object type - if self.assigned_object_type not in ObjectType.objects.with_feature('journaling'): + if not has_feature(self.assigned_object_type, 'journaling'): raise ValidationError( _("Journaling is not supported for this object type ({type}).").format(type=self.assigned_object_type) ) @@ -863,7 +862,7 @@ class Bookmark(models.Model): super().clean() # Validate the assigned object type - if self.object_type not in ObjectType.objects.with_feature('bookmarks'): + if not has_feature(self.object_type, 'bookmarks'): raise ValidationError( _("Bookmarks cannot be assigned to this object type ({type}).").format(type=self.object_type) ) diff --git a/netbox/extras/models/notifications.py b/netbox/extras/models/notifications.py index 44874a4c8..c8e8c4fd8 100644 --- a/netbox/extras/models/notifications.py +++ b/netbox/extras/models/notifications.py @@ -7,9 +7,9 @@ from django.db import models from django.urls import reverse from django.utils.translation import gettext_lazy as _ -from core.models import ObjectType from extras.querysets import NotificationQuerySet from netbox.models import ChangeLoggedModel +from netbox.models.features import has_feature from netbox.registry import registry from users.models import User from utilities.querysets import RestrictedQuerySet @@ -94,7 +94,7 @@ class Notification(models.Model): super().clean() # Validate the assigned object type - if self.object_type not in ObjectType.objects.with_feature('notifications'): + if not has_feature(self.object_type, 'notifications'): raise ValidationError( _("Objects of this type ({type}) do not support notifications.").format(type=self.object_type) ) @@ -235,7 +235,7 @@ class Subscription(models.Model): super().clean() # Validate the assigned object type - if self.object_type not in ObjectType.objects.with_feature('notifications'): + if not has_feature(self.object_type, 'notifications'): raise ValidationError( _("Objects of this type ({type}) do not support notifications.").format(type=self.object_type) ) diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index c81727d68..ca6cbcbd0 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -3,6 +3,7 @@ from collections import defaultdict from functools import cached_property from django.contrib.contenttypes.fields import GenericRelation +from django.contrib.contenttypes.models import ContentType from django.core.validators import ValidationError from django.db import models from django.db.models import Q @@ -39,6 +40,7 @@ __all__ = ( 'NotificationsMixin', 'SyncedDataMixin', 'TagsMixin', + 'has_feature', 'register_models', ) @@ -640,6 +642,15 @@ registry['model_features'].update({ }) +def has_feature(model, feature): + """ + Returns True if the model supports the specified feature. + """ + if type(model) is ContentType: + model = model.model_class() + return feature in ObjectType.objects.get_for_model(model).features + + def register_models(*models): """ Register one or more models in NetBox. This entails: diff --git a/netbox/tenancy/models/contacts.py b/netbox/tenancy/models/contacts.py index 34e444ee7..19ffb2b0b 100644 --- a/netbox/tenancy/models/contacts.py +++ b/netbox/tenancy/models/contacts.py @@ -4,9 +4,8 @@ from django.db import models from django.urls import reverse from django.utils.translation import gettext_lazy as _ -from core.models import ObjectType from netbox.models import ChangeLoggedModel, NestedGroupModel, OrganizationalModel, PrimaryModel -from netbox.models.features import CustomFieldsMixin, ExportTemplatesMixin, TagsMixin +from netbox.models.features import CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, has_feature from tenancy.choices import * __all__ = ( @@ -151,7 +150,7 @@ class ContactAssignment(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, Chan super().clean() # Validate the assigned object type - if self.object_type not in ObjectType.objects.with_feature('contacts'): + if not has_feature(self.object_type, 'contacts'): raise ValidationError( _("Contacts cannot be assigned to this object type ({type}).").format(type=self.object_type) ) From 34d9ecb7f3f3acd42852485596ce2783bd1c9750 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 24 Jul 2025 08:14:09 -0400 Subject: [PATCH 07/14] ObjectTypeManager should not inherit from ContentTypeManager --- .../migrations/0017_concrete_objecttype.py | 6 +-- netbox/core/models/contenttypes.py | 40 ++++++++++--------- netbox/core/signals.py | 12 +----- netbox/netbox/models/features.py | 15 ++++++- 4 files changed, 37 insertions(+), 36 deletions(-) diff --git a/netbox/core/migrations/0017_concrete_objecttype.py b/netbox/core/migrations/0017_concrete_objecttype.py index 42b624376..ec8c3a751 100644 --- a/netbox/core/migrations/0017_concrete_objecttype.py +++ b/netbox/core/migrations/0017_concrete_objecttype.py @@ -2,8 +2,6 @@ import django.contrib.postgres.fields import django.db.models.deletion from django.db import migrations, models -import core.models.contenttypes - def populate_object_types(apps, schema_editor): """ @@ -69,9 +67,7 @@ class Migration(migrations.Migration): 'verbose_name_plural': 'object types', }, bases=('contenttypes.contenttype',), - managers=[ - ('objects', core.models.contenttypes.ObjectTypeManager()), - ], + managers=[], ), # Create an ObjectType record for each ContentType migrations.RunPython( diff --git a/netbox/core/models/contenttypes.py b/netbox/core/models/contenttypes.py index 9418172e1..40a02ea2c 100644 --- a/netbox/core/models/contenttypes.py +++ b/netbox/core/models/contenttypes.py @@ -1,4 +1,4 @@ -from django.contrib.contenttypes.models import ContentType, ContentTypeManager +from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.fields import ArrayField from django.core.exceptions import ObjectDoesNotExist from django.db import models @@ -29,7 +29,7 @@ class ObjectTypeQuerySet(models.QuerySet): return super().create(**kwargs) -class ObjectTypeManager(ContentTypeManager): +class ObjectTypeManager(models.Manager): def get_queryset(self): return ObjectTypeQuerySet(self.model, using=self._db) @@ -37,33 +37,35 @@ class ObjectTypeManager(ContentTypeManager): def create(self, **kwargs): return self.get_queryset().create(**kwargs) - def get_for_model(self, model, for_concrete_model=True): - """ - Return the ContentType object for a given model, creating the - ContentType if necessary. Lookups are cached so that subsequent lookups - for the same model don't hit the database. - """ - opts = self._get_opts(model, for_concrete_model) - try: - return self._get_from_cache(opts) - except KeyError: - pass + def _get_opts(self, model, for_concrete_model): + if for_concrete_model: + model = model._meta.concrete_model + return model._meta + + def get_by_natural_key(self, app_label, model): + return self.get(app_label=app_label, model=model) + + def get_for_id(self, id): + return self.get(pk=id) + + def get_for_model(self, model, for_concrete_model=True): + from netbox.models.features import get_model_features, model_is_public + opts = self._get_opts(model, for_concrete_model) - # The ContentType entry was not found in the cache, therefore we - # proceed to load or create it. try: # Start with get() and not get_or_create() in order to use # the db_for_read (see #20401). - ct = self.get(app_label=opts.app_label, model=opts.model_name) + ot = self.get(app_label=opts.app_label, model=opts.model_name) except self.model.DoesNotExist: # Not found in the database; we proceed to create it. This time # use get_or_create to take care of any race conditions. - ct, __ = self.get_or_create( + ot, __ = self.get_or_create( app_label=opts.app_label, model=opts.model_name, + public=model_is_public(model), + features=get_model_features(model.__class__), ) - self._add_to_cache(self.db, ct) - return ct + return ot def public(self): """ diff --git a/netbox/core/signals.py b/netbox/core/signals.py index 50a360421..1c91e1d48 100644 --- a/netbox/core/signals.py +++ b/netbox/core/signals.py @@ -16,7 +16,7 @@ from extras.events import enqueue_event from extras.utils import run_validators from netbox.config import get_config from netbox.context import current_request, events_queue -from netbox.models.features import ChangeLoggingMixin, FEATURES_MAP +from netbox.models.features import ChangeLoggingMixin, get_model_features, model_is_public from utilities.exceptions import AbortRequest from .models import ConfigRevision, DataSource, ObjectChange @@ -40,16 +40,6 @@ post_sync = Signal() clear_events = Signal() -def model_is_public(model): - return not getattr(model, '_netbox_private', False) - - -def get_model_features(model): - return [ - feature for feature, cls in FEATURES_MAP.items() if issubclass(model, cls) - ] - - # # Object types # diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index ca6cbcbd0..db8bdb094 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -40,7 +40,9 @@ __all__ = ( 'NotificationsMixin', 'SyncedDataMixin', 'TagsMixin', + 'get_model_features', 'has_feature', + 'model_is_public', 'register_models', ) @@ -642,13 +644,24 @@ registry['model_features'].update({ }) +def model_is_public(model): + return not getattr(model, '_netbox_private', False) + + +def get_model_features(model): + return [ + feature for feature, cls in FEATURES_MAP.items() if issubclass(model, cls) + ] + + def has_feature(model, feature): """ Returns True if the model supports the specified feature. """ if type(model) is ContentType: model = model.model_class() - return feature in ObjectType.objects.get_for_model(model).features + ot = ObjectType.objects.get_for_model(model) + return feature in ot.features def register_models(*models): From 533fe555eac075124d967c6813f640108f38c166 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 24 Jul 2025 08:25:55 -0400 Subject: [PATCH 08/14] Misc cleanup --- netbox/extras/events.py | 4 ++-- netbox/extras/signals.py | 10 ++++++---- netbox/netbox/models/features.py | 1 + netbox/netbox/tests/test_plugins.py | 4 ++-- 4 files changed, 11 insertions(+), 8 deletions(-) diff --git a/netbox/extras/events.py b/netbox/extras/events.py index 36fa87661..f8447fdb2 100644 --- a/netbox/extras/events.py +++ b/netbox/extras/events.py @@ -9,9 +9,9 @@ from django.utils.translation import gettext as _ from django_rq import get_queue from core.events import * -from core.models import ObjectType from netbox.config import get_config from netbox.constants import RQ_QUEUE_DEFAULT +from netbox.models.features import has_feature from users.models import User from utilities.api import get_serializer_for_model from utilities.rqworker import get_rq_retry @@ -56,7 +56,7 @@ def enqueue_event(queue, instance, user, request_id, event_type): events once the request has completed. """ # Bail if this type of object does not support event rules - if 'event_rules' not in ObjectType.objects.get_for_model(instance).features: + if not has_feature(instance, 'event_rules'): return app_label = instance._meta.app_label diff --git a/netbox/extras/signals.py b/netbox/extras/signals.py index bee31acc9..611e61e5e 100644 --- a/netbox/extras/signals.py +++ b/netbox/extras/signals.py @@ -8,6 +8,7 @@ from core.signals import job_end, job_start from extras.events import process_event_rules from extras.models import EventRule, Notification, Subscription from netbox.config import get_config +from netbox.models.features import has_feature from netbox.signals import post_clean from utilities.exceptions import AbortRequest from .models import CustomField, TaggedItem @@ -149,13 +150,14 @@ def notify_object_changed(sender, instance, **kwargs): event_type = OBJECT_DELETED # Skip unsupported object types - object_type = ObjectType.objects.get_for_model(instance) - if 'notifications' not in object_type.features: + if not has_feature(instance, 'notifications'): return + ct = ContentType.objects.get_for_model(instance) + # Find all subscribed Users subscribed_users = Subscription.objects.filter( - object_type=object_type, + object_type=ct, object_id=instance.pk ).values_list('user', flat=True) if not subscribed_users: @@ -163,7 +165,7 @@ def notify_object_changed(sender, instance, **kwargs): # Delete any existing Notifications for the object Notification.objects.filter( - object_type=object_type, + object_type=ct, object_id=instance.pk, user__in=subscribed_users ).delete() diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index db8bdb094..39ae76dea 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -658,6 +658,7 @@ def has_feature(model, feature): """ Returns True if the model supports the specified feature. """ + # Resolve a ContentType to its model class if type(model) is ContentType: model = model.model_class() ot = ObjectType.objects.get_for_model(model) diff --git a/netbox/netbox/tests/test_plugins.py b/netbox/netbox/tests/test_plugins.py index f1d2b34c8..45d6cf171 100644 --- a/netbox/netbox/tests/test_plugins.py +++ b/netbox/netbox/tests/test_plugins.py @@ -24,8 +24,8 @@ class PluginTest(TestCase): self.assertIn('netbox.tests.dummy_plugin.DummyPluginConfig', settings.INSTALLED_APPS) def test_model_registration(self): - self.assertIsNone( - ObjectType.objects.filter(app_label='dummy_plugin', model='dummymodel') + self.assertTrue( + ObjectType.objects.get(app_label='dummy_plugin', model='dummymodel').exists() ) def test_models(self): From 13c3ce39ee06ea7bd199157e8222715b86a55e1b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 24 Jul 2025 08:41:47 -0400 Subject: [PATCH 09/14] Don't populate ObjectTypes during migration --- .../migrations/0017_concrete_objecttype.py | 22 ------------------- netbox/core/models/contenttypes.py | 2 -- netbox/core/signals.py | 6 ++--- 3 files changed, 2 insertions(+), 28 deletions(-) diff --git a/netbox/core/migrations/0017_concrete_objecttype.py b/netbox/core/migrations/0017_concrete_objecttype.py index ec8c3a751..b64d9074f 100644 --- a/netbox/core/migrations/0017_concrete_objecttype.py +++ b/netbox/core/migrations/0017_concrete_objecttype.py @@ -3,23 +3,6 @@ import django.db.models.deletion from django.db import migrations, models -def populate_object_types(apps, schema_editor): - """ - Create an ObjectType record for each valid ContentType. - """ - ContentType = apps.get_model('contenttypes', 'ContentType') - ObjectType = apps.get_model('core', 'ObjectType') - - for ct in ContentType.objects.all(): - try: - # Validate ContentType - apps.get_model(ct.app_label, ct.model) - except LookupError: - continue - # TODO assign public/features - ObjectType(pk=ct.pk, features=[]).save_base(raw=True) - - class Migration(migrations.Migration): dependencies = [ @@ -69,9 +52,4 @@ class Migration(migrations.Migration): bases=('contenttypes.contenttype',), managers=[], ), - # Create an ObjectType record for each ContentType - migrations.RunPython( - code=populate_object_types, - reverse_code=migrations.RunPython.noop, - ), ] diff --git a/netbox/core/models/contenttypes.py b/netbox/core/models/contenttypes.py index 40a02ea2c..b3d2c4681 100644 --- a/netbox/core/models/contenttypes.py +++ b/netbox/core/models/contenttypes.py @@ -22,8 +22,6 @@ class ObjectTypeQuerySet(models.QuerySet): if (app_label := kwargs.get('app_label')) and (model := kwargs.get('model')): try: kwargs['contenttype_ptr'] = ContentType.objects.get(app_label=app_label, model=model) - kwargs.pop('app_label') - kwargs.pop('model') except ObjectDoesNotExist: pass return super().create(**kwargs) diff --git a/netbox/core/signals.py b/netbox/core/signals.py index 1c91e1d48..8ce2d521b 100644 --- a/netbox/core/signals.py +++ b/netbox/core/signals.py @@ -62,16 +62,14 @@ def update_object_types(sender, **kwargs): ot = ObjectType.objects.get_by_natural_key(app_label=app_label, model=model_name) ot.public = is_public ot.features = features + ot.save() except ObjectDoesNotExist: - ct = ContentType.objects.get_for_model(model) - ot = ObjectType( - contenttype_ptr=ct, + ObjectType.objects.create( app_label=app_label, model=model_name, public=is_public, features=features, ) - ot.save() @receiver(post_save, sender=ContentType) From 3589f73da1f8dd1dc38b3bbb7cf0bf1d7209e2bc Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 24 Jul 2025 09:17:58 -0400 Subject: [PATCH 10/14] Don't automatically create ObjectTypes when a ContentType is created --- netbox/core/signals.py | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/netbox/core/signals.py b/netbox/core/signals.py index 8ce2d521b..62dc06dcf 100644 --- a/netbox/core/signals.py +++ b/netbox/core/signals.py @@ -2,7 +2,6 @@ import logging from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist, ValidationError -from django.db import ProgrammingError from django.db.models.fields.reverse_related import ManyToManyRel, ManyToOneRel from django.db.models.signals import m2m_changed, post_migrate, post_save, pre_delete from django.dispatch import receiver, Signal @@ -72,21 +71,6 @@ def update_object_types(sender, **kwargs): ) -@receiver(post_save, sender=ContentType) -def create_object_type(sender, instance, created, **kwargs): - if created: - model = instance.model_class() - try: - ObjectType.objects.create( - contenttype_ptr=instance, - public=model_is_public(model), - features=get_model_features(model), - ) - except ProgrammingError: - # Will fail during migrations if ObjectType hasn't been created yet - pass - - # # Change logging & event handling # From 9f2ef2b909a7e23d2b326b793fe3b4d4a87e4921 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 24 Jul 2025 09:19:56 -0400 Subject: [PATCH 11/14] Fix test --- netbox/netbox/tests/test_plugins.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/netbox/tests/test_plugins.py b/netbox/netbox/tests/test_plugins.py index 45d6cf171..9f2033936 100644 --- a/netbox/netbox/tests/test_plugins.py +++ b/netbox/netbox/tests/test_plugins.py @@ -25,7 +25,7 @@ class PluginTest(TestCase): def test_model_registration(self): self.assertTrue( - ObjectType.objects.get(app_label='dummy_plugin', model='dummymodel').exists() + ObjectType.objects.filter(app_label='dummy_plugin', model='dummymodel').exists() ) def test_models(self): From 943f98e86d521eff2193c1fa96e559a5e052315c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 24 Jul 2025 09:29:27 -0400 Subject: [PATCH 12/14] Extend has_feature() to accept a model or OT/CT --- netbox/netbox/models/features.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index 39ae76dea..7f471a878 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -654,14 +654,19 @@ def get_model_features(model): ] -def has_feature(model, feature): +def has_feature(model_or_ct, feature): """ Returns True if the model supports the specified feature. """ - # Resolve a ContentType to its model class - if type(model) is ContentType: - model = model.model_class() - ot = ObjectType.objects.get_for_model(model) + # If an ObjectType was passed, we can use it directly + if type(model_or_ct) is ObjectType: + ot = model_or_ct + # If a ContentType was passed, resolve its model class + elif type(model_or_ct) is ContentType: + ot = ObjectType.objects.get_for_model(model_or_ct.model_class()) + # For anything else, look up the ObjectType + else: + ot = ObjectType.objects.get_for_model(model_or_ct) return feature in ot.features From 944ea00a864705dc936f1a1c1eac8fd76215dae6 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 24 Jul 2025 10:02:24 -0400 Subject: [PATCH 13/14] Misc cleanup --- netbox/core/models/contenttypes.py | 49 +++++++++++++++++++----------- netbox/core/signals.py | 11 +++---- 2 files changed, 36 insertions(+), 24 deletions(-) diff --git a/netbox/core/models/contenttypes.py b/netbox/core/models/contenttypes.py index b3d2c4681..964a3a60a 100644 --- a/netbox/core/models/contenttypes.py +++ b/netbox/core/models/contenttypes.py @@ -32,50 +32,63 @@ class ObjectTypeManager(models.Manager): def get_queryset(self): return ObjectTypeQuerySet(self.model, using=self._db) - def create(self, **kwargs): - return self.get_queryset().create(**kwargs) + def get_by_natural_key(self, app_label, model): + """ + Retrieve an ObjectType by its application label & model name. + + This method exists to provide parity with ContentTypeManager. + """ + return self.get(app_label=app_label, model=model) + + def get_for_id(self, id): + """ + Retrieve an ObjectType by its primary key (numeric ID). + + This method exists to provide parity with ContentTypeManager. + """ + return self.get(pk=id) def _get_opts(self, model, for_concrete_model): if for_concrete_model: model = model._meta.concrete_model return model._meta - def get_by_natural_key(self, app_label, model): - return self.get(app_label=app_label, model=model) - - def get_for_id(self, id): - return self.get(pk=id) - def get_for_model(self, model, for_concrete_model=True): + """ + Retrieve or create and return the ObjectType for a model. + """ from netbox.models.features import get_model_features, model_is_public opts = self._get_opts(model, for_concrete_model) try: - # Start with get() and not get_or_create() in order to use - # the db_for_read (see #20401). + # Use .get() instead of .get_or_create() initially to ensure db_for_read is honored (Django bug #20401). ot = self.get(app_label=opts.app_label, model=opts.model_name) except self.model.DoesNotExist: - # Not found in the database; we proceed to create it. This time - # use get_or_create to take care of any race conditions. - ot, __ = self.get_or_create( + # If the ObjectType doesn't exist, create it. (Use .get_or_create() to avoid race conditions.) + ot = self.get_or_create( app_label=opts.app_label, model=opts.model_name, public=model_is_public(model), features=get_model_features(model.__class__), - ) + )[0] return ot def public(self): """ - Filter the base queryset to return only ObjectTypes corresponding to "public" models; those which are intended - for reference by other objects. + Includes only ObjectTypes for "public" models. + + Filter the base queryset to return only ObjectTypes corresponding to public models; those which are intended + for reference by other objects within the application. """ return self.get_queryset().filter(public=True) def with_feature(self, feature): """ - Return the ContentTypes only for models which are registered as supporting the specified feature. For example, - we can find all ContentTypes for models which support event rules with: + Return ObjectTypes only for models which support the given feature. + + Only ObjectTypes which list the specified feature will be included. Supported features are declared in + netbox.models.features.FEATURES_MAP. For example, we can find all ObjectTypes for models which support event + rules with: ObjectType.objects.with_feature('event_rules') """ diff --git a/netbox/core/signals.py b/netbox/core/signals.py index 62dc06dcf..d1da76fbf 100644 --- a/netbox/core/signals.py +++ b/netbox/core/signals.py @@ -45,15 +45,14 @@ clear_events = Signal() @receiver(post_migrate) def update_object_types(sender, **kwargs): - models = sender.get_models() - - for model in models: + """ + Create or update the corresponding ObjectType for each model within the migrated app. + """ + for model in sender.get_models(): app_label, model_name = model._meta.label_lower.split('.') - # Determine whether model is public + # Determine whether model is public and its supported features is_public = model_is_public(model) - - # Determine NetBox features supported by the model features = get_model_features(model) # Create/update the ObjectType for the model From b31f185f4209fd5fca842734c3cf8749b11236f7 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 24 Jul 2025 10:24:04 -0400 Subject: [PATCH 14/14] Deprecate get_for_id() on ObjectTypeManager --- netbox/core/models/contenttypes.py | 1 + netbox/dcim/models/cables.py | 5 +++-- netbox/dcim/models/devices.py | 4 ++-- netbox/ipam/models/ip.py | 4 ++-- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/netbox/core/models/contenttypes.py b/netbox/core/models/contenttypes.py index 964a3a60a..7a59caeec 100644 --- a/netbox/core/models/contenttypes.py +++ b/netbox/core/models/contenttypes.py @@ -40,6 +40,7 @@ class ObjectTypeManager(models.Manager): """ return self.get(app_label=app_label, model=model) + # TODO: Remove in NetBox v4.5 def get_for_id(self, id): """ Retrieve an ObjectType by its primary key (numeric ID). diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index 0a28d5acb..32e26f982 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -1,6 +1,7 @@ import itertools from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.db import models from django.dispatch import Signal @@ -479,13 +480,13 @@ class CablePath(models.Model): def origin_type(self): if self.path: ct_id, _ = decompile_path_node(self.path[0][0]) - return ObjectType.objects.get_for_id(ct_id) + return ContentType.objects.get_for_id(ct_id) @property def destination_type(self): if self.is_complete: ct_id, _ = decompile_path_node(self.path[-1][0]) - return ObjectType.objects.get_for_id(ct_id) + return ContentType.objects.get_for_id(ct_id) @property def _path_decompiled(self): diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 264c1e025..78fd881a7 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -4,6 +4,7 @@ import yaml from functools import cached_property from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation +from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.core.files.storage import default_storage from django.core.validators import MaxValueValidator, MinValueValidator @@ -15,7 +16,6 @@ from django.urls import reverse from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ -from core.models import ObjectType from dcim.choices import * from dcim.constants import * from dcim.fields import MACAddressField @@ -1328,7 +1328,7 @@ class MACAddress(PrimaryModel): super().clean() if self._original_assigned_object_id and self._original_assigned_object_type_id: assigned_object = self.assigned_object - ct = ObjectType.objects.get_for_id(self._original_assigned_object_type_id) + ct = ContentType.objects.get_for_id(self._original_assigned_object_type_id) original_assigned_object = ct.get_object_for_this_type(pk=self._original_assigned_object_id) if ( diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index db116e9e4..73c3310dc 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -1,5 +1,6 @@ import netaddr from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.db import models from django.db.models import F @@ -7,7 +8,6 @@ from django.db.models.functions import Cast from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ -from core.models import ObjectType from dcim.models.mixins import CachedScopeMixin from ipam.choices import * from ipam.constants import * @@ -917,7 +917,7 @@ class IPAddress(ContactsMixin, PrimaryModel): if self._original_assigned_object_id and self._original_assigned_object_type_id: parent = getattr(self.assigned_object, 'parent_object', None) - ct = ObjectType.objects.get_for_id(self._original_assigned_object_type_id) + ct = ContentType.objects.get_for_id(self._original_assigned_object_type_id) original_assigned_object = ct.get_object_for_this_type(pk=self._original_assigned_object_id) original_parent = getattr(original_assigned_object, 'parent_object', None)