Sfoglia il codice sorgente

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 anni fa
parent
commit
e15647a2ce

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

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

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

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

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

@@ -21,6 +21,24 @@ class ContentTypeManager(ContentTypeManager_):
             q |= Q(app_label=app_label, model__in=models)
             q |= Q(app_label=app_label, model__in=models)
         return self.get_queryset().filter(q)
         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_):
 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.conf import settings
 from django.contrib.contenttypes.fields import GenericForeignKey
 from django.contrib.contenttypes.fields import GenericForeignKey
-from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
 from django.core.validators import RegexValidator
 from django.core.validators import RegexValidator
 from django.db import models
 from django.db import models
@@ -368,7 +367,7 @@ class AutoSyncRecord(models.Model):
         related_name='+'
         related_name='+'
     )
     )
     object_type = models.ForeignKey(
     object_type = models.ForeignKey(
-        to=ContentType,
+        to='contenttypes.ContentType',
         on_delete=models.CASCADE,
         on_delete=models.CASCADE,
         related_name='+'
         related_name='+'
     )
     )

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

@@ -3,7 +3,7 @@ import uuid
 import django_rq
 import django_rq
 from django.conf import settings
 from django.conf import settings
 from django.contrib.contenttypes.fields import GenericForeignKey
 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.core.validators import MinValueValidator
 from django.db import models
 from django.db import models
 from django.urls import reverse
 from django.urls import reverse
@@ -11,8 +11,8 @@ from django.utils import timezone
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
 from core.choices import JobStatusChoices
 from core.choices import JobStatusChoices
+from core.models import ContentType
 from extras.constants import EVENT_JOB_END, EVENT_JOB_START
 from extras.constants import EVENT_JOB_END, EVENT_JOB_START
