소스 검색

Merge pull request #15327 from netbox-community/15277-object-types

Closes #15277: Standardize names & model for ContentType ForeignKeys
Jeremy Stretch 1 년 전
부모
커밋
7c63d0c500
99개의 변경된 파일855개의 추가작업 그리고 651개의 파일을 삭제
  1. 1 1
      netbox/core/forms/filtersets.py
  2. 2 2
      netbox/core/management/commands/nbshell.py
  3. 2 4
      netbox/core/migrations/0008_contenttype_proxy.py
  4. 6 6
      netbox/core/models/contenttypes.py
  5. 3 3
      netbox/core/models/jobs.py
  6. 6 6
      netbox/dcim/models/cables.py
  7. 3 3
      netbox/dcim/tests/test_models.py
  8. 0 2
      netbox/dcim/tests/test_views.py
  9. 5 5
      netbox/extras/api/customfields.py
  10. 1 1
      netbox/extras/api/serializers.py
  11. 6 6
      netbox/extras/api/serializers_/attachments.py
  12. 2 2
      netbox/extras/api/serializers_/bookmarks.py
  13. 5 5
      netbox/extras/api/serializers_/customfields.py
  14. 4 4
      netbox/extras/api/serializers_/customlinks.py
  15. 5 5
      netbox/extras/api/serializers_/events.py
  16. 4 4
      netbox/extras/api/serializers_/exporttemplates.py
  17. 2 2
      netbox/extras/api/serializers_/journaling.py
  18. 5 5
      netbox/extras/api/serializers_/objecttypes.py
  19. 4 4
      netbox/extras/api/serializers_/savedfilters.py
  20. 2 2
      netbox/extras/api/serializers_/tags.py
  21. 1 1
      netbox/extras/api/urls.py
  22. 7 8
      netbox/extras/api/views.py
  23. 6 6
      netbox/extras/dashboard/widgets.py
  24. 1 1
      netbox/extras/events.py
  25. 36 26
      netbox/extras/filtersets.py
  26. 24 24
      netbox/extras/forms/bulk_import.py
  27. 26 26
      netbox/extras/forms/filtersets.py
  28. 27 28
      netbox/extras/forms/model_forms.py
  29. 12 12
      netbox/extras/graphql/types.py
  30. 107 0
      netbox/extras/migrations/0111_rename_content_types.py
  31. 17 0
      netbox/extras/migrations/0112_tag_update_object_types.py
  32. 2 2
      netbox/extras/models/change_logging.py
  33. 8 8
      netbox/extras/models/customfields.py
  34. 21 21
      netbox/extras/models/models.py
  35. 1 1
      netbox/extras/models/tags.py
  36. 8 7
      netbox/extras/signals.py
  37. 24 24
      netbox/extras/tables/tables.py
  38. 3 3
      netbox/extras/templatetags/custom_links.py
  39. 33 33
      netbox/extras/tests/test_api.py
  40. 7 6
      netbox/extras/tests/test_changelog.py
  41. 45 45
      netbox/extras/tests/test_customfields.py
  42. 12 11
      netbox/extras/tests/test_event_rules.py
  43. 48 49
      netbox/extras/tests/test_filtersets.py
  44. 18 18
      netbox/extras/tests/test_forms.py
  45. 2 2
      netbox/extras/tests/test_models.py
  46. 22 21
      netbox/extras/tests/test_views.py
  47. 1 1
      netbox/extras/utils.py
  48. 5 5
      netbox/extras/views.py
  49. 2 2
      netbox/ipam/models/ip.py
  50. 0 2
      netbox/netbox/api/serializers/features.py
  51. 5 5
      netbox/netbox/api/viewsets/mixins.py
  52. 1 1
      netbox/netbox/filtersets.py
  53. 7 6
      netbox/netbox/forms/base.py
  54. 7 7
      netbox/netbox/forms/mixins.py
  55. 10 0
      netbox/netbox/graphql/types.py
  56. 12 8
      netbox/netbox/models/features.py
  57. 13 12
      netbox/netbox/search/backends.py
  58. 4 4
      netbox/netbox/tables/tables.py
  59. 6 6
      netbox/netbox/tests/test_authentication.py
  60. 3 3
      netbox/netbox/tests/test_import.py
  61. 6 0
      netbox/netbox/tests/test_staging.py
  62. 3 3
      netbox/netbox/views/generic/bulk_views.py
  63. 1 1
      netbox/templates/extras/customfield.html
  64. 1 1
      netbox/templates/extras/customlink.html
  65. 2 2
      netbox/templates/extras/eventrule.html
  66. 2 7
      netbox/templates/extras/exporttemplate.html
  67. 2 2
      netbox/templates/extras/savedfilter.html
  68. 1 1
      netbox/templates/inc/panels/image_attachments.html
  69. 1 1
      netbox/templates/tenancy/object_contacts.html
  70. 3 3
      netbox/tenancy/api/serializers_/contacts.py
  71. 2 2
      netbox/tenancy/filtersets.py
  72. 2 2
      netbox/tenancy/forms/bulk_import.py
  73. 4 4
      netbox/tenancy/forms/filtersets.py
  74. 2 2
      netbox/tenancy/forms/model_forms.py
  75. 40 0
      netbox/tenancy/migrations/0015_contactassignment_rename_content_type.py
  76. 8 8
      netbox/tenancy/models/contacts.py
  77. 3 3
      netbox/tenancy/tables/contacts.py
  78. 3 3
      netbox/tenancy/tests/test_api.py
  79. 3 3
      netbox/tenancy/tests/test_filtersets.py
  80. 3 3
      netbox/tenancy/tests/test_views.py
  81. 4 4
      netbox/tenancy/views.py
  82. 2 2
      netbox/users/api/nested_serializers.py
  83. 2 2
      netbox/users/api/serializers_/permissions.py
  84. 2 2
      netbox/users/forms/model_forms.py
  85. 19 0
      netbox/users/migrations/0007_objectpermission_update_object_types.py
  86. 2 2
      netbox/users/models.py
  87. 3 3
      netbox/users/tests/test_api.py
  88. 5 5
      netbox/users/tests/test_filtersets.py
  89. 3 4
      netbox/users/tests/test_views.py
  90. 4 4
      netbox/utilities/permissions.py
  91. 1 1
      netbox/utilities/templates/buttons/export.html
  92. 5 4
      netbox/utilities/templatetags/buttons.py
  93. 4 4
      netbox/utilities/templatetags/helpers.py
  94. 11 10
      netbox/utilities/testing/api.py
  95. 4 3
      netbox/utilities/testing/base.py
  96. 23 22
      netbox/utilities/testing/views.py
  97. 3 5
      netbox/utilities/tests/test_api.py
  98. 1 3
      netbox/utilities/tests/test_counters.py
  99. 3 3
      netbox/vpn/models/l2vpn.py

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

