Closes #19924: Record model features on ObjectType (#19939)

* Convert ObjectType to a concrete child model of ContentType

* Add public flag to ObjectType

* Catch post_migrate signal to update ObjectTypes

* Reference ObjectType records instead of registry for feature support

* Automatically create ObjectTypes

* Introduce has_feature() utility function

* ObjectTypeManager should not inherit from ContentTypeManager

* Misc cleanup

* Don't populate ObjectTypes during migration

* Don't automatically create ObjectTypes when a ContentType is created

* Fix test

* Extend has_feature() to accept a model or OT/CT

* Misc cleanup

* Deprecate get_for_id() on ObjectTypeManager

* Rename contenttypes.py to object_types.py

* Add index to features ArrayField

* Keep FK & M2M fields pointing to ContentType

* Add get_for_models() to ObjectTypeManager

* Add tests for manager methods & utility functions

* Fix migrations for M2M relations to ObjectType

* model_is_public() should return False for non-core & non-plugin models

* Order ObjectType by app_label & model name

* Resolve migrations conflict
This commit is contained in:
Jeremy Stretch
2025-07-30 13:05:34 -04:00
committed by GitHub
parent 24a0e1907a
commit b610cf37cf
30 changed files with 633 additions and 154 deletions

View File

@@ -11,7 +11,7 @@ from django_rq import get_queue
from core.events import *
from netbox.config import get_config
from netbox.constants import RQ_QUEUE_DEFAULT
from netbox.registry import registry
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
@@ -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 not has_feature(instance, 'event_rules'):
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}'

View File

@@ -24,7 +24,7 @@ class Migration(migrations.Migration):
model_name='customfield',
name='object_type',
field=models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='core.objecttype'
blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='contenttypes.contenttype'
),
),
migrations.RunSQL((

View File

@@ -37,7 +37,9 @@ class Migration(migrations.Migration):
(
'object_type',
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, related_name='table_configs', to='core.objecttype'
on_delete=django.db.models.deletion.CASCADE,
related_name='table_configs',
to='contenttypes.contenttype'
),
),
(

View File

@@ -0,0 +1,42 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('extras', '0130_imageattachment_description'),
]
operations = [
migrations.AlterField(
model_name='customfield',
name='object_types',
field=models.ManyToManyField(related_name='custom_fields', to='contenttypes.contenttype'),
),
migrations.AlterField(
model_name='customlink',
name='object_types',
field=models.ManyToManyField(related_name='custom_links', to='contenttypes.contenttype'),
),
migrations.AlterField(
model_name='eventrule',
name='object_types',
field=models.ManyToManyField(related_name='event_rules', to='contenttypes.contenttype'),
),
migrations.AlterField(
model_name='exporttemplate',
name='object_types',
field=models.ManyToManyField(related_name='export_templates', to='contenttypes.contenttype'),
),
migrations.AlterField(
model_name='savedfilter',
name='object_types',
field=models.ManyToManyField(related_name='saved_filters', to='contenttypes.contenttype'),
),
migrations.AlterField(
model_name='tag',
name='object_types',
field=models.ManyToManyField(blank=True, related_name='+', to='contenttypes.contenttype'),
),
]

View File

@@ -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:

View File

@@ -72,7 +72,7 @@ class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)):
class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
object_types = models.ManyToManyField(
to='core.ObjectType',
to='contenttypes.ContentType',
related_name='custom_fields',
help_text=_('The object(s) to which this field applies.')
)
@@ -84,7 +84,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
help_text=_('The type of data this custom field holds')
)
related_object_type = models.ForeignKey(
to='core.ObjectType',
to='contenttypes.ContentType',
on_delete=models.PROTECT,
blank=True,
null=True,

View File

@@ -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
@@ -50,7 +49,7 @@ class EventRule(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLogged
webhook or executing a custom script.
"""
object_types = models.ManyToManyField(
to='core.ObjectType',
to='contenttypes.ContentType',
related_name='event_rules',
verbose_name=_('object types'),
help_text=_("The object(s) to which this rule applies.")
@@ -299,7 +298,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
code to be rendered with an object as context.
"""
object_types = models.ManyToManyField(
to='core.ObjectType',
to='contenttypes.ContentType',
related_name='custom_links',
help_text=_('The object type(s) to which this link applies.')
)
@@ -395,7 +394,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, ChangeLoggedModel, RenderTemplateMixin):
object_types = models.ManyToManyField(
to='core.ObjectType',
to='contenttypes.ContentType',
related_name='export_templates',
help_text=_('The object type(s) to which this template applies.')
)
@@ -460,7 +459,7 @@ class SavedFilter(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
A set of predefined keyword parameters that can be reused to filter for specific objects.
"""
object_types = models.ManyToManyField(
to='core.ObjectType',
to='contenttypes.ContentType',
related_name='saved_filters',
help_text=_('The object type(s) to which this filter applies.')
)
@@ -540,7 +539,7 @@ class TableConfig(CloningMixin, ChangeLoggedModel):
A saved configuration of columns and ordering which applies to a specific table.
"""
object_type = models.ForeignKey(
to='core.ObjectType',
to='contenttypes.ContentType',
on_delete=models.CASCADE,
related_name='table_configs',
help_text=_("The table's object type"),
@@ -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)
)

View File

@@ -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)
)

View File

@@ -35,7 +35,7 @@ class Tag(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel, TagBase):
blank=True,
)
object_types = models.ManyToManyField(
to='core.ObjectType',
to='contenttypes.ContentType',
related_name='+',
blank=True,
help_text=_("The object type(s) to which this tag can be applied.")

View File

@@ -3,12 +3,11 @@ from django.db.models.signals import m2m_changed, post_save, pre_delete
from django.dispatch import receiver
from core.events import *
from core.models import ObjectType
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.models.features import has_feature
from netbox.signals import post_clean
from utilities.exceptions import AbortRequest
from .models import CustomField, TaggedItem
@@ -82,7 +81,7 @@ def validate_assigned_tags(sender, instance, action, model, pk_set, **kwargs):
"""
if action != 'pre_add':
return
ct = ObjectType.objects.get_for_model(instance)
ct = ContentType.objects.get_for_model(instance)
# Retrieve any applied Tags that are restricted to certain object types
for tag in model.objects.filter(pk__in=pk_set, object_types__isnull=False).prefetch_related('object_types'):
if ct not in tag.object_types.all():
@@ -150,17 +149,25 @@ 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, []):
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=ct, object_id=instance.pk).values_list('user', flat=True)
subscribed_users = Subscription.objects.filter(
object_type=ct,
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=ct,
object_id=instance.pk,
user__in=subscribed_users
).delete()
# Create Notifications for Subscribers
Notification.objects.bulk_create([