-from extras.utils import FeatureQuery
 from netbox.config import get_config
 from netbox.config import get_config
 from netbox.constants import RQ_QUEUE_DEFAULT
 from netbox.constants import RQ_QUEUE_DEFAULT
 from utilities.querysets import RestrictedQuerySet
 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).
     Tracks the lifecycle of a job which represents a background task (e.g. the execution of a custom script).
     """
     """
     object_type = models.ForeignKey(
     object_type = models.ForeignKey(
-        to=ContentType,
+        to='contenttypes.ContentType',
         related_name='jobs',
         related_name='jobs',
-        limit_choices_to=FeatureQuery('jobs'),
         on_delete=models.CASCADE,
         on_delete=models.CASCADE,
     )
     )
     object_id = models.PositiveBigIntegerField(
     object_id = models.PositiveBigIntegerField(
@@ -123,6 +122,15 @@ class Job(models.Model):
     def get_status_color(self):
     def get_status_color(self):
         return JobStatusChoices.colors.get(self.status)
         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
     @property
     def duration(self):
     def duration(self):
         if not self.completed:
         if not self.completed:

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

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

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

@@ -1,7 +1,6 @@
 from functools import cached_property
 from functools import cached_property
 
 
 from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
 from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
-from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.db import models
 from django.db import models
@@ -1181,7 +1180,7 @@ class InventoryItem(MPTTModel, ComponentModel, TrackingModelMixin):
         db_index=True
         db_index=True
     )
     )
     component_type = models.ForeignKey(
     component_type = models.ForeignKey(
-        to=ContentType,
+        to='contenttypes.ContentType',
         limit_choices_to=MODULAR_COMPONENT_MODELS,
         limit_choices_to=MODULAR_COMPONENT_MODELS,
         on_delete=models.PROTECT,
         on_delete=models.PROTECT,
         related_name='+',
         related_name='+',

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

@@ -1,10 +1,10 @@
 from django.contrib.auth import get_user_model
 from django.contrib.auth import get_user_model
-from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ObjectDoesNotExist
 from django.core.exceptions import ObjectDoesNotExist
 from rest_framework import serializers
 from rest_framework import serializers
 
 
 from core.api.serializers import JobSerializer
 from core.api.serializers import JobSerializer
 from core.api.nested_serializers import NestedDataSourceSerializer, NestedDataFileSerializer, NestedJobSerializer
 from core.api.nested_serializers import NestedDataSourceSerializer, NestedDataFileSerializer, NestedJobSerializer
+from core.models import ContentType
 from dcim.api.nested_serializers import (
 from dcim.api.nested_serializers import (
     NestedDeviceRoleSerializer, NestedDeviceTypeSerializer, NestedLocationSerializer, NestedPlatformSerializer,
     NestedDeviceRoleSerializer, NestedDeviceTypeSerializer, NestedLocationSerializer, NestedPlatformSerializer,
     NestedRegionSerializer, NestedSiteSerializer, NestedSiteGroupSerializer,
     NestedRegionSerializer, NestedSiteSerializer, NestedSiteGroupSerializer,
@@ -14,7 +14,6 @@ from drf_spectacular.utils import extend_schema_field
 from drf_spectacular.types import OpenApiTypes
 from drf_spectacular.types import OpenApiTypes
 from extras.choices import *
 from extras.choices import *
 from extras.models import *
 from extras.models import *
-from extras.utils import FeatureQuery
 from netbox.api.exceptions import SerializerNotFound
 from netbox.api.exceptions import SerializerNotFound
 from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField
 from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField
 from netbox.api.serializers import BaseModelSerializer, NetBoxModelSerializer, ValidatedModelSerializer
 from netbox.api.serializers import BaseModelSerializer, NetBoxModelSerializer, ValidatedModelSerializer
@@ -64,7 +63,7 @@ __all__ = (
 class WebhookSerializer(NetBoxModelSerializer):
 class WebhookSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='extras-api:webhook-detail')
     url = serializers.HyperlinkedIdentityField(view_name='extras-api:webhook-detail')
     content_types = ContentTypeField(
     content_types = ContentTypeField(
-        queryset=ContentType.objects.filter(FeatureQuery('webhooks').get_query()),
+        queryset=ContentType.objects.with_feature('webhooks'),
         many=True
         many=True
     )
     )
 
 
@@ -85,7 +84,7 @@ class WebhookSerializer(NetBoxModelSerializer):
 class CustomFieldSerializer(ValidatedModelSerializer):
 class CustomFieldSerializer(ValidatedModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='extras-api:customfield-detail')
     url = serializers.HyperlinkedIdentityField(view_name='extras-api:customfield-detail')
     content_types = ContentTypeField(
     content_types = ContentTypeField(
-        queryset=ContentType.objects.filter(FeatureQuery('custom_fields').get_query()),
+        queryset=ContentType.objects.with_feature('custom_fields'),
         many=True
         many=True
     )
     )
     type = ChoiceField(choices=CustomFieldTypeChoices)
     type = ChoiceField(choices=CustomFieldTypeChoices)
@@ -151,7 +150,7 @@ class CustomFieldChoiceSetSerializer(ValidatedModelSerializer):
 class CustomLinkSerializer(ValidatedModelSerializer):
 class CustomLinkSerializer(ValidatedModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='extras-api:customlink-detail')
     url = serializers.HyperlinkedIdentityField(view_name='extras-api:customlink-detail')
     content_types = ContentTypeField(
     content_types = ContentTypeField(
-        queryset=ContentType.objects.filter(FeatureQuery('custom_links').get_query()),
+        queryset=ContentType.objects.with_feature('custom_links'),
         many=True
         many=True
     )
     )
 
 
@@ -170,7 +169,7 @@ class CustomLinkSerializer(ValidatedModelSerializer):
 class ExportTemplateSerializer(ValidatedModelSerializer):
 class ExportTemplateSerializer(ValidatedModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='extras-api:exporttemplate-detail')
     url = serializers.HyperlinkedIdentityField(view_name='extras-api:exporttemplate-detail')
     content_types = ContentTypeField(
     content_types = ContentTypeField(
-        queryset=ContentType.objects.filter(FeatureQuery('export_templates').get_query()),
+        queryset=ContentType.objects.with_feature('export_templates'),
         many=True
         many=True
     )
     )
     data_source = NestedDataSourceSerializer(
     data_source = NestedDataSourceSerializer(
@@ -215,7 +214,7 @@ class SavedFilterSerializer(ValidatedModelSerializer):
 class BookmarkSerializer(ValidatedModelSerializer):
 class BookmarkSerializer(ValidatedModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='extras-api:bookmark-detail')
     url = serializers.HyperlinkedIdentityField(view_name='extras-api:bookmark-detail')
     object_type = ContentTypeField(
     object_type = ContentTypeField(
-        queryset=ContentType.objects.filter(FeatureQuery('bookmarks').get_query()),
+        queryset=ContentType.objects.with_feature('bookmarks'),
     )
     )
     object = serializers.SerializerMethodField(read_only=True)
     object = serializers.SerializerMethodField(read_only=True)
     user = NestedUserSerializer()
     user = NestedUserSerializer()
@@ -239,7 +238,7 @@ class BookmarkSerializer(ValidatedModelSerializer):
 class TagSerializer(ValidatedModelSerializer):
 class TagSerializer(ValidatedModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='extras-api:tag-detail')
     url = serializers.HyperlinkedIdentityField(view_name='extras-api:tag-detail')
     object_types = ContentTypeField(
     object_types = ContentTypeField(
-        queryset=ContentType.objects.filter(FeatureQuery('tags').get_query()),
+        queryset=ContentType.objects.with_feature('tags'),
         many=True,
         many=True,
         required=False
         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 [
     return [
         (content_type_identifier(ct), content_type_name(ct))
         (content_type_identifier(ct), content_type_name(ct))
         for ct in ContentType.objects.public().order_by('app_label', 'model')
         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):
 def get_models_from_content_types(content_types):
     """
     """
     Return a list of models corresponding to the given content types, identified by natural key.
     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):
     class ConfigForm(WidgetConfigForm):
         models = forms.MultipleChoiceField(
         models = forms.MultipleChoiceField(
-            choices=get_content_type_labels
+            choices=get_object_type_choices
         )
         )
         filters = forms.JSONField(
         filters = forms.JSONField(
             required=False,
             required=False,
@@ -207,7 +214,7 @@ class ObjectListWidget(DashboardWidget):
 
 
     class ConfigForm(WidgetConfigForm):
     class ConfigForm(WidgetConfigForm):
         model = forms.ChoiceField(
         model = forms.ChoiceField(
-            choices=get_content_type_labels
+            choices=get_object_type_choices
         )
         )
         page_size = forms.IntegerField(
         page_size = forms.IntegerField(
             required=False,
             required=False,
@@ -343,8 +350,7 @@ class BookmarksWidget(DashboardWidget):
 
 
     class ConfigForm(WidgetConfigForm):
     class ConfigForm(WidgetConfigForm):
         object_types = forms.MultipleChoiceField(
         object_types = forms.MultipleChoiceField(
-            # TODO: Restrict the choices by FeatureQuery('bookmarks')
-            choices=get_content_type_labels,
+            choices=get_bookmarks_object_type_choices,
             required=False
             required=False
         )
         )
         order_by = forms.ChoiceField(
         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 core.models import ContentType
 from extras.choices import *
 from extras.choices import *
 from extras.models import *
 from extras.models import *
-from extras.utils import FeatureQuery
 from netbox.forms import NetBoxModelImportForm
 from netbox.forms import NetBoxModelImportForm
 from utilities.forms import CSVModelForm
 from utilities.forms import CSVModelForm
 from utilities.forms.fields import (
 from utilities.forms.fields import (
@@ -29,8 +28,7 @@ __all__ = (
 class CustomFieldImportForm(CSVModelForm):
 class CustomFieldImportForm(CSVModelForm):
     content_types = CSVMultipleContentTypeField(
     content_types = CSVMultipleContentTypeField(
         label=_('Content types'),
         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")
         help_text=_("One or more assigned object types")
     )
     )
     type = CSVChoiceField(
     type = CSVChoiceField(
@@ -88,8 +86,7 @@ class CustomFieldChoiceSetImportForm(CSVModelForm):
 class CustomLinkImportForm(CSVModelForm):
 class CustomLinkImportForm(CSVModelForm):
     content_types = CSVMultipleContentTypeField(
     content_types = CSVMultipleContentTypeField(
         label=_('Content types'),
         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")
         help_text=_("One or more assigned object types")
     )
     )
 
 
@@ -104,8 +101,7 @@ class CustomLinkImportForm(CSVModelForm):
 class ExportTemplateImportForm(CSVModelForm):
 class ExportTemplateImportForm(CSVModelForm):
     content_types = CSVMultipleContentTypeField(
     content_types = CSVMultipleContentTypeField(
         label=_('Content types'),
         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")
         help_text=_("One or more assigned object types")
     )
     )
 
 
@@ -142,8 +138,7 @@ class SavedFilterImportForm(CSVModelForm):
 class WebhookImportForm(NetBoxModelImportForm):
 class WebhookImportForm(NetBoxModelImportForm):
     content_types = CSVMultipleContentTypeField(
     content_types = CSVMultipleContentTypeField(
         label=_('Content types'),
         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")
         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 dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
 from extras.choices import *
 from extras.choices import *
 from extras.models import *
 from extras.models import *
-from extras.utils import FeatureQuery
 from netbox.forms.base import NetBoxModelFilterSetForm
 from netbox.forms.base import NetBoxModelFilterSetForm
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
 from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice
 from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice
@@ -44,7 +43,7 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
         )),
         )),
     )
     )
     content_type_id = ContentTypeMultipleChoiceField(
     content_type_id = ContentTypeMultipleChoiceField(
-        queryset=ContentType.objects.filter(FeatureQuery('custom_fields').get_query()),
+        queryset=ContentType.objects.with_feature('custom_fields'),
         required=False,
         required=False,
         label=_('Object type')
         label=_('Object type')
     )
     )
@@ -108,7 +107,7 @@ class CustomLinkFilterForm(SavedFiltersMixin, FilterForm):
     )
     )
     content_types = ContentTypeMultipleChoiceField(
     content_types = ContentTypeMultipleChoiceField(
         label=_('Content types'),
         label=_('Content types'),
-        queryset=ContentType.objects.filter(FeatureQuery('custom_links').get_query()),
+        queryset=ContentType.objects.with_feature('custom_links'),
         required=False
         required=False
     )
     )
     enabled = forms.NullBooleanField(
     enabled = forms.NullBooleanField(
@@ -151,7 +150,7 @@ class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
         }
         }
     )
     )
     content_type_id = ContentTypeMultipleChoiceField(
     content_type_id = ContentTypeMultipleChoiceField(
-        queryset=ContentType.objects.filter(FeatureQuery('export_templates').get_query()),
+        queryset=ContentType.objects.with_feature('export_templates'),
         required=False,
         required=False,
         label=_('Content types')
         label=_('Content types')
     )
     )
@@ -179,7 +178,7 @@ class ImageAttachmentFilterForm(SavedFiltersMixin, FilterForm):
     )
     )
     content_type_id = ContentTypeChoiceField(
     content_type_id = ContentTypeChoiceField(
         label=_('Content type'),
         label=_('Content type'),
-        queryset=ContentType.objects.filter(FeatureQuery('image_attachments').get_query()),
+        queryset=ContentType.objects.with_feature('image_attachments'),
         required=False
         required=False
     )
     )
     name = forms.CharField(
     name = forms.CharField(
@@ -228,7 +227,7 @@ class WebhookFilterForm(NetBoxModelFilterSetForm):
         (_('Events'), ('type_create', 'type_update', 'type_delete', 'type_job_start', 'type_job_end')),
         (_('Events'), ('type_create', 'type_update', 'type_delete', 'type_job_start', 'type_job_end')),
     )
     )
     content_type_id = ContentTypeMultipleChoiceField(
     content_type_id = ContentTypeMultipleChoiceField(
-        queryset=ContentType.objects.filter(FeatureQuery('webhooks').get_query()),
+        queryset=ContentType.objects.with_feature('webhooks'),
         required=False,
         required=False,
         label=_('Object type')
         label=_('Object type')
     )
     )
@@ -284,12 +283,12 @@ class WebhookFilterForm(NetBoxModelFilterSetForm):
 class TagFilterForm(SavedFiltersMixin, FilterForm):
 class TagFilterForm(SavedFiltersMixin, FilterForm):
     model = Tag
     model = Tag
     content_type_id = ContentTypeMultipleChoiceField(
     content_type_id = ContentTypeMultipleChoiceField(
-        queryset=ContentType.objects.filter(FeatureQuery('tags').get_query()),
+        queryset=ContentType.objects.with_feature('tags'),
         required=False,
         required=False,
         label=_('Tagged object type')
         label=_('Tagged object type')
     )
     )
     for_object_type_id = ContentTypeChoiceField(
     for_object_type_id = ContentTypeChoiceField(
-        queryset=ContentType.objects.filter(FeatureQuery('tags').get_query()),
+        queryset=ContentType.objects.with_feature('tags'),
         required=False,
         required=False,
         label=_('Allowed object type')
         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 dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
 from extras.choices import *
 from extras.choices import *
 from extras.models import *
 from extras.models import *
-from extras.utils import FeatureQuery
 from netbox.config import get_config, PARAMS
 from netbox.config import get_config, PARAMS
 from netbox.forms import NetBoxModelForm
 from netbox.forms import NetBoxModelForm
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
@@ -43,8 +42,7 @@ __all__ = (
 class CustomFieldForm(BootstrapMixin, forms.ModelForm):
 class CustomFieldForm(BootstrapMixin, forms.ModelForm):
     content_types = ContentTypeMultipleChoiceField(
     content_types = ContentTypeMultipleChoiceField(
         label=_('Content types'),
         label=_('Content types'),
-        queryset=ContentType.objects.all(),
-        limit_choices_to=FeatureQuery('custom_fields'),
+        queryset=ContentType.objects.with_feature('custom_fields')
     )
     )
     object_type = ContentTypeChoiceField(
     object_type = ContentTypeChoiceField(
         label=_('Object type'),
         label=_('Object type'),
@@ -114,8 +112,7 @@ class CustomFieldChoiceSetForm(BootstrapMixin, forms.ModelForm):
 class CustomLinkForm(BootstrapMixin, forms.ModelForm):
 class CustomLinkForm(BootstrapMixin, forms.ModelForm):
     content_types = ContentTypeMultipleChoiceField(
     content_types = ContentTypeMultipleChoiceField(
         label=_('Content types'),
         label=_('Content types'),
-        queryset=ContentType.objects.all(),
-        limit_choices_to=FeatureQuery('custom_links')
+        queryset=ContentType.objects.with_feature('custom_links')
     )
     )
 
 
     fieldsets = (
     fieldsets = (
@@ -142,8 +139,7 @@ class CustomLinkForm(BootstrapMixin, forms.ModelForm):
 class ExportTemplateForm(BootstrapMixin, SyncedDataMixin, forms.ModelForm):
 class ExportTemplateForm(BootstrapMixin, SyncedDataMixin, forms.ModelForm):
     content_types = ContentTypeMultipleChoiceField(
     content_types = ContentTypeMultipleChoiceField(
         label=_('Content types'),
         label=_('Content types'),
-        queryset=ContentType.objects.all(),
-        limit_choices_to=FeatureQuery('export_templates')
+        queryset=ContentType.objects.with_feature('export_templates')
     )
     )
     template_code = forms.CharField(
     template_code = forms.CharField(
         label=_('Template code'),
         label=_('Template code'),
@@ -210,8 +206,7 @@ class SavedFilterForm(BootstrapMixin, forms.ModelForm):
 class BookmarkForm(BootstrapMixin, forms.ModelForm):
 class BookmarkForm(BootstrapMixin, forms.ModelForm):
     object_type = ContentTypeChoiceField(
     object_type = ContentTypeChoiceField(
         label=_('Object type'),
         label=_('Object type'),
-        queryset=ContentType.objects.all(),
-        limit_choices_to=FeatureQuery('bookmarks').get_query()
+        queryset=ContentType.objects.with_feature('bookmarks')
     )
     )
 
 
     class Meta:
     class Meta:
@@ -222,8 +217,7 @@ class BookmarkForm(BootstrapMixin, forms.ModelForm):
 class WebhookForm(NetBoxModelForm):
 class WebhookForm(NetBoxModelForm):
     content_types = ContentTypeMultipleChoiceField(
     content_types = ContentTypeMultipleChoiceField(
         label=_('Content types'),
         label=_('Content types'),
-        queryset=ContentType.objects.all(),
-        limit_choices_to=FeatureQuery('webhooks')
+        queryset=ContentType.objects.with_feature('webhooks')
     )
     )
 
 
     fieldsets = (
     fieldsets = (
@@ -257,8 +251,7 @@ class TagForm(BootstrapMixin, forms.ModelForm):
     slug = SlugField()
     slug = SlugField()
     object_types = ContentTypeMultipleChoiceField(
     object_types = ContentTypeMultipleChoiceField(
         label=_('Object types'),
         label=_('Object types'),
-        queryset=ContentType.objects.all(),
-        limit_choices_to=FeatureQuery('tags'),
+        queryset=ContentType.objects.with_feature('tags'),
         required=False
         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)),
                 ('secret', models.CharField(blank=True, max_length=255)),
                 ('ssl_verification', models.BooleanField(default=True)),
                 ('ssl_verification', models.BooleanField(default=True)),
                 ('ca_file_path', models.CharField(blank=True, max_length=4096, null=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={
             options={
                 'ordering': ('name',),
                 'ordering': ('name',),
@@ -151,7 +151,7 @@ class Migration(migrations.Migration):
                 ('status', models.CharField(default='pending', max_length=30)),
                 ('status', models.CharField(default='pending', max_length=30)),
                 ('data', models.JSONField(blank=True, null=True)),
                 ('data', models.JSONField(blank=True, null=True)),
                 ('job_id', models.UUIDField(unique=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)),
                 ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
             ],
             ],
             options={
             options={
@@ -184,7 +184,7 @@ class Migration(migrations.Migration):
                 ('mime_type', models.CharField(blank=True, max_length=50)),
                 ('mime_type', models.CharField(blank=True, max_length=50)),
                 ('file_extension', models.CharField(blank=True, max_length=15)),
                 ('file_extension', models.CharField(blank=True, max_length=15)),
                 ('as_attachment', models.BooleanField(default=True)),
                 ('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={
             options={
                 'ordering': ['content_type', 'name'],
                 'ordering': ['content_type', 'name'],
@@ -201,7 +201,7 @@ class Migration(migrations.Migration):
                 ('group_name', models.CharField(blank=True, max_length=50)),
                 ('group_name', models.CharField(blank=True, max_length=50)),
                 ('button_class', models.CharField(default='default', max_length=30)),
                 ('button_class', models.CharField(default='default', max_length=30)),
                 ('new_window', models.BooleanField(default=False)),
                 ('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={
             options={
                 'ordering': ['group_name', 'weight', 'name'],
                 'ordering': ['group_name', 'weight', 'name'],
@@ -223,7 +223,7 @@ class Migration(migrations.Migration):
                 ('validation_maximum', models.PositiveIntegerField(blank=True, null=True)),
                 ('validation_maximum', models.PositiveIntegerField(blank=True, null=True)),
                 ('validation_regex', models.CharField(blank=True, max_length=500, validators=[utilities.validators.validate_regex])),
                 ('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)),
                 ('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={
             options={
                 'ordering': ['weight', 'name'],
                 'ordering': ['weight', 'name'],

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

@@ -1,5 +1,4 @@
 from django.db import migrations, models
 from django.db import migrations, models
-import extras.utils
 
 
 
 
 class Migration(migrations.Migration):
 class Migration(migrations.Migration):
@@ -13,7 +12,7 @@ class Migration(migrations.Migration):
         migrations.AddField(
         migrations.AddField(
             model_name='tag',
             model_name='tag',
             name='object_types',
             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(
         migrations.RenameIndex(
             model_name='taggeditem',
             model_name='taggeditem',

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

@@ -1,10 +1,11 @@
 from django.conf import settings
 from django.conf import settings
 from django.contrib.contenttypes.fields import GenericForeignKey
 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 import models
 from django.urls import reverse
 from django.urls import reverse
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
+from core.models import ContentType
 from extras.choices import *
 from extras.choices import *
 from ..querysets import ObjectChangeQuerySet
 from ..querysets import ObjectChangeQuerySet
 
 
@@ -48,7 +49,7 @@ class ObjectChange(models.Model):
         choices=ObjectChangeActionChoices
         choices=ObjectChangeActionChoices
     )
     )
     changed_object_type = models.ForeignKey(
     changed_object_type = models.ForeignKey(
-        to=ContentType,
+        to='contenttypes.ContentType',
         on_delete=models.PROTECT,
         on_delete=models.PROTECT,
         related_name='+'
         related_name='+'
     )
     )
@@ -58,7 +59,7 @@ class ObjectChange(models.Model):
         fk_field='changed_object_id'
         fk_field='changed_object_id'
     )
     )
     related_object_type = models.ForeignKey(
     related_object_type = models.ForeignKey(
-        to=ContentType,
+        to='contenttypes.ContentType',
         on_delete=models.PROTECT,
         on_delete=models.PROTECT,
         related_name='+',
         related_name='+',
         blank=True,
         blank=True,
@@ -104,6 +105,17 @@ class ObjectChange(models.Model):
             self.user_name
             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):
     def save(self, *args, **kwargs):
 
 
         # Record the user's name and the object's representation as static strings
         # 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
 import django_filters
 from django import forms
 from django import forms
 from django.conf import settings
 from django.conf import settings
-from django.contrib.contenttypes.models import ContentType
 from django.contrib.postgres.fields import ArrayField
 from django.contrib.postgres.fields import ArrayField
 from django.core.validators import RegexValidator, ValidationError
 from django.core.validators import RegexValidator, ValidationError
 from django.db import models
 from django.db import models
 from django.urls import reverse
 from django.urls import reverse
-from django.utils.html import escape
 from django.utils.safestring import mark_safe
 from django.utils.safestring import mark_safe
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
+from core.models import ContentType
 from extras.choices import *
 from extras.choices import *
 from extras.data import CHOICE_SETS
 from extras.data import CHOICE_SETS
-from extras.utils import FeatureQuery
 from netbox.models import ChangeLoggedModel
 from netbox.models import ChangeLoggedModel
 from netbox.models.features import CloningMixin, ExportTemplatesMixin
 from netbox.models.features import CloningMixin, ExportTemplatesMixin
 from netbox.search import FieldTypes
 from netbox.search import FieldTypes
@@ -60,9 +58,8 @@ class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)):
 
 
 class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
 class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
     content_types = models.ManyToManyField(
     content_types = models.ManyToManyField(
-        to=ContentType,
+        to='contenttypes.ContentType',
         related_name='custom_fields',
         related_name='custom_fields',
-        limit_choices_to=FeatureQuery('custom_fields'),
         help_text=_('The object(s) to which this field applies.')
         help_text=_('The object(s) to which this field applies.')
     )
     )
     type = models.CharField(
     type = models.CharField(
@@ -73,7 +70,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
         help_text=_('The type of data this custom field holds')
         help_text=_('The type of data this custom field holds')
     )
     )
     object_type = models.ForeignKey(
     object_type = models.ForeignKey(
-        to=ContentType,
+        to='contenttypes.ContentType',
         on_delete=models.PROTECT,
         on_delete=models.PROTECT,
         blank=True,
         blank=True,
         null=True,
         null=True,

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

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

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

@@ -1,6 +1,5 @@
 import uuid
 import uuid
 
 
-from django.contrib.contenttypes.models import ContentType
 from django.db import models
 from django.db import models
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
@@ -27,7 +26,7 @@ class CachedValue(models.Model):
         editable=False
         editable=False
     )
     )
     object_type = models.ForeignKey(
     object_type = models.ForeignKey(
-        to=ContentType,
+        to='contenttypes.ContentType',
         on_delete=models.CASCADE,
         on_delete=models.CASCADE,
         related_name='+'
         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.auth import get_user_model
 from django.contrib.contenttypes.fields import GenericForeignKey
 from django.contrib.contenttypes.fields import GenericForeignKey
-from django.contrib.contenttypes.models import ContentType
 from django.db import models, transaction
 from django.db import models, transaction
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
@@ -71,7 +70,7 @@ class StagedChange(ChangeLoggedModel):
         choices=ChangeActionChoices
         choices=ChangeActionChoices
     )
     )
     object_type = models.ForeignKey(
     object_type = models.ForeignKey(
-        to=ContentType,
+        to='contenttypes.ContentType',
         on_delete=models.CASCADE,
         on_delete=models.CASCADE,
         related_name='+'
         related_name='+'
     )
     )

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

@@ -1,13 +1,10 @@
 from django.conf import settings
 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.db import models
 from django.urls import reverse
 from django.urls import reverse
 from django.utils.text import slugify
 from django.utils.text import slugify
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 from taggit.models import TagBase, GenericTaggedItemBase
 from taggit.models import TagBase, GenericTaggedItemBase
 
 
-from extras.utils import FeatureQuery
 from netbox.models import ChangeLoggedModel
 from netbox.models import ChangeLoggedModel
 from netbox.models.features import CloningMixin, ExportTemplatesMixin
 from netbox.models.features import CloningMixin, ExportTemplatesMixin
 from utilities.choices import ColorChoices
 from utilities.choices import ColorChoices
@@ -37,9 +34,8 @@ class Tag(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel, TagBase):
         blank=True,
         blank=True,
     )
     )
     object_types = models.ManyToManyField(
     object_types = models.ManyToManyField(
-        to=ContentType,
+        to='contenttypes.ContentType',
         related_name='+',
         related_name='+',
-        limit_choices_to=FeatureQuery('tags'),
         blank=True,
         blank=True,
         help_text=_("The object type(s) to which this this tag can be applied.")
         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 taggit.managers import _TaggableManager
 
 
 from netbox.registry import registry
 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)
     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):
 def register_features(model, features):
     """
     """
     Register model features in the application registry.
     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.fields import GenericForeignKey, GenericRelation
-from django.contrib.contenttypes.models import ContentType
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.db import models
 from django.db import models
 from django.urls import reverse
 from django.urls import reverse
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
-from netbox.models import ChangeLoggedModel, PrimaryModel
 from ipam.choices import *
 from ipam.choices import *
 from ipam.constants import *
 from ipam.constants import *
+from netbox.models import ChangeLoggedModel, PrimaryModel
 
 
 __all__ = (
 __all__ = (
     'FHRPGroup',
     'FHRPGroup',
@@ -78,7 +77,7 @@ class FHRPGroup(PrimaryModel):
 
 
 class FHRPGroupAssignment(ChangeLoggedModel):
 class FHRPGroupAssignment(ChangeLoggedModel):
     interface_type = models.ForeignKey(
     interface_type = models.ForeignKey(
-        to=ContentType,
+        to='contenttypes.ContentType',
         on_delete=models.CASCADE
         on_delete=models.CASCADE
     )
     )
     interface_id = models.PositiveBigIntegerField()
     interface_id = models.PositiveBigIntegerField()

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

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

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

@@ -1,11 +1,11 @@
 from django.contrib.contenttypes.fields import GenericForeignKey
 from django.contrib.contenttypes.fields import GenericForeignKey
-from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
 from django.db import models
 from django.db import models
 from django.urls import reverse
 from django.urls import reverse
 from django.utils.functional import cached_property
 from django.utils.functional import cached_property
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
+from core.models import ContentType
 from ipam.choices import L2VPNTypeChoices
 from ipam.choices import L2VPNTypeChoices
 from ipam.constants import L2VPN_ASSIGNMENT_MODELS
 from ipam.constants import L2VPN_ASSIGNMENT_MODELS
 from netbox.models import NetBoxModel, PrimaryModel
 from netbox.models import NetBoxModel, PrimaryModel
@@ -86,7 +86,7 @@ class L2VPNTermination(NetBoxModel):
         related_name='terminations'
         related_name='terminations'
     )
     )
     assigned_object_type = models.ForeignKey(
     assigned_object_type = models.ForeignKey(
-        to=ContentType,
+        to='contenttypes.ContentType',
         limit_choices_to=L2VPN_ASSIGNMENT_MODELS,
         limit_choices_to=L2VPN_ASSIGNMENT_MODELS,
         on_delete=models.PROTECT,
         on_delete=models.PROTECT,
         related_name='+'
         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.fields import GenericForeignKey, GenericRelation
-from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.db import models
 from django.db import models
@@ -32,7 +31,7 @@ class VLANGroup(OrganizationalModel):
         max_length=100
         max_length=100
     )
     )
     scope_type = models.ForeignKey(
     scope_type = models.ForeignKey(
-        to=ContentType,
+        to='contenttypes.ContentType',
         on_delete=models.CASCADE,
         on_delete=models.CASCADE,
         limit_choices_to=Q(model__in=VLANGROUP_SCOPE_TYPES),
         limit_choices_to=Q(model__in=VLANGROUP_SCOPE_TYPES),
         blank=True,
         blank=True,

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

@@ -3,7 +3,6 @@ from collections import defaultdict
 from functools import cached_property
 from functools import cached_property
 
 
 from django.contrib.contenttypes.fields import GenericRelation
 from django.contrib.contenttypes.fields import GenericRelation
-from django.contrib.contenttypes.models import ContentType
 from django.core.validators import ValidationError
 from django.core.validators import ValidationError
 from django.db import models
 from django.db import models
 from django.db.models.signals import class_prepared
 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 taggit.managers import TaggableManager
 
 
 from core.choices import JobStatusChoices
 from core.choices import JobStatusChoices
+from core.models import ContentType
 from extras.choices import CustomFieldVisibilityChoices, ObjectChangeActionChoices
 from extras.choices import CustomFieldVisibilityChoices, ObjectChangeActionChoices
 from extras.utils import is_taggable, register_features
 from extras.utils import is_taggable, register_features
 from netbox.registry import registry
 from netbox.registry import registry

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

@@ -1,8 +1,7 @@
 from django import forms
 from django import forms
-from django.contrib.contenttypes.models import ContentType
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
-from extras.utils import FeatureQuery
+from core.models import ContentType
 from netbox.forms import NetBoxModelFilterSetForm
 from netbox.forms import NetBoxModelFilterSetForm
 from tenancy.choices import *
 from tenancy.choices import *
 from tenancy.models import *
 from tenancy.models import *
@@ -87,8 +86,7 @@ class ContactAssignmentFilterForm(NetBoxModelFilterSetForm):
         (_('Assignment'), ('content_type_id', 'group_id', 'contact_id', 'role_id', 'priority')),
         (_('Assignment'), ('content_type_id', 'group_id', 'contact_id', 'role_id', 'priority')),
     )
     )
     content_type_id = ContentTypeMultipleChoiceField(
     content_type_id = ContentTypeMultipleChoiceField(
-        queryset=ContentType.objects.all(),
-        limit_choices_to=FeatureQuery('contacts'),
+        queryset=ContentType.objects.with_feature('contacts'),
         required=False,
         required=False,
         label=_('Object type')
         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.fields import GenericForeignKey
-from django.contrib.contenttypes.models import ContentType
+from django.core.exceptions import ValidationError
 from django.db import models
 from django.db import models
 from django.urls import reverse
 from django.urls import reverse
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
+from core.models import ContentType
 from netbox.models import ChangeLoggedModel, NestedGroupModel, OrganizationalModel, PrimaryModel
 from netbox.models import ChangeLoggedModel, NestedGroupModel, OrganizationalModel, PrimaryModel
 from netbox.models.features import CustomFieldsMixin, TagsMixin
 from netbox.models.features import CustomFieldsMixin, TagsMixin
 from tenancy.choices import *
 from tenancy.choices import *
@@ -111,7 +112,7 @@ class Contact(PrimaryModel):
 
 
 class ContactAssignment(CustomFieldsMixin, TagsMixin, ChangeLoggedModel):
 class ContactAssignment(CustomFieldsMixin, TagsMixin, ChangeLoggedModel):
     content_type = models.ForeignKey(
     content_type = models.ForeignKey(
-        to=ContentType,
+        to='contenttypes.ContentType',
         on_delete=models.CASCADE
         on_delete=models.CASCADE
     )
     )
     object_id = models.PositiveBigIntegerField()
     object_id = models.PositiveBigIntegerField()
@@ -157,6 +158,15 @@ class ContactAssignment(CustomFieldsMixin, TagsMixin, ChangeLoggedModel):
     def get_absolute_url(self):
     def get_absolute_url(self):
         return reverse('tenancy:contact', args=[self.contact.pk])
         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):
     def to_objectchange(self, action):
         objectchange = super().to_objectchange(action)
         objectchange = super().to_objectchange(action)
         objectchange.related_object = self.object
         objectchange.related_object = self.object

+ 2 - 2
netbox/users/models.py

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