@@ -68,7 +68,7 @@ class JobFilterForm(SavedFiltersMixin, FilterForm):
     )
     object_type = ContentTypeChoiceField(
         label=_('Object Type'),
-        queryset=ContentType.objects.with_feature('jobs'),
+        queryset=ObjectType.objects.with_feature('jobs'),
         required=False,
     )
     status = forms.MultipleChoiceField(

+ 2 - 2
netbox/core/management/commands/nbshell.py

@@ -8,7 +8,7 @@ from django.conf import settings
 from django.contrib.auth import get_user_model
 from django.core.management.base import BaseCommand
 
-from core.models import ContentType
+from core.models import ObjectType
 
 APPS = ('circuits', 'core', 'dcim', 'extras', 'ipam', 'tenancy', 'users', 'virtualization', 'vpn', 'wireless')
 
@@ -60,7 +60,7 @@ class Command(BaseCommand):
                 pass
 
         # Additional objects to include
-        namespace['ContentType'] = ContentType
+        namespace['ObjectType'] = ObjectType
         namespace['User'] = get_user_model()
 
         # Load convenience commands

+ 2 - 4
netbox/core/migrations/0008_contenttype_proxy.py

@@ -1,5 +1,3 @@
-# Generated by Django 4.2.6 on 2023-10-31 19:38
-
 import core.models.contenttypes
 from django.db import migrations
 
@@ -13,7 +11,7 @@ class Migration(migrations.Migration):
 
     operations = [
         migrations.CreateModel(
-            name='ContentType',
+            name='ObjectType',
             fields=[
             ],
             options={
@@ -23,7 +21,7 @@ class Migration(migrations.Migration):
             },
             bases=('contenttypes.contenttype',),
             managers=[
-                ('objects', core.models.contenttypes.ContentTypeManager()),
+                ('objects', core.models.contenttypes.ObjectTypeManager()),
             ],
         ),
     ]

+ 6 - 6
netbox/core/models/contenttypes.py

@@ -1,15 +1,15 @@
-from django.contrib.contenttypes.models import ContentType as ContentType_, ContentTypeManager as ContentTypeManager_
+from django.contrib.contenttypes.models import ContentType, ContentTypeManager
 from django.db.models import Q
 
 from netbox.registry import registry
 
 __all__ = (
-    'ContentType',
-    'ContentTypeManager',
+    'ObjectType',
+    'ObjectTypeManager',
 )
 
 
-class ContentTypeManager(ContentTypeManager_):
+class ObjectTypeManager(ContentTypeManager):
 
     def public(self):
         """
@@ -40,11 +40,11 @@ class ContentTypeManager(ContentTypeManager_):
         return self.get_queryset().filter(q)
 
 
-class ContentType(ContentType_):
+class ObjectType(ContentType):
     """
     Wrap Django's native ContentType model to use our custom manager.
     """
-    objects = ContentTypeManager()
+    objects = ObjectTypeManager()
 
     class Meta:
         proxy = True

+ 3 - 3
netbox/core/models/jobs.py

@@ -11,7 +11,7 @@ from django.utils import timezone
 from django.utils.translation import gettext as _
 
 from core.choices import JobStatusChoices
-from core.models import ContentType
+from core.models import ObjectType
 from core.signals import job_end, job_start
 from extras.constants import EVENT_JOB_END, EVENT_JOB_START
 from netbox.config import get_config
@@ -130,7 +130,7 @@ class Job(models.Model):
         super().clean()
 
         # Validate the assigned object type
-        if self.object_type not in ContentType.objects.with_feature('jobs'):
+        if self.object_type not in ObjectType.objects.with_feature('jobs'):
             raise ValidationError(
                 _("Jobs cannot be assigned to this object type ({type}).").format(type=self.object_type)
             )
@@ -210,7 +210,7 @@ class Job(models.Model):
             schedule_at: Schedule the job to be executed at the passed date and time
             interval: Recurrence interval (in minutes)
         """
-        object_type = ContentType.objects.get_for_model(instance, for_concrete_model=False)
+        object_type = ObjectType.objects.get_for_model(instance, for_concrete_model=False)
         rq_queue_name = get_queue_for_model(object_type.model)
         queue = django_rq.get_queue(rq_queue_name)
         status = JobStatusChoices.STATUS_SCHEDULED if schedule_at else JobStatusChoices.STATUS_PENDING

+ 6 - 6
netbox/dcim/models/cables.py

@@ -9,7 +9,7 @@ from django.dispatch import Signal
 from django.urls import reverse
 from django.utils.translation import gettext_lazy as _
 
-from core.models import ContentType
+from core.models import ObjectType
 from dcim.choices import *
 from dcim.constants import *
 from dcim.fields import PathField
@@ -481,13 +481,13 @@ class CablePath(models.Model):
     def origin_type(self):
         if self.path:
             ct_id, _ = decompile_path_node(self.path[0][0])
-            return ContentType.objects.get_for_id(ct_id)
+            return ObjectType.objects.get_for_id(ct_id)
 
     @property
     def destination_type(self):
         if self.is_complete:
             ct_id, _ = decompile_path_node(self.path[-1][0])
-            return ContentType.objects.get_for_id(ct_id)
+            return ObjectType.objects.get_for_id(ct_id)
 
     @property
     def path_objects(self):
@@ -594,7 +594,7 @@ class CablePath(models.Model):
 
             # Step 6: Determine the far-end terminations
             if isinstance(links[0], Cable):
-                termination_type = ContentType.objects.get_for_model(terminations[0])
+                termination_type = ObjectType.objects.get_for_model(terminations[0])
                 local_cable_terminations = CableTermination.objects.filter(
                     termination_type=termination_type,
                     termination_id__in=[t.pk for t in terminations]
@@ -747,7 +747,7 @@ class CablePath(models.Model):
         # Prefetch path objects using one query per model type. Prefetch related devices where appropriate.
         prefetched = {}
         for ct_id, object_ids in to_prefetch.items():
-            model_class = ContentType.objects.get_for_id(ct_id).model_class()
+            model_class = ObjectType.objects.get_for_id(ct_id).model_class()
             queryset = model_class.objects.filter(pk__in=object_ids)
             if hasattr(model_class, 'device'):
                 queryset = queryset.prefetch_related('device')
@@ -774,7 +774,7 @@ class CablePath(models.Model):
         """
         Return all Cable IDs within the path.
         """
-        cable_ct = ContentType.objects.get_for_model(Cable).pk
+        cable_ct = ObjectType.objects.get_for_model(Cable).pk
         cable_ids = []
 
         for node in self._nodes:

+ 3 - 3
netbox/dcim/tests/test_models.py

@@ -1,8 +1,8 @@
-from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ValidationError
 from django.test import TestCase
 
 from circuits.models import *
+from core.models import ObjectType
 from dcim.choices import *
 from dcim.models import *
 from extras.models import CustomField
@@ -293,8 +293,8 @@ class DeviceTestCase(TestCase):
 
         # Create a CustomField with a default value & assign it to all component models
         cf1 = CustomField.objects.create(name='cf1', default='foo')
-        cf1.content_types.set(
-            ContentType.objects.filter(app_label='dcim', model__in=[
+        cf1.object_types.set(
+            ObjectType.objects.filter(app_label='dcim', model__in=[
                 'consoleport',
                 'consoleserverport',
                 'powerport',

+ 0 - 2
netbox/dcim/tests/test_views.py

@@ -3,7 +3,6 @@ from zoneinfo import ZoneInfo
 
 import yaml
 from django.contrib.auth import get_user_model
-from django.contrib.contenttypes.models import ContentType
 from django.test import override_settings
 from django.urls import reverse
 from netaddr import EUI
@@ -2982,7 +2981,6 @@ class CableTestCase(
 
         tags = create_tags('Alpha', 'Bravo', 'Charlie')
 
-        interface_ct = ContentType.objects.get_for_model(Interface)
         cls.form_data = {
             # TODO: Revisit this limitation
             # Changing terminations not supported when editing an existing Cable

+ 5 - 5
netbox/extras/api/customfields.py

@@ -1,10 +1,10 @@
-from django.contrib.contenttypes.models import ContentType
 from django.utils.translation import gettext as _
 from drf_spectacular.types import OpenApiTypes
 from drf_spectacular.utils import extend_schema_field
 from rest_framework.fields import Field
 from rest_framework.serializers import ValidationError
 
+from core.models import ObjectType
 from extras.choices import CustomFieldTypeChoices
 from extras.models import CustomField
 from utilities.api import get_serializer_for_model
@@ -24,8 +24,8 @@ class CustomFieldDefaultValues:
         self.model = serializer_field.parent.Meta.model
 
         # Retrieve the CustomFields for the parent model
-        content_type = ContentType.objects.get_for_model(self.model)
-        fields = CustomField.objects.filter(content_types=content_type)
+        object_type = ObjectType.objects.get_for_model(self.model)
+        fields = CustomField.objects.filter(object_types=object_type)
 
         # Populate the default value for each CustomField
         value = {}
@@ -46,8 +46,8 @@ class CustomFieldsDataField(Field):
         Cache CustomFields assigned to this model to avoid redundant database queries
         """
         if not hasattr(self, '_custom_fields'):
-            content_type = ContentType.objects.get_for_model(self.parent.Meta.model)
-            self._custom_fields = CustomField.objects.filter(content_types=content_type)
+            object_type = ObjectType.objects.get_for_model(self.parent.Meta.model)
+            self._custom_fields = CustomField.objects.filter(object_types=object_type)
         return self._custom_fields
 
     def to_representation(self, obj):

+ 1 - 1
netbox/extras/api/serializers.py

@@ -1,7 +1,7 @@
+from .serializers_.objecttypes import *
 from .serializers_.attachments import *
 from .serializers_.bookmarks import *
 from .serializers_.change_logging import *
-from .serializers_.contenttypes import *
 from .serializers_.customfields import *
 from .serializers_.customlinks import *
 from .serializers_.dashboard import *

+ 6 - 6
netbox/extras/api/serializers_/attachments.py

@@ -2,7 +2,7 @@ from django.core.exceptions import ObjectDoesNotExist
 from drf_spectacular.utils import extend_schema_field
 from rest_framework import serializers
 
-from core.models import ContentType
+from core.models import ObjectType
 from extras.models import ImageAttachment
 from netbox.api.fields import ContentTypeField
 from netbox.api.serializers import ValidatedModelSerializer
@@ -15,15 +15,15 @@ __all__ = (
 
 class ImageAttachmentSerializer(ValidatedModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='extras-api:imageattachment-detail')
-    content_type = ContentTypeField(
-        queryset=ContentType.objects.all()
+    object_type = ContentTypeField(
+        queryset=ObjectType.objects.all()
     )
     parent = serializers.SerializerMethodField(read_only=True)
 
     class Meta:
         model = ImageAttachment
         fields = [
-            'id', 'url', 'display', 'content_type', 'object_id', 'parent', 'name', 'image', 'image_height',
+            'id', 'url', 'display', 'object_type', 'object_id', 'parent', 'name', 'image', 'image_height',
             'image_width', 'created', 'last_updated',
         ]
         brief_fields = ('id', 'url', 'display', 'name', 'image')
@@ -32,10 +32,10 @@ class ImageAttachmentSerializer(ValidatedModelSerializer):
 
         # Validate that the parent object exists
         try:
-            data['content_type'].get_object_for_this_type(id=data['object_id'])
+            data['object_type'].get_object_for_this_type(id=data['object_id'])
         except ObjectDoesNotExist:
             raise serializers.ValidationError(
-                "Invalid parent object: {} ID {}".format(data['content_type'], data['object_id'])
+                "Invalid parent object: {} ID {}".format(data['object_type'], data['object_id'])
             )
 
         # Enforce model validation

+ 2 - 2
netbox/extras/api/serializers_/bookmarks.py

@@ -1,7 +1,7 @@
 from drf_spectacular.utils import extend_schema_field
 from rest_framework import serializers
 
-from core.models import ContentType
+from core.models import ObjectType
 from extras.models import Bookmark
 from netbox.api.fields import ContentTypeField
 from netbox.api.serializers import ValidatedModelSerializer
@@ -16,7 +16,7 @@ __all__ = (
 class BookmarkSerializer(ValidatedModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='extras-api:bookmark-detail')
     object_type = ContentTypeField(
-        queryset=ContentType.objects.with_feature('bookmarks'),
+        queryset=ObjectType.objects.with_feature('bookmarks'),
     )
     object = serializers.SerializerMethodField(read_only=True)
     user = UserSerializer(nested=True)

+ 5 - 5
netbox/extras/api/serializers_/customfields.py

@@ -3,7 +3,7 @@ from drf_spectacular.types import OpenApiTypes
 from drf_spectacular.utils import extend_schema_field
 from rest_framework import serializers
 
-from core.models import ContentType
+from core.models import ObjectType
 from extras.choices import *
 from extras.models import CustomField, CustomFieldChoiceSet
 from netbox.api.fields import ChoiceField, ContentTypeField
@@ -39,13 +39,13 @@ class CustomFieldChoiceSetSerializer(ValidatedModelSerializer):
 
 class CustomFieldSerializer(ValidatedModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='extras-api:customfield-detail')
-    content_types = ContentTypeField(
-        queryset=ContentType.objects.with_feature('custom_fields'),
+    object_types = ContentTypeField(
+        queryset=ObjectType.objects.with_feature('custom_fields'),
         many=True
     )
     type = ChoiceField(choices=CustomFieldTypeChoices)
     object_type = ContentTypeField(
-        queryset=ContentType.objects.all(),
+        queryset=ObjectType.objects.all(),
         required=False,
         allow_null=True
     )
@@ -62,7 +62,7 @@ class CustomFieldSerializer(ValidatedModelSerializer):
     class Meta:
         model = CustomField
         fields = [
-            'id', 'url', 'display', 'content_types', 'type', 'object_type', 'data_type', 'name', 'label', 'group_name',
+            'id', 'url', 'display', 'object_types', 'type', 'object_type', 'data_type', 'name', 'label', 'group_name',
             'description', 'required', 'search_weight', 'filter_logic', 'ui_visible', 'ui_editable', 'is_cloneable',
             'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choice_set',
             'created', 'last_updated',

+ 4 - 4
netbox/extras/api/serializers_/customlinks.py

@@ -1,6 +1,6 @@
 from rest_framework import serializers
 
-from core.models import ContentType
+from core.models import ObjectType
 from extras.models import CustomLink
 from netbox.api.fields import ContentTypeField
 from netbox.api.serializers import ValidatedModelSerializer
@@ -12,15 +12,15 @@ __all__ = (
 
 class CustomLinkSerializer(ValidatedModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='extras-api:customlink-detail')
-    content_types = ContentTypeField(
-        queryset=ContentType.objects.with_feature('custom_links'),
+    object_types = ContentTypeField(
+        queryset=ObjectType.objects.with_feature('custom_links'),
         many=True
     )
 
     class Meta:
         model = CustomLink
         fields = [
-            'id', 'url', 'display', 'content_types', 'name', 'enabled', 'link_text', 'link_url', 'weight', 'group_name',
+            'id', 'url', 'display', 'object_types', 'name', 'enabled', 'link_text', 'link_url', 'weight', 'group_name',
             'button_class', 'new_window', 'created', 'last_updated',
         ]
         brief_fields = ('id', 'url', 'display', 'name')

+ 5 - 5
netbox/extras/api/serializers_/events.py

@@ -2,7 +2,7 @@ from drf_spectacular.types import OpenApiTypes
 from drf_spectacular.utils import extend_schema_field
 from rest_framework import serializers
 
-from core.models import ContentType
+from core.models import ObjectType
 from extras.choices import *
 from extras.models import EventRule, Webhook
 from netbox.api.fields import ChoiceField, ContentTypeField
@@ -22,20 +22,20 @@ __all__ = (
 
 class EventRuleSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='extras-api:eventrule-detail')
-    content_types = ContentTypeField(
-        queryset=ContentType.objects.with_feature('event_rules'),
+    object_types = ContentTypeField(
+        queryset=ObjectType.objects.with_feature('event_rules'),
         many=True
     )
     action_type = ChoiceField(choices=EventRuleActionChoices)
     action_object_type = ContentTypeField(
-        queryset=ContentType.objects.with_feature('event_rules'),
+        queryset=ObjectType.objects.with_feature('event_rules'),
     )
     action_object = serializers.SerializerMethodField(read_only=True)
 
     class Meta:
         model = EventRule
         fields = [
-            'id', 'url', 'display', 'content_types', 'name', 'type_create', 'type_update', 'type_delete',
+            'id', 'url', 'display', 'object_types', 'name', 'type_create', 'type_update', 'type_delete',
             'type_job_start', 'type_job_end', 'enabled', 'conditions', 'action_type', 'action_object_type',
             'action_object_id', 'action_object', 'description', 'custom_fields', 'tags', 'created', 'last_updated',
         ]

+ 4 - 4
netbox/extras/api/serializers_/exporttemplates.py

@@ -1,7 +1,7 @@
 from rest_framework import serializers
 
 from core.api.serializers_.data import DataFileSerializer, DataSourceSerializer
-from core.models import ContentType
+from core.models import ObjectType
 from extras.models import ExportTemplate
 from netbox.api.fields import ContentTypeField
 from netbox.api.serializers import ValidatedModelSerializer
@@ -13,8 +13,8 @@ __all__ = (
 
 class ExportTemplateSerializer(ValidatedModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='extras-api:exporttemplate-detail')
-    content_types = ContentTypeField(
-        queryset=ContentType.objects.with_feature('export_templates'),
+    object_types = ContentTypeField(
+        queryset=ObjectType.objects.with_feature('export_templates'),
         many=True
     )
     data_source = DataSourceSerializer(
@@ -29,7 +29,7 @@ class ExportTemplateSerializer(ValidatedModelSerializer):
     class Meta:
         model = ExportTemplate
         fields = [
-            'id', 'url', 'display', 'content_types', 'name', 'description', 'template_code', 'mime_type',
+            'id', 'url', 'display', 'object_types', 'name', 'description', 'template_code', 'mime_type',
             'file_extension', 'as_attachment', 'data_source', 'data_path', 'data_file', 'data_synced', 'created',
             'last_updated',
         ]

+ 2 - 2
netbox/extras/api/serializers_/journaling.py

@@ -3,7 +3,7 @@ from django.core.exceptions import ObjectDoesNotExist
 from drf_spectacular.utils import extend_schema_field
 from rest_framework import serializers
 
-from core.models import ContentType
+from core.models import ObjectType
 from extras.choices import *
 from extras.models import JournalEntry
 from netbox.api.fields import ChoiceField, ContentTypeField
@@ -18,7 +18,7 @@ __all__ = (
 class JournalEntrySerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='extras-api:journalentry-detail')
     assigned_object_type = ContentTypeField(
-        queryset=ContentType.objects.all()
+        queryset=ObjectType.objects.all()
     )
     assigned_object = serializers.SerializerMethodField(read_only=True)
     created_by = serializers.PrimaryKeyRelatedField(

+ 5 - 5
netbox/extras/api/serializers_/contenttypes.py → netbox/extras/api/serializers_/objecttypes.py

@@ -1,16 +1,16 @@
 from rest_framework import serializers
 
-from core.models import ContentType
+from core.models import ObjectType
 from netbox.api.serializers import BaseModelSerializer
 
 __all__ = (
-    'ContentTypeSerializer',
+    'ObjectTypeSerializer',
 )
 
 
-class ContentTypeSerializer(BaseModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='extras-api:contenttype-detail')
+class ObjectTypeSerializer(BaseModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='extras-api:objecttype-detail')
 
     class Meta:
-        model = ContentType
+        model = ObjectType
         fields = ['id', 'url', 'display', 'app_label', 'model']

+ 4 - 4
netbox/extras/api/serializers_/savedfilters.py

@@ -1,6 +1,6 @@
 from rest_framework import serializers
 
-from core.models import ContentType
+from core.models import ObjectType
 from extras.models import SavedFilter
 from netbox.api.fields import ContentTypeField
 from netbox.api.serializers import ValidatedModelSerializer
@@ -12,15 +12,15 @@ __all__ = (
 
 class SavedFilterSerializer(ValidatedModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='extras-api:savedfilter-detail')
-    content_types = ContentTypeField(
-        queryset=ContentType.objects.all(),
+    object_types = ContentTypeField(
+        queryset=ObjectType.objects.all(),
         many=True
     )
 
     class Meta:
         model = SavedFilter
         fields = [
-            'id', 'url', 'display', 'content_types', 'name', 'slug', 'description', 'user', 'weight', 'enabled',
+            'id', 'url', 'display', 'object_types', 'name', 'slug', 'description', 'user', 'weight', 'enabled',
             'shared', 'parameters', 'created', 'last_updated',
         ]
         brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description')

+ 2 - 2
netbox/extras/api/serializers_/tags.py

@@ -1,6 +1,6 @@
 from rest_framework import serializers
 
-from core.models import ContentType
+from core.models import ObjectType
 from extras.models import Tag
 from netbox.api.fields import ContentTypeField, RelatedObjectCountField
 from netbox.api.serializers import ValidatedModelSerializer
@@ -13,7 +13,7 @@ __all__ = (
 class TagSerializer(ValidatedModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='extras-api:tag-detail')
     object_types = ContentTypeField(
-        queryset=ContentType.objects.with_feature('tags'),
+        queryset=ObjectType.objects.with_feature('tags'),
         many=True,
         required=False
     )

+ 1 - 1
netbox/extras/api/urls.py

@@ -22,7 +22,7 @@ router.register('config-contexts', views.ConfigContextViewSet)
 router.register('config-templates', views.ConfigTemplateViewSet)
 router.register('scripts', views.ScriptViewSet, basename='script')
 router.register('object-changes', views.ObjectChangeViewSet)
-router.register('content-types', views.ContentTypeViewSet)
+router.register('object-types', views.ObjectTypeViewSet)
 
 app_name = 'extras-api'
 urlpatterns = [

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

@@ -1,4 +1,3 @@
-from django.contrib.contenttypes.models import ContentType
 from django.shortcuts import get_object_or_404
 from django_rq.queues import get_connection
 from rest_framework import status
@@ -11,7 +10,7 @@ from rest_framework.routers import APIRootView
 from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
 from rq import Worker
 
-from core.models import Job
+from core.models import Job, ObjectType
 from extras import filtersets
 from extras.models import *
 from extras.scripts import run_script
@@ -275,17 +274,17 @@ class ObjectChangeViewSet(ReadOnlyModelViewSet):
 
 
 #
-# ContentTypes
+# Object types
 #
 
-class ContentTypeViewSet(ReadOnlyModelViewSet):
+class ObjectTypeViewSet(ReadOnlyModelViewSet):
     """
-    Read-only list of ContentTypes. Limit results to ContentTypes pertinent to NetBox objects.
+    Read-only list of ObjectTypes.
     """
     permission_classes = [IsAuthenticatedOrLoginNotRequired]
-    queryset = ContentType.objects.order_by('app_label', 'model')
-    serializer_class = serializers.ContentTypeSerializer
-    filterset_class = filtersets.ContentTypeFilterSet
+    queryset = ObjectType.objects.order_by('app_label', 'model')
+    serializer_class = serializers.ObjectTypeSerializer
+    filterset_class = filtersets.ObjectTypeFilterSet
 
 
 #

+ 6 - 6
netbox/extras/dashboard/widgets.py

@@ -12,7 +12,7 @@ from django.template.loader import render_to_string
 from django.urls import NoReverseMatch, resolve, reverse
 from django.utils.translation import gettext as _
 
-from core.models import ContentType
+from core.models import ObjectType
 from extras.choices import BookmarkOrderingChoices
 from utilities.choices import ButtonColorChoices
 from utilities.permissions import get_permission_for_model
@@ -34,14 +34,14 @@ __all__ = (
 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')
+        for ct in ObjectType.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')
+        for ct in ObjectType.objects.with_feature('bookmarks').order_by('app_label', 'model')
     ]
 
 
@@ -52,7 +52,7 @@ def get_models_from_content_types(content_types):
     models = []
     for content_type_id in content_types:
         app_label, model_name = content_type_id.split('.')
-        content_type = ContentType.objects.get_by_natural_key(app_label, model_name)
+        content_type = ObjectType.objects.get_by_natural_key(app_label, model_name)
         models.append(content_type.model_class())
     return models
 
@@ -238,7 +238,7 @@ class ObjectListWidget(DashboardWidget):
 
     def render(self, request):
         app_label, model_name = self.config['model'].split('.')
-        model = ContentType.objects.get_by_natural_key(app_label, model_name).model_class()
+        model = ObjectType.objects.get_by_natural_key(app_label, model_name).model_class()
         viewname = get_viewname(model, action='list')
 
         # Evaluate user's permission. Note that this controls only whether the HTMX element is
@@ -371,7 +371,7 @@ class BookmarksWidget(DashboardWidget):
             bookmarks = Bookmark.objects.filter(user=request.user).order_by(self.config['order_by'])
             if object_types := self.config.get('object_types'):
                 models = get_models_from_content_types(object_types)
-                conent_types = ContentType.objects.get_for_models(*models).values()
+                conent_types = ObjectType.objects.get_for_models(*models).values()
                 bookmarks = bookmarks.filter(object_type__in=conent_types)
             if max_items := self.config.get('max_items'):
                 bookmarks = bookmarks[:max_items]

+ 1 - 1
netbox/extras/events.py

@@ -155,7 +155,7 @@ def process_event_queue(events):
         if content_type not in events_cache[action_flag]:
             events_cache[action_flag][content_type] = EventRule.objects.filter(
                 **{action_flag: True},
-                content_types=content_type,
+                object_types=content_type,
                 enabled=True
             )
         event_rules = events_cache[action_flag][content_type]

+ 36 - 26
netbox/extras/filtersets.py

@@ -4,7 +4,7 @@ from django.contrib.contenttypes.models import ContentType
 from django.db.models import Q
 from django.utils.translation import gettext as _
 
-from core.models import DataSource
+from core.models import DataSource, ObjectType
 from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
 from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet
 from tenancy.models import Tenant, TenantGroup
@@ -18,7 +18,6 @@ __all__ = (
     'BookmarkFilterSet',
     'ConfigContextFilterSet',
     'ConfigTemplateFilterSet',
-    'ContentTypeFilterSet',
     'CustomFieldChoiceSetFilterSet',
     'CustomFieldFilterSet',
     'CustomLinkFilterSet',
@@ -28,6 +27,7 @@ __all__ = (
     'JournalEntryFilterSet',
     'LocalConfigContextFilterSet',
     'ObjectChangeFilterSet',
+    'ObjectTypeFilterSet',
     'SavedFilterFilterSet',
     'ScriptFilterSet',
     'TagFilterSet',
@@ -89,10 +89,12 @@ class EventRuleFilterSet(NetBoxModelFilterSet):
         method='search',
         label=_('Search'),
     )
-    content_type_id = MultiValueNumberFilter(
-        field_name='content_types__id'
+    object_type_id = MultiValueNumberFilter(
+        field_name='object_types__id'
+    )
+    object_type = ContentTypeFilter(
+        field_name='object_types'
     )
-    content_types = ContentTypeFilter()
     action_type = django_filters.MultipleChoiceFilter(
         choices=EventRuleActionChoices
     )
@@ -124,10 +126,12 @@ class CustomFieldFilterSet(BaseFilterSet):
     type = django_filters.MultipleChoiceFilter(
         choices=CustomFieldTypeChoices
     )
-    content_type_id = MultiValueNumberFilter(
-        field_name='content_types__id'
+    object_type_id = MultiValueNumberFilter(
+        field_name='object_types__id'
+    )
+    object_type = ContentTypeFilter(
+        field_name='object_types'
     )
-    content_types = ContentTypeFilter()
     choice_set_id = django_filters.ModelMultipleChoiceFilter(
         queryset=CustomFieldChoiceSet.objects.all()
     )
@@ -140,8 +144,8 @@ class CustomFieldFilterSet(BaseFilterSet):
     class Meta:
         model = CustomField
         fields = [
-            'id', 'content_types', 'name', 'group_name', 'required', 'search_weight', 'filter_logic', 'ui_visible',
-            'ui_editable', 'weight', 'is_cloneable', 'description',
+            'id', 'name', 'group_name', 'required', 'search_weight', 'filter_logic', 'ui_visible', 'ui_editable',
+            'weight', 'is_cloneable', 'description',
         ]
 
     def search(self, queryset, name, value):
@@ -188,15 +192,17 @@ class CustomLinkFilterSet(BaseFilterSet):
         method='search',
         label=_('Search'),
     )
-    content_type_id = MultiValueNumberFilter(
-        field_name='content_types__id'
+    object_type_id = MultiValueNumberFilter(
+        field_name='object_types__id'
+    )
+    object_type = ContentTypeFilter(
+        field_name='object_types'
     )
-    content_types = ContentTypeFilter()
 
     class Meta:
         model = CustomLink
         fields = [
-            'id', 'content_types', 'name', 'enabled', 'link_text', 'link_url', 'weight', 'group_name', 'new_window',
+            'id', 'name', 'enabled', 'link_text', 'link_url', 'weight', 'group_name', 'new_window',
         ]
 
     def search(self, queryset, name, value):
@@ -215,10 +221,12 @@ class ExportTemplateFilterSet(BaseFilterSet):
         method='search',
         label=_('Search'),
     )
-    content_type_id = MultiValueNumberFilter(
-        field_name='content_types__id'
+    object_type_id = MultiValueNumberFilter(
+        field_name='object_types__id'
+    )
+    object_type = ContentTypeFilter(
+        field_name='object_types'
     )
-    content_types = ContentTypeFilter()
     data_source_id = django_filters.ModelMultipleChoiceFilter(
         queryset=DataSource.objects.all(),
         label=_('Data source (ID)'),
@@ -230,7 +238,7 @@ class ExportTemplateFilterSet(BaseFilterSet):
 
     class Meta:
         model = ExportTemplate
-        fields = ['id', 'content_types', 'name', 'description', 'data_synced']
+        fields = ['id', 'name', 'description', 'data_synced']
 
     def search(self, queryset, name, value):
         if not value.strip():
@@ -246,10 +254,12 @@ class SavedFilterFilterSet(BaseFilterSet):
         method='search',
         label=_('Search'),
     )
-    content_type_id = MultiValueNumberFilter(
-        field_name='content_types__id'
+    object_type_id = MultiValueNumberFilter(
+        field_name='object_types__id'
+    )
+    object_type = ContentTypeFilter(
+        field_name='object_types'
     )
-    content_types = ContentTypeFilter()
     user_id = django_filters.ModelMultipleChoiceFilter(
         queryset=get_user_model().objects.all(),
         label=_('User (ID)'),
@@ -266,7 +276,7 @@ class SavedFilterFilterSet(BaseFilterSet):
 
     class Meta:
         model = SavedFilter
-        fields = ['id', 'content_types', 'name', 'slug', 'description', 'enabled', 'shared', 'weight']
+        fields = ['id', 'name', 'slug', 'description', 'enabled', 'shared', 'weight']
 
     def search(self, queryset, name, value):
         if not value.strip():
@@ -316,11 +326,11 @@ class ImageAttachmentFilterSet(BaseFilterSet):
         label=_('Search'),
     )
     created = django_filters.DateTimeFilter()
-    content_type = ContentTypeFilter()
+    object_type = ContentTypeFilter()
 
     class Meta:
         model = ImageAttachment
-        fields = ['id', 'content_type_id', 'object_id', 'name']
+        fields = ['id', 'object_type_id', 'object_id', 'name']
 
     def search(self, queryset, name, value):
         if not value.strip():
@@ -660,14 +670,14 @@ class ObjectChangeFilterSet(BaseFilterSet):
 # ContentTypes
 #
 
-class ContentTypeFilterSet(django_filters.FilterSet):
+class ObjectTypeFilterSet(django_filters.FilterSet):
     q = django_filters.CharFilter(
         method='search',
         label=_('Search'),
     )
 
     class Meta:
-        model = ContentType
+        model = ObjectType
         fields = ['id', 'app_label', 'model']
 
     def search(self, queryset, name, value):

+ 24 - 24
netbox/extras/forms/bulk_import.py

@@ -6,7 +6,7 @@ from django.core.exceptions import ObjectDoesNotExist
 from django.utils.safestring import mark_safe
 from django.utils.translation import gettext_lazy as _
 
-from core.models import ContentType
+from core.models import ObjectType
 from extras.choices import *
 from extras.models import *
 from netbox.forms import NetBoxModelImportForm
@@ -30,9 +30,9 @@ __all__ = (
 
 
 class CustomFieldImportForm(CSVModelForm):
-    content_types = CSVMultipleContentTypeField(
-        label=_('Content types'),
-        queryset=ContentType.objects.with_feature('custom_fields'),
+    object_types = CSVMultipleContentTypeField(
+        label=_('Object types'),
+        queryset=ObjectType.objects.with_feature('custom_fields'),
         help_text=_("One or more assigned object types")
     )
     type = CSVChoiceField(
@@ -42,7 +42,7 @@ class CustomFieldImportForm(CSVModelForm):
     )
     object_type = CSVContentTypeField(
         label=_('Object type'),
-        queryset=ContentType.objects.public(),
+        queryset=ObjectType.objects.public(),
         required=False,
         help_text=_("Object type (for object or multi-object fields)")
     )
@@ -69,7 +69,7 @@ class CustomFieldImportForm(CSVModelForm):
     class Meta:
         model = CustomField
         fields = (
-            'name', 'label', 'group_name', 'type', 'content_types', 'object_type', 'required', 'description',
+            'name', 'label', 'group_name', 'type', 'object_types', 'object_type', 'required', 'description',
             'search_weight', 'filter_logic', 'default', 'choice_set', 'weight', 'validation_minimum',
             'validation_maximum', 'validation_regex', 'ui_visible', 'ui_editable', 'is_cloneable',
         )
@@ -111,31 +111,31 @@ class CustomFieldChoiceSetImportForm(CSVModelForm):
 
 
 class CustomLinkImportForm(CSVModelForm):
-    content_types = CSVMultipleContentTypeField(
-        label=_('Content types'),
-        queryset=ContentType.objects.with_feature('custom_links'),
+    object_types = CSVMultipleContentTypeField(
+        label=_('Object types'),
+        queryset=ObjectType.objects.with_feature('custom_links'),
         help_text=_("One or more assigned object types")
     )
 
     class Meta:
         model = CustomLink
         fields = (
-            'name', 'content_types', 'enabled', 'weight', 'group_name', 'button_class', 'new_window', 'link_text',
+            'name', 'object_types', 'enabled', 'weight', 'group_name', 'button_class', 'new_window', 'link_text',
             'link_url',
         )
 
 
 class ExportTemplateImportForm(CSVModelForm):
-    content_types = CSVMultipleContentTypeField(
-        label=_('Content types'),
-        queryset=ContentType.objects.with_feature('export_templates'),
+    object_types = CSVMultipleContentTypeField(
+        label=_('Object types'),
+        queryset=ObjectType.objects.with_feature('export_templates'),
         help_text=_("One or more assigned object types")
     )
 
     class Meta:
         model = ExportTemplate
         fields = (
-            'name', 'content_types', 'description', 'mime_type', 'file_extension', 'as_attachment', 'template_code',
+            'name', 'object_types', 'description', 'mime_type', 'file_extension', 'as_attachment', 'template_code',
         )
 
 
@@ -149,16 +149,16 @@ class ConfigTemplateImportForm(CSVModelForm):
 
 
 class SavedFilterImportForm(CSVModelForm):
-    content_types = CSVMultipleContentTypeField(
-        label=_('Content types'),
-        queryset=ContentType.objects.all(),
+    object_types = CSVMultipleContentTypeField(
+        label=_('Object types'),
+        queryset=ObjectType.objects.all(),
         help_text=_("One or more assigned object types")
     )
 
     class Meta:
         model = SavedFilter
         fields = (
-            'name', 'slug', 'content_types', 'description', 'weight', 'enabled', 'shared', 'parameters',
+            'name', 'slug', 'object_types', 'description', 'weight', 'enabled', 'shared', 'parameters',
         )
 
 
@@ -173,9 +173,9 @@ class WebhookImportForm(NetBoxModelImportForm):
 
 
 class EventRuleImportForm(NetBoxModelImportForm):
-    content_types = CSVMultipleContentTypeField(
-        label=_('Content types'),
-        queryset=ContentType.objects.with_feature('event_rules'),
+    object_types = CSVMultipleContentTypeField(
+        label=_('Object types'),
+        queryset=ObjectType.objects.with_feature('event_rules'),
         help_text=_("One or more assigned object types")
     )
     action_object = forms.CharField(
@@ -187,7 +187,7 @@ class EventRuleImportForm(NetBoxModelImportForm):
     class Meta:
         model = EventRule
         fields = (
-            'name', 'description', 'enabled', 'conditions', 'content_types', 'type_create', 'type_update',
+            'name', 'description', 'enabled', 'conditions', 'object_types', 'type_create', 'type_update',
             'type_delete', 'type_job_start', 'type_job_end', 'action_type', 'action_object', 'comments', 'tags'
         )
 
@@ -213,7 +213,7 @@ class EventRuleImportForm(NetBoxModelImportForm):
                 except ObjectDoesNotExist:
                     raise forms.ValidationError(_("Script {name} not found").format(name=action_object))
                 self.instance.action_object = script
-                self.instance.action_object_type = ContentType.objects.get_for_model(script, for_concrete_model=False)
+                self.instance.action_object_type = ObjectType.objects.get_for_model(script, for_concrete_model=False)
 
 
 class TagImportForm(CSVModelForm):
@@ -229,7 +229,7 @@ class TagImportForm(CSVModelForm):
 
 class JournalEntryImportForm(NetBoxModelImportForm):
     assigned_object_type = CSVContentTypeField(
-        queryset=ContentType.objects.all(),
+        queryset=ObjectType.objects.all(),
         label=_('Assigned object type'),
     )
     kind = CSVChoiceField(

+ 26 - 26
netbox/extras/forms/filtersets.py

@@ -2,7 +2,7 @@ from django import forms
 from django.contrib.auth import get_user_model
 from django.utils.translation import gettext_lazy as _
 
-from core.models import ContentType, DataFile, DataSource
+from core.models import ObjectType, DataFile, DataSource
 from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
 from extras.choices import *
 from extras.models import *
@@ -38,12 +38,12 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
     fieldsets = (
         (None, ('q', 'filter_id')),
         (_('Attributes'), (
-            'type', 'content_type_id', 'group_name', 'weight', 'required', 'choice_set_id', 'ui_visible', 'ui_editable',
+            'type', 'object_type_id', 'group_name', 'weight', 'required', 'choice_set_id', 'ui_visible', 'ui_editable',
             'is_cloneable',
         )),
     )
-    content_type_id = ContentTypeMultipleChoiceField(
-        queryset=ContentType.objects.with_feature('custom_fields'),
+    object_type_id = ContentTypeMultipleChoiceField(
+        queryset=ObjectType.objects.with_feature('custom_fields'),
         required=False,
         label=_('Object type')
     )
@@ -108,11 +108,11 @@ class CustomFieldChoiceSetFilterForm(SavedFiltersMixin, FilterForm):
 class CustomLinkFilterForm(SavedFiltersMixin, FilterForm):
     fieldsets = (
         (None, ('q', 'filter_id')),
-        (_('Attributes'), ('content_types', 'enabled', 'new_window', 'weight')),
+        (_('Attributes'), ('object_type', 'enabled', 'new_window', 'weight')),
     )
-    content_types = ContentTypeMultipleChoiceField(
-        label=_('Content types'),
-        queryset=ContentType.objects.with_feature('custom_links'),
+    object_type = ContentTypeMultipleChoiceField(
+        label=_('Object types'),
+        queryset=ObjectType.objects.with_feature('custom_links'),
         required=False
     )
     enabled = forms.NullBooleanField(
@@ -139,7 +139,7 @@ class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
     fieldsets = (
         (None, ('q', 'filter_id')),
         (_('Data'), ('data_source_id', 'data_file_id')),
-        (_('Attributes'), ('content_type_id', 'mime_type', 'file_extension', 'as_attachment')),
+        (_('Attributes'), ('object_type_id', 'mime_type', 'file_extension', 'as_attachment')),
     )
     data_source_id = DynamicModelMultipleChoiceField(
         queryset=DataSource.objects.all(),
@@ -154,8 +154,8 @@ class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
             'source_id': '$data_source_id'
         }
     )
-    content_type_id = ContentTypeMultipleChoiceField(
-        queryset=ContentType.objects.with_feature('export_templates'),
+    object_type_id = ContentTypeMultipleChoiceField(
+        queryset=ObjectType.objects.with_feature('export_templates'),
         required=False,
         label=_('Content types')
     )
@@ -179,11 +179,11 @@ class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
 class ImageAttachmentFilterForm(SavedFiltersMixin, FilterForm):
     fieldsets = (
         (None, ('q', 'filter_id')),
-        (_('Attributes'), ('content_type_id', 'name',)),
+        (_('Attributes'), ('object_type_id', 'name',)),
     )
-    content_type_id = ContentTypeChoiceField(
-        label=_('Content type'),
-        queryset=ContentType.objects.with_feature('image_attachments'),
+    object_type_id = ContentTypeChoiceField(
+        label=_('Object type'),
+        queryset=ObjectType.objects.with_feature('image_attachments'),
         required=False
     )
     name = forms.CharField(
@@ -195,11 +195,11 @@ class ImageAttachmentFilterForm(SavedFiltersMixin, FilterForm):
 class SavedFilterFilterForm(SavedFiltersMixin, FilterForm):
     fieldsets = (
         (None, ('q', 'filter_id')),
-        (_('Attributes'), ('content_types', 'enabled', 'shared', 'weight')),
+        (_('Attributes'), ('object_type', 'enabled', 'shared', 'weight')),
     )
-    content_types = ContentTypeMultipleChoiceField(
-        label=_('Content types'),
-        queryset=ContentType.objects.public(),
+    object_type = ContentTypeMultipleChoiceField(
+        label=_('Object types'),
+        queryset=ObjectType.objects.public(),
         required=False
     )
     enabled = forms.NullBooleanField(
@@ -250,11 +250,11 @@ class EventRuleFilterForm(NetBoxModelFilterSetForm):
 
     fieldsets = (
         (None, ('q', 'filter_id', 'tag')),
-        (_('Attributes'), ('content_type_id', 'action_type', 'enabled')),
+        (_('Attributes'), ('object_type_id', 'action_type', 'enabled')),
         (_('Events'), ('type_create', 'type_update', 'type_delete', 'type_job_start', 'type_job_end')),
     )
-    content_type_id = ContentTypeMultipleChoiceField(
-        queryset=ContentType.objects.with_feature('event_rules'),
+    object_type_id = ContentTypeMultipleChoiceField(
+        queryset=ObjectType.objects.with_feature('event_rules'),
         required=False,
         label=_('Object type')
     )
@@ -310,12 +310,12 @@ class EventRuleFilterForm(NetBoxModelFilterSetForm):
 class TagFilterForm(SavedFiltersMixin, FilterForm):
     model = Tag
     content_type_id = ContentTypeMultipleChoiceField(
-        queryset=ContentType.objects.with_feature('tags'),
+        queryset=ObjectType.objects.with_feature('tags'),
         required=False,
         label=_('Tagged object type')
     )
     for_object_type_id = ContentTypeChoiceField(
-        queryset=ContentType.objects.with_feature('tags'),
+        queryset=ObjectType.objects.with_feature('tags'),
         required=False,
         label=_('Allowed object type')
     )
@@ -464,7 +464,7 @@ class JournalEntryFilterForm(NetBoxModelFilterSetForm):
         label=_('User')
     )
     assigned_object_type_id = DynamicModelMultipleChoiceField(
-        queryset=ContentType.objects.all(),
+        queryset=ObjectType.objects.all(),
         required=False,
         label=_('Object Type'),
         widget=APISelectMultiple(
@@ -507,7 +507,7 @@ class ObjectChangeFilterForm(SavedFiltersMixin, FilterForm):
         label=_('User')
     )
     changed_object_type_id = DynamicModelMultipleChoiceField(
-        queryset=ContentType.objects.all(),
+        queryset=ObjectType.objects.all(),
         required=False,
         label=_('Object Type'),
         widget=APISelectMultiple(

+ 27 - 28
netbox/extras/forms/model_forms.py

@@ -2,12 +2,11 @@ import json
 import re
 
 from django import forms
-from django.contrib.contenttypes.models import ContentType
 from django.utils.safestring import mark_safe
 from django.utils.translation import gettext_lazy as _
 
 from core.forms.mixins import SyncedDataMixin
-from core.models import ContentType
+from core.models import ObjectType
 from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
 from extras.choices import *
 from extras.models import *
@@ -39,13 +38,13 @@ __all__ = (
 
 
 class CustomFieldForm(forms.ModelForm):
-    content_types = ContentTypeMultipleChoiceField(
-        label=_('Content types'),
-        queryset=ContentType.objects.with_feature('custom_fields')
+    object_types = ContentTypeMultipleChoiceField(
+        label=_('Object types'),
+        queryset=ObjectType.objects.with_feature('custom_fields')
     )
     object_type = ContentTypeChoiceField(
         label=_('Object type'),
-        queryset=ContentType.objects.public(),
+        queryset=ObjectType.objects.public(),
         required=False,
         help_text=_("Type of the related object (for object/multi-object fields only)")
     )
@@ -56,7 +55,7 @@ class CustomFieldForm(forms.ModelForm):
 
     fieldsets = (
         (_('Custom Field'), (
-            'content_types', 'name', 'label', 'group_name', 'type', 'object_type', 'required', 'description',
+            'object_types', 'name', 'label', 'group_name', 'type', 'object_type', 'required', 'description',
         )),
         (_('Behavior'), ('search_weight', 'filter_logic', 'ui_visible', 'ui_editable', 'weight', 'is_cloneable')),
         (_('Values'), ('default', 'choice_set')),
@@ -123,13 +122,13 @@ class CustomFieldChoiceSetForm(forms.ModelForm):
 
 
 class CustomLinkForm(forms.ModelForm):
-    content_types = ContentTypeMultipleChoiceField(
-        label=_('Content types'),
-        queryset=ContentType.objects.with_feature('custom_links')
+    object_types = ContentTypeMultipleChoiceField(
+        label=_('Object types'),
+        queryset=ObjectType.objects.with_feature('custom_links')
     )
 
     fieldsets = (
-        (_('Custom Link'), ('name', 'content_types', 'weight', 'group_name', 'button_class', 'enabled', 'new_window')),
+        (_('Custom Link'), ('name', 'object_types', 'weight', 'group_name', 'button_class', 'enabled', 'new_window')),
         (_('Templates'), ('link_text', 'link_url')),
     )
 
@@ -152,9 +151,9 @@ class CustomLinkForm(forms.ModelForm):
 
 
 class ExportTemplateForm(SyncedDataMixin, forms.ModelForm):
-    content_types = ContentTypeMultipleChoiceField(
-        label=_('Content types'),
-        queryset=ContentType.objects.with_feature('export_templates')
+    object_types = ContentTypeMultipleChoiceField(
+        label=_('Object types'),
+        queryset=ObjectType.objects.with_feature('export_templates')
     )
     template_code = forms.CharField(
         label=_('Template code'),
@@ -163,7 +162,7 @@ class ExportTemplateForm(SyncedDataMixin, forms.ModelForm):
     )
 
     fieldsets = (
-        (_('Export Template'), ('name', 'content_types', 'description', 'template_code')),
+        (_('Export Template'), ('name', 'object_types', 'description', 'template_code')),
         (_('Data Source'), ('data_source', 'data_file', 'auto_sync_enabled')),
         (_('Rendering'), ('mime_type', 'file_extension', 'as_attachment')),
     )
@@ -193,14 +192,14 @@ class ExportTemplateForm(SyncedDataMixin, forms.ModelForm):
 
 class SavedFilterForm(forms.ModelForm):
     slug = SlugField()
-    content_types = ContentTypeMultipleChoiceField(
-        label=_('Content types'),
-        queryset=ContentType.objects.all()
+    object_types = ContentTypeMultipleChoiceField(
+        label=_('Object types'),
+        queryset=ObjectType.objects.all()
     )
     parameters = JSONField()
 
     fieldsets = (
-        (_('Saved Filter'), ('name', 'slug', 'content_types', 'description', 'weight', 'enabled', 'shared')),
+        (_('Saved Filter'), ('name', 'slug', 'object_types', 'description', 'weight', 'enabled', 'shared')),
         (_('Parameters'), ('parameters',)),
     )
 
@@ -221,7 +220,7 @@ class SavedFilterForm(forms.ModelForm):
 class BookmarkForm(forms.ModelForm):
     object_type = ContentTypeChoiceField(
         label=_('Object type'),
-        queryset=ContentType.objects.with_feature('bookmarks')
+        queryset=ObjectType.objects.with_feature('bookmarks')
     )
 
     class Meta:
@@ -249,9 +248,9 @@ class WebhookForm(NetBoxModelForm):
 
 
 class EventRuleForm(NetBoxModelForm):
-    content_types = ContentTypeMultipleChoiceField(
-        label=_('Content types'),
-        queryset=ContentType.objects.with_feature('event_rules'),
+    object_types = ContentTypeMultipleChoiceField(
+        label=_('Object types'),
+        queryset=ObjectType.objects.with_feature('event_rules'),
     )
     action_choice = forms.ChoiceField(
         label=_('Action choice'),
@@ -267,7 +266,7 @@ class EventRuleForm(NetBoxModelForm):
     )
 
     fieldsets = (
-        (_('Event Rule'), ('name', 'description', 'content_types', 'enabled', 'tags')),
+        (_('Event Rule'), ('name', 'description', 'object_types', 'enabled', 'tags')),
         (_('Events'), ('type_create', 'type_update', 'type_delete', 'type_job_start', 'type_job_end')),
         (_('Conditions'), ('conditions',)),
         (_('Action'), (
@@ -278,7 +277,7 @@ class EventRuleForm(NetBoxModelForm):
     class Meta:
         model = EventRule
         fields = (
-            'content_types', 'name', 'description', 'type_create', 'type_update', 'type_delete', 'type_job_start',
+            'object_types', 'name', 'description', 'type_create', 'type_update', 'type_delete', 'type_job_start',
             'type_job_end', 'enabled', 'conditions', 'action_type', 'action_object_type', 'action_object_id',
             'action_data', 'comments', 'tags'
         )
@@ -339,11 +338,11 @@ class EventRuleForm(NetBoxModelForm):
         action_choice = self.cleaned_data.get('action_choice')
         # Webhook
         if self.cleaned_data.get('action_type') == EventRuleActionChoices.WEBHOOK:
-            self.cleaned_data['action_object_type'] = ContentType.objects.get_for_model(action_choice)
+            self.cleaned_data['action_object_type'] = ObjectType.objects.get_for_model(action_choice)
             self.cleaned_data['action_object_id'] = action_choice.id
         # Script
         elif self.cleaned_data.get('action_type') == EventRuleActionChoices.SCRIPT:
-            self.cleaned_data['action_object_type'] = ContentType.objects.get_for_model(
+            self.cleaned_data['action_object_type'] = ObjectType.objects.get_for_model(
                 Script,
                 for_concrete_model=False
             )
@@ -356,7 +355,7 @@ class TagForm(forms.ModelForm):
     slug = SlugField()
     object_types = ContentTypeMultipleChoiceField(
         label=_('Object types'),
-        queryset=ContentType.objects.with_feature('tags'),
+        queryset=ObjectType.objects.with_feature('tags'),
         required=False
     )
 

+ 12 - 12
netbox/extras/graphql/types.py

@@ -39,7 +39,7 @@ class CustomFieldType(ObjectType):
 
     class Meta:
         model = models.CustomField
-        exclude = ('content_types', )
+        fields = '__all__'
         filterset_class = filtersets.CustomFieldFilterSet
 
 
@@ -55,15 +55,23 @@ class CustomLinkType(ObjectType):
 
     class Meta:
         model = models.CustomLink
-        exclude = ('content_types', )
+        fields = '__all__'
         filterset_class = filtersets.CustomLinkFilterSet
 
 
+class EventRuleType(OrganizationalObjectType):
+
+    class Meta:
+        model = models.EventRule
+        fields = '__all__'
+        filterset_class = filtersets.EventRuleFilterSet
+
+
 class ExportTemplateType(ObjectType):
 
     class Meta:
         model = models.ExportTemplate
-        exclude = ('content_types', )
+        fields = '__all__'
         filterset_class = filtersets.ExportTemplateFilterSet
 
 
@@ -95,7 +103,7 @@ class SavedFilterType(ObjectType):
 
     class Meta:
         model = models.SavedFilter
-        exclude = ('content_types', )
+        fields = '__all__'
         filterset_class = filtersets.SavedFilterFilterSet
 
 
@@ -112,11 +120,3 @@ class WebhookType(OrganizationalObjectType):
     class Meta:
         model = models.Webhook
         filterset_class = filtersets.WebhookFilterSet
-
-
-class EventRuleType(OrganizationalObjectType):
-
-    class Meta:
-        model = models.EventRule
-        exclude = ('content_types', )
-        filterset_class = filtersets.EventRuleFilterSet

+ 107 - 0
netbox/extras/migrations/0111_rename_content_types.py

@@ -0,0 +1,107 @@
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('core', '0010_gfk_indexes'),
+        ('extras', '0110_remove_eventrule_action_parameters'),
+    ]
+
+    operations = [
+        # Custom fields
+        migrations.RenameField(
+            model_name='customfield',
+            old_name='content_types',
+            new_name='object_types',
+        ),
+        migrations.AlterField(
+            model_name='customfield',
+            name='object_types',
+            field=models.ManyToManyField(related_name='custom_fields', to='core.objecttype'),
+        ),
+        migrations.AlterField(
+            model_name='customfield',
+            name='object_type',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='core.objecttype'),
+        ),
+        migrations.RunSQL(
+            "ALTER TABLE extras_customfield_content_types_id_seq RENAME TO extras_customfield_object_types_id_seq"
+        ),
+
+        # Custom links
+        migrations.RenameField(
+            model_name='customlink',
+            old_name='content_types',
+            new_name='object_types',
+        ),
+        migrations.AlterField(
+            model_name='customlink',
+            name='object_types',
+            field=models.ManyToManyField(related_name='custom_links', to='core.objecttype'),
+        ),
+        migrations.RunSQL(
+            "ALTER TABLE extras_customlink_content_types_id_seq RENAME TO extras_customlink_object_types_id_seq"
+        ),
+
+        # Event rules
+        migrations.RenameField(
+            model_name='eventrule',
+            old_name='content_types',
+            new_name='object_types',
+        ),
+        migrations.AlterField(
+            model_name='eventrule',
+            name='object_types',
+            field=models.ManyToManyField(related_name='event_rules', to='core.objecttype'),
+        ),
+        migrations.RunSQL(
+            "ALTER TABLE extras_eventrule_content_types_id_seq RENAME TO extras_eventrule_object_types_id_seq"
+        ),
+
+        # Export templates
+        migrations.RenameField(
+            model_name='exporttemplate',
+            old_name='content_types',
+            new_name='object_types',
+        ),
+        migrations.AlterField(
+            model_name='exporttemplate',
+            name='object_types',
+            field=models.ManyToManyField(related_name='export_templates', to='core.objecttype'),
+        ),
+        migrations.RunSQL(
+            "ALTER TABLE extras_exporttemplate_content_types_id_seq RENAME TO extras_exporttemplate_object_types_id_seq"
+        ),
+
+        # Saved filters
+        migrations.RenameField(
+            model_name='savedfilter',
+            old_name='content_types',
+            new_name='object_types',
+        ),
+        migrations.AlterField(
+            model_name='savedfilter',
+            name='object_types',
+            field=models.ManyToManyField(related_name='saved_filters', to='core.objecttype'),
+        ),
+        migrations.RunSQL(
+            "ALTER TABLE extras_savedfilter_content_types_id_seq RENAME TO extras_savedfilter_object_types_id_seq"
+        ),
+
+        # Image attachments
+        migrations.RemoveIndex(
+            model_name='imageattachment',
+            name='extras_imag_content_94728e_idx',
+        ),
+        migrations.RenameField(
+            model_name='imageattachment',
+            old_name='content_type',
+            new_name='object_type',
+        ),
+        migrations.AddIndex(
+            model_name='imageattachment',
+            index=models.Index(fields=['object_type', 'object_id'], name='extras_imag_object__96bebc_idx'),
+        ),
+    ]

+ 17 - 0
netbox/extras/migrations/0112_tag_update_object_types.py

@@ -0,0 +1,17 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('core', '0010_gfk_indexes'),
+        ('extras', '0111_rename_content_types'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='tag',
+            name='object_types',
+            field=models.ManyToManyField(blank=True, related_name='+', to='core.objecttype'),
+        ),
+    ]

+ 2 - 2
netbox/extras/models/change_logging.py

@@ -5,7 +5,7 @@ from django.db import models
 from django.urls import reverse
 from django.utils.translation import gettext_lazy as _
 
-from core.models import ContentType
+from core.models import ObjectType
 from extras.choices import *
 from ..querysets import ObjectChangeQuerySet
 
@@ -113,7 +113,7 @@ class ObjectChange(models.Model):
         super().clean()
 
         # Validate the assigned object type
-        if self.changed_object_type not in ContentType.objects.with_feature('change_logging'):
+        if self.changed_object_type not in ObjectType.objects.with_feature('change_logging'):
             raise ValidationError(
                 _("Change logging is not supported for this object type ({type}).").format(
                     type=self.changed_object_type

+ 8 - 8
netbox/extras/models/customfields.py

@@ -12,7 +12,7 @@ from django.urls import reverse
 from django.utils.safestring import mark_safe
 from django.utils.translation import gettext_lazy as _
 
-from core.models import ContentType
+from core.models import ObjectType
 from extras.choices import *
 from extras.data import CHOICE_SETS
 from netbox.models import ChangeLoggedModel
@@ -52,8 +52,8 @@ class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)):
         """
         Return all CustomFields assigned to the given model.
         """
-        content_type = ContentType.objects.get_for_model(model._meta.concrete_model)
-        return self.get_queryset().filter(content_types=content_type)
+        content_type = ObjectType.objects.get_for_model(model._meta.concrete_model)
+        return self.get_queryset().filter(object_types=content_type)
 
     def get_defaults_for_model(self, model):
         """
@@ -66,8 +66,8 @@ class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)):
 
 
 class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
-    content_types = models.ManyToManyField(
-        to='contenttypes.ContentType',
+    object_types = models.ManyToManyField(
+        to='core.ObjectType',
         related_name='custom_fields',
         help_text=_('The object(s) to which this field applies.')
     )
@@ -79,7 +79,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
         help_text=_('The type of data this custom field holds')
     )
     object_type = models.ForeignKey(
-        to='contenttypes.ContentType',
+        to='core.ObjectType',
         on_delete=models.PROTECT,
         blank=True,
         null=True,
@@ -209,7 +209,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
     objects = CustomFieldManager()
 
     clone_fields = (
-        'content_types', 'type', 'object_type', 'group_name', 'description', 'required', 'search_weight',
+        'object_types', 'type', 'object_type', 'group_name', 'description', 'required', 'search_weight',
         'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex',
         'choice_set', 'ui_visible', 'ui_editable', 'is_cloneable',
     )
@@ -284,7 +284,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
         """
         Called when a CustomField has been renamed. Updates all assigned object data.
         """
-        for ct in self.content_types.all():
+        for ct in self.object_types.all():
             model = ct.model_class()
             params = {f'custom_field_data__{old_name}__isnull': False}
             instances = model.objects.filter(**params)

+ 21 - 21
netbox/extras/models/models.py

@@ -12,7 +12,7 @@ from django.utils.formats import date_format
 from django.utils.translation import gettext_lazy as _
 from rest_framework.utils.encoders import JSONEncoder
 
-from core.models import ContentType
+from core.models import ObjectType
 from extras.choices import *
 from extras.conditions import ConditionSet
 from extras.constants import *
@@ -43,9 +43,9 @@ class EventRule(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLogged
     specific type of object is created, modified, or deleted. The action to be taken might entail transmitting a
     webhook or executing a custom script.
     """
-    content_types = models.ManyToManyField(
-        to='contenttypes.ContentType',
-        related_name='eventrules',
+    object_types = models.ManyToManyField(
+        to='core.ObjectType',
+        related_name='event_rules',
         verbose_name=_('object types'),
         help_text=_("The object(s) to which this rule applies.")
     )
@@ -313,8 +313,8 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
     A custom link to an external representation of a NetBox object. The link text and URL fields accept Jinja2 template
     code to be rendered with an object as context.
     """
-    content_types = models.ManyToManyField(
-        to='contenttypes.ContentType',
+    object_types = models.ManyToManyField(
+        to='core.ObjectType',
         related_name='custom_links',
         help_text=_('The object type(s) to which this link applies.')
     )
@@ -359,7 +359,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
     )
 
     clone_fields = (
-        'content_types', 'enabled', 'weight', 'group_name', 'button_class', 'new_window',
+        'object_types', 'enabled', 'weight', 'group_name', 'button_class', 'new_window',
     )
 
     class Meta:
@@ -409,8 +409,8 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
 
 
 class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
-    content_types = models.ManyToManyField(
-        to='contenttypes.ContentType',
+    object_types = models.ManyToManyField(
+        to='core.ObjectType',
         related_name='export_templates',
         help_text=_('The object type(s) to which this template applies.')
     )
@@ -448,7 +448,7 @@ class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, Change
     )
 
     clone_fields = (
-        'content_types', 'template_code', 'mime_type', 'file_extension', 'as_attachment',
+        'object_types', 'template_code', 'mime_type', 'file_extension', 'as_attachment',
     )
 
     class Meta:
@@ -518,8 +518,8 @@ 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='contenttypes.ContentType',
+    object_types = models.ManyToManyField(
+        to='core.ObjectType',
         related_name='saved_filters',
         help_text=_('The object type(s) to which this filter applies.')
     )
@@ -561,7 +561,7 @@ class SavedFilter(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
     )
 
     clone_fields = (
-        'content_types', 'weight', 'enabled', 'parameters',
+        'object_types', 'weight', 'enabled', 'parameters',
     )
 
     class Meta:
@@ -598,13 +598,13 @@ class ImageAttachment(ChangeLoggedModel):
     """
     An uploaded image which is associated with an object.
     """
-    content_type = models.ForeignKey(
+    object_type = models.ForeignKey(
         to='contenttypes.ContentType',
         on_delete=models.CASCADE
     )
     object_id = models.PositiveBigIntegerField()
     parent = GenericForeignKey(
-        ct_field='content_type',
+        ct_field='object_type',
         fk_field='object_id'
     )
     image = models.ImageField(
@@ -626,12 +626,12 @@ class ImageAttachment(ChangeLoggedModel):
 
     objects = RestrictedQuerySet.as_manager()
 
-    clone_fields = ('content_type', 'object_id')
+    clone_fields = ('object_type', 'object_id')
 
     class Meta:
         ordering = ('name', 'pk')  # name may be non-unique
         indexes = (
-            models.Index(fields=('content_type', 'object_id')),
+            models.Index(fields=('object_type', 'object_id')),
         )
         verbose_name = _('image attachment')
         verbose_name_plural = _('image attachments')
@@ -646,9 +646,9 @@ class ImageAttachment(ChangeLoggedModel):
         super().clean()
 
         # Validate the assigned object type
-        if self.content_type not in ContentType.objects.with_feature('image_attachments'):
+        if self.object_type not in ObjectType.objects.with_feature('image_attachments'):
             raise ValidationError(
-                _("Image attachments cannot be assigned to this object type ({type}).").format(type=self.content_type)
+                _("Image attachments cannot be assigned to this object type ({type}).").format(type=self.object_type)
             )
 
     def delete(self, *args, **kwargs):
@@ -739,7 +739,7 @@ class JournalEntry(CustomFieldsMixin, CustomLinksMixin, TagsMixin, ExportTemplat
         super().clean()
 
         # Validate the assigned object type
-        if self.assigned_object_type not in ContentType.objects.with_feature('journaling'):
+        if self.assigned_object_type not in ObjectType.objects.with_feature('journaling'):
             raise ValidationError(
                 _("Journaling is not supported for this object type ({type}).").format(type=self.assigned_object_type)
             )
@@ -795,7 +795,7 @@ class Bookmark(models.Model):
         super().clean()
 
         # Validate the assigned object type
-        if self.object_type not in ContentType.objects.with_feature('bookmarks'):
+        if self.object_type not in ObjectType.objects.with_feature('bookmarks'):
             raise ValidationError(
                 _("Bookmarks cannot be assigned to this object type ({type}).").format(type=self.object_type)
             )

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

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

+ 8 - 7
netbox/extras/signals.py

@@ -8,6 +8,7 @@ from django.dispatch import receiver, Signal
 from django.utils.translation import gettext_lazy as _
 from django_prometheus.models import model_deletes, model_inserts, model_updates
 
+from core.models import ObjectType
 from core.signals import job_end, job_start
 from extras.constants import EVENT_JOB_END, EVENT_JOB_START
 from extras.events import process_event_rules
@@ -205,13 +206,13 @@ def handle_cf_deleted(instance, **kwargs):
     """
     Handle the cleanup of old custom field data when a CustomField is deleted.
     """
-    instance.remove_stale_data(instance.content_types.all())
+    instance.remove_stale_data(instance.object_types.all())
 
 
 post_save.connect(handle_cf_renamed, sender=CustomField)
 pre_delete.connect(handle_cf_deleted, sender=CustomField)
-m2m_changed.connect(handle_cf_added_obj_types, sender=CustomField.content_types.through)
-m2m_changed.connect(handle_cf_removed_obj_types, sender=CustomField.content_types.through)
+m2m_changed.connect(handle_cf_added_obj_types, sender=CustomField.object_types.through)
+m2m_changed.connect(handle_cf_removed_obj_types, sender=CustomField.object_types.through)
 
 
 #
@@ -240,8 +241,8 @@ def validate_assigned_tags(sender, instance, action, model, pk_set, **kwargs):
     """
     if action != 'pre_add':
         return
-    ct = ContentType.objects.get_for_model(instance)
-    # Retrieve any applied Tags that are restricted to certain object_types
+    ct = ObjectType.objects.get_for_model(instance)
+    # Retrieve any applied Tags that are restricted to certain object types
     for tag in model.objects.filter(pk__in=pk_set, object_types__isnull=False).prefetch_related('object_types'):
         if ct not in tag.object_types.all():
             raise AbortRequest(f"Tag {tag} cannot be assigned to {ct.model} objects.")
@@ -256,7 +257,7 @@ def process_job_start_event_rules(sender, **kwargs):
     """
     Process event rules for jobs starting.
     """
-    event_rules = EventRule.objects.filter(type_job_start=True, enabled=True, content_types=sender.object_type)
+    event_rules = EventRule.objects.filter(type_job_start=True, enabled=True, object_types=sender.object_type)
     username = sender.user.username if sender.user else None
     process_event_rules(event_rules, sender.object_type.model, EVENT_JOB_START, sender.data, username)
 
@@ -266,6 +267,6 @@ def process_job_end_event_rules(sender, **kwargs):
     """
     Process event rules for jobs terminating.
     """
-    event_rules = EventRule.objects.filter(type_job_end=True, enabled=True, content_types=sender.object_type)
+    event_rules = EventRule.objects.filter(type_job_end=True, enabled=True, object_types=sender.object_type)
     username = sender.user.username if sender.user else None
     process_event_rules(event_rules, sender.object_type.model, EVENT_JOB_END, sender.data, username)

+ 24 - 24
netbox/extras/tables/tables.py

@@ -40,8 +40,8 @@ class CustomFieldTable(NetBoxTable):
         verbose_name=_('Name'),
         linkify=True
     )
-    content_types = columns.ContentTypesColumn(
-        verbose_name=_('Content Types')
+    object_types = columns.ContentTypesColumn(
+        verbose_name=_('Object Types')
     )
     required = columns.BooleanColumn(
         verbose_name=_('Required')
@@ -71,11 +71,11 @@ class CustomFieldTable(NetBoxTable):
     class Meta(NetBoxTable.Meta):
         model = CustomField
         fields = (
-            'pk', 'id', 'name', 'content_types', 'label', 'type', 'group_name', 'required', 'default', 'description',
+            'pk', 'id', 'name', 'object_types', 'label', 'type', 'group_name', 'required', 'default', 'description',
             'search_weight', 'filter_logic', 'ui_visible', 'ui_editable', 'is_cloneable', 'weight', 'choice_set',
             'choices', 'created', 'last_updated',
         )
-        default_columns = ('pk', 'name', 'content_types', 'label', 'group_name', 'type', 'required', 'description')
+        default_columns = ('pk', 'name', 'object_types', 'label', 'group_name', 'type', 'required', 'description')
 
 
 class CustomFieldChoiceSetTable(NetBoxTable):
@@ -115,8 +115,8 @@ class CustomLinkTable(NetBoxTable):
         verbose_name=_('Name'),
         linkify=True
     )
-    content_types = columns.ContentTypesColumn(
-        verbose_name=_('Content Types'),
+    object_types = columns.ContentTypesColumn(
+        verbose_name=_('Object Types'),
     )
     enabled = columns.BooleanColumn(
         verbose_name=_('Enabled'),
@@ -128,10 +128,10 @@ class CustomLinkTable(NetBoxTable):
     class Meta(NetBoxTable.Meta):
         model = CustomLink
         fields = (
-            'pk', 'id', 'name', 'content_types', 'enabled', 'link_text', 'link_url', 'weight', 'group_name',
+            'pk', 'id', 'name', 'object_types', 'enabled', 'link_text', 'link_url', 'weight', 'group_name',
             'button_class', 'new_window', 'created', 'last_updated',
         )
-        default_columns = ('pk', 'name', 'content_types', 'enabled', 'group_name', 'button_class', 'new_window')
+        default_columns = ('pk', 'name', 'object_types', 'enabled', 'group_name', 'button_class', 'new_window')
 
 
 class ExportTemplateTable(NetBoxTable):
@@ -139,8 +139,8 @@ class ExportTemplateTable(NetBoxTable):
         verbose_name=_('Name'),
         linkify=True
     )
-    content_types = columns.ContentTypesColumn(
-        verbose_name=_('Content Types'),
+    object_types = columns.ContentTypesColumn(
+        verbose_name=_('Object Types'),
     )
     as_attachment = columns.BooleanColumn(
         verbose_name=_('As Attachment'),
@@ -161,11 +161,11 @@ class ExportTemplateTable(NetBoxTable):
     class Meta(NetBoxTable.Meta):
         model = ExportTemplate
         fields = (
-            'pk', 'id', 'name', 'content_types', 'description', 'mime_type', 'file_extension', 'as_attachment',
+            'pk', 'id', 'name', 'object_types', 'description', 'mime_type', 'file_extension', 'as_attachment',
             'data_source', 'data_file', 'data_synced', 'created', 'last_updated',
         )
         default_columns = (
-            'pk', 'name', 'content_types', 'description', 'mime_type', 'file_extension', 'as_attachment', 'is_synced',
+            'pk', 'name', 'object_types', 'description', 'mime_type', 'file_extension', 'as_attachment', 'is_synced',
         )
 
 
@@ -174,8 +174,8 @@ class ImageAttachmentTable(NetBoxTable):
         verbose_name=_('ID'),
         linkify=False
     )
-    content_type = columns.ContentTypeColumn(
-        verbose_name=_('Content Type'),
+    object_type = columns.ContentTypeColumn(
+        verbose_name=_('Object Type'),
     )
     parent = tables.Column(
         verbose_name=_('Parent'),
@@ -193,10 +193,10 @@ class ImageAttachmentTable(NetBoxTable):
     class Meta(NetBoxTable.Meta):
         model = ImageAttachment
         fields = (
-            'pk', 'content_type', 'parent', 'image', 'name', 'image_height', 'image_width', 'size', 'created',
+            'pk', 'object_type', 'parent', 'image', 'name', 'image_height', 'image_width', 'size', 'created',
             'last_updated',
         )
-        default_columns = ('content_type', 'parent', 'image', 'name', 'size', 'created')
+        default_columns = ('object_type', 'parent', 'image', 'name', 'size', 'created')
 
 
 class SavedFilterTable(NetBoxTable):
@@ -204,8 +204,8 @@ class SavedFilterTable(NetBoxTable):
         verbose_name=_('Name'),
         linkify=True
     )
-    content_types = columns.ContentTypesColumn(
-        verbose_name=_('Content Types'),
+    object_types = columns.ContentTypesColumn(
+        verbose_name=_('Object Types'),
     )
     enabled = columns.BooleanColumn(
         verbose_name=_('Enabled'),
@@ -220,11 +220,11 @@ class SavedFilterTable(NetBoxTable):
     class Meta(NetBoxTable.Meta):
         model = SavedFilter
         fields = (
-            'pk', 'id', 'name', 'slug', 'content_types', 'description', 'user', 'weight', 'enabled', 'shared',
+            'pk', 'id', 'name', 'slug', 'object_types', 'description', 'user', 'weight', 'enabled', 'shared',
             'created', 'last_updated', 'parameters'
         )
         default_columns = (
-            'pk', 'name', 'content_types', 'user', 'description', 'enabled', 'shared',
+            'pk', 'name', 'object_types', 'user', 'description', 'enabled', 'shared',
         )
 
 
@@ -281,8 +281,8 @@ class EventRuleTable(NetBoxTable):
         linkify=True,
         verbose_name=_('Object'),
     )
-    content_types = columns.ContentTypesColumn(
-        verbose_name=_('Content Types'),
+    object_types = columns.ContentTypesColumn(
+        verbose_name=_('Object Types'),
     )
     enabled = columns.BooleanColumn(
         verbose_name=_('Enabled'),
@@ -309,12 +309,12 @@ class EventRuleTable(NetBoxTable):
     class Meta(NetBoxTable.Meta):
         model = EventRule
         fields = (
-            'pk', 'id', 'name', 'enabled', 'description', 'action_type', 'action_object', 'content_types',
+            'pk', 'id', 'name', 'enabled', 'description', 'action_type', 'action_object', 'object_types',
             'type_create', 'type_update', 'type_delete', 'type_job_start', 'type_job_end', 'tags', 'created',
             'last_updated',
         )
         default_columns = (
-            'pk', 'name', 'enabled', 'action_type', 'action_object', 'content_types', 'type_create', 'type_update',
+            'pk', 'name', 'enabled', 'action_type', 'action_object', 'object_types', 'type_create', 'type_update',
             'type_delete', 'type_job_start', 'type_job_end',
         )
 

+ 3 - 3
netbox/extras/templatetags/custom_links.py

@@ -1,7 +1,7 @@
 from django import template
-from django.contrib.contenttypes.models import ContentType
 from django.utils.safestring import mark_safe
 
+from core.models import ObjectType
 from extras.models import CustomLink
 
 
@@ -32,8 +32,8 @@ def custom_links(context, obj):
     """
     Render all applicable links for the given object.
     """
-    content_type = ContentType.objects.get_for_model(obj)
-    custom_links = CustomLink.objects.filter(content_types=content_type, enabled=True)
+    object_type = ObjectType.objects.get_for_model(obj)
+    custom_links = CustomLink.objects.filter(object_types=object_type, enabled=True)
     if not custom_links:
         return ''
 

+ 33 - 33
netbox/extras/tests/test_api.py

@@ -7,10 +7,10 @@ from django.utils.timezone import make_aware
 from rest_framework import status
 
 from core.choices import ManagedFileRootPathChoices
+from core.models import ObjectType
 from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, Location, RackRole, Site
 from extras.choices import *
 from extras.models import *
-from extras.reports import Report
 from extras.scripts import BooleanVar, IntegerVar, Script as PythonClass, StringVar
 from utilities.testing import APITestCase, APIViewTestCases
 
@@ -122,7 +122,7 @@ class EventRuleTest(APIViewTestCases.APIViewTestCase):
         cls.create_data = [
             {
                 'name': 'EventRule 4',
-                'content_types': ['dcim.device', 'dcim.devicetype'],
+                'object_types': ['dcim.device', 'dcim.devicetype'],
                 'type_create': True,
                 'action_type': EventRuleActionChoices.WEBHOOK,
                 'action_object_type': 'extras.webhook',
@@ -130,7 +130,7 @@ class EventRuleTest(APIViewTestCases.APIViewTestCase):
             },
             {
                 'name': 'EventRule 5',
-                'content_types': ['dcim.device', 'dcim.devicetype'],
+                'object_types': ['dcim.device', 'dcim.devicetype'],
                 'type_create': True,
                 'action_type': EventRuleActionChoices.WEBHOOK,
                 'action_object_type': 'extras.webhook',
@@ -138,7 +138,7 @@ class EventRuleTest(APIViewTestCases.APIViewTestCase):
             },
             {
                 'name': 'EventRule 6',
-                'content_types': ['dcim.device', 'dcim.devicetype'],
+                'object_types': ['dcim.device', 'dcim.devicetype'],
                 'type_create': True,
                 'action_type': EventRuleActionChoices.WEBHOOK,
                 'action_object_type': 'extras.webhook',
@@ -152,17 +152,17 @@ class CustomFieldTest(APIViewTestCases.APIViewTestCase):
     brief_fields = ['description', 'display', 'id', 'name', 'url']
     create_data = [
         {
-            'content_types': ['dcim.site'],
+            'object_types': ['dcim.site'],
             'name': 'cf4',
             'type': 'date',
         },
         {
-            'content_types': ['dcim.site'],
+            'object_types': ['dcim.site'],
             'name': 'cf5',
             'type': 'url',
         },
         {
-            'content_types': ['dcim.site'],
+            'object_types': ['dcim.site'],
             'name': 'cf6',
             'type': 'text',
         },
@@ -171,14 +171,14 @@ class CustomFieldTest(APIViewTestCases.APIViewTestCase):
         'description': 'New description',
     }
     update_data = {
-        'content_types': ['dcim.device'],
+        'object_types': ['dcim.device'],
         'name': 'New_Name',
         'description': 'New description',
     }
 
     @classmethod
     def setUpTestData(cls):
-        site_ct = ContentType.objects.get_for_model(Site)
+        site_ct = ObjectType.objects.get_for_model(Site)
 
         custom_fields = (
             CustomField(
@@ -196,7 +196,7 @@ class CustomFieldTest(APIViewTestCases.APIViewTestCase):
         )
         CustomField.objects.bulk_create(custom_fields)
         for cf in custom_fields:
-            cf.content_types.add(site_ct)
+            cf.object_types.add(site_ct)
 
 
 class CustomFieldChoiceSetTest(APIViewTestCases.APIViewTestCase):
@@ -273,21 +273,21 @@ class CustomLinkTest(APIViewTestCases.APIViewTestCase):
     brief_fields = ['display', 'id', 'name', 'url']
     create_data = [
         {
-            'content_types': ['dcim.site'],
+            'object_types': ['dcim.site'],
             'name': 'Custom Link 4',
             'enabled': True,
             'link_text': 'Link 4',
             'link_url': 'http://example.com/?4',
         },
         {
-            'content_types': ['dcim.site'],
+            'object_types': ['dcim.site'],
             'name': 'Custom Link 5',
             'enabled': True,
             'link_text': 'Link 5',
             'link_url': 'http://example.com/?5',
         },
         {
-            'content_types': ['dcim.site'],
+            'object_types': ['dcim.site'],
             'name': 'Custom Link 6',
             'enabled': False,
             'link_text': 'Link 6',
@@ -301,7 +301,7 @@ class CustomLinkTest(APIViewTestCases.APIViewTestCase):
 
     @classmethod
     def setUpTestData(cls):
-        site_ct = ContentType.objects.get_for_model(Site)
+        site_type = ObjectType.objects.get_for_model(Site)
 
         custom_links = (
             CustomLink(
@@ -325,7 +325,7 @@ class CustomLinkTest(APIViewTestCases.APIViewTestCase):
         )
         CustomLink.objects.bulk_create(custom_links)
         for i, custom_link in enumerate(custom_links):
-            custom_link.content_types.set([site_ct])
+            custom_link.object_types.set([site_type])
 
 
 class SavedFilterTest(APIViewTestCases.APIViewTestCase):
@@ -333,7 +333,7 @@ class SavedFilterTest(APIViewTestCases.APIViewTestCase):
     brief_fields = ['description', 'display', 'id', 'name', 'slug', 'url']
     create_data = [
         {
-            'content_types': ['dcim.site'],
+            'object_types': ['dcim.site'],
             'name': 'Saved Filter 4',
             'slug': 'saved-filter-4',
             'weight': 100,
@@ -342,7 +342,7 @@ class SavedFilterTest(APIViewTestCases.APIViewTestCase):
             'parameters': {'status': ['active']},
         },
         {
-            'content_types': ['dcim.site'],
+            'object_types': ['dcim.site'],
             'name': 'Saved Filter 5',
             'slug': 'saved-filter-5',
             'weight': 200,
@@ -351,7 +351,7 @@ class SavedFilterTest(APIViewTestCases.APIViewTestCase):
             'parameters': {'status': ['planned']},
         },
         {
-            'content_types': ['dcim.site'],
+            'object_types': ['dcim.site'],
             'name': 'Saved Filter 6',
             'slug': 'saved-filter-6',
             'weight': 300,
@@ -368,7 +368,7 @@ class SavedFilterTest(APIViewTestCases.APIViewTestCase):
 
     @classmethod
     def setUpTestData(cls):
-        site_ct = ContentType.objects.get_for_model(Site)
+        site_type = ObjectType.objects.get_for_model(Site)
 
         saved_filters = (
             SavedFilter(
@@ -398,7 +398,7 @@ class SavedFilterTest(APIViewTestCases.APIViewTestCase):
         )
         SavedFilter.objects.bulk_create(saved_filters)
         for i, savedfilter in enumerate(saved_filters):
-            savedfilter.content_types.set([site_ct])
+            savedfilter.object_types.set([site_type])
 
 
 class BookmarkTest(
@@ -458,17 +458,17 @@ class ExportTemplateTest(APIViewTestCases.APIViewTestCase):
     brief_fields = ['description', 'display', 'id', 'name', 'url']
     create_data = [
         {
-            'content_types': ['dcim.device'],
+            'object_types': ['dcim.device'],
             'name': 'Test Export Template 4',
             'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
         },
         {
-            'content_types': ['dcim.device'],
+            'object_types': ['dcim.device'],
             'name': 'Test Export Template 5',
             'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
         },
         {
-            'content_types': ['dcim.device'],
+            'object_types': ['dcim.device'],
             'name': 'Test Export Template 6',
             'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
         },
@@ -495,7 +495,7 @@ class ExportTemplateTest(APIViewTestCases.APIViewTestCase):
         )
         ExportTemplate.objects.bulk_create(export_templates)
         for et in export_templates:
-            et.content_types.set([ContentType.objects.get_for_model(Device)])
+            et.object_types.set([ObjectType.objects.get_for_model(Device)])
 
 
 class TagTest(APIViewTestCases.APIViewTestCase):
@@ -548,7 +548,7 @@ class ImageAttachmentTest(
 
         image_attachments = (
             ImageAttachment(
-                content_type=ct,
+                object_type=ct,
                 object_id=site.pk,
                 name='Image Attachment 1',
                 image='http://example.com/image1.png',
@@ -556,7 +556,7 @@ class ImageAttachmentTest(
                 image_width=100
             ),
             ImageAttachment(
-                content_type=ct,
+                object_type=ct,
                 object_id=site.pk,
                 name='Image Attachment 2',
                 image='http://example.com/image2.png',
@@ -564,7 +564,7 @@ class ImageAttachmentTest(
                 image_width=100
             ),
             ImageAttachment(
-                content_type=ct,
+                object_type=ct,
                 object_id=site.pk,
                 name='Image Attachment 3',
                 image='http://example.com/image3.png',
@@ -876,17 +876,17 @@ class CreatedUpdatedFilterTest(APITestCase):
         self.assertEqual(response.data['results'][0]['id'], rack2.pk)
 
 
-class ContentTypeTest(APITestCase):
+class ObjectTypeTest(APITestCase):
 
     def test_list_objects(self):
-        contenttype_count = ContentType.objects.count()
+        object_type_count = ObjectType.objects.count()
 
-        response = self.client.get(reverse('extras-api:contenttype-list'), **self.header)
+        response = self.client.get(reverse('extras-api:objecttype-list'), **self.header)
         self.assertHttpStatus(response, status.HTTP_200_OK)
-        self.assertEqual(response.data['count'], contenttype_count)
+        self.assertEqual(response.data['count'], object_type_count)
 
     def test_get_object(self):
-        contenttype = ContentType.objects.first()
+        object_type = ObjectType.objects.first()
 
-        url = reverse('extras-api:contenttype-detail', kwargs={'pk': contenttype.pk})
+        url = reverse('extras-api:objecttype-detail', kwargs={'pk': object_type.pk})
         self.assertHttpStatus(self.client.get(url, **self.header), status.HTTP_200_OK)

+ 7 - 6
netbox/extras/tests/test_changelog.py

@@ -3,6 +3,7 @@ from django.test import override_settings
 from django.urls import reverse
 from rest_framework import status
 
+from core.models import ObjectType
 from dcim.choices import SiteStatusChoices
 from dcim.models import Site
 from extras.choices import *
@@ -23,14 +24,14 @@ class ChangeLogViewTest(ModelViewTestCase):
         )
 
         # Create a custom field on the Site model
-        ct = ContentType.objects.get_for_model(Site)
+        site_type = ObjectType.objects.get_for_model(Site)
         cf = CustomField(
             type=CustomFieldTypeChoices.TYPE_TEXT,
             name='cf1',
             required=False
         )
         cf.save()
-        cf.content_types.set([ct])
+        cf.object_types.set([site_type])
 
         # Create a select custom field on the Site model
         cf_select = CustomField(
@@ -40,7 +41,7 @@ class ChangeLogViewTest(ModelViewTestCase):
             choice_set=choice_set
         )
         cf_select.save()
-        cf_select.content_types.set([ct])
+        cf_select.object_types.set([site_type])
 
     def test_create_object(self):
         tags = create_tags('Tag 1', 'Tag 2')
@@ -275,14 +276,14 @@ class ChangeLogAPITest(APITestCase):
     def setUpTestData(cls):
 
         # Create a custom field on the Site model
-        ct = ContentType.objects.get_for_model(Site)
+        site_type = ObjectType.objects.get_for_model(Site)
         cf = CustomField(
             type=CustomFieldTypeChoices.TYPE_TEXT,
             name='cf1',
             required=False
         )
         cf.save()
-        cf.content_types.set([ct])
+        cf.object_types.set([site_type])
 
         # Create a select custom field on the Site model
         choice_set = CustomFieldChoiceSet.objects.create(
@@ -296,7 +297,7 @@ class ChangeLogAPITest(APITestCase):
             choice_set=choice_set
         )
         cf_select.save()
-        cf_select.content_types.set([ct])
+        cf_select.object_types.set([site_type])
 
         # Create some tags
         tags = (

+ 45 - 45
netbox/extras/tests/test_customfields.py

@@ -1,11 +1,11 @@
 import datetime
 from decimal import Decimal
 
-from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ValidationError
 from django.urls import reverse
 from rest_framework import status
 
+from core.models import ObjectType
 from dcim.filtersets import SiteFilterSet
 from dcim.forms import SiteImportForm
 from dcim.models import Manufacturer, Rack, Site
@@ -28,7 +28,7 @@ class CustomFieldTest(TestCase):
             Site(name='Site C', slug='site-c'),
         ])
 
-        cls.object_type = ContentType.objects.get_for_model(Site)
+        cls.object_type = ObjectType.objects.get_for_model(Site)
 
     def test_invalid_name(self):
         """
@@ -50,7 +50,7 @@ class CustomFieldTest(TestCase):
             type=CustomFieldTypeChoices.TYPE_TEXT,
             required=False
         )
-        cf.content_types.set([self.object_type])
+        cf.object_types.set([self.object_type])
         instance = Site.objects.first()
         self.assertIsNone(instance.custom_field_data[cf.name])
 
@@ -75,7 +75,7 @@ class CustomFieldTest(TestCase):
             type=CustomFieldTypeChoices.TYPE_LONGTEXT,
             required=False
         )
-        cf.content_types.set([self.object_type])
+        cf.object_types.set([self.object_type])
         instance = Site.objects.first()
         self.assertIsNone(instance.custom_field_data[cf.name])
 
@@ -99,7 +99,7 @@ class CustomFieldTest(TestCase):
             type=CustomFieldTypeChoices.TYPE_INTEGER,
             required=False
         )
-        cf.content_types.set([self.object_type])
+        cf.object_types.set([self.object_type])
         instance = Site.objects.first()
         self.assertIsNone(instance.custom_field_data[cf.name])
 
@@ -125,7 +125,7 @@ class CustomFieldTest(TestCase):
             type=CustomFieldTypeChoices.TYPE_DECIMAL,
             required=False
         )
-        cf.content_types.set([self.object_type])
+        cf.object_types.set([self.object_type])
         instance = Site.objects.first()
         self.assertIsNone(instance.custom_field_data[cf.name])
 
@@ -151,7 +151,7 @@ class CustomFieldTest(TestCase):
             type=CustomFieldTypeChoices.TYPE_INTEGER,
             required=False
         )
-        cf.content_types.set([self.object_type])
+        cf.object_types.set([self.object_type])
         instance = Site.objects.first()
         self.assertIsNone(instance.custom_field_data[cf.name])
 
@@ -178,7 +178,7 @@ class CustomFieldTest(TestCase):
             type=CustomFieldTypeChoices.TYPE_DATE,
             required=False
         )
-        cf.content_types.set([self.object_type])
+        cf.object_types.set([self.object_type])
         instance = Site.objects.first()
         self.assertIsNone(instance.custom_field_data[cf.name])
 
@@ -203,7 +203,7 @@ class CustomFieldTest(TestCase):
             type=CustomFieldTypeChoices.TYPE_DATETIME,
             required=False
         )
-        cf.content_types.set([self.object_type])
+        cf.object_types.set([self.object_type])
         instance = Site.objects.first()
         self.assertIsNone(instance.custom_field_data[cf.name])
 
@@ -228,7 +228,7 @@ class CustomFieldTest(TestCase):
             type=CustomFieldTypeChoices.TYPE_URL,
             required=False
         )
-        cf.content_types.set([self.object_type])
+        cf.object_types.set([self.object_type])
         instance = Site.objects.first()
         self.assertIsNone(instance.custom_field_data[cf.name])
 
@@ -253,7 +253,7 @@ class CustomFieldTest(TestCase):
             type=CustomFieldTypeChoices.TYPE_JSON,
             required=False
         )
-        cf.content_types.set([self.object_type])
+        cf.object_types.set([self.object_type])
         instance = Site.objects.first()
         self.assertIsNone(instance.custom_field_data[cf.name])
 
@@ -290,7 +290,7 @@ class CustomFieldTest(TestCase):
             required=False,
             choice_set=choice_set
         )
-        cf.content_types.set([self.object_type])
+        cf.object_types.set([self.object_type])
         instance = Site.objects.first()
         self.assertIsNone(instance.custom_field_data[cf.name])
 
@@ -327,7 +327,7 @@ class CustomFieldTest(TestCase):
             required=False,
             choice_set=choice_set
         )
-        cf.content_types.set([self.object_type])
+        cf.object_types.set([self.object_type])
         instance = Site.objects.first()
         self.assertIsNone(instance.custom_field_data[cf.name])
 
@@ -350,10 +350,10 @@ class CustomFieldTest(TestCase):
         cf = CustomField.objects.create(
             name='object_field',
             type=CustomFieldTypeChoices.TYPE_OBJECT,
-            object_type=ContentType.objects.get_for_model(VLAN),
+            object_type=ObjectType.objects.get_for_model(VLAN),
             required=False
         )
-        cf.content_types.set([self.object_type])
+        cf.object_types.set([self.object_type])
         instance = Site.objects.first()
         self.assertIsNone(instance.custom_field_data[cf.name])
 
@@ -382,10 +382,10 @@ class CustomFieldTest(TestCase):
         cf = CustomField.objects.create(
             name='object_field',
             type=CustomFieldTypeChoices.TYPE_MULTIOBJECT,
-            object_type=ContentType.objects.get_for_model(VLAN),
+            object_type=ObjectType.objects.get_for_model(VLAN),
             required=False
         )
-        cf.content_types.set([self.object_type])
+        cf.object_types.set([self.object_type])
         instance = Site.objects.first()
         self.assertIsNone(instance.custom_field_data[cf.name])
 
@@ -402,13 +402,13 @@ class CustomFieldTest(TestCase):
         self.assertIsNone(instance.custom_field_data.get(cf.name))
 
     def test_rename_customfield(self):
-        obj_type = ContentType.objects.get_for_model(Site)
+        obj_type = ObjectType.objects.get_for_model(Site)
         FIELD_DATA = 'abc'
 
         # Create a custom field
         cf = CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='field1')
         cf.save()
-        cf.content_types.set([obj_type])
+        cf.object_types.set([obj_type])
 
         # Assign custom field data to an object
         site = Site.objects.create(
@@ -437,7 +437,7 @@ class CustomFieldTest(TestCase):
             )
         )
         site = Site.objects.create(name='Site 1', slug='site-1')
