Просмотр исходного кода

Closes #14153: Filter ContentTypes by supported feature (#14191)

* WIP

* Remove FeatureQuery

* Standardize use of proxy ContentType for models

* Remove TODO

* Correctly filter BookmarksWidget object_types choices

* Add feature-specific object type validation
Jeremy Stretch 2 лет назад
Родитель
Сommit
e15647a2ce

+ 1 - 3
netbox/core/forms/filtersets.py

@@ -1,12 +1,10 @@
 from django import forms
 from django.contrib.auth import get_user_model
-from django.contrib.contenttypes.models import ContentType
 from django.utils.translation import gettext_lazy as _
 
 from core.choices import *
 from core.models import *
 from extras.forms.mixins import SavedFiltersMixin
-from extras.utils import FeatureQuery
 from netbox.forms import NetBoxModelFilterSetForm
 from netbox.utils import get_data_backend_choices
 from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm
@@ -69,7 +67,7 @@ class JobFilterForm(SavedFiltersMixin, FilterForm):
     )
     object_type = ContentTypeChoiceField(
         label=_('Object Type'),
-        queryset=ContentType.objects.filter(FeatureQuery('jobs').get_query()),
+        queryset=ContentType.objects.with_feature('jobs'),
         required=False,
     )
     status = forms.MultipleChoiceField(

+ 1 - 2
netbox/core/migrations/0003_job.py

@@ -4,7 +4,6 @@ from django.conf import settings
 import django.core.validators
 from django.db import migrations, models
 import django.db.models.deletion
-import extras.utils
 
 
 class Migration(migrations.Migration):
@@ -30,7 +29,7 @@ class Migration(migrations.Migration):
                 ('status', models.CharField(default='pending', max_length=30)),
                 ('data', models.JSONField(blank=True, null=True)),
                 ('job_id', models.UUIDField(unique=True)),
-                ('object_type', models.ForeignKey(limit_choices_to=extras.utils.FeatureQuery('jobs'), on_delete=django.db.models.deletion.CASCADE, related_name='jobs', to='contenttypes.contenttype')),
+                ('object_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='jobs', to='contenttypes.contenttype')),
                 ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
             ],
             options={

+ 18 - 0
netbox/core/models/contenttypes.py

@@ -21,6 +21,24 @@ class ContentTypeManager(ContentTypeManager_):
             q |= Q(app_label=app_label, model__in=models)
         return self.get_queryset().filter(q)
 
+    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
+
+            ContentType.objects.with_feature('webhooks')
+        """
+        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, models in registry['model_features'][feature].items():
+            q |= Q(app_label=app_label, model__in=models)
+
+        return self.get_queryset().filter(q)
+
 
 class ContentType(ContentType_):
     """

+ 1 - 2
netbox/core/models/data.py

@@ -6,7 +6,6 @@ from urllib.parse import urlparse
 
 from django.conf import settings
 from django.contrib.contenttypes.fields import GenericForeignKey
-from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ValidationError
 from django.core.validators import RegexValidator
 from django.db import models
@@ -368,7 +367,7 @@ class AutoSyncRecord(models.Model):
         related_name='+'
     )
     object_type = models.ForeignKey(
-        to=ContentType,
+        to='contenttypes.ContentType',
         on_delete=models.CASCADE,
         related_name='+'
     )

+ 12 - 4
netbox/core/models/jobs.py

@@ -3,7 +3,7 @@ import uuid
 import django_rq
 from django.conf import settings
 from django.contrib.contenttypes.fields import GenericForeignKey
-from django.contrib.contenttypes.models import ContentType
+from django.core.exceptions import ValidationError
 from django.core.validators import MinValueValidator
 from django.db import models
 from django.urls import reverse
@@ -11,8 +11,8 @@ from django.utils import timezone
 from django.utils.translation import gettext as _
 
 from core.choices import JobStatusChoices
+from core.models import ContentType
 from extras.constants import EVENT_JOB_END, EVENT_JOB_START
-from extras.utils import FeatureQuery
 from netbox.config import get_config
 from netbox.constants import RQ_QUEUE_DEFAULT
 from utilities.querysets import RestrictedQuerySet
@@ -28,9 +28,8 @@ class Job(models.Model):
     Tracks the lifecycle of a job which represents a background task (e.g. the execution of a custom script).
     """
     object_type = models.ForeignKey(
-        to=ContentType,
+        to='contenttypes.ContentType',
         related_name='jobs',
-        limit_choices_to=FeatureQuery('jobs'),
         on_delete=models.CASCADE,
     )
     object_id = models.PositiveBigIntegerField(
@@ -123,6 +122,15 @@ class Job(models.Model):
     def get_status_color(self):
         return JobStatusChoices.colors.get(self.status)
 
+    def clean(self):
+        super().clean()
+
+        # Validate the assigned object type
+        if self.object_type not in ContentType.objects.with_feature('jobs'):
+            raise ValidationError(
+                _("Jobs cannot be assigned to this object type ({type}).").format(type=self.object_type)
+            )
+
     @property
     def duration(self):
         if not self.completed:

+ 2 - 3
netbox/dcim/models/cables.py

@@ -2,7 +2,6 @@ import itertools
 from collections import defaultdict
 
 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 Sum
@@ -10,12 +9,12 @@ from django.dispatch import Signal
 from django.urls import reverse
 from django.utils.translation import gettext_lazy as _
 
+from core.models import ContentType
 from dcim.choices import *
 from dcim.constants import *
 from dcim.fields import PathField
 from dcim.utils import decompile_path_node, object_to_path_node
 from netbox.models import ChangeLoggedModel, PrimaryModel
-
 from utilities.fields import ColorField
 from utilities.querysets import RestrictedQuerySet
 from utilities.utils import to_meters
@@ -258,7 +257,7 @@ class CableTermination(ChangeLoggedModel):
         verbose_name=_('end')
     )
     termination_type = models.ForeignKey(
-        to=ContentType,
+        to='contenttypes.ContentType',
         limit_choices_to=CABLE_TERMINATION_MODELS,
         on_delete=models.PROTECT,
         related_name='+'

+ 1 - 2
netbox/dcim/models/device_component_templates.py

@@ -1,5 +1,4 @@
 from django.contrib.contenttypes.fields import GenericForeignKey
-from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ValidationError
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.db import models
@@ -709,7 +708,7 @@ class InventoryItemTemplate(MPTTModel, ComponentTemplateModel):
         db_index=True
     )
     component_type = models.ForeignKey(
-        to=ContentType,
+        to='contenttypes.ContentType',
         limit_choices_to=MODULAR_COMPONENT_TEMPLATE_MODELS,
         on_delete=models.PROTECT,
         related_name='+',

+ 1 - 2
netbox/dcim/models/device_components.py

@@ -1,7 +1,6 @@
 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.validators import MaxValueValidator, MinValueValidator
 from django.db import models
@@ -1181,7 +1180,7 @@ class InventoryItem(MPTTModel, ComponentModel, TrackingModelMixin):
         db_index=True
     )
     component_type = models.ForeignKey(
-        to=ContentType,
+        to='contenttypes.ContentType',
         limit_choices_to=MODULAR_COMPONENT_MODELS,
         on_delete=models.PROTECT,
         related_name='+',

+ 7 - 8
netbox/extras/api/serializers.py

@@ -1,10 +1,10 @@
 from django.contrib.auth import get_user_model
-from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ObjectDoesNotExist
 from rest_framework import serializers
 
 from core.api.serializers import JobSerializer
 from core.api.nested_serializers import NestedDataSourceSerializer, NestedDataFileSerializer, NestedJobSerializer
+from core.models import ContentType
 from dcim.api.nested_serializers import (
     NestedDeviceRoleSerializer, NestedDeviceTypeSerializer, NestedLocationSerializer, NestedPlatformSerializer,
     NestedRegionSerializer, NestedSiteSerializer, NestedSiteGroupSerializer,
@@ -14,7 +14,6 @@ from drf_spectacular.utils import extend_schema_field
 from drf_spectacular.types import OpenApiTypes
 from extras.choices import *
 from extras.models import *
-from extras.utils import FeatureQuery
 from netbox.api.exceptions import SerializerNotFound
 from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField
 from netbox.api.serializers import BaseModelSerializer, NetBoxModelSerializer, ValidatedModelSerializer
@@ -64,7 +63,7 @@ __all__ = (
 class WebhookSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='extras-api:webhook-detail')
     content_types = ContentTypeField(
-        queryset=ContentType.objects.filter(FeatureQuery('webhooks').get_query()),
+        queryset=ContentType.objects.with_feature('webhooks'),
         many=True
     )
 
@@ -85,7 +84,7 @@ class WebhookSerializer(NetBoxModelSerializer):
 class CustomFieldSerializer(ValidatedModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='extras-api:customfield-detail')
     content_types = ContentTypeField(
-        queryset=ContentType.objects.filter(FeatureQuery('custom_fields').get_query()),
+        queryset=ContentType.objects.with_feature('custom_fields'),
         many=True
     )
     type = ChoiceField(choices=CustomFieldTypeChoices)
@@ -151,7 +150,7 @@ class CustomFieldChoiceSetSerializer(ValidatedModelSerializer):
 class CustomLinkSerializer(ValidatedModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='extras-api:customlink-detail')
     content_types = ContentTypeField(
-        queryset=ContentType.objects.filter(FeatureQuery('custom_links').get_query()),
+        queryset=ContentType.objects.with_feature('custom_links'),
         many=True
     )
 
@@ -170,7 +169,7 @@ class CustomLinkSerializer(ValidatedModelSerializer):
 class ExportTemplateSerializer(ValidatedModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='extras-api:exporttemplate-detail')
     content_types = ContentTypeField(
-        queryset=ContentType.objects.filter(FeatureQuery('export_templates').get_query()),
+        queryset=ContentType.objects.with_feature('export_templates'),
         many=True
     )
     data_source = NestedDataSourceSerializer(
@@ -215,7 +214,7 @@ class SavedFilterSerializer(ValidatedModelSerializer):
 class BookmarkSerializer(ValidatedModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='extras-api:bookmark-detail')
     object_type = ContentTypeField(
-        queryset=ContentType.objects.filter(FeatureQuery('bookmarks').get_query()),
+        queryset=ContentType.objects.with_feature('bookmarks'),
     )
     object = serializers.SerializerMethodField(read_only=True)
     user = NestedUserSerializer()
@@ -239,7 +238,7 @@ class BookmarkSerializer(ValidatedModelSerializer):
 class TagSerializer(ValidatedModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='extras-api:tag-detail')
     object_types = ContentTypeField(
-        queryset=ContentType.objects.filter(FeatureQuery('tags').get_query()),
+        queryset=ContentType.objects.with_feature('tags'),
         many=True,
         required=False
     )

+ 11 - 5
netbox/extras/dashboard/widgets.py

@@ -32,13 +32,20 @@ __all__ = (
 )
 
 
-def get_content_type_labels():
+def get_object_type_choices():
     return [
         (content_type_identifier(ct), content_type_name(ct))
         for ct in ContentType.objects.public().order_by('app_label', 'model')
     ]
 
 
+def get_bookmarks_object_type_choices():
+    return [
+        (content_type_identifier(ct), content_type_name(ct))
+        for ct in ContentType.objects.with_feature('bookmarks').order_by('app_label', 'model')
+    ]
+
+
 def get_models_from_content_types(content_types):
     """
     Return a list of models corresponding to the given content types, identified by natural key.
@@ -158,7 +165,7 @@ class ObjectCountsWidget(DashboardWidget):
 
     class ConfigForm(WidgetConfigForm):
         models = forms.MultipleChoiceField(
-            choices=get_content_type_labels
+            choices=get_object_type_choices
         )
         filters = forms.JSONField(
             required=False,
@@ -207,7 +214,7 @@ class ObjectListWidget(DashboardWidget):
 
     class ConfigForm(WidgetConfigForm):
         model = forms.ChoiceField(
-            choices=get_content_type_labels
+            choices=get_object_type_choices
         )
         page_size = forms.IntegerField(
             required=False,
@@ -343,8 +350,7 @@ class BookmarksWidget(DashboardWidget):
 
     class ConfigForm(WidgetConfigForm):
         object_types = forms.MultipleChoiceField(
-            # TODO: Restrict the choices by FeatureQuery('bookmarks')
-            choices=get_content_type_labels,
+            choices=get_bookmarks_object_type_choices,
             required=False
         )
         order_by = forms.ChoiceField(

+ 4 - 9
netbox/extras/forms/bulk_import.py

@@ -6,7 +6,6 @@ from django.utils.translation import gettext_lazy as _
 from core.models import ContentType
 from extras.choices import *
 from extras.models import *
-from extras.utils import FeatureQuery
 from netbox.forms import NetBoxModelImportForm
 from utilities.forms import CSVModelForm
 from utilities.forms.fields import (
@@ -29,8 +28,7 @@ __all__ = (
 class CustomFieldImportForm(CSVModelForm):
     content_types = CSVMultipleContentTypeField(
         label=_('Content types'),
-        queryset=ContentType.objects.all(),
-        limit_choices_to=FeatureQuery('custom_fields'),
+        queryset=ContentType.objects.with_feature('custom_fields'),
         help_text=_("One or more assigned object types")
     )
     type = CSVChoiceField(
@@ -88,8 +86,7 @@ class CustomFieldChoiceSetImportForm(CSVModelForm):
 class CustomLinkImportForm(CSVModelForm):
     content_types = CSVMultipleContentTypeField(
         label=_('Content types'),
-        queryset=ContentType.objects.all(),
-        limit_choices_to=FeatureQuery('custom_links'),
+        queryset=ContentType.objects.with_feature('custom_links'),
         help_text=_("One or more assigned object types")
     )
 
@@ -104,8 +101,7 @@ class CustomLinkImportForm(CSVModelForm):
 class ExportTemplateImportForm(CSVModelForm):
     content_types = CSVMultipleContentTypeField(
         label=_('Content types'),
-        queryset=ContentType.objects.all(),
-        limit_choices_to=FeatureQuery('export_templates'),
+        queryset=ContentType.objects.with_feature('export_templates'),
         help_text=_("One or more assigned object types")
     )
 
@@ -142,8 +138,7 @@ class SavedFilterImportForm(CSVModelForm):
 class WebhookImportForm(NetBoxModelImportForm):
     content_types = CSVMultipleContentTypeField(
         label=_('Content types'),
-        queryset=ContentType.objects.all(),
-        limit_choices_to=FeatureQuery('webhooks'),
+        queryset=ContentType.objects.with_feature('webhooks'),
         help_text=_("One or more assigned object types")
     )
 

+ 7 - 8
netbox/extras/forms/filtersets.py

@@ -6,7 +6,6 @@ from core.models import ContentType, DataFile, DataSource
 from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
 from extras.choices import *
 from extras.models import *
-from extras.utils import FeatureQuery
 from netbox.forms.base import NetBoxModelFilterSetForm
 from tenancy.models import Tenant, TenantGroup
 from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice
@@ -44,7 +43,7 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
         )),
     )
     content_type_id = ContentTypeMultipleChoiceField(
-        queryset=ContentType.objects.filter(FeatureQuery('custom_fields').get_query()),
+        queryset=ContentType.objects.with_feature('custom_fields'),
         required=False,
         label=_('Object type')
     )
@@ -108,7 +107,7 @@ class CustomLinkFilterForm(SavedFiltersMixin, FilterForm):
     )
     content_types = ContentTypeMultipleChoiceField(
         label=_('Content types'),
-        queryset=ContentType.objects.filter(FeatureQuery('custom_links').get_query()),
+        queryset=ContentType.objects.with_feature('custom_links'),
         required=False
     )
     enabled = forms.NullBooleanField(
@@ -151,7 +150,7 @@ class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
         }
     )
     content_type_id = ContentTypeMultipleChoiceField(
-        queryset=ContentType.objects.filter(FeatureQuery('export_templates').get_query()),
+        queryset=ContentType.objects.with_feature('export_templates'),
         required=False,
         label=_('Content types')
     )
@@ -179,7 +178,7 @@ class ImageAttachmentFilterForm(SavedFiltersMixin, FilterForm):
     )
     content_type_id = ContentTypeChoiceField(
         label=_('Content type'),
-        queryset=ContentType.objects.filter(FeatureQuery('image_attachments').get_query()),
+        queryset=ContentType.objects.with_feature('image_attachments'),
         required=False
     )
     name = forms.CharField(
@@ -228,7 +227,7 @@ class WebhookFilterForm(NetBoxModelFilterSetForm):
         (_('Events'), ('type_create', 'type_update', 'type_delete', 'type_job_start', 'type_job_end')),
     )
     content_type_id = ContentTypeMultipleChoiceField(
-        queryset=ContentType.objects.filter(FeatureQuery('webhooks').get_query()),
+        queryset=ContentType.objects.with_feature('webhooks'),
         required=False,
         label=_('Object type')
     )
@@ -284,12 +283,12 @@ class WebhookFilterForm(NetBoxModelFilterSetForm):
 class TagFilterForm(SavedFiltersMixin, FilterForm):
     model = Tag
     content_type_id = ContentTypeMultipleChoiceField(
-        queryset=ContentType.objects.filter(FeatureQuery('tags').get_query()),
+        queryset=ContentType.objects.with_feature('tags'),
         required=False,
         label=_('Tagged object type')
     )
     for_object_type_id = ContentTypeChoiceField(
-        queryset=ContentType.objects.filter(FeatureQuery('tags').get_query()),
+        queryset=ContentType.objects.with_feature('tags'),
         required=False,
         label=_('Allowed object type')
     )

+ 6 - 13
netbox/extras/forms/model_forms.py

@@ -10,7 +10,6 @@ from core.models import ContentType
 from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
 from extras.choices import *
 from extras.models import *
-from extras.utils import FeatureQuery
 from netbox.config import get_config, PARAMS
 from netbox.forms import NetBoxModelForm
 from tenancy.models import Tenant, TenantGroup
@@ -43,8 +42,7 @@ __all__ = (
 class CustomFieldForm(BootstrapMixin, forms.ModelForm):
     content_types = ContentTypeMultipleChoiceField(
         label=_('Content types'),
-        queryset=ContentType.objects.all(),
-        limit_choices_to=FeatureQuery('custom_fields'),
+        queryset=ContentType.objects.with_feature('custom_fields')
     )
     object_type = ContentTypeChoiceField(
         label=_('Object type'),
@@ -114,8 +112,7 @@ class CustomFieldChoiceSetForm(BootstrapMixin, forms.ModelForm):
 class CustomLinkForm(BootstrapMixin, forms.ModelForm):
     content_types = ContentTypeMultipleChoiceField(
         label=_('Content types'),
-        queryset=ContentType.objects.all(),
-        limit_choices_to=FeatureQuery('custom_links')
+        queryset=ContentType.objects.with_feature('custom_links')
     )
 
     fieldsets = (
@@ -142,8 +139,7 @@ class CustomLinkForm(BootstrapMixin, forms.ModelForm):
 class ExportTemplateForm(BootstrapMixin, SyncedDataMixin, forms.ModelForm):
     content_types = ContentTypeMultipleChoiceField(
         label=_('Content types'),
-        queryset=ContentType.objects.all(),
-        limit_choices_to=FeatureQuery('export_templates')
+        queryset=ContentType.objects.with_feature('export_templates')
     )
     template_code = forms.CharField(
         label=_('Template code'),
@@ -210,8 +206,7 @@ class SavedFilterForm(BootstrapMixin, forms.ModelForm):
 class BookmarkForm(BootstrapMixin, forms.ModelForm):
     object_type = ContentTypeChoiceField(
         label=_('Object type'),
-        queryset=ContentType.objects.all(),
-        limit_choices_to=FeatureQuery('bookmarks').get_query()
+        queryset=ContentType.objects.with_feature('bookmarks')
     )
 
     class Meta:
@@ -222,8 +217,7 @@ class BookmarkForm(BootstrapMixin, forms.ModelForm):
 class WebhookForm(NetBoxModelForm):
     content_types = ContentTypeMultipleChoiceField(
         label=_('Content types'),
-        queryset=ContentType.objects.all(),
-        limit_choices_to=FeatureQuery('webhooks')
+        queryset=ContentType.objects.with_feature('webhooks')
     )
 
     fieldsets = (
@@ -257,8 +251,7 @@ class TagForm(BootstrapMixin, forms.ModelForm):
     slug = SlugField()
     object_types = ContentTypeMultipleChoiceField(
         label=_('Object types'),
-        queryset=ContentType.objects.all(),
-        limit_choices_to=FeatureQuery('tags'),
+        queryset=ContentType.objects.with_feature('tags'),
         required=False
     )
 

+ 5 - 5
netbox/extras/migrations/0001_squashed.py

@@ -88,7 +88,7 @@ class Migration(migrations.Migration):
                 ('secret', models.CharField(blank=True, max_length=255)),
                 ('ssl_verification', models.BooleanField(default=True)),
                 ('ca_file_path', models.CharField(blank=True, max_length=4096, null=True)),
-                ('content_types', models.ManyToManyField(limit_choices_to=extras.utils.FeatureQuery('webhooks'), related_name='webhooks', to='contenttypes.ContentType')),
+                ('content_types', models.ManyToManyField(related_name='webhooks', to='contenttypes.ContentType')),
             ],
             options={
                 'ordering': ('name',),
@@ -151,7 +151,7 @@ class Migration(migrations.Migration):
                 ('status', models.CharField(default='pending', max_length=30)),
                 ('data', models.JSONField(blank=True, null=True)),
                 ('job_id', models.UUIDField(unique=True)),
-                ('obj_type', models.ForeignKey(limit_choices_to=extras.utils.FeatureQuery('jobs'), on_delete=django.db.models.deletion.CASCADE, related_name='job_results', to='contenttypes.contenttype')),
+                ('obj_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='job_results', to='contenttypes.contenttype')),
                 ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
             ],
             options={
@@ -184,7 +184,7 @@ class Migration(migrations.Migration):
                 ('mime_type', models.CharField(blank=True, max_length=50)),
                 ('file_extension', models.CharField(blank=True, max_length=15)),
                 ('as_attachment', models.BooleanField(default=True)),
-                ('content_type', models.ForeignKey(limit_choices_to=extras.utils.FeatureQuery('export_templates'), on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')),
+                ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')),
             ],
             options={
                 'ordering': ['content_type', 'name'],
@@ -201,7 +201,7 @@ class Migration(migrations.Migration):
                 ('group_name', models.CharField(blank=True, max_length=50)),
                 ('button_class', models.CharField(default='default', max_length=30)),
                 ('new_window', models.BooleanField(default=False)),
-                ('content_type', models.ForeignKey(limit_choices_to=extras.utils.FeatureQuery('custom_links'), on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')),
+                ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')),
             ],
             options={
                 'ordering': ['group_name', 'weight', 'name'],
@@ -223,7 +223,7 @@ class Migration(migrations.Migration):
                 ('validation_maximum', models.PositiveIntegerField(blank=True, null=True)),
                 ('validation_regex', models.CharField(blank=True, max_length=500, validators=[utilities.validators.validate_regex])),
                 ('choices', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), blank=True, null=True, size=None)),
-                ('content_types', models.ManyToManyField(limit_choices_to=extras.utils.FeatureQuery('custom_fields'), related_name='custom_fields', to='contenttypes.ContentType')),
+                ('content_types', models.ManyToManyField(related_name='custom_fields', to='contenttypes.ContentType')),
             ],
             options={
                 'ordering': ['weight', 'name'],

+ 1 - 2
netbox/extras/migrations/0094_tag_object_types.py

@@ -1,5 +1,4 @@
 from django.db import migrations, models
-import extras.utils
 
 
 class Migration(migrations.Migration):
@@ -13,7 +12,7 @@ class Migration(migrations.Migration):
         migrations.AddField(
             model_name='tag',
             name='object_types',
-            field=models.ManyToManyField(blank=True, limit_choices_to=extras.utils.FeatureQuery('tags'), related_name='+', to='contenttypes.contenttype'),
+            field=models.ManyToManyField(blank=True, related_name='+', to='contenttypes.contenttype'),
         ),
         migrations.RenameIndex(
             model_name='taggeditem',

+ 15 - 3
netbox/extras/models/change_logging.py

@@ -1,10 +1,11 @@
 from django.conf import settings
 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.urls import reverse
 from django.utils.translation import gettext_lazy as _
 
+from core.models import ContentType
 from extras.choices import *
 from ..querysets import ObjectChangeQuerySet
 
@@ -48,7 +49,7 @@ class ObjectChange(models.Model):
         choices=ObjectChangeActionChoices
     )
     changed_object_type = models.ForeignKey(
-        to=ContentType,
+        to='contenttypes.ContentType',
         on_delete=models.PROTECT,
         related_name='+'
     )
@@ -58,7 +59,7 @@ class ObjectChange(models.Model):
         fk_field='changed_object_id'
     )
     related_object_type = models.ForeignKey(
-        to=ContentType,
+        to='contenttypes.ContentType',
         on_delete=models.PROTECT,
         related_name='+',
         blank=True,
@@ -104,6 +105,17 @@ class ObjectChange(models.Model):
             self.user_name
         )
 
+    def clean(self):
+        super().clean()
+
+        # Validate the assigned object type
+        if self.changed_object_type not in ContentType.objects.with_feature('change_logging'):
+            raise ValidationError(
+                _("Change logging is not supported for this object type ({type}).").format(
+                    type=self.changed_object_type
+                )
+            )
+
     def save(self, *args, **kwargs):
 
         # Record the user's name and the object's representation as static strings

+ 3 - 6
netbox/extras/models/customfields.py

@@ -5,18 +5,16 @@ from datetime import datetime, date
 import django_filters
 from django import forms
 from django.conf import settings
-from django.contrib.contenttypes.models import ContentType
 from django.contrib.postgres.fields import ArrayField
 from django.core.validators import RegexValidator, ValidationError
 from django.db import models
 from django.urls import reverse
-from django.utils.html import escape
 from django.utils.safestring import mark_safe
 from django.utils.translation import gettext_lazy as _
 
+from core.models import ContentType
 from extras.choices import *
 from extras.data import CHOICE_SETS
-from extras.utils import FeatureQuery
 from netbox.models import ChangeLoggedModel
 from netbox.models.features import CloningMixin, ExportTemplatesMixin
 from netbox.search import FieldTypes
@@ -60,9 +58,8 @@ class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)):
 
 class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
     content_types = models.ManyToManyField(
-        to=ContentType,
+        to='contenttypes.ContentType',
         related_name='custom_fields',
-        limit_choices_to=FeatureQuery('custom_fields'),
         help_text=_('The object(s) to which this field applies.')
     )
     type = models.CharField(
@@ -73,7 +70,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
         help_text=_('The type of data this custom field holds')
     )
     object_type = models.ForeignKey(
-        to=ContentType,
+        to='contenttypes.ContentType',
         on_delete=models.PROTECT,
         blank=True,
         null=True,

+ 29 - 13
netbox/extras/models/models.py

@@ -3,7 +3,6 @@ import urllib.parse
 
 from django.conf import settings
 from django.contrib.contenttypes.fields import GenericForeignKey
-from django.contrib.contenttypes.models import ContentType
 from django.core.cache import cache
 from django.core.validators import ValidationError
 from django.db import models
@@ -14,10 +13,11 @@ from django.utils.formats import date_format
 from django.utils.translation import gettext, gettext_lazy as _
 from rest_framework.utils.encoders import JSONEncoder
 
+from core.models import ContentType
 from extras.choices import *
 from extras.conditions import ConditionSet
 from extras.constants import *
-from extras.utils import FeatureQuery, image_upload
+from extras.utils import image_upload
 from netbox.config import get_config
 from netbox.models import ChangeLoggedModel
 from netbox.models.features import (
@@ -45,10 +45,9 @@ class Webhook(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedMo
     Each Webhook can be limited to firing only on certain actions or certain object types.
     """
     content_types = models.ManyToManyField(
-        to=ContentType,
+        to='contenttypes.ContentType',
         related_name='webhooks',
         verbose_name=_('object types'),
-        limit_choices_to=FeatureQuery('webhooks'),
         help_text=_("The object(s) to which this Webhook applies.")
     )
     name = models.CharField(
@@ -235,7 +234,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
     code to be rendered with an object as context.
     """
     content_types = models.ManyToManyField(
-        to=ContentType,
+        to='contenttypes.ContentType',
         related_name='custom_links',
         help_text=_('The object type(s) to which this link applies.')
     )
@@ -331,7 +330,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
 
 class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
     content_types = models.ManyToManyField(
-        to=ContentType,
+        to='contenttypes.ContentType',
         related_name='export_templates',
         help_text=_('The object type(s) to which this template applies.')
     )
@@ -440,7 +439,7 @@ class SavedFilter(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
     A set of predefined keyword parameters that can be reused to filter for specific objects.
     """
     content_types = models.ManyToManyField(
-        to=ContentType,
+        to='contenttypes.ContentType',
         related_name='saved_filters',
         help_text=_('The object type(s) to which this filter applies.')
     )
@@ -520,7 +519,7 @@ class ImageAttachment(ChangeLoggedModel):
     An uploaded image which is associated with an object.
     """
     content_type = models.ForeignKey(
-        to=ContentType,
+        to='contenttypes.ContentType',
         on_delete=models.CASCADE
     )
     object_id = models.PositiveBigIntegerField()
@@ -560,6 +559,15 @@ class ImageAttachment(ChangeLoggedModel):
         filename = self.image.name.rsplit('/', 1)[-1]
         return filename.split('_', 2)[2]
 
+    def clean(self):
+        super().clean()
+
+        # Validate the assigned object type
+        if self.content_type not in ContentType.objects.with_feature('image_attachments'):
+            raise ValidationError(
+                _("Image attachments cannot be assigned to this object type ({type}).").format(type=self.content_type)
+            )
+
     def delete(self, *args, **kwargs):
 
         _name = self.image.name
@@ -605,7 +613,7 @@ class JournalEntry(CustomFieldsMixin, CustomLinksMixin, TagsMixin, ExportTemplat
     might record a new journal entry when a device undergoes maintenance, or when a prefix is expanded.
     """
     assigned_object_type = models.ForeignKey(
-        to=ContentType,
+        to='contenttypes.ContentType',
         on_delete=models.CASCADE
     )
     assigned_object_id = models.PositiveBigIntegerField()
@@ -644,9 +652,8 @@ class JournalEntry(CustomFieldsMixin, CustomLinksMixin, TagsMixin, ExportTemplat
     def clean(self):
         super().clean()
 
-        # Prevent the creation of journal entries on unsupported models
-        permitted_types = ContentType.objects.filter(FeatureQuery('journaling').get_query())
-        if self.assigned_object_type not in permitted_types:
+        # Validate the assigned object type
+        if self.assigned_object_type not in ContentType.objects.with_feature('journaling'):
             raise ValidationError(
                 _("Journaling is not supported for this object type ({type}).").format(type=self.assigned_object_type)
             )
@@ -664,7 +671,7 @@ class Bookmark(models.Model):
         auto_now_add=True
     )
     object_type = models.ForeignKey(
-        to=ContentType,
+        to='contenttypes.ContentType',
         on_delete=models.PROTECT
     )
     object_id = models.PositiveBigIntegerField()
@@ -695,6 +702,15 @@ class Bookmark(models.Model):
             return str(self.object)
         return super().__str__()
 
+    def clean(self):
+        super().clean()
+
+        # Validate the assigned object type
+        if self.object_type not in ContentType.objects.with_feature('bookmarks'):
+            raise ValidationError(
+                _("Bookmarks cannot be assigned to this object type ({type}).").format(type=self.object_type)
+            )
+
 
 class ConfigRevision(models.Model):
     """

+ 1 - 2
netbox/extras/models/search.py

@@ -1,6 +1,5 @@
 import uuid
 
-from django.contrib.contenttypes.models import ContentType
 from django.db import models
 from django.utils.translation import gettext_lazy as _
 
@@ -27,7 +26,7 @@ class CachedValue(models.Model):
         editable=False
     )
     object_type = models.ForeignKey(
-        to=ContentType,
+        to='contenttypes.ContentType',
         on_delete=models.CASCADE,
         related_name='+'
     )

+ 1 - 2
netbox/extras/models/staging.py

@@ -2,7 +2,6 @@ import logging
 
 from django.contrib.auth import get_user_model
 from django.contrib.contenttypes.fields import GenericForeignKey
-from django.contrib.contenttypes.models import ContentType
 from django.db import models, transaction
 from django.utils.translation import gettext_lazy as _
 
@@ -71,7 +70,7 @@ class StagedChange(ChangeLoggedModel):
         choices=ChangeActionChoices
     )
     object_type = models.ForeignKey(
-        to=ContentType,
+        to='contenttypes.ContentType',
         on_delete=models.CASCADE,
         related_name='+'
     )

+ 1 - 5
netbox/extras/models/tags.py

@@ -1,13 +1,10 @@
 from django.conf import settings
-from django.contrib.contenttypes.models import ContentType
-from django.core.exceptions import ValidationError
 from django.db import models
 from django.urls import reverse
 from django.utils.text import slugify
 from django.utils.translation import gettext_lazy as _
 from taggit.models import TagBase, GenericTaggedItemBase
 
-from extras.utils import FeatureQuery
 from netbox.models import ChangeLoggedModel
 from netbox.models.features import CloningMixin, ExportTemplatesMixin
 from utilities.choices import ColorChoices
@@ -37,9 +34,8 @@ class Tag(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel, TagBase):
         blank=True,
     )
     object_types = models.ManyToManyField(
-        to=ContentType,
+        to='contenttypes.ContentType',
         related_name='+',
-        limit_choices_to=FeatureQuery('tags'),
         blank=True,
         help_text=_("The object type(s) to which this this tag can be applied.")
     )

+ 0 - 25
netbox/extras/utils.py

@@ -1,5 +1,3 @@
-from django.db.models import Q
-from django.utils.deconstruct import deconstructible
 from taggit.managers import _TaggableManager
 
 from netbox.registry import registry
@@ -31,29 +29,6 @@ def image_upload(instance, filename):
     return '{}{}_{}_{}'.format(path, instance.content_type.name, instance.object_id, filename)
 
 
-@deconstructible
-class FeatureQuery:
-    """
-    Helper class that delays evaluation of the registry contents for the functionality store
-    until it has been populated.
-    """
-    def __init__(self, feature):
-        self.feature = feature
-
-    def __call__(self):
-        return self.get_query()
-
-    def get_query(self):
-        """
-        Given an extras feature, return a Q object for content type lookup
-        """
-        query = Q()
-        for app_label, models in registry['model_features'][self.feature].items():
-            query |= Q(app_label=app_label, model__in=models)
-
-        return query
-
-
 def register_features(model, features):
     """
     Register model features in the application registry.

+ 2 - 3
netbox/ipam/models/fhrp.py

@@ -1,13 +1,12 @@
 from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
-from django.contrib.contenttypes.models import ContentType
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.db import models
 from django.urls import reverse
 from django.utils.translation import gettext_lazy as _
 
-from netbox.models import ChangeLoggedModel, PrimaryModel
 from ipam.choices import *
 from ipam.constants import *
+from netbox.models import ChangeLoggedModel, PrimaryModel
 
 __all__ = (
     'FHRPGroup',
@@ -78,7 +77,7 @@ class FHRPGroup(PrimaryModel):
 
 class FHRPGroupAssignment(ChangeLoggedModel):
     interface_type = models.ForeignKey(
-        to=ContentType,
+        to='contenttypes.ContentType',
         on_delete=models.CASCADE
     )
     interface_id = models.PositiveBigIntegerField()

+ 2 - 2
netbox/ipam/models/ip.py

@@ -1,6 +1,5 @@
 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
@@ -9,6 +8,7 @@ from django.urls import reverse
 from django.utils.functional import cached_property
 from django.utils.translation import gettext_lazy as _
 
+from core.models import ContentType
 from ipam.choices import *
 from ipam.constants import *
 from ipam.fields import IPNetworkField, IPAddressField
@@ -740,7 +740,7 @@ class IPAddress(PrimaryModel):
         help_text=_('The functional role of this IP')
     )
     assigned_object_type = models.ForeignKey(
-        to=ContentType,
+        to='contenttypes.ContentType',
         limit_choices_to=IPADDRESS_ASSIGNMENT_MODELS,
         on_delete=models.PROTECT,
         related_name='+',

+ 2 - 2
netbox/ipam/models/l2vpn.py

@@ -1,11 +1,11 @@
 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.urls import reverse
 from django.utils.functional import cached_property
 from django.utils.translation import gettext_lazy as _
 
+from core.models import ContentType
 from ipam.choices import L2VPNTypeChoices
 from ipam.constants import L2VPN_ASSIGNMENT_MODELS
 from netbox.models import NetBoxModel, PrimaryModel
@@ -86,7 +86,7 @@ class L2VPNTermination(NetBoxModel):
         related_name='terminations'
     )
     assigned_object_type = models.ForeignKey(
-        to=ContentType,
+        to='contenttypes.ContentType',
         limit_choices_to=L2VPN_ASSIGNMENT_MODELS,
         on_delete=models.PROTECT,
         related_name='+'

+ 1 - 2
netbox/ipam/models/vlans.py

@@ -1,5 +1,4 @@
 from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
-from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ValidationError
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.db import models
@@ -32,7 +31,7 @@ class VLANGroup(OrganizationalModel):
         max_length=100
     )
     scope_type = models.ForeignKey(
-        to=ContentType,
+        to='contenttypes.ContentType',
         on_delete=models.CASCADE,
         limit_choices_to=Q(model__in=VLANGROUP_SCOPE_TYPES),
         blank=True,

+ 1 - 1
netbox/netbox/models/features.py

@@ -3,7 +3,6 @@ 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.signals import class_prepared
@@ -13,6 +12,7 @@ from django.utils.translation import gettext_lazy as _
 from taggit.managers import TaggableManager
 
 from core.choices import JobStatusChoices
+from core.models import ContentType
 from extras.choices import CustomFieldVisibilityChoices, ObjectChangeActionChoices
 from extras.utils import is_taggable, register_features
 from netbox.registry import registry

+ 2 - 4
netbox/tenancy/forms/filtersets.py

@@ -1,8 +1,7 @@
 from django import forms
-from django.contrib.contenttypes.models import ContentType
 from django.utils.translation import gettext_lazy as _
 
-from extras.utils import FeatureQuery
+from core.models import ContentType
 from netbox.forms import NetBoxModelFilterSetForm
 from tenancy.choices import *
 from tenancy.models import *
@@ -87,8 +86,7 @@ class ContactAssignmentFilterForm(NetBoxModelFilterSetForm):
         (_('Assignment'), ('content_type_id', 'group_id', 'contact_id', 'role_id', 'priority')),
     )
     content_type_id = ContentTypeMultipleChoiceField(
-        queryset=ContentType.objects.all(),
-        limit_choices_to=FeatureQuery('contacts'),
+        queryset=ContentType.objects.with_feature('contacts'),
         required=False,
         label=_('Object type')
     )

+ 12 - 2
netbox/tenancy/models/contacts.py

@@ -1,9 +1,10 @@
 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.urls import reverse
 from django.utils.translation import gettext_lazy as _
 
+from core.models import ContentType
 from netbox.models import ChangeLoggedModel, NestedGroupModel, OrganizationalModel, PrimaryModel
 from netbox.models.features import CustomFieldsMixin, TagsMixin
 from tenancy.choices import *
@@ -111,7 +112,7 @@ class Contact(PrimaryModel):
 
 class ContactAssignment(CustomFieldsMixin, TagsMixin, ChangeLoggedModel):
     content_type = models.ForeignKey(
-        to=ContentType,
+        to='contenttypes.ContentType',
         on_delete=models.CASCADE
     )
     object_id = models.PositiveBigIntegerField()
@@ -157,6 +158,15 @@ class ContactAssignment(CustomFieldsMixin, TagsMixin, ChangeLoggedModel):
     def get_absolute_url(self):
         return reverse('tenancy:contact', args=[self.contact.pk])
 
+    def clean(self):
+        super().clean()
+
+        # Validate the assigned object type
+        if self.content_type not in ContentType.objects.with_feature('contacts'):
+            raise ValidationError(
+                _("Contacts cannot be assigned to this object type ({type}).").format(type=self.content_type)
+            )
+
     def to_objectchange(self, action):
         objectchange = super().to_objectchange(action)
         objectchange.related_object = self.object

+ 2 - 2
netbox/users/models.py

@@ -3,7 +3,6 @@ import os
 
 from django.conf import settings
 from django.contrib.auth.models import Group, GroupManager, User, UserManager
-from django.contrib.contenttypes.models import ContentType
 from django.contrib.postgres.fields import ArrayField
 from django.core.exceptions import ValidationError
 from django.core.validators import MinLengthValidator
@@ -15,6 +14,7 @@ from django.utils import timezone
 from django.utils.translation import gettext_lazy as _
 from netaddr import IPNetwork
 
+from core.models import ContentType
 from ipam.fields import IPNetworkField
 from netbox.config import get_config
 from utilities.querysets import RestrictedQuerySet
@@ -353,7 +353,7 @@ class ObjectPermission(models.Model):
         default=True
     )
     object_types = models.ManyToManyField(
-        to=ContentType,
+        to='contenttypes.ContentType',
         limit_choices_to=OBJECTPERMISSION_OBJECT_TYPES,
         related_name='object_permissions'
     )