-        object_type = ContentType.objects.get_for_model(Site)
+        object_type = ObjectType.objects.get_for_model(Site)
 
         # Text
         CustomField(name='test', type='text', required=True, default="Default text").full_clean()
@@ -524,10 +524,10 @@ class CustomFieldManagerTest(TestCase):
 
     @classmethod
     def setUpTestData(cls):
-        content_type = ContentType.objects.get_for_model(Site)
+        object_type = ObjectType.objects.get_for_model(Site)
         custom_field = CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='text_field', default='foo')
         custom_field.save()
-        custom_field.content_types.set([content_type])
+        custom_field.object_types.set([object_type])
 
     def test_get_for_model(self):
         self.assertEqual(CustomField.objects.get_for_model(Site).count(), 1)
@@ -538,7 +538,7 @@ class CustomFieldAPITest(APITestCase):
 
     @classmethod
     def setUpTestData(cls):
-        content_type = ContentType.objects.get_for_model(Site)
+        object_type = ObjectType.objects.get_for_model(Site)
 
         # Create some VLANs
         vlans = (
@@ -581,19 +581,19 @@ class CustomFieldAPITest(APITestCase):
             CustomField(
                 type=CustomFieldTypeChoices.TYPE_OBJECT,
                 name='object_field',
-                object_type=ContentType.objects.get_for_model(VLAN),
+                object_type=ObjectType.objects.get_for_model(VLAN),
                 default=vlans[0].pk,
             ),
             CustomField(
                 type=CustomFieldTypeChoices.TYPE_MULTIOBJECT,
                 name='multiobject_field',
-                object_type=ContentType.objects.get_for_model(VLAN),
+                object_type=ObjectType.objects.get_for_model(VLAN),
                 default=[vlans[0].pk, vlans[1].pk],
             ),
         )
         for cf in custom_fields:
             cf.save()
-            cf.content_types.set([content_type])
+            cf.object_types.set([object_type])
 
         # Create some sites *after* creating the custom fields. This ensures that
         # default values are not set for the assigned objects.
@@ -1163,7 +1163,7 @@ class CustomFieldImportTest(TestCase):
         )
         for cf in custom_fields:
             cf.save()
-            cf.content_types.set([ContentType.objects.get_for_model(Site)])
+            cf.object_types.set([ObjectType.objects.get_for_model(Site)])
 
     def test_import(self):
         """
@@ -1256,11 +1256,11 @@ class CustomFieldModelTest(TestCase):
     def setUpTestData(cls):
         cf1 = CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='foo')
         cf1.save()
-        cf1.content_types.set([ContentType.objects.get_for_model(Site)])
+        cf1.object_types.set([ObjectType.objects.get_for_model(Site)])
 
         cf2 = CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='bar')
         cf2.save()
-        cf2.content_types.set([ContentType.objects.get_for_model(Rack)])
+        cf2.object_types.set([ObjectType.objects.get_for_model(Rack)])
 
     def test_cf_data(self):
         """
@@ -1299,7 +1299,7 @@ class CustomFieldModelTest(TestCase):
         """
         cf3 = CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='baz', required=True)
         cf3.save()
-        cf3.content_types.set([ContentType.objects.get_for_model(Site)])
+        cf3.object_types.set([ObjectType.objects.get_for_model(Site)])
 
         site = Site(name='Test Site', slug='test-site')
 
@@ -1318,7 +1318,7 @@ class CustomFieldModelFilterTest(TestCase):
 
     @classmethod
     def setUpTestData(cls):
-        obj_type = ContentType.objects.get_for_model(Site)
+        object_type = ObjectType.objects.get_for_model(Site)
 
         manufacturers = Manufacturer.objects.bulk_create((
             Manufacturer(name='Manufacturer 1', slug='manufacturer-1'),
@@ -1335,17 +1335,17 @@ class CustomFieldModelFilterTest(TestCase):
         # Integer filtering
         cf = CustomField(name='cf1', type=CustomFieldTypeChoices.TYPE_INTEGER)
         cf.save()
-        cf.content_types.set([obj_type])
+        cf.object_types.set([object_type])
 
         # Decimal filtering
         cf = CustomField(name='cf2', type=CustomFieldTypeChoices.TYPE_DECIMAL)
         cf.save()
-        cf.content_types.set([obj_type])
+        cf.object_types.set([object_type])
 
         # Boolean filtering
         cf = CustomField(name='cf3', type=CustomFieldTypeChoices.TYPE_BOOLEAN)
         cf.save()
-        cf.content_types.set([obj_type])
+        cf.object_types.set([object_type])
 
         # Exact text filtering
         cf = CustomField(
@@ -1354,7 +1354,7 @@ class CustomFieldModelFilterTest(TestCase):
             filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT
         )
         cf.save()
-        cf.content_types.set([obj_type])
+        cf.object_types.set([object_type])
 
         # Loose text filtering
         cf = CustomField(
@@ -1363,12 +1363,12 @@ class CustomFieldModelFilterTest(TestCase):
             filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE
         )
         cf.save()
-        cf.content_types.set([obj_type])
+        cf.object_types.set([object_type])
 
         # Date filtering
         cf = CustomField(name='cf6', type=CustomFieldTypeChoices.TYPE_DATE)
         cf.save()
-        cf.content_types.set([obj_type])
+        cf.object_types.set([object_type])
 
         # Exact URL filtering
         cf = CustomField(
@@ -1377,7 +1377,7 @@ class CustomFieldModelFilterTest(TestCase):
             filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT
         )
         cf.save()
-        cf.content_types.set([obj_type])
+        cf.object_types.set([object_type])
 
         # Loose URL filtering
         cf = CustomField(
@@ -1386,7 +1386,7 @@ class CustomFieldModelFilterTest(TestCase):
             filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE
         )
         cf.save()
-        cf.content_types.set([obj_type])
+        cf.object_types.set([object_type])
 
         # Selection filtering
         cf = CustomField(
@@ -1395,7 +1395,7 @@ class CustomFieldModelFilterTest(TestCase):
             choice_set=choice_set
         )
         cf.save()
-        cf.content_types.set([obj_type])
+        cf.object_types.set([object_type])
 
         # Multiselect filtering
         cf = CustomField(
@@ -1404,25 +1404,25 @@ class CustomFieldModelFilterTest(TestCase):
             choice_set=choice_set
         )
         cf.save()
-        cf.content_types.set([obj_type])
+        cf.object_types.set([object_type])
 
         # Object filtering
         cf = CustomField(
             name='cf11',
             type=CustomFieldTypeChoices.TYPE_OBJECT,
-            object_type=ContentType.objects.get_for_model(Manufacturer)
+            object_type=ObjectType.objects.get_for_model(Manufacturer)
         )
         cf.save()
-        cf.content_types.set([obj_type])
+        cf.object_types.set([object_type])
 
         # Multi-object filtering
         cf = CustomField(
             name='cf12',
             type=CustomFieldTypeChoices.TYPE_MULTIOBJECT,
-            object_type=ContentType.objects.get_for_model(Manufacturer)
+            object_type=ObjectType.objects.get_for_model(Manufacturer)
         )
         cf.save()
-        cf.content_types.set([obj_type])
+        cf.object_types.set([object_type])
 
         Site.objects.bulk_create([
             Site(name='Site 1', slug='site-1', custom_field_data={

+ 12 - 11
netbox/extras/tests/test_event_rules.py

@@ -3,17 +3,18 @@ import uuid
 from unittest.mock import patch
 
 import django_rq
-from dcim.choices import SiteStatusChoices
-from dcim.models import Site
-from django.contrib.contenttypes.models import ContentType
 from django.http import HttpResponse
 from django.urls import reverse
+from requests import Session
+from rest_framework import status
+
+from core.models import ObjectType
+from dcim.choices import SiteStatusChoices
+from dcim.models import Site
 from extras.choices import EventRuleActionChoices, ObjectChangeActionChoices
 from extras.events import enqueue_object, flush_events, serialize_for_event
 from extras.models import EventRule, Tag, Webhook
 from extras.webhooks import generate_signature, send_webhook
-from requests import Session
-from rest_framework import status
 from utilities.testing import APITestCase
 
 
@@ -29,7 +30,7 @@ class EventRuleTest(APITestCase):
     @classmethod
     def setUpTestData(cls):
 
-        site_ct = ContentType.objects.get_for_model(Site)
+        site_type = ObjectType.objects.get_for_model(Site)
         DUMMY_URL = 'http://localhost:9000/'
         DUMMY_SECRET = 'LOOKATMEIMASECRETSTRING'
 
@@ -39,32 +40,32 @@ class EventRuleTest(APITestCase):
             Webhook(name='Webhook 3', payload_url=DUMMY_URL, secret=DUMMY_SECRET),
         ))
 
-        ct = ContentType.objects.get(app_label='extras', model='webhook')
+        webhook_type = ObjectType.objects.get(app_label='extras', model='webhook')
         event_rules = EventRule.objects.bulk_create((
             EventRule(
                 name='Webhook Event 1',
                 type_create=True,
                 action_type=EventRuleActionChoices.WEBHOOK,
-                action_object_type=ct,
+                action_object_type=webhook_type,
                 action_object_id=webhooks[0].id
             ),
             EventRule(
                 name='Webhook Event 2',
                 type_update=True,
                 action_type=EventRuleActionChoices.WEBHOOK,
-                action_object_type=ct,
+                action_object_type=webhook_type,
                 action_object_id=webhooks[0].id
             ),
             EventRule(
                 name='Webhook Event 3',
                 type_delete=True,
                 action_type=EventRuleActionChoices.WEBHOOK,
-                action_object_type=ct,
+                action_object_type=webhook_type,
                 action_object_id=webhooks[0].id
             ),
         ))
         for event_rule in event_rules:
-            event_rule.content_types.set([site_ct])
+            event_rule.object_types.set([site_type])
 
         Tag.objects.bulk_create((
             Tag(name='Foo', slug='foo'),

+ 48 - 49
netbox/extras/tests/test_filtersets.py

@@ -7,6 +7,7 @@ from django.test import TestCase
 
 from circuits.models import Provider
 from core.choices import ManagedFileRootPathChoices
+from core.models import ObjectType
 from dcim.filtersets import SiteFilterSet
 from dcim.models import DeviceRole, DeviceType, Manufacturer, Platform, Rack, Region, Site, SiteGroup
 from dcim.models import Location
@@ -87,11 +88,11 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests):
             ),
         )
         CustomField.objects.bulk_create(custom_fields)
-        custom_fields[0].content_types.add(ContentType.objects.get_by_natural_key('dcim', 'site'))
-        custom_fields[1].content_types.add(ContentType.objects.get_by_natural_key('dcim', 'rack'))
-        custom_fields[2].content_types.add(ContentType.objects.get_by_natural_key('dcim', 'device'))
-        custom_fields[3].content_types.add(ContentType.objects.get_by_natural_key('dcim', 'device'))
-        custom_fields[4].content_types.add(ContentType.objects.get_by_natural_key('dcim', 'device'))
+        custom_fields[0].object_types.add(ObjectType.objects.get_by_natural_key('dcim', 'site'))
+        custom_fields[1].object_types.add(ObjectType.objects.get_by_natural_key('dcim', 'rack'))
+        custom_fields[2].object_types.add(ObjectType.objects.get_by_natural_key('dcim', 'device'))
+        custom_fields[3].object_types.add(ObjectType.objects.get_by_natural_key('dcim', 'device'))
+        custom_fields[4].object_types.add(ObjectType.objects.get_by_natural_key('dcim', 'device'))
 
     def test_q(self):
         params = {'q': 'foobar1'}
@@ -101,10 +102,10 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests):
         params = {'name': ['Custom Field 1', 'Custom Field 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
-    def test_content_types(self):
-        params = {'content_types': 'dcim.site'}
+    def test_object_type(self):
+        params = {'object_type': 'dcim.site'}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
-        params = {'content_type_id': [ContentType.objects.get_by_natural_key('dcim', 'site').pk]}
+        params = {'object_type_id': [ObjectType.objects.get_by_natural_key('dcim', 'site').pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
     def test_required(self):
@@ -174,8 +175,6 @@ class WebhookTestCase(TestCase, BaseFilterSetTests):
 
     @classmethod
     def setUpTestData(cls):
-        content_types = ContentType.objects.filter(model__in=['region', 'site', 'rack', 'location', 'device'])
-
         webhooks = (
             Webhook(
                 name='Webhook 1',
@@ -240,7 +239,7 @@ class EventRuleTestCase(TestCase, BaseFilterSetTests):
 
     @classmethod
     def setUpTestData(cls):
-        content_types = ContentType.objects.filter(
+        object_types = ObjectType.objects.filter(
             model__in=['region', 'site', 'rack', 'location', 'device']
         )
 
@@ -333,11 +332,11 @@ class EventRuleTestCase(TestCase, BaseFilterSetTests):
             ),
         )
         EventRule.objects.bulk_create(event_rules)
-        event_rules[0].content_types.add(content_types[0])
-        event_rules[1].content_types.add(content_types[1])
-        event_rules[2].content_types.add(content_types[2])
-        event_rules[3].content_types.add(content_types[3])
-        event_rules[4].content_types.add(content_types[4])
+        event_rules[0].object_types.add(object_types[0])
+        event_rules[1].object_types.add(object_types[1])
+        event_rules[2].object_types.add(object_types[2])
+        event_rules[3].object_types.add(object_types[3])
+        event_rules[4].object_types.add(object_types[4])
 
     def test_q(self):
         params = {'q': 'foobar1'}
@@ -351,10 +350,10 @@ class EventRuleTestCase(TestCase, BaseFilterSetTests):
         params = {'description': ['foobar1', 'foobar2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
-    def test_content_types(self):
-        params = {'content_types': 'dcim.region'}
+    def test_object_type(self):
+        params = {'object_type': 'dcim.region'}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
-        params = {'content_type_id': [ContentType.objects.get_for_model(Region).pk]}
+        params = {'object_type_id': [ObjectType.objects.get_for_model(Region).pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
     def test_action_type(self):
@@ -396,7 +395,7 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests):
 
     @classmethod
     def setUpTestData(cls):
-        content_types = ContentType.objects.filter(model__in=['site', 'rack', 'device'])
+        object_types = ObjectType.objects.filter(model__in=['site', 'rack', 'device'])
 
         custom_links = (
             CustomLink(
@@ -426,7 +425,7 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests):
         )
         CustomLink.objects.bulk_create(custom_links)
         for i, custom_link in enumerate(custom_links):
-            custom_link.content_types.set([content_types[i]])
+            custom_link.object_types.set([object_types[i]])
 
     def test_q(self):
         params = {'q': 'Custom Link 1'}
@@ -436,10 +435,10 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests):
         params = {'name': ['Custom Link 1', 'Custom Link 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
-    def test_content_types(self):
-        params = {'content_types': 'dcim.site'}
+    def test_object_type(self):
+        params = {'object_type': 'dcim.site'}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
-        params = {'content_type_id': [ContentType.objects.get_for_model(Site).pk]}
+        params = {'object_type_id': [ObjectType.objects.get_for_model(Site).pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
     def test_weight(self):
@@ -465,7 +464,7 @@ class SavedFilterTestCase(TestCase, BaseFilterSetTests):
 
     @classmethod
     def setUpTestData(cls):
-        content_types = ContentType.objects.filter(model__in=['site', 'rack', 'device'])
+        object_types = ObjectType.objects.filter(model__in=['site', 'rack', 'device'])
 
         users = (
             User(username='User 1'),
@@ -508,7 +507,7 @@ class SavedFilterTestCase(TestCase, BaseFilterSetTests):
         )
         SavedFilter.objects.bulk_create(saved_filters)
         for i, savedfilter in enumerate(saved_filters):
-            savedfilter.content_types.set([content_types[i]])
+            savedfilter.object_types.set([object_types[i]])
 
     def test_q(self):
         params = {'q': 'foobar1'}
@@ -526,10 +525,10 @@ class SavedFilterTestCase(TestCase, BaseFilterSetTests):
         params = {'description': ['foobar1', 'foobar2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
-    def test_content_types(self):
-        params = {'content_types': 'dcim.site'}
+    def test_object_type(self):
+        params = {'object_type': 'dcim.site'}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
-        params = {'content_type_id': [ContentType.objects.get_for_model(Site).pk]}
+        params = {'object_type_id': [ObjectType.objects.get_for_model(Site).pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
     def test_user(self):
@@ -638,7 +637,7 @@ class ExportTemplateTestCase(TestCase, BaseFilterSetTests):
 
     @classmethod
     def setUpTestData(cls):
-        content_types = ContentType.objects.filter(model__in=['site', 'rack', 'device'])
+        object_types = ObjectType.objects.filter(model__in=['site', 'rack', 'device'])
 
         export_templates = (
             ExportTemplate(name='Export Template 1', template_code='TESTING', description='foobar1'),
@@ -647,7 +646,7 @@ class ExportTemplateTestCase(TestCase, BaseFilterSetTests):
         )
         ExportTemplate.objects.bulk_create(export_templates)
         for i, et in enumerate(export_templates):
-            et.content_types.set([content_types[i]])
+            et.object_types.set([object_types[i]])
 
     def test_q(self):
         params = {'q': 'foobar1'}
@@ -657,10 +656,10 @@ class ExportTemplateTestCase(TestCase, BaseFilterSetTests):
         params = {'name': ['Export Template 1', 'Export Template 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
-    def test_content_types(self):
-        params = {'content_types': 'dcim.site'}
+    def test_object_type(self):
+        params = {'object_type': 'dcim.site'}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
-        params = {'content_type_id': [ContentType.objects.get_for_model(Site).pk]}
+        params = {'object_type_id': [ObjectType.objects.get_for_model(Site).pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
     def test_description(self):
@@ -692,7 +691,7 @@ class ImageAttachmentTestCase(TestCase, BaseFilterSetTests):
 
         image_attachments = (
             ImageAttachment(
-                content_type=site_ct,
+                object_type=site_ct,
                 object_id=sites[0].pk,
                 name='Image Attachment 1',
                 image='http://example.com/image1.png',
@@ -700,7 +699,7 @@ class ImageAttachmentTestCase(TestCase, BaseFilterSetTests):
                 image_width=100
             ),
             ImageAttachment(
-                content_type=site_ct,
+                object_type=site_ct,
                 object_id=sites[1].pk,
                 name='Image Attachment 2',
                 image='http://example.com/image2.png',
@@ -708,7 +707,7 @@ class ImageAttachmentTestCase(TestCase, BaseFilterSetTests):
                 image_width=100
             ),
             ImageAttachment(
-                content_type=rack_ct,
+                object_type=rack_ct,
                 object_id=racks[0].pk,
                 name='Image Attachment 3',
                 image='http://example.com/image3.png',
@@ -716,7 +715,7 @@ class ImageAttachmentTestCase(TestCase, BaseFilterSetTests):
                 image_width=100
             ),
             ImageAttachment(
-                content_type=rack_ct,
+                object_type=rack_ct,
                 object_id=racks[1].pk,
                 name='Image Attachment 4',
                 image='http://example.com/image4.png',
@@ -734,13 +733,13 @@ class ImageAttachmentTestCase(TestCase, BaseFilterSetTests):
         params = {'name': ['Image Attachment 1', 'Image Attachment 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
-    def test_content_type(self):
-        params = {'content_type': 'dcim.site'}
+    def test_object_type(self):
+        params = {'object_type': 'dcim.site'}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
-    def test_content_type_id_and_object_id(self):
+    def test_object_type_id_and_object_id(self):
         params = {
-            'content_type_id': ContentType.objects.get(app_label='dcim', model='site').pk,
+            'object_type_id': ContentType.objects.get(app_label='dcim', model='site').pk,
             'object_id': [Site.objects.first().pk],
         }
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
@@ -1113,9 +1112,9 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
 
     @classmethod
     def setUpTestData(cls):
-        content_types = {
-            'site': ContentType.objects.get_by_natural_key('dcim', 'site'),
-            'provider': ContentType.objects.get_by_natural_key('circuits', 'provider'),
+        object_types = {
+            'site': ObjectType.objects.get_by_natural_key('dcim', 'site'),
+            'provider': ObjectType.objects.get_by_natural_key('circuits', 'provider'),
         }
 
         tags = (
@@ -1124,8 +1123,8 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
             Tag(name='Tag 3', slug='tag-3', color='0000ff'),
         )
         Tag.objects.bulk_create(tags)
-        tags[0].object_types.add(content_types['site'])
-        tags[1].object_types.add(content_types['provider'])
+        tags[0].object_types.add(object_types['site'])
+        tags[1].object_types.add(object_types['provider'])
 
         # Apply some tags so we can filter by content type
         site = Site.objects.create(name='Site 1', slug='site-1')
@@ -1163,12 +1162,12 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
     def test_object_types(self):
-        params = {'for_object_type_id': [ContentType.objects.get_by_natural_key('dcim', 'site').pk]}
+        params = {'for_object_type_id': [ObjectType.objects.get_by_natural_key('dcim', 'site').pk]}
         self.assertEqual(
             list(self.filterset(params, self.queryset).qs.values_list('name', flat=True)),
             ['Tag 1', 'Tag 3']
         )
-        params = {'for_object_type_id': [ContentType.objects.get_by_natural_key('circuits', 'provider').pk]}
+        params = {'for_object_type_id': [ObjectType.objects.get_by_natural_key('circuits', 'provider').pk]}
         self.assertEqual(
             list(self.filterset(params, self.queryset).qs.values_list('name', flat=True)),
             ['Tag 2', 'Tag 3']

+ 18 - 18
netbox/extras/tests/test_forms.py

@@ -1,6 +1,6 @@
-from django.contrib.contenttypes.models import ContentType
 from django.test import TestCase
 
+from core.models import ObjectType
 from dcim.forms import SiteForm
 from dcim.models import Site
 from extras.choices import CustomFieldTypeChoices
@@ -12,66 +12,66 @@ class CustomFieldModelFormTest(TestCase):
 
     @classmethod
     def setUpTestData(cls):
-        obj_type = ContentType.objects.get_for_model(Site)
+        object_type = ObjectType.objects.get_for_model(Site)
         choice_set = CustomFieldChoiceSet.objects.create(
             name='Choice Set 1',
             extra_choices=(('a', 'A'), ('b', 'B'), ('c', 'C'))
         )
 
         cf_text = CustomField.objects.create(name='text', type=CustomFieldTypeChoices.TYPE_TEXT)
-        cf_text.content_types.set([obj_type])
+        cf_text.object_types.set([object_type])
 
         cf_longtext = CustomField.objects.create(name='longtext', type=CustomFieldTypeChoices.TYPE_LONGTEXT)
-        cf_longtext.content_types.set([obj_type])
+        cf_longtext.object_types.set([object_type])
 
         cf_integer = CustomField.objects.create(name='integer', type=CustomFieldTypeChoices.TYPE_INTEGER)
-        cf_integer.content_types.set([obj_type])
+        cf_integer.object_types.set([object_type])
 
         cf_integer = CustomField.objects.create(name='decimal', type=CustomFieldTypeChoices.TYPE_DECIMAL)
-        cf_integer.content_types.set([obj_type])
+        cf_integer.object_types.set([object_type])
 
         cf_boolean = CustomField.objects.create(name='boolean', type=CustomFieldTypeChoices.TYPE_BOOLEAN)
-        cf_boolean.content_types.set([obj_type])
+        cf_boolean.object_types.set([object_type])
 
         cf_date = CustomField.objects.create(name='date', type=CustomFieldTypeChoices.TYPE_DATE)
-        cf_date.content_types.set([obj_type])
+        cf_date.object_types.set([object_type])
 
         cf_datetime = CustomField.objects.create(name='datetime', type=CustomFieldTypeChoices.TYPE_DATETIME)
-        cf_datetime.content_types.set([obj_type])
+        cf_datetime.object_types.set([object_type])
 
         cf_url = CustomField.objects.create(name='url', type=CustomFieldTypeChoices.TYPE_URL)
-        cf_url.content_types.set([obj_type])
+        cf_url.object_types.set([object_type])
 
         cf_json = CustomField.objects.create(name='json', type=CustomFieldTypeChoices.TYPE_JSON)
-        cf_json.content_types.set([obj_type])
+        cf_json.object_types.set([object_type])
 
         cf_select = CustomField.objects.create(
             name='select',
             type=CustomFieldTypeChoices.TYPE_SELECT,
             choice_set=choice_set
         )
-        cf_select.content_types.set([obj_type])
+        cf_select.object_types.set([object_type])
 
         cf_multiselect = CustomField.objects.create(
             name='multiselect',
             type=CustomFieldTypeChoices.TYPE_MULTISELECT,
             choice_set=choice_set
         )
-        cf_multiselect.content_types.set([obj_type])
+        cf_multiselect.object_types.set([object_type])
 
         cf_object = CustomField.objects.create(
             name='object',
             type=CustomFieldTypeChoices.TYPE_OBJECT,
-            object_type=ContentType.objects.get_for_model(Site)
+            object_type=ObjectType.objects.get_for_model(Site)
         )
-        cf_object.content_types.set([obj_type])
+        cf_object.object_types.set([object_type])
 
         cf_multiobject = CustomField.objects.create(
             name='multiobject',
             type=CustomFieldTypeChoices.TYPE_MULTIOBJECT,
-            object_type=ContentType.objects.get_for_model(Site)
+            object_type=ObjectType.objects.get_for_model(Site)
         )
-        cf_multiobject.content_types.set([obj_type])
+        cf_multiobject.object_types.set([object_type])
 
     def test_empty_values(self):
         """
@@ -99,7 +99,7 @@ class SavedFilterFormTest(TestCase):
         form = SavedFilterForm({
             'name': 'test-sf',
             'slug': 'test-sf',
-            'content_types': [ContentType.objects.get_for_model(Site).pk],
+            'object_types': [ObjectType.objects.get_for_model(Site).pk],
             'weight': 100,
             'parameters': {
                 "status": [

+ 2 - 2
netbox/extras/tests/test_models.py

@@ -1,6 +1,6 @@
-from django.contrib.contenttypes.models import ContentType
 from django.test import TestCase
 
+from core.models import ObjectType
 from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Platform, Region, Site, SiteGroup
 from extras.models import ConfigContext, Tag
 from tenancy.models import Tenant, TenantGroup
@@ -22,7 +22,7 @@ class TagTest(TestCase):
 
         # Create a Tag that can only be applied to Regions
         tag = Tag.objects.create(name='Tag 1', slug='tag-1')
-        tag.object_types.add(ContentType.objects.get_by_natural_key('dcim', 'region'))
+        tag.object_types.add(ObjectType.objects.get_by_natural_key('dcim', 'region'))
 
         # Apply the Tag to a Region
         region.tags.add(tag)

+ 22 - 21
netbox/extras/tests/test_views.py

@@ -5,6 +5,7 @@ from django.contrib.auth import get_user_model
 from django.contrib.contenttypes.models import ContentType
 from django.urls import reverse
 
+from core.models import ObjectType
 from dcim.models import DeviceType, Manufacturer, Site
 from extras.choices import *
 from extras.models import *
@@ -19,7 +20,7 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     @classmethod
     def setUpTestData(cls):
 
-        site_ct = ContentType.objects.get_for_model(Site)
+        site_type = ObjectType.objects.get_for_model(Site)
         CustomFieldChoiceSet.objects.create(
             name='Choice Set 1',
             extra_choices=(
@@ -36,13 +37,13 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         )
         for customfield in custom_fields:
             customfield.save()
-            customfield.content_types.add(site_ct)
+            customfield.object_types.add(site_type)
 
         cls.form_data = {
             'name': 'field_x',
             'label': 'Field X',
             'type': 'text',
-            'content_types': [site_ct.pk],
+            'object_types': [site_type.pk],
             'search_weight': 2000,
             'filter_logic': CustomFieldFilterLogicChoices.FILTER_EXACT,
             'default': None,
@@ -53,7 +54,7 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         }
 
         cls.csv_data = (
-            'name,label,type,content_types,object_type,weight,search_weight,filter_logic,choice_set,validation_minimum,validation_maximum,validation_regex,ui_visible,ui_editable',
+            'name,label,type,object_types,object_type,weight,search_weight,filter_logic,choice_set,validation_minimum,validation_maximum,validation_regex,ui_visible,ui_editable',
             'field4,Field 4,text,dcim.site,,100,1000,exact,,,,[a-z]{3},always,yes',
             'field5,Field 5,integer,dcim.site,,100,2000,exact,,1,100,,always,yes',
             'field6,Field 6,select,dcim.site,,100,3000,exact,Choice Set 1,,,,always,yes',
@@ -137,7 +138,7 @@ class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 
     @classmethod
     def setUpTestData(cls):
-        site_ct = ContentType.objects.get_for_model(Site)
+        site_type = ObjectType.objects.get_for_model(Site)
         custom_links = (
             CustomLink(name='Custom Link 1', enabled=True, link_text='Link 1', link_url='http://example.com/?1'),
             CustomLink(name='Custom Link 2', enabled=True, link_text='Link 2', link_url='http://example.com/?2'),
@@ -145,11 +146,11 @@ class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         )
         CustomLink.objects.bulk_create(custom_links)
         for i, custom_link in enumerate(custom_links):
-            custom_link.content_types.set([site_ct])
+            custom_link.object_types.set([site_type])
 
         cls.form_data = {
             'name': 'Custom Link X',
-            'content_types': [site_ct.pk],
+            'object_types': [site_type.pk],
             'enabled': False,
             'weight': 100,
             'button_class': CustomLinkButtonClassChoices.DEFAULT,
@@ -158,7 +159,7 @@ class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         }
 
         cls.csv_data = (
-            "name,content_types,enabled,weight,button_class,link_text,link_url",
+            "name,object_types,enabled,weight,button_class,link_text,link_url",
             "Custom Link 4,dcim.site,True,100,blue,Link 4,http://exmaple.com/?4",
             "Custom Link 5,dcim.site,True,100,blue,Link 5,http://exmaple.com/?5",
             "Custom Link 6,dcim.site,False,100,blue,Link 6,http://exmaple.com/?6",
@@ -183,7 +184,7 @@ class SavedFilterTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 
     @classmethod
     def setUpTestData(cls):
-        site_ct = ContentType.objects.get_for_model(Site)
+        site_type = ObjectType.objects.get_for_model(Site)
 
         users = (
             User(username='User 1'),
@@ -217,12 +218,12 @@ class SavedFilterTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         )
         SavedFilter.objects.bulk_create(saved_filters)
         for i, savedfilter in enumerate(saved_filters):
-            savedfilter.content_types.set([site_ct])
+            savedfilter.object_types.set([site_type])
 
         cls.form_data = {
             'name': 'Saved Filter X',
             'slug': 'saved-filter-x',
-            'content_types': [site_ct.pk],
+            'object_types': [site_type.pk],
             'description': 'Foo',
             'weight': 1000,
             'enabled': True,
@@ -231,7 +232,7 @@ class SavedFilterTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         }
 
         cls.csv_data = (
-            'name,slug,content_types,weight,enabled,shared,parameters',
+            'name,slug,object_types,weight,enabled,shared,parameters',
             'Saved Filter 4,saved-filter-4,dcim.device,400,True,True,{"foo": "a"}',
             'Saved Filter 5,saved-filter-5,dcim.device,500,True,True,{"foo": "b"}',
             'Saved Filter 6,saved-filter-6,dcim.device,600,True,True,{"foo": "c"}',
@@ -302,7 +303,7 @@ class ExportTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 
     @classmethod
     def setUpTestData(cls):
-        site_ct = ContentType.objects.get_for_model(Site)
+        site_type = ObjectType.objects.get_for_model(Site)
         TEMPLATE_CODE = """{% for object in queryset %}{{ object }}{% endfor %}"""
 
         export_templates = (
@@ -312,16 +313,16 @@ class ExportTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         )
         ExportTemplate.objects.bulk_create(export_templates)
         for et in export_templates:
-            et.content_types.set([site_ct])
+            et.object_types.set([site_type])
 
         cls.form_data = {
             'name': 'Export Template X',
-            'content_types': [site_ct.pk],
+            'object_types': [site_type.pk],
             'template_code': TEMPLATE_CODE,
         }
 
         cls.csv_data = (
-            "name,content_types,template_code",
+            "name,object_types,template_code",
             f"Export Template 4,dcim.site,{TEMPLATE_CODE}",
             f"Export Template 5,dcim.site,{TEMPLATE_CODE}",
             f"Export Template 6,dcim.site,{TEMPLATE_CODE}",
@@ -396,7 +397,7 @@ class EventRulesTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         for webhook in webhooks:
             webhook.save()
 
-        site_ct = ContentType.objects.get_for_model(Site)
+        site_type = ObjectType.objects.get_for_model(Site)
         event_rules = (
             EventRule(name='EventRule 1', type_create=True, action_object=webhooks[0]),
             EventRule(name='EventRule 2', type_create=True, action_object=webhooks[1]),
@@ -404,12 +405,12 @@ class EventRulesTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         )
         for event in event_rules:
             event.save()
-            event.content_types.add(site_ct)
+            event.object_types.add(site_type)
 
         webhook_ct = ContentType.objects.get_for_model(Webhook)
         cls.form_data = {
             'name': 'Event X',
-            'content_types': [site_ct.pk],
+            'object_types': [site_type.pk],
             'type_create': False,
             'type_update': True,
             'type_delete': True,
@@ -422,7 +423,7 @@ class EventRulesTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         }
 
         cls.csv_data = (
-            "name,content_types,type_create,action_type,action_object",
+            "name,object_types,type_create,action_type,action_object",
             "Webhook 4,dcim.site,True,webhook,Webhook 1",
         )
 
@@ -651,7 +652,7 @@ class CustomLinkTest(TestCase):
             new_window=False
         )
         customlink.save()
-        customlink.content_types.set([ContentType.objects.get_for_model(Site)])
+        customlink.object_types.set([ObjectType.objects.get_for_model(Site)])
 
         site = Site(name='Test Site', slug='test-site')
         site.save()

+ 1 - 1
netbox/extras/utils.py

@@ -24,7 +24,7 @@ def image_upload(instance, filename):
     elif instance.name:
         filename = instance.name
 
-    return '{}{}_{}_{}'.format(path, instance.content_type.name, instance.object_id, filename)
+    return '{}{}_{}_{}'.format(path, instance.object_type.name, instance.object_id, filename)
 
 
 def is_script(obj):

+ 5 - 5
netbox/extras/views.py

@@ -46,9 +46,9 @@ class CustomFieldView(generic.ObjectView):
     def get_extra_context(self, request, instance):
         related_models = ()
 
-        for content_type in instance.content_types.all():
+        for object_type in instance.object_types.all():
             related_models += (
-                content_type.model_class().objects.restrict(request.user, 'view').exclude(
+                object_type.model_class().objects.restrict(request.user, 'view').exclude(
                     Q(**{f'custom_field_data__{instance.name}': ''}) |
                     Q(**{f'custom_field_data__{instance.name}': None})
                 ),
@@ -762,8 +762,8 @@ class ImageAttachmentEditView(generic.ObjectEditView):
     def alter_object(self, instance, request, args, kwargs):
         if not instance.pk:
             # Assign the parent object based on URL kwargs
-            content_type = get_object_or_404(ContentType, pk=request.GET.get('content_type'))
-            instance.parent = get_object_or_404(content_type.model_class(), pk=request.GET.get('object_id'))
+            object_type = get_object_or_404(ContentType, pk=request.GET.get('object_type'))
+            instance.parent = get_object_or_404(object_type.model_class(), pk=request.GET.get('object_id'))
         return instance
 
     def get_return_url(self, request, obj=None):
@@ -771,7 +771,7 @@ class ImageAttachmentEditView(generic.ObjectEditView):
 
     def get_extra_addanother_params(self, request):
         return {
-            'content_type': request.GET.get('content_type'),
+            'object_type': request.GET.get('object_type'),
             'object_id': request.GET.get('object_id'),
         }
 

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

@@ -8,7 +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 core.models import ObjectType
 from ipam.choices import *
 from ipam.constants import *
 from ipam.fields import IPNetworkField, IPAddressField
@@ -861,7 +861,7 @@ class IPAddress(PrimaryModel):
 
         if self._original_assigned_object_id and self._original_assigned_object_type_id:
             parent = getattr(self.assigned_object, 'parent_object', None)
-            ct = ContentType.objects.get_for_id(self._original_assigned_object_type_id)
+            ct = ObjectType.objects.get_for_id(self._original_assigned_object_type_id)
             original_assigned_object = ct.get_object_for_this_type(pk=self._original_assigned_object_id)
             original_parent = getattr(original_assigned_object, 'parent_object', None)
 

+ 0 - 2
netbox/netbox/api/serializers/features.py

@@ -1,9 +1,7 @@
-from django.contrib.contenttypes.models import ContentType
 from rest_framework import serializers
 from rest_framework.fields import CreateOnlyDefault
 
 from extras.api.customfields import CustomFieldsDataField, CustomFieldDefaultValues
-from extras.models import CustomField
 from .nested import NestedTagSerializer
 
 __all__ = (

+ 5 - 5
netbox/netbox/api/viewsets/mixins.py

@@ -1,10 +1,10 @@
-from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ObjectDoesNotExist
 from django.db import transaction
 from django.http import Http404
 from rest_framework import status
 from rest_framework.response import Response
 
+from core.models import ObjectType
 from extras.models import ExportTemplate
 from netbox.api.serializers import BulkOperationSerializer
 
@@ -26,9 +26,9 @@ class CustomFieldsMixin:
         context = super().get_serializer_context()
 
         if hasattr(self.queryset.model, 'custom_fields'):
-            content_type = ContentType.objects.get_for_model(self.queryset.model)
+            object_type = ObjectType.objects.get_for_model(self.queryset.model)
             context.update({
-                'custom_fields': content_type.custom_fields.all(),
+                'custom_fields': object_type.custom_fields.all(),
             })
 
         return context
@@ -40,8 +40,8 @@ class ExportTemplatesMixin:
     """
     def list(self, request, *args, **kwargs):
         if 'export' in request.GET:
-            content_type = ContentType.objects.get_for_model(self.get_serializer_class().Meta.model)
-            et = ExportTemplate.objects.filter(content_types=content_type, name=request.GET['export']).first()
+            object_type = ObjectType.objects.get_for_model(self.get_serializer_class().Meta.model)
+            et = ExportTemplate.objects.filter(object_types=object_type, name=request.GET['export']).first()
             if et is None:
                 raise Http404
             queryset = self.filter_queryset(self.get_queryset())

+ 1 - 1
netbox/netbox/filtersets.py

@@ -281,7 +281,7 @@ class NetBoxModelFilterSet(ChangeLoggedModelFilterSet):
 
         # Dynamically add a Filter for each CustomField applicable to the parent model
         custom_fields = CustomField.objects.filter(
-            content_types=ContentType.objects.get_for_model(self._meta.model)
+            object_types=ContentType.objects.get_for_model(self._meta.model)
         ).exclude(
             filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED
         )

+ 7 - 6
netbox/netbox/forms/base.py

@@ -3,6 +3,7 @@ from django.contrib.contenttypes.models import ContentType
 from django.db.models import Q
 from django.utils.translation import gettext_lazy as _
 
+from core.models import ObjectType
 from extras.choices import *
 from extras.models import CustomField, Tag
 from utilities.forms import CSVModelForm
@@ -88,7 +89,7 @@ class NetBoxModelImportForm(CSVModelForm, NetBoxModelForm):
 
     def _get_custom_fields(self, content_type):
         return CustomField.objects.filter(
-            content_types=content_type,
+            object_types=content_type,
             ui_editable=CustomFieldUIEditableChoices.YES
         )
 
@@ -129,9 +130,9 @@ class NetBoxModelBulkEditForm(CustomFieldsMixin, forms.Form):
         self.fields['pk'].queryset = self.model.objects.all()
 
         # Restrict tag fields by model
-        ct = ContentType.objects.get_for_model(self.model)
-        self.fields['add_tags'].widget.add_query_param('for_object_type_id', ct.pk)
-        self.fields['remove_tags'].widget.add_query_param('for_object_type_id', ct.pk)
+        object_type = ObjectType.objects.get_for_model(self.model)
+        self.fields['add_tags'].widget.add_query_param('for_object_type_id', object_type.pk)
+        self.fields['remove_tags'].widget.add_query_param('for_object_type_id', object_type.pk)
 
         self._extend_nullable_fields()
 
@@ -169,9 +170,9 @@ class NetBoxModelFilterSetForm(CustomFieldsMixin, SavedFiltersMixin, forms.Form)
         super().__init__(*args, **kwargs)
 
         # Limit saved filters to those applicable to the form's model
-        content_type = ContentType.objects.get_for_model(self.model)
+        object_type = ObjectType.objects.get_for_model(self.model)
         self.fields['filter_id'].widget.add_query_params({
-            'content_type_id': content_type.pk,
+            'object_types_id': object_type.pk,
         })
 
     def _get_custom_fields(self, content_type):

+ 7 - 7
netbox/netbox/forms/mixins.py

@@ -1,7 +1,7 @@
 from django import forms
-from django.contrib.contenttypes.models import ContentType
 from django.utils.translation import gettext as _
 
+from core.models import ObjectType
 from extras.choices import *
 from extras.models import *
 from utilities.forms.fields import DynamicModelMultipleChoiceField
@@ -32,16 +32,16 @@ class CustomFieldsMixin:
 
     def _get_content_type(self):
         """
-        Return the ContentType of the form's model.
+        Return the ObjectType of the form's model.
         """
         if not getattr(self, 'model', None):
             raise NotImplementedError(_("{class_name} must specify a model class.").format(
                 class_name=self.__class__.__name__
             ))
-        return ContentType.objects.get_for_model(self.model)
+        return ObjectType.objects.get_for_model(self.model)
 
     def _get_custom_fields(self, content_type):
-        return CustomField.objects.filter(content_types=content_type).exclude(
+        return CustomField.objects.filter(object_types=content_type).exclude(
             ui_editable=CustomFieldUIEditableChoices.HIDDEN
         )
 
@@ -85,6 +85,6 @@ class TagsMixin(forms.Form):
         super().__init__(*args, **kwargs)
 
         # Limit tags to those applicable to the object type
-        content_type = ContentType.objects.get_for_model(self._meta.model)
-        if content_type and hasattr(self.fields['tags'].widget, 'add_query_param'):
-            self.fields['tags'].widget.add_query_param('for_object_type_id', content_type.pk)
+        object_type = ObjectType.objects.get_for_model(self._meta.model)
+        if object_type and hasattr(self.fields['tags'].widget, 'add_query_param'):
+            self.fields['tags'].widget.add_query_param('for_object_type_id', object_type.pk)

+ 10 - 0
netbox/netbox/graphql/types.py

@@ -1,5 +1,6 @@
 import graphene
 
+from core.models import ObjectType as ObjectType_
 from django.contrib.contenttypes.models import ContentType
 from extras.graphql.mixins import (
     ChangelogMixin,
@@ -11,7 +12,9 @@ from graphene_django import DjangoObjectType
 
 __all__ = (
     'BaseObjectType',
+    'ContentTypeType',
     'ObjectType',
+    'ObjectTypeType',
     'OrganizationalObjectType',
     'NetBoxObjectType',
 )
@@ -90,3 +93,10 @@ class ContentTypeType(DjangoObjectType):
     class Meta:
         model = ContentType
         fields = ('id', 'app_label', 'model')
+
+
+class ObjectTypeType(DjangoObjectType):
+
+    class Meta:
+        model = ObjectType_
+        fields = ('id', 'app_label', 'model')

+ 12 - 8
netbox/netbox/models/features.py

@@ -10,7 +10,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 core.models import ObjectType
 from extras.choices import *
 from extras.utils import is_taggable
 from netbox.config import get_config
@@ -329,7 +329,9 @@ class ImageAttachmentsMixin(models.Model):
     Enables the assignments of ImageAttachments.
     """
     images = GenericRelation(
-        to='extras.ImageAttachment'
+        to='extras.ImageAttachment',
+        content_type_field='object_type',
+        object_id_field='object_id'
     )
 
     class Meta:
@@ -341,7 +343,9 @@ class ContactsMixin(models.Model):
     Enables the assignments of Contacts (via ContactAssignment).
     """
     contacts = GenericRelation(
-        to='tenancy.ContactAssignment'
+        to='tenancy.ContactAssignment',
+        content_type_field='object_type',
+        object_id_field='object_id'
     )
 
     class Meta:
@@ -490,17 +494,17 @@ class SyncedDataMixin(models.Model):
         ret = super().save(*args, **kwargs)
 
         # Create/delete AutoSyncRecord as needed
-        content_type = ContentType.objects.get_for_model(self)
+        object_type = ObjectType.objects.get_for_model(self)
         if self.auto_sync_enabled:
             AutoSyncRecord.objects.update_or_create(
-                object_type=content_type,
+                object_type=object_type,
                 object_id=self.pk,
                 defaults={'datafile': self.data_file}
             )
         else:
             AutoSyncRecord.objects.filter(
                 datafile=self.data_file,
-                object_type=content_type,
+                object_type=object_type,
                 object_id=self.pk
             ).delete()
 
@@ -510,10 +514,10 @@ class SyncedDataMixin(models.Model):
         from core.models import AutoSyncRecord
 
         # Delete AutoSyncRecord
-        content_type = ContentType.objects.get_for_model(self)
+        object_type = ObjectType.objects.get_for_model(self)
         AutoSyncRecord.objects.filter(
             datafile=self.data_file,
-            object_type=content_type,
+            object_type=object_type,
             object_id=self.pk
         ).delete()
 

+ 13 - 12
netbox/netbox/search/backends.py

@@ -11,6 +11,7 @@ from django.utils.module_loading import import_string
 import netaddr
 from netaddr.core import AddrFormatError
 
+from core.models import ObjectType
 from extras.models import CachedValue, CustomField
 from netbox.registry import registry
 from utilities.querysets import RestrictedPrefetch
@@ -130,11 +131,11 @@ class CachedValueSearchBackend(SearchBackend):
             )
         )[:MAX_RESULTS]
 
-        # Gather all ContentTypes present in the search results (used for prefetching related
+        # Gather all ObjectTypes present in the search results (used for prefetching related
         # objects). This must be done before generating the final results list, which returns
         # a RawQuerySet.
-        content_type_ids = set(queryset.values_list('object_type', flat=True))
-        content_types = ContentType.objects.filter(pk__in=content_type_ids)
+        object_type_ids = set(queryset.values_list('object_type', flat=True))
+        object_types = ObjectType.objects.filter(pk__in=object_type_ids)
 
         # Construct a Prefetch to pre-fetch only those related objects for which the
         # user has permission to view.
@@ -151,11 +152,11 @@ class CachedValueSearchBackend(SearchBackend):
             params
         )
 
-        # Iterate through each ContentType represented in the search results and prefetch any
+        # Iterate through each ObjectType represented in the search results and prefetch any
         # related objects necessary to render the prescribed display attributes (display_attrs).
-        for ct in content_types:
-            model = ct.model_class()
-            indexer = registry['search'].get(content_type_identifier(ct))
+        for object_type in object_types:
+            model = object_type.model_class()
+            indexer = registry['search'].get(content_type_identifier(object_type))
             if not (display_attrs := getattr(indexer, 'display_attrs', None)):
                 continue
 
@@ -169,7 +170,7 @@ class CachedValueSearchBackend(SearchBackend):
             # Compile a list of all CachedValues referencing this object type, and prefetch
             # any related objects
             if prefetch_fields:
-                objects = [r for r in results if r.object_type == ct]
+                objects = [r for r in results if r.object_type == object_type]
                 prefetch_related_objects(objects, *prefetch_fields)
 
         # Omit any results pertaining to an object the user does not have permission to view
@@ -182,7 +183,7 @@ class CachedValueSearchBackend(SearchBackend):
         return ret
 
     def cache(self, instances, indexer=None, remove_existing=True):
-        content_type = None
+        object_type = None
         custom_fields = None
 
         # Convert a single instance to an iterable
@@ -204,8 +205,8 @@ class CachedValueSearchBackend(SearchBackend):
                         break
 
                 # Prefetch any associated custom fields
-                content_type = ContentType.objects.get_for_model(indexer.model)
-                custom_fields = CustomField.objects.filter(content_types=content_type).exclude(search_weight=0)
+                object_type = ObjectType.objects.get_for_model(indexer.model)
+                custom_fields = CustomField.objects.filter(object_types=object_type).exclude(search_weight=0)
 
             # Wipe out any previously cached values for the object
             if remove_existing:
@@ -215,7 +216,7 @@ class CachedValueSearchBackend(SearchBackend):
             for field in indexer.to_cache(instance, custom_fields=custom_fields):
                 buffer.append(
                     CachedValue(
-                        object_type=content_type,
+                        object_type=object_type,
                         object_id=instance.pk,
                         field=field.name,
                         type=field.type,

+ 4 - 4
netbox/netbox/tables/tables.py

@@ -3,7 +3,6 @@ from copy import deepcopy
 import django_tables2 as tables
 from django.contrib.auth.models import AnonymousUser
 from django.contrib.contenttypes.fields import GenericForeignKey
-from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import FieldDoesNotExist
 from django.db.models.fields.related import RelatedField
 from django.urls import reverse
@@ -12,6 +11,7 @@ from django.utils.safestring import mark_safe
 from django.utils.translation import gettext_lazy as _
 from django_tables2.data import TableQuerysetData
 
+from core.models import ObjectType
 from extras.choices import *
 from extras.models import CustomField, CustomLink
 from netbox.registry import registry
@@ -201,14 +201,14 @@ class NetBoxTable(BaseTable):
             ])
 
         # Add custom field & custom link columns
-        content_type = ContentType.objects.get_for_model(self._meta.model)
+        object_type = ObjectType.objects.get_for_model(self._meta.model)
         custom_fields = CustomField.objects.filter(
-            content_types=content_type
+            object_types=object_type
         ).exclude(ui_visible=CustomFieldUIVisibleChoices.HIDDEN)
         extra_columns.extend([
             (f'cf_{cf.name}', columns.CustomFieldColumn(cf)) for cf in custom_fields
         ])
-        custom_links = CustomLink.objects.filter(content_types=content_type, enabled=True)
+        custom_links = CustomLink.objects.filter(object_types=object_type, enabled=True)
         extra_columns.extend([
             (f'cl_{cl.name}', columns.CustomLinkColumn(cl)) for cl in custom_links
         ])

+ 6 - 6
netbox/netbox/tests/test_authentication.py

@@ -2,13 +2,13 @@ import datetime
 
 from django.conf import settings
 from django.contrib.auth import get_user_model
-from django.contrib.contenttypes.models import ContentType
 from django.test import Client
 from django.test.utils import override_settings
 from django.urls import reverse
 from netaddr import IPNetwork
 from rest_framework.test import APIClient
 
+from core.models import ObjectType
 from dcim.models import Site
 from ipam.models import Prefix
 from users.models import Group, ObjectPermission, Token
@@ -452,7 +452,7 @@ class ObjectPermissionAPIViewTestCase(TestCase):
         )
         obj_perm.save()
         obj_perm.users.add(self.user)
-        obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix))
+        obj_perm.object_types.add(ObjectType.objects.get_for_model(Prefix))
 
         # Retrieve permitted object
         url = reverse('ipam-api:prefix-detail',
@@ -482,7 +482,7 @@ class ObjectPermissionAPIViewTestCase(TestCase):
         )
         obj_perm.save()
         obj_perm.users.add(self.user)
-        obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix))
+        obj_perm.object_types.add(ObjectType.objects.get_for_model(Prefix))
 
         # Retrieve all objects. Only permitted objects should be returned.
         response = self.client.get(url, **self.header)
@@ -510,7 +510,7 @@ class ObjectPermissionAPIViewTestCase(TestCase):
         )
         obj_perm.save()
         obj_perm.users.add(self.user)
-        obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix))
+        obj_perm.object_types.add(ObjectType.objects.get_for_model(Prefix))
 
         # Attempt to create a non-permitted object
         response = self.client.post(url, data, format='json', **self.header)
@@ -541,7 +541,7 @@ class ObjectPermissionAPIViewTestCase(TestCase):
         )
         obj_perm.save()
         obj_perm.users.add(self.user)
-        obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix))
+        obj_perm.object_types.add(ObjectType.objects.get_for_model(Prefix))
 
         # Attempt to edit a non-permitted object
         data = {'site': self.sites[0].pk}
@@ -581,7 +581,7 @@ class ObjectPermissionAPIViewTestCase(TestCase):
         )
         obj_perm.save()
         obj_perm.users.add(self.user)
-        obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix))
+        obj_perm.object_types.add(ObjectType.objects.get_for_model(Prefix))
 
         # Attempt to delete a non-permitted object
         url = reverse('ipam-api:prefix-detail',

+ 3 - 3
netbox/netbox/tests/test_import.py

@@ -1,6 +1,6 @@
-from django.contrib.contenttypes.models import ContentType
 from django.test import override_settings
 
+from core.models import ObjectType
 from dcim.models import *
 from users.models import ObjectPermission
 from utilities.choices import CSVDelimiterChoices, ImportFormatChoices
@@ -67,7 +67,7 @@ class CSVImportTestCase(ModelViewTestCase):
         obj_perm = ObjectPermission(name='Test permission', actions=['add'])
         obj_perm.save()
         obj_perm.users.add(self.user)
-        obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
+        obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
 
         # Try GET with model-level permission
         self.assertHttpStatus(self.client.get(self._get_url('import')), 200)
@@ -108,7 +108,7 @@ class CSVImportTestCase(ModelViewTestCase):
         obj_perm = ObjectPermission(name='Test permission', actions=['add'])
         obj_perm.save()
         obj_perm.users.add(self.user)
-        obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
+        obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
 
         # Try GET with model-level permission
         self.assertHttpStatus(self.client.get(self._get_url('import')), 200)

+ 6 - 0
netbox/netbox/tests/test_staging.py

@@ -1,9 +1,11 @@
+from django.db.models.signals import post_save
 from django.test import TransactionTestCase
 
 from circuits.models import Provider, Circuit, CircuitType
 from extras.choices import ChangeActionChoices
 from extras.models import Branch, StagedChange, Tag
 from ipam.models import ASN, RIR
+from netbox.search.backends import search_backend
 from netbox.staging import checkout
 from utilities.testing import create_tags
 
@@ -11,6 +13,10 @@ from utilities.testing import create_tags
 class StagingTestCase(TransactionTestCase):
 
     def setUp(self):
+        # Disconnect search backend to avoid issues with cached ObjectTypes being deleted
+        # from the database upon transaction rollback
+        post_save.disconnect(search_backend.caching_handler)
+
         create_tags('Alpha', 'Bravo', 'Charlie')
 
         rir = RIR.objects.create(name='RIR 1', slug='rir-1')

+ 3 - 3
netbox/netbox/views/generic/bulk_views.py

@@ -4,7 +4,6 @@ from copy import deepcopy
 
 from django.contrib import messages
 from django.contrib.contenttypes.fields import GenericRel
-from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist, ValidationError
 from django.db import transaction, IntegrityError
 from django.db.models import ManyToManyField, ProtectedError, RestrictedError
@@ -17,6 +16,7 @@ from django.utils.safestring import mark_safe
 from django.utils.translation import gettext as _
 from django_tables2.export import TableExport
 
+from core.models import ObjectType
 from extras.models import ExportTemplate
 from extras.signals import clear_events
 from utilities.error_handlers import handle_protectederror
@@ -124,7 +124,7 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
             request: The current request
         """
         model = self.queryset.model
-        content_type = ContentType.objects.get_for_model(model)
+        object_type = ObjectType.objects.get_for_model(model)
 
         if self.filterset:
             self.queryset = self.filterset(request.GET, self.queryset, request=request).qs
@@ -143,7 +143,7 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
 
             # Render an ExportTemplate
             elif request.GET['export']:
-                template = get_object_or_404(ExportTemplate, content_types=content_type, name=request.GET['export'])
+                template = get_object_or_404(ExportTemplate, object_types=object_type, name=request.GET['export'])
                 return self.export_template(template, request)
 
             # Check for YAML export support on the model

+ 1 - 1
netbox/templates/extras/customfield.html

@@ -89,7 +89,7 @@
     <div class="card">
       <h5 class="card-header">{% trans "Object Types" %}</h5>
       <table class="table table-hover attr-table">
-        {% for ct in object.content_types.all %}
+        {% for ct in object.object_types.all %}
           <tr>
             <td>{{ ct }}</td>
           </tr>

+ 1 - 1
netbox/templates/extras/customlink.html

@@ -38,7 +38,7 @@
     <div class="card">
       <h5 class="card-header">{% trans "Assigned Models" %}</h5>
       <table class="table table-hover attr-table">
-        {% for ct in object.content_types.all %}
+        {% for ct in object.object_types.all %}
           <tr>
             <td>{{ ct }}</td>
           </tr>

+ 2 - 2
netbox/templates/extras/eventrule.html

@@ -26,9 +26,9 @@
       <div class="card">
         <h5 class="card-header">{% trans "Object Types" %}</h5>
         <table class="table table-hover attr-table">
-          {% for ct in object.content_types.all %}
+          {% for object_type in object.object_types.all %}
             <tr>
-              <td>{{ ct }}</td>
+              <td>{{ object_type }}</td>
             </tr>
           {% endfor %}
         </table>

+ 2 - 7
netbox/templates/extras/exporttemplate.html

@@ -5,11 +5,6 @@
 
 {% block title %}{{ object.name }}{% endblock %}
 
-{% block breadcrumbs %}
-  {{ block.super }}
-  <li class="breadcrumb-item"><a href="{% url 'extras:exporttemplate_list' %}?content_type={{ object.content_type.pk }}">{{ object.content_type }}</a></li>
-{% endblock %}
-
 {% block content %}
   <div class="row mb-3">
     <div class="col col-md-5">
@@ -70,9 +65,9 @@
       <div class="card">
         <h5 class="card-header">{% trans "Assigned Models" %}</h5>
         <table class="table table-hover attr-table">
-          {% for ct in object.content_types.all %}
+          {% for object_type in object.object_types.all %}
             <tr>
-              <td>{{ ct }}</td>
+              <td>{{ object_type }}</td>
             </tr>
           {% endfor %}
         </table>

+ 2 - 2
netbox/templates/extras/savedfilter.html

@@ -38,9 +38,9 @@
     <div class="card">
       <h5 class="card-header">{% trans "Assigned Models" %}</h5>
       <table class="table table-hover attr-table">
-        {% for ct in object.content_types.all %}
+        {% for object_type in object.object_types.all %}
           <tr>
-            <td>{{ ct }}</td>
+            <td>{{ object_type }}</td>
           </tr>
         {% endfor %}
       </table>

+ 1 - 1
netbox/templates/inc/panels/image_attachments.html

@@ -6,7 +6,7 @@
     {% trans "Images" %}
     {% if perms.extras.add_imageattachment %}
       <div class="card-actions">
-        <a href="{% url 'extras:imageattachment_add' %}?content_type={{ object|content_type_id }}&object_id={{ object.pk }}" class="btn btn-ghost-primary btn-sm">
+        <a href="{% url 'extras:imageattachment_add' %}?object_type={{ object|content_type_id }}&object_id={{ object.pk }}" class="btn btn-ghost-primary btn-sm">
           <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Attach an image" %}
         </a>
       </div>

+ 1 - 1
netbox/templates/tenancy/object_contacts.html

@@ -5,7 +5,7 @@
 {% block extra_controls %}
   {% if perms.tenancy.add_contactassignment %}
     {% with viewname=object|viewname:"contacts" %}
-      <a href="{% url 'tenancy:contactassignment_add' %}?content_type={{ object|content_type_id }}&object_id={{ object.pk }}&return_url={% url viewname pk=object.pk %}" class="btn btn-primary">
+      <a href="{% url 'tenancy:contactassignment_add' %}?object_type={{ object|content_type_id }}&object_id={{ object.pk }}&return_url={% url viewname pk=object.pk %}" class="btn btn-primary">
         <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add a contact" %}
       </a>
     {% endwith %}

+ 3 - 3
netbox/tenancy/api/serializers_/contacts.py

@@ -58,7 +58,7 @@ class ContactSerializer(NetBoxModelSerializer):
 
 class ContactAssignmentSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contactassignment-detail')
-    content_type = ContentTypeField(
+    object_type = ContentTypeField(
         queryset=ContentType.objects.all()
     )
     object = serializers.SerializerMethodField(read_only=True)
@@ -69,13 +69,13 @@ class ContactAssignmentSerializer(NetBoxModelSerializer):
     class Meta:
         model = ContactAssignment
         fields = [
-            'id', 'url', 'display', 'content_type', 'object_id', 'object', 'contact', 'role', 'priority', 'tags',
+            'id', 'url', 'display', 'object_type', 'object_id', 'object', 'contact', 'role', 'priority', 'tags',
             'custom_fields', 'created', 'last_updated',
         ]
         brief_fields = ('id', 'url', 'display', 'contact', 'role', 'priority')
 
     @extend_schema_field(OpenApiTypes.OBJECT)
     def get_object(self, instance):
-        serializer = get_serializer_for_model(instance.content_type.model_class())
+        serializer = get_serializer_for_model(instance.object_type.model_class())
         context = {'request': self.context['request']}
         return serializer(instance.object, nested=True, context=context).data

+ 2 - 2
netbox/tenancy/filtersets.py

@@ -86,7 +86,7 @@ class ContactAssignmentFilterSet(NetBoxModelFilterSet):
         method='search',
         label=_('Search'),
     )
-    content_type = ContentTypeFilter()
+    object_type = ContentTypeFilter()
     contact_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Contact.objects.all(),
         label=_('Contact (ID)'),
@@ -118,7 +118,7 @@ class ContactAssignmentFilterSet(NetBoxModelFilterSet):
 
     class Meta:
         model = ContactAssignment
-        fields = ['id', 'content_type_id', 'object_id', 'priority', 'tag']
+        fields = ['id', 'object_type_id', 'object_id', 'priority', 'tag']
 
     def search(self, queryset, name, value):
         if not value.strip():

+ 2 - 2
netbox/tenancy/forms/bulk_import.py

@@ -91,7 +91,7 @@ class ContactImportForm(NetBoxModelImportForm):
 
 
 class ContactAssignmentImportForm(NetBoxModelImportForm):
-    content_type = CSVContentTypeField(
+    object_type = CSVContentTypeField(
         queryset=ContentType.objects.all(),
         help_text=_("One or more assigned object types")
     )
@@ -108,4 +108,4 @@ class ContactAssignmentImportForm(NetBoxModelImportForm):
 
     class Meta:
         model = ContactAssignment
-        fields = ('content_type', 'object_id', 'contact', 'priority', 'role')
+        fields = ('object_type', 'object_id', 'contact', 'priority', 'role')

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

@@ -1,7 +1,7 @@
 from django import forms
 from django.utils.translation import gettext_lazy as _
 
-from core.models import ContentType
+from core.models import ObjectType
 from netbox.forms import NetBoxModelFilterSetForm
 from tenancy.choices import *
 from tenancy.models import *
@@ -83,10 +83,10 @@ class ContactAssignmentFilterForm(NetBoxModelFilterSetForm):
     model = ContactAssignment
     fieldsets = (
         (None, ('q', 'filter_id', 'tag')),
-        (_('Assignment'), ('content_type_id', 'group_id', 'contact_id', 'role_id', 'priority')),
+        (_('Assignment'), ('object_type_id', 'group_id', 'contact_id', 'role_id', 'priority')),
     )
-    content_type_id = ContentTypeMultipleChoiceField(
-        queryset=ContentType.objects.with_feature('contacts'),
+    object_type_id = ContentTypeMultipleChoiceField(
+        queryset=ObjectType.objects.with_feature('contacts'),
         required=False,
         label=_('Object type')
     )

+ 2 - 2
netbox/tenancy/forms/model_forms.py

@@ -143,9 +143,9 @@ class ContactAssignmentForm(NetBoxModelForm):
     class Meta:
         model = ContactAssignment
         fields = (
-            'content_type', 'object_id', 'group', 'contact', 'role', 'priority', 'tags'
+            'object_type', 'object_id', 'group', 'contact', 'role', 'priority', 'tags'
         )
         widgets = {
-            'content_type': forms.HiddenInput(),
+            'object_type': forms.HiddenInput(),
             'object_id': forms.HiddenInput(),
         }

+ 40 - 0
netbox/tenancy/migrations/0015_contactassignment_rename_content_type.py

@@ -0,0 +1,40 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('contenttypes', '0002_remove_content_type_name'),
+        ('extras', '0111_rename_content_types'),
+        ('tenancy', '0014_contactassignment_ordering'),
+    ]
+
+    operations = [
+        migrations.RemoveConstraint(
+            model_name='contactassignment',
+            name='tenancy_contactassignment_unique_object_contact_role',
+        ),
+        migrations.RemoveIndex(
+            model_name='contactassignment',
+            name='tenancy_con_content_693ff4_idx',
+        ),
+        migrations.RenameField(
+            model_name='contactassignment',
+            old_name='content_type',
+            new_name='object_type',
+        ),
+        migrations.AddIndex(
+            model_name='contactassignment',
+            index=models.Index(
+                fields=['object_type', 'object_id'],
+                name='tenancy_con_object__6f20f7_idx'
+            ),
+        ),
+        migrations.AddConstraint(
+            model_name='contactassignment',
+            constraint=models.UniqueConstraint(
+                fields=('object_type', 'object_id', 'contact', 'role'),
+                name='tenancy_contactassignment_unique_object_contact_role'
+            ),
+        ),
+    ]

+ 8 - 8
netbox/tenancy/models/contacts.py

@@ -4,7 +4,7 @@ from django.db import models
 from django.urls import reverse
 from django.utils.translation import gettext_lazy as _
 
-from core.models import ContentType
+from core.models import ObjectType
 from netbox.models import ChangeLoggedModel, NestedGroupModel, OrganizationalModel, PrimaryModel
 from netbox.models.features import CustomFieldsMixin, ExportTemplatesMixin, TagsMixin
 from tenancy.choices import *
@@ -111,13 +111,13 @@ class Contact(PrimaryModel):
 
 
 class ContactAssignment(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel):
-    content_type = models.ForeignKey(
+    object_type = models.ForeignKey(
         to='contenttypes.ContentType',
         on_delete=models.CASCADE
     )
     object_id = models.PositiveBigIntegerField()
     object = GenericForeignKey(
-        ct_field='content_type',
+        ct_field='object_type',
         fk_field='object_id'
     )
     contact = models.ForeignKey(
@@ -137,16 +137,16 @@ class ContactAssignment(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, Chan
         blank=True
     )
 
-    clone_fields = ('content_type', 'object_id', 'role', 'priority')
+    clone_fields = ('object_type', 'object_id', 'role', 'priority')
 
     class Meta:
         ordering = ('contact', 'priority', 'role', 'pk')
         indexes = (
-            models.Index(fields=('content_type', 'object_id')),
+            models.Index(fields=('object_type', 'object_id')),
         )
         constraints = (
             models.UniqueConstraint(
-                fields=('content_type', 'object_id', 'contact', 'role'),
+                fields=('object_type', 'object_id', 'contact', 'role'),
                 name='%(app_label)s_%(class)s_unique_object_contact_role'
             ),
         )
@@ -165,9 +165,9 @@ class ContactAssignment(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, Chan
         super().clean()
 
         # Validate the assigned object type
-        if self.content_type not in ContentType.objects.with_feature('contacts'):
+        if self.object_type not in ObjectType.objects.with_feature('contacts'):
             raise ValidationError(
-                _("Contacts cannot be assigned to this object type ({type}).").format(type=self.content_type)
+                _("Contacts cannot be assigned to this object type ({type}).").format(type=self.object_type)
             )
 
     def to_objectchange(self, action):

+ 3 - 3
netbox/tenancy/tables/contacts.py

@@ -86,7 +86,7 @@ class ContactTable(NetBoxTable):
 
 
 class ContactAssignmentTable(NetBoxTable):
-    content_type = columns.ContentTypeColumn(
+    object_type = columns.ContentTypeColumn(
         verbose_name=_('Object Type')
     )
     object = tables.Column(
@@ -141,10 +141,10 @@ class ContactAssignmentTable(NetBoxTable):
     class Meta(NetBoxTable.Meta):
         model = ContactAssignment
         fields = (
-            'pk', 'content_type', 'object', 'contact', 'role', 'priority', 'contact_title', 'contact_phone',
+            'pk', 'object_type', 'object', 'contact', 'role', 'priority', 'contact_title', 'contact_phone',
             'contact_email', 'contact_address', 'contact_link', 'contact_description', 'contact_group', 'tags',
             'actions'
         )
         default_columns = (
-            'pk', 'content_type', 'object', 'contact', 'role', 'priority', 'contact_email', 'contact_phone'
+            'pk', 'object_type', 'object', 'contact', 'role', 'priority', 'contact_email', 'contact_phone'
         )

+ 3 - 3
netbox/tenancy/tests/test_api.py

@@ -246,21 +246,21 @@ class ContactAssignmentTest(APIViewTestCases.APIViewTestCase):
 
         cls.create_data = [
             {
-                'content_type': 'dcim.site',
+                'object_type': 'dcim.site',
                 'object_id': sites[1].pk,
                 'contact': contacts[3].pk,
                 'role': contact_roles[0].pk,
                 'priority': ContactPriorityChoices.PRIORITY_PRIMARY,
             },
             {
-                'content_type': 'dcim.site',
+                'object_type': 'dcim.site',
                 'object_id': sites[1].pk,
                 'contact': contacts[4].pk,
                 'role': contact_roles[1].pk,
                 'priority': ContactPriorityChoices.PRIORITY_SECONDARY,
             },
             {
-                'content_type': 'dcim.site',
+                'object_type': 'dcim.site',
                 'object_id': sites[1].pk,
                 'contact': contacts[5].pk,
                 'role': contact_roles[2].pk,

+ 3 - 3
netbox/tenancy/tests/test_filtersets.py

@@ -1,6 +1,6 @@
-from django.contrib.contenttypes.models import ContentType
 from django.test import TestCase
 
+from core.models import ObjectType
 from dcim.models import Manufacturer, Site
 from tenancy.filtersets import *
 from tenancy.models import *
@@ -295,8 +295,8 @@ class ContactAssignmentTestCase(TestCase, ChangeLoggedFilterSetTests):
         )
         ContactAssignment.objects.bulk_create(assignments)
 
-    def test_content_type(self):
-        params = {'content_type_id': ContentType.objects.get_by_natural_key('dcim', 'site')}
+    def test_object_type(self):
+        params = {'object_type_id': ObjectType.objects.get_by_natural_key('dcim', 'site')}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
 
     def test_contact(self):

+ 3 - 3
netbox/tenancy/tests/test_views.py

@@ -292,7 +292,7 @@ class ContactAssignmentTestCase(
         tags = create_tags('Alpha', 'Bravo', 'Charlie')
 
         cls.form_data = {
-            'content_type': ContentType.objects.get_for_model(Site).pk,
+            'object_type': ContentType.objects.get_for_model(Site).pk,
             'object_id': sites[3].pk,
             'contact': contacts[3].pk,
             'role': contact_roles[3].pk,
@@ -306,11 +306,11 @@ class ContactAssignmentTestCase(
         }
 
     def _get_url(self, action, instance=None):
-        # Override creation URL to append content_type & object_id parameters
+        # Override creation URL to append object_type & object_id parameters
         if action == 'add':
             url = reverse('tenancy:contactassignment_add')
             content_type = ContentType.objects.get_for_model(Site).pk
             object_id = Site.objects.first().pk
-            return f"{url}?content_type={content_type}&object_id={object_id}"
+            return f"{url}?object_type={content_type}&object_id={object_id}"
 
         return super()._get_url(action, instance=instance)

+ 4 - 4
netbox/tenancy/views.py

@@ -23,7 +23,7 @@ class ObjectContactsView(generic.ObjectChildrenView):
 
     def get_children(self, request, parent):
         return ContactAssignment.objects.restrict(request.user, 'view').filter(
-            content_type=ContentType.objects.get_for_model(parent),
+            object_type=ContentType.objects.get_for_model(parent),
             object_id=parent.pk
         ).order_by('priority', 'contact', 'role')
 
@@ -31,7 +31,7 @@ class ObjectContactsView(generic.ObjectChildrenView):
         table = super().get_table(*args, **kwargs)
 
         # Hide object columns
-        table.columns.hide('content_type')
+        table.columns.hide('object_type')
         table.columns.hide('object')
 
         return table
@@ -374,8 +374,8 @@ class ContactAssignmentEditView(generic.ObjectEditView):
     def alter_object(self, instance, request, args, kwargs):
         if not instance.pk:
             # Assign the object based on URL kwargs
-            content_type = get_object_or_404(ContentType, pk=request.GET.get('content_type'))
-            instance.object = get_object_or_404(content_type.model_class(), pk=request.GET.get('object_id'))
+            object_type = get_object_or_404(ContentType, pk=request.GET.get('object_type'))
+            instance.object = get_object_or_404(object_type.model_class(), pk=request.GET.get('object_id'))
         return instance
 
     def get_extra_addanother_params(self, request):

+ 2 - 2
netbox/users/api/nested_serializers.py

@@ -1,9 +1,9 @@
 from django.contrib.auth import get_user_model
-from django.contrib.contenttypes.models import ContentType
 from drf_spectacular.utils import extend_schema_field
 from drf_spectacular.types import OpenApiTypes
 from rest_framework import serializers
 
+from core.models import ObjectType
 from netbox.api.fields import ContentTypeField
 from netbox.api.serializers import WritableNestedSerializer
 from users.models import Group, ObjectPermission, Token
@@ -49,7 +49,7 @@ class NestedTokenSerializer(WritableNestedSerializer):
 class NestedObjectPermissionSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='users-api:objectpermission-detail')
     object_types = ContentTypeField(
-        queryset=ContentType.objects.all(),
+        queryset=ObjectType.objects.all(),
         many=True
     )
     groups = serializers.SerializerMethodField(read_only=True)

+ 2 - 2
netbox/users/api/serializers_/permissions.py

@@ -1,7 +1,7 @@
 from django.contrib.auth import get_user_model
-from django.contrib.contenttypes.models import ContentType
 from rest_framework import serializers
 
+from core.models import ObjectType
 from netbox.api.fields import ContentTypeField, SerializedPKRelatedField
 from netbox.api.serializers import ValidatedModelSerializer
 from users.models import Group, ObjectPermission
@@ -15,7 +15,7 @@ __all__ = (
 class ObjectPermissionSerializer(ValidatedModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='users-api:objectpermission-detail')
     object_types = ContentTypeField(
-        queryset=ContentType.objects.all(),
+        queryset=ObjectType.objects.all(),
         many=True
     )
     groups = SerializedPKRelatedField(

+ 2 - 2
netbox/users/forms/model_forms.py

@@ -1,12 +1,12 @@
 from django import forms
 from django.conf import settings
 from django.contrib.auth import get_user_model
-from django.contrib.contenttypes.models import ContentType
 from django.contrib.postgres.forms import SimpleArrayField
 from django.core.exceptions import FieldError
 from django.utils.html import mark_safe
 from django.utils.translation import gettext_lazy as _
 
+from core.models import ObjectType
 from ipam.formfields import IPNetworkFormField
 from ipam.validators import prefix_validator
 from netbox.preferences import PREFERENCES
@@ -278,7 +278,7 @@ class GroupForm(forms.ModelForm):
 class ObjectPermissionForm(forms.ModelForm):
     object_types = ContentTypeMultipleChoiceField(
         label=_('Object types'),
-        queryset=ContentType.objects.all(),
+        queryset=ObjectType.objects.all(),
         limit_choices_to=OBJECTPERMISSION_OBJECT_TYPES,
         widget=forms.SelectMultiple(attrs={'size': 6})
     )

+ 19 - 0
netbox/users/migrations/0007_objectpermission_update_object_types.py

@@ -0,0 +1,19 @@
+# Generated by Django 5.0.1 on 2024-03-04 14:33
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('core', '0010_gfk_indexes'),
+        ('users', '0006_custom_group_model'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='objectpermission',
+            name='object_types',
+            field=models.ManyToManyField(limit_choices_to=models.Q(models.Q(models.Q(('app_label__in', ['account', 'admin', 'auth', 'contenttypes', 'sessions', 'taggit', 'users']), _negated=True), models.Q(('app_label', 'auth'), ('model__in', ['group', 'user'])), models.Q(('app_label', 'users'), ('model__in', ['objectpermission', 'token'])), _connector='OR')), related_name='object_permissions', to='core.objecttype'),
+        ),
+    ]

+ 2 - 2
netbox/users/models.py

@@ -22,7 +22,7 @@ from django.utils import timezone
 from django.utils.translation import gettext_lazy as _
 from netaddr import IPNetwork
 
-from core.models import ContentType
+from core.models import ObjectType
 from ipam.fields import IPNetworkField
 from netbox.config import get_config
 from utilities.querysets import RestrictedQuerySet
@@ -383,7 +383,7 @@ class ObjectPermission(models.Model):
         default=True
     )
     object_types = models.ManyToManyField(
-        to='contenttypes.ContentType',
+        to='core.ObjectType',
         limit_choices_to=OBJECTPERMISSION_OBJECT_TYPES,
         related_name='object_permissions'
     )

+ 3 - 3
netbox/users/tests/test_api.py

@@ -1,7 +1,7 @@
 from django.contrib.auth import get_user_model
-from django.contrib.contenttypes.models import ContentType
 from django.urls import reverse
 
+from core.models import ObjectType
 from users.models import Group, ObjectPermission, Token
 from utilities.testing import APIViewTestCases, APITestCase, create_test_user
 from utilities.utils import deepmerge
@@ -64,7 +64,7 @@ class UserTest(APIViewTestCases.APIViewTestCase):
         )
         obj_perm.save()
         obj_perm.users.add(self.user)
-        obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
+        obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
 
         user_credentials = {
             'username': 'user1',
@@ -261,7 +261,7 @@ class ObjectPermissionTest(
         )
         User.objects.bulk_create(users)
 
-        object_type = ContentType.objects.get(app_label='dcim', model='device')
+        object_type = ObjectType.objects.get(app_label='dcim', model='device')
 
         for i in range(3):
             objectpermission = ObjectPermission(

+ 5 - 5
netbox/users/tests/test_filtersets.py

@@ -1,10 +1,10 @@
 import datetime
 
 from django.contrib.auth import get_user_model
-from django.contrib.contenttypes.models import ContentType
 from django.test import TestCase
 from django.utils.timezone import make_aware
 
+from core.models import ObjectType
 from users import filtersets
 from users.models import Group, ObjectPermission, Token
 from utilities.testing import BaseFilterSetTests
@@ -151,9 +151,9 @@ class ObjectPermissionTestCase(TestCase, BaseFilterSetTests):
         User.objects.bulk_create(users)
 
         object_types = (
-            ContentType.objects.get(app_label='dcim', model='site'),
-            ContentType.objects.get(app_label='dcim', model='rack'),
-            ContentType.objects.get(app_label='dcim', model='device'),
+            ObjectType.objects.get(app_label='dcim', model='site'),
+            ObjectType.objects.get(app_label='dcim', model='rack'),
+            ObjectType.objects.get(app_label='dcim', model='device'),
         )
 
         permissions = (
@@ -198,7 +198,7 @@ class ObjectPermissionTestCase(TestCase, BaseFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
     def test_object_types(self):
-        object_types = ContentType.objects.filter(model__in=['site', 'rack'])
+        object_types = ObjectType.objects.filter(model__in=['site', 'rack'])
         params = {'object_types': [object_types[0].pk, object_types[1].pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 

+ 3 - 4
netbox/users/tests/test_views.py

@@ -1,5 +1,4 @@
-from django.contrib.contenttypes.models import ContentType
-
+from core.models import ObjectType
 from users.models import *
 from utilities.testing import ViewTestCases, create_test_user
 
@@ -115,7 +114,7 @@ class ObjectPermissionTestCase(
 
     @classmethod
     def setUpTestData(cls):
-        ct = ContentType.objects.get_by_natural_key('dcim', 'site')
+        object_type = ObjectType.objects.get_by_natural_key('dcim', 'site')
 
         permissions = (
             ObjectPermission(name='Permission 1', actions=['view', 'add', 'delete']),
@@ -127,7 +126,7 @@ class ObjectPermissionTestCase(
         cls.form_data = {
             'name': 'Permission X',
             'description': 'A new permission',
-            'object_types': [ct.pk],
+            'object_types': [object_type.pk],
             'actions': 'view,edit,delete',
         }
 

+ 4 - 4
netbox/utilities/permissions.py

@@ -1,5 +1,4 @@
 from django.conf import settings
-from django.contrib.contenttypes.models import ContentType
 from django.db.models import Q
 from django.utils.translation import gettext_lazy as _
 
@@ -50,13 +49,14 @@ def resolve_permission_ct(name):
 
     :param name: Permission name in the format <app_label>.<action>_<model>
     """
+    from core.models import ObjectType
     app_label, action, model_name = resolve_permission(name)
     try:
-        content_type = ContentType.objects.get(app_label=app_label, model=model_name)
-    except ContentType.DoesNotExist:
+        object_type = ObjectType.objects.get(app_label=app_label, model=model_name)
+    except ObjectType.DoesNotExist:
         raise ValueError(_("Unknown app_label/model_name for {name}").format(name=name))
 
-    return content_type, action
+    return object_type, action
 
 
 def permission_is_exempt(name):

+ 1 - 1
netbox/utilities/templates/buttons/export.html

@@ -25,7 +25,7 @@
         <hr class="dropdown-divider">
       </li>
       <li>
-        <a class="dropdown-item" href="{% url 'extras:exporttemplate_add' %}?content_types={{ content_type.pk }}">{% trans "Add export template" %}...</a>
+        <a class="dropdown-item" href="{% url 'extras:exporttemplate_add' %}?object_types={{ object_type.pk }}">{% trans "Add export template" %}...</a>
       </li>
     {% endif %}
   </ul>

+ 5 - 4
netbox/utilities/templatetags/buttons.py

@@ -2,6 +2,7 @@ from django import template
 from django.contrib.contenttypes.models import ContentType
 from django.urls import NoReverseMatch, reverse
 
+from core.models import ObjectType
 from extras.models import Bookmark, ExportTemplate
 from utilities.utils import get_viewname, prepare_cloned_fields
 
@@ -132,18 +133,18 @@ def import_button(model, action='import'):
 
 @register.inclusion_tag('buttons/export.html', takes_context=True)
 def export_button(context, model):
-    content_type = ContentType.objects.get_for_model(model)
+    object_type = ObjectType.objects.get_for_model(model)
     user = context['request'].user
 
     # Determine if the "all data" export returns CSV or YAML
-    data_format = 'YAML' if hasattr(content_type.model_class(), 'to_yaml') else 'CSV'
+    data_format = 'YAML' if hasattr(object_type.model_class(), 'to_yaml') else 'CSV'
 
     # Retrieve all export templates for this model
-    export_templates = ExportTemplate.objects.restrict(user, 'view').filter(content_types=content_type)
+    export_templates = ExportTemplate.objects.restrict(user, 'view').filter(object_types=object_type)
 
     return {
         'perms': context['perms'],
-        'content_type': content_type,
+        'object_type': object_type,
         'url_params': context['request'].GET.urlencode() if context['request'].GET else '',
         'export_templates': export_templates,
         'data_format': data_format,

+ 4 - 4
netbox/utilities/templatetags/helpers.py

@@ -1,16 +1,16 @@
 import datetime
 import json
-from urllib.parse import quote
 from typing import Dict, Any
+from urllib.parse import quote
 
 from django import template
 from django.conf import settings
-from django.contrib.contenttypes.models import ContentType
 from django.template.defaultfilters import date
 from django.urls import NoReverseMatch, reverse
 from django.utils import timezone
 from django.utils.safestring import mark_safe
 
+from core.models import ObjectType
 from utilities.forms import get_selected_values, TableConfigForm
 from utilities.utils import get_viewname
 
@@ -322,10 +322,10 @@ def applied_filters(context, model, form, query_params):
 
     save_link = None
     if user.has_perm('extras.add_savedfilter') and 'filter_id' not in context['request'].GET:
-        content_type = ContentType.objects.get_for_model(model).pk
+        object_type = ObjectType.objects.get_for_model(model).pk
         parameters = json.dumps(dict(context['request'].GET.lists()))
         url = reverse('extras:savedfilter_add')
-        save_link = f"{url}?content_types={content_type}&parameters={quote(parameters)}"
+        save_link = f"{url}?object_types={object_type}&parameters={quote(parameters)}"
 
     return {
         'applied_filters': applied_filters,

+ 11 - 10
netbox/utilities/testing/api.py

@@ -10,6 +10,7 @@ from graphene.types import Dynamic as GQLDynamic, List as GQLList, Union as GQLU
 from rest_framework import status
 from rest_framework.test import APIClient
 
+from core.models import ObjectType
 from extras.choices import ObjectChangeActionChoices
 from extras.models import ObjectChange
 from users.models import ObjectPermission, Token
@@ -109,7 +110,7 @@ class APIViewTestCases:
             )
             obj_perm.save()
             obj_perm.users.add(self.user)
-            obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
+            obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
 
             # Try GET to permitted object
             url = self._get_detail_url(instance1)
@@ -183,7 +184,7 @@ class APIViewTestCases:
             )
             obj_perm.save()
             obj_perm.users.add(self.user)
-            obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
+            obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
 
             # Try GET to permitted objects
             response = self.client.get(self._get_list_url(), **self.header)
@@ -224,7 +225,7 @@ class APIViewTestCases:
             )
             obj_perm.save()
             obj_perm.users.add(self.user)
-            obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
+            obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
 
             initial_count = self._get_queryset().count()
             response = self.client.post(self._get_list_url(), self.create_data[0], format='json', **self.header)
@@ -258,7 +259,7 @@ class APIViewTestCases:
             )
             obj_perm.save()
             obj_perm.users.add(self.user)
-            obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
+            obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
 
             initial_count = self._get_queryset().count()
             response = self.client.post(self._get_list_url(), self.create_data, format='json', **self.header)
@@ -309,7 +310,7 @@ class APIViewTestCases:
             )
             obj_perm.save()
             obj_perm.users.add(self.user)
-            obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
+            obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
 
             response = self.client.patch(url, update_data, format='json', **self.header)
             self.assertHttpStatus(response, status.HTTP_200_OK)
@@ -344,7 +345,7 @@ class APIViewTestCases:
             )
             obj_perm.save()
             obj_perm.users.add(self.user)
-            obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
+            obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
 
             id_list = list(self._get_queryset().values_list('id', flat=True)[:3])
             self.assertEqual(len(id_list), 3, "Insufficient number of objects to test bulk update")
@@ -387,7 +388,7 @@ class APIViewTestCases:
             )
             obj_perm.save()
             obj_perm.users.add(self.user)
-            obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
+            obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
 
             response = self.client.delete(url, **self.header)
             self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
@@ -413,7 +414,7 @@ class APIViewTestCases:
             )
             obj_perm.save()
             obj_perm.users.add(self.user)
-            obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
+            obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
 
             # Target the three most recently created objects to avoid triggering recursive deletions
             # (e.g. with MPTT objects)
@@ -504,7 +505,7 @@ class APIViewTestCases:
             )
             obj_perm.save()
             obj_perm.users.add(self.user)
-            obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
+            obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
 
             response = self.client.post(url, data={'query': query}, **self.header)
             self.assertHttpStatus(response, status.HTTP_200_OK)
@@ -529,7 +530,7 @@ class APIViewTestCases:
             )
             obj_perm.save()
             obj_perm.users.add(self.user)
-            obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
+            obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
 
             response = self.client.post(url, data={'query': query}, **self.header)
             self.assertHttpStatus(response, status.HTTP_200_OK)

+ 4 - 3
netbox/utilities/testing/base.py

@@ -10,6 +10,7 @@ from django.test import Client, TestCase as _TestCase
 from netaddr import IPNetwork
 from taggit.managers import TaggableManager
 
+from core.models import ObjectType
 from users.models import ObjectPermission
 from utilities.permissions import resolve_permission_ct
 from utilities.utils import content_type_identifier
@@ -112,7 +113,7 @@ class ModelTestCase(TestCase):
             # Handle ManyToManyFields
             if value and type(field) in (ManyToManyField, TaggableManager):
 
-                if field.related_model is ContentType and api:
+                if field.related_model in (ContentType, ObjectType) and api:
                     model_dict[key] = sorted([content_type_identifier(ct) for ct in value])
                 else:
                     model_dict[key] = sorted([obj.pk for obj in value])
@@ -120,8 +121,8 @@ class ModelTestCase(TestCase):
             elif api:
 
                 # Replace ContentType numeric IDs with <app_label>.<model>
-                if type(getattr(instance, key)) is ContentType:
-                    ct = ContentType.objects.get(pk=value)
+                if type(getattr(instance, key)) in (ContentType, ObjectType):
+                    ct = ObjectType.objects.get(pk=value)
                     model_dict[key] = content_type_identifier(ct)
 
                 # Convert IPNetwork instances to strings

+ 23 - 22
netbox/utilities/testing/views.py

@@ -8,6 +8,7 @@ from django.test import override_settings
 from django.urls import reverse
 from django.utils.translation import gettext as _
 
+from core.models import ObjectType
 from extras.choices import ObjectChangeActionChoices
 from extras.models import ObjectChange
 from netbox.models.features import ChangeLoggingMixin
@@ -93,7 +94,7 @@ class ViewTestCases:
             )
             obj_perm.save()
             obj_perm.users.add(self.user)
-            obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
+            obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
 
             # Try GET with model-level permission
             self.assertHttpStatus(self.client.get(instance.get_absolute_url()), 200)
@@ -109,7 +110,7 @@ class ViewTestCases:
             )
             obj_perm.save()
             obj_perm.users.add(self.user)
-            obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
+            obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
 
             # Try GET to permitted object
             self.assertHttpStatus(self.client.get(instance1.get_absolute_url()), 200)
@@ -161,7 +162,7 @@ class ViewTestCases:
             )
             obj_perm.save()
             obj_perm.users.add(self.user)
-            obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
+            obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
 
             # Try GET with model-level permission
             self.assertHttpStatus(self.client.get(self._get_url('add')), 200)
@@ -197,7 +198,7 @@ class ViewTestCases:
             )
             obj_perm.save()
             obj_perm.users.add(self.user)
-            obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
+            obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
 
             # Try GET with object-level permission
             self.assertHttpStatus(self.client.get(self._get_url('add')), 200)
@@ -260,7 +261,7 @@ class ViewTestCases:
             )
             obj_perm.save()
             obj_perm.users.add(self.user)
-            obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
+            obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
 
             # Try GET with model-level permission
             self.assertHttpStatus(self.client.get(self._get_url('edit', instance)), 200)
@@ -295,7 +296,7 @@ class ViewTestCases:
             )
             obj_perm.save()
             obj_perm.users.add(self.user)
-            obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
+            obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
 
             # Try GET with a permitted object
             self.assertHttpStatus(self.client.get(self._get_url('edit', instance1)), 200)
@@ -349,7 +350,7 @@ class ViewTestCases:
             )
             obj_perm.save()
             obj_perm.users.add(self.user)
-            obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
+            obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
 
             # Try GET with model-level permission
             self.assertHttpStatus(self.client.get(self._get_url('delete', instance)), 200)
@@ -384,7 +385,7 @@ class ViewTestCases:
             )
             obj_perm.save()
             obj_perm.users.add(self.user)
-            obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
+            obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
 
             # Try GET with a permitted object
             self.assertHttpStatus(self.client.get(self._get_url('delete', instance1)), 200)
@@ -442,7 +443,7 @@ class ViewTestCases:
             )
             obj_perm.save()
             obj_perm.users.add(self.user)
-            obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
+            obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
 
             # Try GET with model-level permission
             self.assertHttpStatus(self.client.get(self._get_url('list')), 200)
@@ -458,7 +459,7 @@ class ViewTestCases:
             )
             obj_perm.save()
             obj_perm.users.add(self.user)
-            obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
+            obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
 
             # Try GET with object-level permission
             response = self.client.get(self._get_url('list'))
@@ -477,7 +478,7 @@ class ViewTestCases:
             )
             obj_perm.save()
             obj_perm.users.add(self.user)
-            obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
+            obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
 
             # Test default CSV export
             response = self.client.get(f'{url}?export')
@@ -524,7 +525,7 @@ class ViewTestCases:
             )
             obj_perm.save()
             obj_perm.users.add(self.user)
-            obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
+            obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
 
             # Bulk create objects
             response = self.client.post(**request)
@@ -548,7 +549,7 @@ class ViewTestCases:
             )
             obj_perm.save()
             obj_perm.users.add(self.user)
-            obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
+            obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
 
             # Attempt to make the request with unmet constraints
             self.assertHttpStatus(self.client.post(**request), 200)
@@ -610,7 +611,7 @@ class ViewTestCases:
             )
             obj_perm.save()
             obj_perm.users.add(self.user)
-            obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
+            obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
 
             # Try GET with model-level permission
             self.assertHttpStatus(self.client.get(self._get_url('import')), 200)
@@ -639,7 +640,7 @@ class ViewTestCases:
             )
             obj_perm.save()
             obj_perm.users.add(self.user)
-            obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
+            obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
 
             # Test POST with permission
             self.assertHttpStatus(self.client.post(self._get_url('import'), data), 302)
@@ -674,7 +675,7 @@ class ViewTestCases:
             )
             obj_perm.save()
             obj_perm.users.add(self.user)
-            obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
+            obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
 
             # Attempt to import non-permitted objects
             self.assertHttpStatus(self.client.post(self._get_url('import'), data), 200)
@@ -730,7 +731,7 @@ class ViewTestCases:
             )
             obj_perm.save()
             obj_perm.users.add(self.user)
-            obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
+            obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
 
             # Try POST with model-level permission
             self.assertHttpStatus(self.client.post(self._get_url('bulk_edit'), data), 302)
@@ -761,7 +762,7 @@ class ViewTestCases:
             )
             obj_perm.save()
             obj_perm.users.add(self.user)
-            obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
+            obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
 
             # Attempt to bulk edit permitted objects into a non-permitted state
             response = self.client.post(self._get_url('bulk_edit'), data)
@@ -811,7 +812,7 @@ class ViewTestCases:
             )
             obj_perm.save()
             obj_perm.users.add(self.user)
-            obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
+            obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
 
             # Try POST with model-level permission
             self.assertHttpStatus(self.client.post(self._get_url('bulk_delete'), data), 302)
@@ -833,7 +834,7 @@ class ViewTestCases:
             )
             obj_perm.save()
             obj_perm.users.add(self.user)
-            obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
+            obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
 
             # Attempt to bulk delete non-permitted objects
             initial_count = self._get_queryset().count()
@@ -891,7 +892,7 @@ class ViewTestCases:
             )
             obj_perm.save()
             obj_perm.users.add(self.user)
-            obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
+            obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
 
             # Try POST with model-level permission
             self.assertHttpStatus(self.client.post(self._get_url('bulk_rename'), data), 302)
@@ -916,7 +917,7 @@ class ViewTestCases:
             )
             obj_perm.save()
             obj_perm.users.add(self.user)
-            obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
+            obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
 
             # Attempt to bulk edit permitted objects into a non-permitted state
             response = self.client.post(self._get_url('bulk_rename'), data)

+ 3 - 5
netbox/utilities/tests/test_api.py

@@ -1,10 +1,8 @@
-import urllib.parse
-
-from django.contrib.contenttypes.models import ContentType
 from django.test import Client, TestCase, override_settings
 from django.urls import reverse
 from rest_framework import status
 
+from core.models import ObjectType
 from dcim.models import Region, Site
 from extras.choices import CustomFieldTypeChoices
 from extras.models import CustomField
@@ -240,10 +238,10 @@ class APIDocsTestCase(TestCase):
         self.client = Client()
 
         # Populate a CustomField to activate CustomFieldSerializer
-        content_type = ContentType.objects.get_for_model(Site)
+        object_type = ObjectType.objects.get_for_model(Site)
         self.cf_text = CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='test')
         self.cf_text.save()
-        self.cf_text.content_types.set([content_type])
+        self.cf_text.object_types.set([object_type])
         self.cf_text.save()
 
     def test_api_docs(self):

+ 1 - 3
netbox/utilities/tests/test_counters.py

@@ -1,11 +1,9 @@
-from django.contrib.contenttypes.models import ContentType
 from django.test import override_settings
 from django.urls import reverse
 
 from dcim.models import *
-from users.models import ObjectPermission
 from utilities.testing.base import TestCase
-from utilities.testing.utils import create_test_device, create_test_user
+from utilities.testing.utils import create_test_device
 
 
 class CountersTest(TestCase):

+ 3 - 3
netbox/vpn/models/l2vpn.py

@@ -5,7 +5,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 core.models import ObjectType
 from netbox.models import NetBoxModel, PrimaryModel
 from netbox.models.features import ContactsMixin
 from vpn.choices import L2VPNTypeChoices
@@ -128,7 +128,7 @@ class L2VPNTermination(NetBoxModel):
         # Only check is assigned_object is set.  Required otherwise we have an Integrity Error thrown.
         if self.assigned_object:
             obj_id = self.assigned_object.pk
-            obj_type = ContentType.objects.get_for_model(self.assigned_object)
+            obj_type = ObjectType.objects.get_for_model(self.assigned_object)
             if L2VPNTermination.objects.filter(assigned_object_id=obj_id, assigned_object_type=obj_type).\
                     exclude(pk=self.pk).count() > 0:
                 raise ValidationError(
@@ -150,7 +150,7 @@ class L2VPNTermination(NetBoxModel):
 
     @property
     def assigned_object_parent(self):
-        obj_type = ContentType.objects.get_for_model(self.assigned_object)
+        obj_type = ObjectType.objects.get_for_model(self.assigned_object)
         if obj_type.model == 'vminterface':
             return self.assigned_object.virtual_machine
         elif obj_type.model == 'interface':