Arthur пре 1 година
родитељ
комит
c2a3275c79
100 измењених фајлова са 1358 додато и 826 уклоњено
  1. 1 1
      docs/models/extras/customfield.md
  2. 35 1
      docs/release-notes/version-4.0.md
  3. 1 1
      netbox/core/forms/filtersets.py
  4. 2 2
      netbox/core/management/commands/nbshell.py
  5. 2 4
      netbox/core/migrations/0008_contenttype_proxy.py
  6. 6 6
      netbox/core/models/contenttypes.py
  7. 3 3
      netbox/core/models/jobs.py
  8. 38 2
      netbox/dcim/filtersets.py
  9. 6 6
      netbox/dcim/models/cables.py
  10. 92 41
      netbox/dcim/tests/test_filtersets.py
  11. 3 3
      netbox/dcim/tests/test_models.py
  12. 0 2
      netbox/dcim/tests/test_views.py
  13. 8 8
      netbox/extras/api/customfields.py
  14. 1 1
      netbox/extras/api/serializers.py
  15. 6 6
      netbox/extras/api/serializers_/attachments.py
  16. 2 2
      netbox/extras/api/serializers_/bookmarks.py
  17. 9 9
      netbox/extras/api/serializers_/customfields.py
  18. 4 4
      netbox/extras/api/serializers_/customlinks.py
  19. 5 5
      netbox/extras/api/serializers_/events.py
  20. 4 4
      netbox/extras/api/serializers_/exporttemplates.py
  21. 2 2
      netbox/extras/api/serializers_/journaling.py
  22. 5 5
      netbox/extras/api/serializers_/objecttypes.py
  23. 4 4
      netbox/extras/api/serializers_/savedfilters.py
  24. 2 2
      netbox/extras/api/serializers_/tags.py
  25. 1 1
      netbox/extras/api/urls.py
  26. 7 8
      netbox/extras/api/views.py
  27. 6 6
      netbox/extras/dashboard/widgets.py
  28. 1 1
      netbox/extras/events.py
  29. 40 26
      netbox/extras/filtersets.py
  30. 25 25
      netbox/extras/forms/bulk_import.py
  31. 28 28
      netbox/extras/forms/filtersets.py
  32. 29 30
      netbox/extras/forms/model_forms.py
  33. 5 5
      netbox/extras/graphql/filters.py
  34. 0 3
      netbox/extras/migrations/0108_convert_reports_to_scripts.py
  35. 12 4
      netbox/extras/migrations/0109_script_model.py
  36. 3 0
      netbox/extras/migrations/0110_remove_eventrule_action_parameters.py
  37. 107 0
      netbox/extras/migrations/0111_rename_content_types.py
  38. 17 0
      netbox/extras/migrations/0112_tag_update_object_types.py
  39. 16 0
      netbox/extras/migrations/0113_customfield_rename_object_type.py
  40. 2 2
      netbox/extras/models/change_logging.py
  41. 15 15
      netbox/extras/models/customfields.py
  42. 21 21
      netbox/extras/models/models.py
  43. 1 1
      netbox/extras/models/tags.py
  44. 8 7
      netbox/extras/signals.py
  45. 90 27
      netbox/extras/tables/tables.py
  46. 3 3
      netbox/extras/templatetags/custom_links.py
  47. 33 33
      netbox/extras/tests/test_api.py
  48. 7 6
      netbox/extras/tests/test_changelog.py
  49. 62 50
      netbox/extras/tests/test_customfields.py
  50. 12 11
      netbox/extras/tests/test_event_rules.py
  51. 64 49
      netbox/extras/tests/test_filtersets.py
  52. 18 18
      netbox/extras/tests/test_forms.py
  53. 2 2
      netbox/extras/tests/test_models.py
  54. 22 21
      netbox/extras/tests/test_views.py
  55. 1 1
      netbox/extras/utils.py
  56. 61 6
      netbox/extras/views.py
  57. 2 2
      netbox/ipam/models/ip.py
  58. 0 2
      netbox/netbox/api/serializers/features.py
  59. 5 5
      netbox/netbox/api/viewsets/mixins.py
  60. 1 1
      netbox/netbox/filtersets.py
  61. 7 6
      netbox/netbox/forms/base.py
  62. 7 7
      netbox/netbox/forms/mixins.py
  63. 12 8
      netbox/netbox/models/features.py
  64. 13 12
      netbox/netbox/search/backends.py
  65. 4 4
      netbox/netbox/tables/tables.py
  66. 6 6
      netbox/netbox/tests/test_authentication.py
  67. 3 3
      netbox/netbox/tests/test_import.py
  68. 6 0
      netbox/netbox/tests/test_staging.py
  69. 3 3
      netbox/netbox/views/generic/bulk_views.py
  70. 4 2
      netbox/templates/extras/customfield.html
  71. 1 1
      netbox/templates/extras/customlink.html
  72. 2 2
      netbox/templates/extras/eventrule.html
  73. 2 7
      netbox/templates/extras/exporttemplate.html
  74. 49 110
      netbox/templates/extras/htmx/script_result.html
  75. 2 2
      netbox/templates/extras/savedfilter.html
  76. 60 14
      netbox/templates/extras/script_result.html
  77. 1 1
      netbox/templates/inc/panels/image_attachments.html
  78. 1 1
      netbox/templates/tenancy/object_contacts.html
  79. 3 3
      netbox/tenancy/api/serializers_/contacts.py
  80. 30 4
      netbox/tenancy/filtersets.py
  81. 2 2
      netbox/tenancy/forms/bulk_import.py
  82. 4 4
      netbox/tenancy/forms/filtersets.py
  83. 2 2
      netbox/tenancy/forms/model_forms.py
  84. 40 0
      netbox/tenancy/migrations/0015_contactassignment_rename_content_type.py
  85. 8 8
      netbox/tenancy/models/contacts.py
  86. 3 3
      netbox/tenancy/tables/contacts.py
  87. 3 3
      netbox/tenancy/tests/test_api.py
  88. 65 35
      netbox/tenancy/tests/test_filtersets.py
  89. 3 3
      netbox/tenancy/tests/test_views.py
  90. 4 4
      netbox/tenancy/views.py
  91. 2 2
      netbox/users/api/nested_serializers.py
  92. 2 2
      netbox/users/api/serializers_/permissions.py
  93. 2 2
      netbox/users/forms/model_forms.py
  94. 1 1
      netbox/users/migrations/0005_alter_user_table.py
  95. 1 1
      netbox/users/migrations/0006_custom_group_model.py
  96. 19 0
      netbox/users/migrations/0007_objectpermission_update_object_types.py
  97. 2 2
      netbox/users/models.py
  98. 3 3
      netbox/users/tests/test_api.py
  99. 5 5
      netbox/users/tests/test_filtersets.py
  100. 3 4
      netbox/users/tests/test_views.py

+ 1 - 1
docs/models/extras/customfield.md

@@ -38,7 +38,7 @@ The type of data this field holds. This must be one of the following:
 | Object             | A single NetBox object of the type defined by `object_type`        |
 | Object             | A single NetBox object of the type defined by `object_type`        |
 | Multiple object    | One or more NetBox objects of the type defined by `object_type`    |
 | Multiple object    | One or more NetBox objects of the type defined by `object_type`    |
 
 
-### Object Type
+### Related Object Type
 
 
 For object and multiple-object fields only. Designates the type of NetBox object being referenced.
 For object and multiple-object fields only. Designates the type of NetBox object being referenced.
 
 

+ 35 - 1
docs/release-notes/version-4.0.md

@@ -34,7 +34,7 @@ The REST API now supports specifying which fields to include in the response dat
 
 
 * [#12325](https://github.com/netbox-community/netbox/issues/12325) - The Django admin UI is now disabled by default (set `DJANGO_ADMIN_ENABLED` to True to enable it)
 * [#12325](https://github.com/netbox-community/netbox/issues/12325) - The Django admin UI is now disabled by default (set `DJANGO_ADMIN_ENABLED` to True to enable it)
 * [#12510](https://github.com/netbox-community/netbox/issues/12510) - Dropped support for legacy reports
 * [#12510](https://github.com/netbox-community/netbox/issues/12510) - Dropped support for legacy reports
-* [#12795](https://github.com/netbox-community/netbox/issues/12795) - NetBox now uses a custom User model rather than the stock model provided by Django
+* [#12795](https://github.com/netbox-community/netbox/issues/12795) - NetBox now uses custom User and Group models rather than the stock models provided by Django
 * [#13647](https://github.com/netbox-community/netbox/issues/13647) - Squash all database migrations prior to v3.7
 * [#13647](https://github.com/netbox-community/netbox/issues/13647) - Squash all database migrations prior to v3.7
 * [#14092](https://github.com/netbox-community/netbox/issues/14092) - Remove backward compatibility for importing plugin resources from `extras.plugins` (now `netbox.plugins`)
 * [#14092](https://github.com/netbox-community/netbox/issues/14092) - Remove backward compatibility for importing plugin resources from `extras.plugins` (now `netbox.plugins`)
 * [#14638](https://github.com/netbox-community/netbox/issues/14638) - Drop support for Python 3.8 and 3.9
 * [#14638](https://github.com/netbox-community/netbox/issues/14638) - Drop support for Python 3.8 and 3.9
@@ -44,3 +44,37 @@ The REST API now supports specifying which fields to include in the response dat
 * [#15042](https://github.com/netbox-community/netbox/issues/15042) - Rearchitect the logic for registering models & model features
 * [#15042](https://github.com/netbox-community/netbox/issues/15042) - Rearchitect the logic for registering models & model features
 * [#15099](https://github.com/netbox-community/netbox/issues/15099) - Remove obsolete `device_role` and `device_role_id` filters for devices
 * [#15099](https://github.com/netbox-community/netbox/issues/15099) - Remove obsolete `device_role` and `device_role_id` filters for devices
 * [#15100](https://github.com/netbox-community/netbox/issues/15100) - Remove obsolete `NullableCharField` class
 * [#15100](https://github.com/netbox-community/netbox/issues/15100) - Remove obsolete `NullableCharField` class
+* [#15277](https://github.com/netbox-community/netbox/issues/15277) - Replace references to ContentType without ObjectType proxy model & standardize field names
+* [#15292](https://github.com/netbox-community/netbox/issues/15292) - Remove obsolete `device_role` attribute from Device model (this field was renamed to `role` in v3.6)
+
+### REST API Changes
+
+* The `/api/extras/content-types/` endpoint has moved to `/api/extras/object-types/`
+* dcim.Device
+  * The obsolete read-only attribute `device_role` has been removed (replaced by `role` in v3.6)
+* extras.CustomField
+  * `content_types` has been renamed to `object_types`
+  * The `content_types` filter is now `object_type`
+  * The `content_type_id` filter is now `object_type_id`
+* extras.CustomLink
+  * `content_types` has been renamed to `object_types`
+  * The `content_types` filter is now `object_type`
+  * The `content_type_id` filter is now `object_type_id`
+* extras.EventRule
+  * `content_types` has been renamed to `object_types`
+  * The `content_types` filter is now `object_type`
+  * The `content_type_id` filter is now `object_type_id`
+* extras.ExportTemplate
+  * `content_types` has been renamed to `object_types`
+  * The `content_types` filter is now `object_type`
+  * The `content_type_id` filter is now `object_type_id`
+* extras.ImageAttachment
+  * `content_type` has been renamed to `object_type`
+  * The `content_type` filter is now `object_type`
+* extras.SavedFilter
+  * `content_types` has been renamed to `object_types`
+  * The `content_types` filter is now `object_type`
+  * The `content_type_id` filter is now `object_type_id`
+* tenancy.ContactAssignment
+  * `content_type` has been renamed to `object_type`
+  * The `content_type_id` filter is now `object_type_id`

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

@@ -68,7 +68,7 @@ class JobFilterForm(SavedFiltersMixin, FilterForm):
     )
     )
     object_type = ContentTypeChoiceField(
     object_type = ContentTypeChoiceField(
         label=_('Object Type'),
         label=_('Object Type'),
-        queryset=ContentType.objects.with_feature('jobs'),
+        queryset=ObjectType.objects.with_feature('jobs'),
         required=False,
         required=False,
     )
     )
     status = forms.MultipleChoiceField(
     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.contrib.auth import get_user_model
 from django.core.management.base import BaseCommand
 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')
 APPS = ('circuits', 'core', 'dcim', 'extras', 'ipam', 'tenancy', 'users', 'virtualization', 'vpn', 'wireless')
 
 
@@ -60,7 +60,7 @@ class Command(BaseCommand):
                 pass
                 pass
 
 
         # Additional objects to include
         # Additional objects to include
-        namespace['ContentType'] = ContentType
+        namespace['ObjectType'] = ObjectType
         namespace['User'] = get_user_model()
         namespace['User'] = get_user_model()
 
 
         # Load convenience commands
         # 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
 import core.models.contenttypes
 from django.db import migrations
 from django.db import migrations
 
 
@@ -13,7 +11,7 @@ class Migration(migrations.Migration):
 
 
     operations = [
     operations = [
         migrations.CreateModel(
         migrations.CreateModel(
-            name='ContentType',
+            name='ObjectType',
             fields=[
             fields=[
             ],
             ],
             options={
             options={
@@ -23,7 +21,7 @@ class Migration(migrations.Migration):
             },
             },
             bases=('contenttypes.contenttype',),
             bases=('contenttypes.contenttype',),
             managers=[
             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 django.db.models import Q
 
 
 from netbox.registry import registry
 from netbox.registry import registry
 
 
 __all__ = (
 __all__ = (
-    'ContentType',
-    'ContentTypeManager',
+    'ObjectType',
+    'ObjectTypeManager',
 )
 )
 
 
 
 
-class ContentTypeManager(ContentTypeManager_):
+class ObjectTypeManager(ContentTypeManager):
 
 
     def public(self):
     def public(self):
         """
         """
@@ -40,11 +40,11 @@ class ContentTypeManager(ContentTypeManager_):
         return self.get_queryset().filter(q)
         return self.get_queryset().filter(q)
 
 
 
 
-class ContentType(ContentType_):
+class ObjectType(ContentType):
     """
     """
     Wrap Django's native ContentType model to use our custom manager.
     Wrap Django's native ContentType model to use our custom manager.
     """
     """
-    objects = ContentTypeManager()
+    objects = ObjectTypeManager()
 
 
     class Meta:
     class Meta:
         proxy = True
         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 django.utils.translation import gettext as _
 
 
 from core.choices import JobStatusChoices
 from core.choices import JobStatusChoices
-from core.models import ContentType
+from core.models import ObjectType
 from core.signals import job_end, job_start
 from core.signals import job_end, job_start
 from extras.constants import EVENT_JOB_END, EVENT_JOB_START
 from extras.constants import EVENT_JOB_END, EVENT_JOB_START
 from netbox.config import get_config
 from netbox.config import get_config
@@ -130,7 +130,7 @@ class Job(models.Model):
         super().clean()
         super().clean()
 
 
         # Validate the assigned object type
         # 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(
             raise ValidationError(
                 _("Jobs cannot be assigned to this object type ({type}).").format(type=self.object_type)
                 _("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
             schedule_at: Schedule the job to be executed at the passed date and time
             interval: Recurrence interval (in minutes)
             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)
         rq_queue_name = get_queue_for_model(object_type.model)
         queue = django_rq.get_queue(rq_queue_name)
         queue = django_rq.get_queue(rq_queue_name)
         status = JobStatusChoices.STATUS_SCHEDULED if schedule_at else JobStatusChoices.STATUS_PENDING
         status = JobStatusChoices.STATUS_SCHEDULED if schedule_at else JobStatusChoices.STATUS_PENDING

+ 38 - 2
netbox/dcim/filtersets.py

@@ -89,6 +89,19 @@ class RegionFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet):
         to_field_name='slug',
         to_field_name='slug',
         label=_('Parent region (slug)'),
         label=_('Parent region (slug)'),
     )
     )
+    ancestor_id = TreeNodeMultipleChoiceFilter(
+        queryset=Region.objects.all(),
+        field_name='parent',
+        lookup_expr='in',
+        label=_('Region (ID)'),
+    )
+    ancestor = TreeNodeMultipleChoiceFilter(
+        queryset=Region.objects.all(),
+        field_name='parent',
+        lookup_expr='in',
+        to_field_name='slug',
+        label=_('Region (slug)'),
+    )
 
 
     class Meta:
     class Meta:
         model = Region
         model = Region
@@ -106,6 +119,19 @@ class SiteGroupFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet):
         to_field_name='slug',
         to_field_name='slug',
         label=_('Parent site group (slug)'),
         label=_('Parent site group (slug)'),
     )
     )
+    ancestor_id = TreeNodeMultipleChoiceFilter(
+        queryset=SiteGroup.objects.all(),
+        field_name='parent',
+        lookup_expr='in',
+        label=_('Site group (ID)'),
+    )
+    ancestor = TreeNodeMultipleChoiceFilter(
+        queryset=SiteGroup.objects.all(),
+        field_name='parent',
+        lookup_expr='in',
+        to_field_name='slug',
+        label=_('Site group (slug)'),
+    )
 
 
     class Meta:
     class Meta:
         model = SiteGroup
         model = SiteGroup
@@ -214,13 +240,23 @@ class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, OrganizationalM
         to_field_name='slug',
         to_field_name='slug',
         label=_('Site (slug)'),
         label=_('Site (slug)'),
     )
     )
-    parent_id = TreeNodeMultipleChoiceFilter(
+    parent_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=Location.objects.all(),
+        label=_('Parent location (ID)'),
+    )
+    parent = django_filters.ModelMultipleChoiceFilter(
+        field_name='parent__slug',
+        queryset=Location.objects.all(),
+        to_field_name='slug',
+        label=_('Parent location (slug)'),
+    )
+    ancestor_id = TreeNodeMultipleChoiceFilter(
         queryset=Location.objects.all(),
         queryset=Location.objects.all(),
         field_name='parent',
         field_name='parent',
         lookup_expr='in',
         lookup_expr='in',
         label=_('Location (ID)'),
         label=_('Location (ID)'),
     )
     )
-    parent = TreeNodeMultipleChoiceFilter(
+    ancestor = TreeNodeMultipleChoiceFilter(
         queryset=Location.objects.all(),
         queryset=Location.objects.all(),
         field_name='parent',
         field_name='parent',
         lookup_expr='in',
         lookup_expr='in',

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

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

+ 92 - 41
netbox/dcim/tests/test_filtersets.py

@@ -64,21 +64,32 @@ class RegionTestCase(TestCase, ChangeLoggedFilterSetTests):
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
 
 
-        regions = (
+        parent_regions = (
             Region(name='Region 1', slug='region-1', description='foobar1'),
             Region(name='Region 1', slug='region-1', description='foobar1'),
             Region(name='Region 2', slug='region-2', description='foobar2'),
             Region(name='Region 2', slug='region-2', description='foobar2'),
             Region(name='Region 3', slug='region-3', description='foobar3'),
             Region(name='Region 3', slug='region-3', description='foobar3'),
         )
         )
+        for region in parent_regions:
+            region.save()
+
+        regions = (
+            Region(name='Region 1A', slug='region-1a', parent=parent_regions[0]),
+            Region(name='Region 1B', slug='region-1b', parent=parent_regions[0]),
+            Region(name='Region 2A', slug='region-2a', parent=parent_regions[1]),
+            Region(name='Region 2B', slug='region-2b', parent=parent_regions[1]),
+            Region(name='Region 3A', slug='region-3a', parent=parent_regions[2]),
+            Region(name='Region 3B', slug='region-3b', parent=parent_regions[2]),
+        )
         for region in regions:
         for region in regions:
             region.save()
             region.save()
 
 
         child_regions = (
         child_regions = (
-            Region(name='Region 1A', slug='region-1a', parent=regions[0]),
-            Region(name='Region 1B', slug='region-1b', parent=regions[0]),
-            Region(name='Region 2A', slug='region-2a', parent=regions[1]),
-            Region(name='Region 2B', slug='region-2b', parent=regions[1]),
-            Region(name='Region 3A', slug='region-3a', parent=regions[2]),
-            Region(name='Region 3B', slug='region-3b', parent=regions[2]),
+            Region(name='Region 1A1', slug='region-1a1', parent=regions[0]),
+            Region(name='Region 1B1', slug='region-1b1', parent=regions[1]),
+            Region(name='Region 2A1', slug='region-2a1', parent=regions[2]),
+            Region(name='Region 2B1', slug='region-2b1', parent=regions[3]),
+            Region(name='Region 3A1', slug='region-3a1', parent=regions[4]),
+            Region(name='Region 3B1', slug='region-3b1', parent=regions[5]),
         )
         )
         for region in child_regions:
         for region in child_regions:
             region.save()
             region.save()
@@ -100,12 +111,19 @@ class RegionTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
     def test_parent(self):
     def test_parent(self):
-        parent_regions = Region.objects.filter(parent__isnull=True)[:2]
-        params = {'parent_id': [parent_regions[0].pk, parent_regions[1].pk]}
+        regions = Region.objects.filter(parent__isnull=True)[:2]
+        params = {'parent_id': [regions[0].pk, regions[1].pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
-        params = {'parent': [parent_regions[0].slug, parent_regions[1].slug]}
+        params = {'parent': [regions[0].slug, regions[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
 
+    def test_ancestor(self):
+        regions = Region.objects.filter(parent__isnull=True)[:2]
+        params = {'ancestor_id': [regions[0].pk, regions[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8)
+        params = {'ancestor': [regions[0].slug, regions[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8)
+
 
 
 class SiteGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
 class SiteGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = SiteGroup.objects.all()
     queryset = SiteGroup.objects.all()
@@ -114,24 +132,35 @@ class SiteGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
 
 
-        sitegroups = (
+        parent_groups = (
             SiteGroup(name='Site Group 1', slug='site-group-1', description='foobar1'),
             SiteGroup(name='Site Group 1', slug='site-group-1', description='foobar1'),
             SiteGroup(name='Site Group 2', slug='site-group-2', description='foobar2'),
             SiteGroup(name='Site Group 2', slug='site-group-2', description='foobar2'),
             SiteGroup(name='Site Group 3', slug='site-group-3', description='foobar3'),
             SiteGroup(name='Site Group 3', slug='site-group-3', description='foobar3'),
         )
         )
-        for sitegroup in sitegroups:
-            sitegroup.save()
+        for site_group in parent_groups:
+            site_group.save()
 
 
-        child_sitegroups = (
-            SiteGroup(name='Site Group 1A', slug='site-group-1a', parent=sitegroups[0]),
-            SiteGroup(name='Site Group 1B', slug='site-group-1b', parent=sitegroups[0]),
-            SiteGroup(name='Site Group 2A', slug='site-group-2a', parent=sitegroups[1]),
-            SiteGroup(name='Site Group 2B', slug='site-group-2b', parent=sitegroups[1]),
-            SiteGroup(name='Site Group 3A', slug='site-group-3a', parent=sitegroups[2]),
-            SiteGroup(name='Site Group 3B', slug='site-group-3b', parent=sitegroups[2]),
-        )
-        for sitegroup in child_sitegroups:
-            sitegroup.save()
+        groups = (
+            SiteGroup(name='Site Group 1A', slug='site-group-1a', parent=parent_groups[0]),
+            SiteGroup(name='Site Group 1B', slug='site-group-1b', parent=parent_groups[0]),
+            SiteGroup(name='Site Group 2A', slug='site-group-2a', parent=parent_groups[1]),
+            SiteGroup(name='Site Group 2B', slug='site-group-2b', parent=parent_groups[1]),
+            SiteGroup(name='Site Group 3A', slug='site-group-3a', parent=parent_groups[2]),
+            SiteGroup(name='Site Group 3B', slug='site-group-3b', parent=parent_groups[2]),
+        )
+        for site_group in groups:
+            site_group.save()
+
+        child_groups = (
+            SiteGroup(name='Site Group 1A1', slug='site-group-1a1', parent=groups[0]),
+            SiteGroup(name='Site Group 1B1', slug='site-group-1b1', parent=groups[1]),
+            SiteGroup(name='Site Group 2A1', slug='site-group-2a1', parent=groups[2]),
+            SiteGroup(name='Site Group 2B1', slug='site-group-2b1', parent=groups[3]),
+            SiteGroup(name='Site Group 3A1', slug='site-group-3a1', parent=groups[4]),
+            SiteGroup(name='Site Group 3B1', slug='site-group-3b1', parent=groups[5]),
+        )
+        for site_group in child_groups:
+            site_group.save()
 
 
     def test_q(self):
     def test_q(self):
         params = {'q': 'foobar1'}
         params = {'q': 'foobar1'}
@@ -150,12 +179,19 @@ class SiteGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
     def test_parent(self):
     def test_parent(self):
-        parent_sitegroups = SiteGroup.objects.filter(parent__isnull=True)[:2]
-        params = {'parent_id': [parent_sitegroups[0].pk, parent_sitegroups[1].pk]}
+        site_groups = SiteGroup.objects.filter(parent__isnull=True)[:2]
+        params = {'parent_id': [site_groups[0].pk, site_groups[1].pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
-        params = {'parent': [parent_sitegroups[0].slug, parent_sitegroups[1].slug]}
+        params = {'parent': [site_groups[0].slug, site_groups[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
 
+    def test_ancestor(self):
+        site_groups = SiteGroup.objects.filter(parent__isnull=True)[:2]
+        params = {'ancestor_id': [site_groups[0].pk, site_groups[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8)
+        params = {'ancestor': [site_groups[0].slug, site_groups[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8)
+
 
 
 class SiteTestCase(TestCase, ChangeLoggedFilterSetTests):
 class SiteTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = Site.objects.all()
     queryset = Site.objects.all()
@@ -314,21 +350,29 @@ class LocationTestCase(TestCase, ChangeLoggedFilterSetTests):
         Site.objects.bulk_create(sites)
         Site.objects.bulk_create(sites)
 
 
         parent_locations = (
         parent_locations = (
-            Location(name='Parent Location 1', slug='parent-location-1', site=sites[0]),
-            Location(name='Parent Location 2', slug='parent-location-2', site=sites[1]),
-            Location(name='Parent Location 3', slug='parent-location-3', site=sites[2]),
+            Location(name='Location 1', slug='location-1', site=sites[0]),
+            Location(name='Location 2', slug='location-2', site=sites[1]),
+            Location(name='Location 3', slug='location-3', site=sites[2]),
         )
         )
         for location in parent_locations:
         for location in parent_locations:
             location.save()
             location.save()
 
 
         locations = (
         locations = (
-            Location(name='Location 1', slug='location-1', site=sites[0], parent=parent_locations[0], status=LocationStatusChoices.STATUS_PLANNED, description='foobar1'),
-            Location(name='Location 2', slug='location-2', site=sites[1], parent=parent_locations[1], status=LocationStatusChoices.STATUS_STAGING, description='foobar2'),
-            Location(name='Location 3', slug='location-3', site=sites[2], parent=parent_locations[2], status=LocationStatusChoices.STATUS_DECOMMISSIONING, description='foobar3'),
+            Location(name='Location 1A', slug='location-1a', site=sites[0], parent=parent_locations[0], status=LocationStatusChoices.STATUS_PLANNED, description='foobar1'),
+            Location(name='Location 2A', slug='location-2a', site=sites[1], parent=parent_locations[1], status=LocationStatusChoices.STATUS_STAGING, description='foobar2'),
+            Location(name='Location 3A', slug='location-3a', site=sites[2], parent=parent_locations[2], status=LocationStatusChoices.STATUS_DECOMMISSIONING, description='foobar3'),
         )
         )
         for location in locations:
         for location in locations:
             location.save()
             location.save()
 
 
+        child_locations = (
+            Location(name='Location 1A1', slug='location-1a1', site=sites[0], parent=locations[0]),
+            Location(name='Location 2A1', slug='location-2a1', site=sites[1], parent=locations[1]),
+            Location(name='Location 3A1', slug='location-3a1', site=sites[2], parent=locations[2]),
+        )
+        for location in child_locations:
+            location.save()
+
     def test_q(self):
     def test_q(self):
         params = {'q': 'foobar1'}
         params = {'q': 'foobar1'}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
@@ -352,31 +396,38 @@ class LocationTestCase(TestCase, ChangeLoggedFilterSetTests):
     def test_region(self):
     def test_region(self):
         regions = Region.objects.all()[:2]
         regions = Region.objects.all()[:2]
         params = {'region_id': [regions[0].pk, regions[1].pk]}
         params = {'region_id': [regions[0].pk, regions[1].pk]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
         params = {'region': [regions[0].slug, regions[1].slug]}
         params = {'region': [regions[0].slug, regions[1].slug]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
 
 
     def test_site_group(self):
     def test_site_group(self):
         site_groups = SiteGroup.objects.all()[:2]
         site_groups = SiteGroup.objects.all()[:2]
         params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]}
         params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
         params = {'site_group': [site_groups[0].slug, site_groups[1].slug]}
         params = {'site_group': [site_groups[0].slug, site_groups[1].slug]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
 
 
     def test_site(self):
     def test_site(self):
         sites = Site.objects.all()[:2]
         sites = Site.objects.all()[:2]
         params = {'site_id': [sites[0].pk, sites[1].pk]}
         params = {'site_id': [sites[0].pk, sites[1].pk]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
         params = {'site': [sites[0].slug, sites[1].slug]}
         params = {'site': [sites[0].slug, sites[1].slug]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
 
 
     def test_parent(self):
     def test_parent(self):
-        parent_groups = Location.objects.filter(name__startswith='Parent')[:2]
-        params = {'parent_id': [parent_groups[0].pk, parent_groups[1].pk]}
+        locations = Location.objects.filter(parent__isnull=True)[:2]
+        params = {'parent_id': [locations[0].pk, locations[1].pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-        params = {'parent': [parent_groups[0].slug, parent_groups[1].slug]}
+        params = {'parent': [locations[0].slug, locations[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
+    def test_ancestor(self):
+        locations = Location.objects.filter(parent__isnull=True)[:2]
+        params = {'ancestor_id': [locations[0].pk, locations[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+        params = {'ancestor': [locations[0].slug, locations[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+
 
 
 class RackRoleTestCase(TestCase, ChangeLoggedFilterSetTests):
 class RackRoleTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = RackRole.objects.all()
     queryset = RackRole.objects.all()

+ 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.core.exceptions import ValidationError
 from django.test import TestCase
 from django.test import TestCase
 
 
 from circuits.models import *
 from circuits.models import *
+from core.models import ObjectType
 from dcim.choices import *
 from dcim.choices import *
 from dcim.models import *
 from dcim.models import *
 from extras.models import CustomField
 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
         # Create a CustomField with a default value & assign it to all component models
         cf1 = CustomField.objects.create(name='cf1', default='foo')
         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',
                 'consoleport',
                 'consoleserverport',
                 'consoleserverport',
                 'powerport',
                 'powerport',

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

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

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

@@ -1,10 +1,10 @@
-from django.contrib.contenttypes.models import ContentType
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 from drf_spectacular.types import OpenApiTypes
 from drf_spectacular.types import OpenApiTypes
 from drf_spectacular.utils import extend_schema_field
 from drf_spectacular.utils import extend_schema_field
 from rest_framework.fields import Field
 from rest_framework.fields import Field
 from rest_framework.serializers import ValidationError
 from rest_framework.serializers import ValidationError
 
 
+from core.models import ObjectType
 from extras.choices import CustomFieldTypeChoices
 from extras.choices import CustomFieldTypeChoices
 from extras.models import CustomField
 from extras.models import CustomField
 from utilities.api import get_serializer_for_model
 from utilities.api import get_serializer_for_model
@@ -24,8 +24,8 @@ class CustomFieldDefaultValues:
         self.model = serializer_field.parent.Meta.model
         self.model = serializer_field.parent.Meta.model
 
 
         # Retrieve the CustomFields for the parent 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
         # Populate the default value for each CustomField
         value = {}
         value = {}
@@ -46,8 +46,8 @@ class CustomFieldsDataField(Field):
         Cache CustomFields assigned to this model to avoid redundant database queries
         Cache CustomFields assigned to this model to avoid redundant database queries
         """
         """
         if not hasattr(self, '_custom_fields'):
         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
         return self._custom_fields
 
 
     def to_representation(self, obj):
     def to_representation(self, obj):
@@ -57,10 +57,10 @@ class CustomFieldsDataField(Field):
         for cf in self._get_custom_fields():
         for cf in self._get_custom_fields():
             value = cf.deserialize(obj.get(cf.name))
             value = cf.deserialize(obj.get(cf.name))
             if value is not None and cf.type == CustomFieldTypeChoices.TYPE_OBJECT:
             if value is not None and cf.type == CustomFieldTypeChoices.TYPE_OBJECT:
-                serializer = get_serializer_for_model(cf.object_type.model_class())
+                serializer = get_serializer_for_model(cf.related_object_type.model_class())
                 value = serializer(value, nested=True, context=self.parent.context).data
                 value = serializer(value, nested=True, context=self.parent.context).data
             elif value is not None and cf.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
             elif value is not None and cf.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
-                serializer = get_serializer_for_model(cf.object_type.model_class())
+                serializer = get_serializer_for_model(cf.related_object_type.model_class())
                 value = serializer(value, nested=True, many=True, context=self.parent.context).data
                 value = serializer(value, nested=True, many=True, context=self.parent.context).data
             data[cf.name] = value
             data[cf.name] = value
 
 
@@ -79,7 +79,7 @@ class CustomFieldsDataField(Field):
                     CustomFieldTypeChoices.TYPE_OBJECT,
                     CustomFieldTypeChoices.TYPE_OBJECT,
                     CustomFieldTypeChoices.TYPE_MULTIOBJECT
                     CustomFieldTypeChoices.TYPE_MULTIOBJECT
             ):
             ):
-                serializer_class = get_serializer_for_model(cf.object_type.model_class())
+                serializer_class = get_serializer_for_model(cf.related_object_type.model_class())
                 many = cf.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT
                 many = cf.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT
                 serializer = serializer_class(data=data[cf.name], nested=True, many=many, context=self.parent.context)
                 serializer = serializer_class(data=data[cf.name], nested=True, many=many, context=self.parent.context)
                 if serializer.is_valid():
                 if serializer.is_valid():

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

@@ -1,7 +1,7 @@
+from .serializers_.objecttypes import *
 from .serializers_.attachments import *
 from .serializers_.attachments import *
 from .serializers_.bookmarks import *
 from .serializers_.bookmarks import *
 from .serializers_.change_logging import *
 from .serializers_.change_logging import *
-from .serializers_.contenttypes import *
 from .serializers_.customfields import *
 from .serializers_.customfields import *
 from .serializers_.customlinks import *
 from .serializers_.customlinks import *
 from .serializers_.dashboard 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 drf_spectacular.utils import extend_schema_field
 from rest_framework import serializers
 from rest_framework import serializers
 
 
-from core.models import ContentType
+from core.models import ObjectType
 from extras.models import ImageAttachment
 from extras.models import ImageAttachment
 from netbox.api.fields import ContentTypeField
 from netbox.api.fields import ContentTypeField
 from netbox.api.serializers import ValidatedModelSerializer
 from netbox.api.serializers import ValidatedModelSerializer
@@ -15,15 +15,15 @@ __all__ = (
 
 
 class ImageAttachmentSerializer(ValidatedModelSerializer):
 class ImageAttachmentSerializer(ValidatedModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='extras-api:imageattachment-detail')
     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)
     parent = serializers.SerializerMethodField(read_only=True)
 
 
     class Meta:
     class Meta:
         model = ImageAttachment
         model = ImageAttachment
         fields = [
         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',
             'image_width', 'created', 'last_updated',
         ]
         ]
         brief_fields = ('id', 'url', 'display', 'name', 'image')
         brief_fields = ('id', 'url', 'display', 'name', 'image')
@@ -32,10 +32,10 @@ class ImageAttachmentSerializer(ValidatedModelSerializer):
 
 
         # Validate that the parent object exists
         # Validate that the parent object exists
         try:
         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:
         except ObjectDoesNotExist:
             raise serializers.ValidationError(
             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
         # Enforce model validation

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

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

+ 9 - 9
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 drf_spectacular.utils import extend_schema_field
 from rest_framework import serializers
 from rest_framework import serializers
 
 
-from core.models import ContentType
+from core.models import ObjectType
 from extras.choices import *
 from extras.choices import *
 from extras.models import CustomField, CustomFieldChoiceSet
 from extras.models import CustomField, CustomFieldChoiceSet
 from netbox.api.fields import ChoiceField, ContentTypeField
 from netbox.api.fields import ChoiceField, ContentTypeField
@@ -39,13 +39,13 @@ class CustomFieldChoiceSetSerializer(ValidatedModelSerializer):
 
 
 class CustomFieldSerializer(ValidatedModelSerializer):
 class CustomFieldSerializer(ValidatedModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='extras-api:customfield-detail')
     url = serializers.HyperlinkedIdentityField(view_name='extras-api:customfield-detail')
-    content_types = ContentTypeField(
-        queryset=ContentType.objects.with_feature('custom_fields'),
+    object_types = ContentTypeField(
+        queryset=ObjectType.objects.with_feature('custom_fields'),
         many=True
         many=True
     )
     )
     type = ChoiceField(choices=CustomFieldTypeChoices)
     type = ChoiceField(choices=CustomFieldTypeChoices)
-    object_type = ContentTypeField(
-        queryset=ContentType.objects.all(),
+    related_object_type = ContentTypeField(
+        queryset=ObjectType.objects.all(),
         required=False,
         required=False,
         allow_null=True
         allow_null=True
     )
     )
@@ -62,10 +62,10 @@ class CustomFieldSerializer(ValidatedModelSerializer):
     class Meta:
     class Meta:
         model = CustomField
         model = CustomField
         fields = [
         fields = [
-            'id', 'url', 'display', 'content_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',
+            'id', 'url', 'display', 'object_types', 'type', 'related_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',
         ]
         ]
         brief_fields = ('id', 'url', 'display', 'name', 'description')
         brief_fields = ('id', 'url', 'display', 'name', 'description')
 
 

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

@@ -1,6 +1,6 @@
 from rest_framework import serializers
 from rest_framework import serializers
 
 
-from core.models import ContentType
+from core.models import ObjectType
 from extras.models import CustomLink
 from extras.models import CustomLink
 from netbox.api.fields import ContentTypeField
 from netbox.api.fields import ContentTypeField
 from netbox.api.serializers import ValidatedModelSerializer
 from netbox.api.serializers import ValidatedModelSerializer
@@ -12,15 +12,15 @@ __all__ = (
 
 
 class CustomLinkSerializer(ValidatedModelSerializer):
 class CustomLinkSerializer(ValidatedModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='extras-api:customlink-detail')
     url = serializers.HyperlinkedIdentityField(view_name='extras-api:customlink-detail')
-    content_types = ContentTypeField(
-        queryset=ContentType.objects.with_feature('custom_links'),
+    object_types = ContentTypeField(
+        queryset=ObjectType.objects.with_feature('custom_links'),
         many=True
         many=True
     )
     )
 
 
     class Meta:
     class Meta:
         model = CustomLink
         model = CustomLink
         fields = [
         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',
             'button_class', 'new_window', 'created', 'last_updated',
         ]
         ]
         brief_fields = ('id', 'url', 'display', 'name')
         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 drf_spectacular.utils import extend_schema_field
 from rest_framework import serializers
 from rest_framework import serializers
 
 
-from core.models import ContentType
+from core.models import ObjectType
 from extras.choices import *
 from extras.choices import *
 from extras.models import EventRule, Webhook
 from extras.models import EventRule, Webhook
 from netbox.api.fields import ChoiceField, ContentTypeField
 from netbox.api.fields import ChoiceField, ContentTypeField
@@ -22,20 +22,20 @@ __all__ = (
 
 
 class EventRuleSerializer(NetBoxModelSerializer):
 class EventRuleSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='extras-api:eventrule-detail')
     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
         many=True
     )
     )
     action_type = ChoiceField(choices=EventRuleActionChoices)
     action_type = ChoiceField(choices=EventRuleActionChoices)
     action_object_type = ContentTypeField(
     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)
     action_object = serializers.SerializerMethodField(read_only=True)
 
 
     class Meta:
     class Meta:
         model = EventRule
         model = EventRule
         fields = [
         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',
             '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',
             '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 rest_framework import serializers
 
 
 from core.api.serializers_.data import DataFileSerializer, DataSourceSerializer
 from core.api.serializers_.data import DataFileSerializer, DataSourceSerializer
-from core.models import ContentType
+from core.models import ObjectType
 from extras.models import ExportTemplate
 from extras.models import ExportTemplate
 from netbox.api.fields import ContentTypeField
 from netbox.api.fields import ContentTypeField
 from netbox.api.serializers import ValidatedModelSerializer
 from netbox.api.serializers import ValidatedModelSerializer
@@ -13,8 +13,8 @@ __all__ = (
 
 
 class ExportTemplateSerializer(ValidatedModelSerializer):
 class ExportTemplateSerializer(ValidatedModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='extras-api:exporttemplate-detail')
     url = serializers.HyperlinkedIdentityField(view_name='extras-api:exporttemplate-detail')
-    content_types = ContentTypeField(
-        queryset=ContentType.objects.with_feature('export_templates'),
+    object_types = ContentTypeField(
+        queryset=ObjectType.objects.with_feature('export_templates'),
         many=True
         many=True
     )
     )
     data_source = DataSourceSerializer(
     data_source = DataSourceSerializer(
@@ -29,7 +29,7 @@ class ExportTemplateSerializer(ValidatedModelSerializer):
     class Meta:
     class Meta:
         model = ExportTemplate
         model = ExportTemplate
         fields = [
         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',
             'file_extension', 'as_attachment', 'data_source', 'data_path', 'data_file', 'data_synced', 'created',
             'last_updated',
             '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 drf_spectacular.utils import extend_schema_field
 from rest_framework import serializers
 from rest_framework import serializers
 
 
-from core.models import ContentType
+from core.models import ObjectType
 from extras.choices import *
 from extras.choices import *
 from extras.models import JournalEntry
 from extras.models import JournalEntry
 from netbox.api.fields import ChoiceField, ContentTypeField
 from netbox.api.fields import ChoiceField, ContentTypeField
@@ -18,7 +18,7 @@ __all__ = (
 class JournalEntrySerializer(NetBoxModelSerializer):
 class JournalEntrySerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='extras-api:journalentry-detail')
     url = serializers.HyperlinkedIdentityField(view_name='extras-api:journalentry-detail')
     assigned_object_type = ContentTypeField(
     assigned_object_type = ContentTypeField(
-        queryset=ContentType.objects.all()
+        queryset=ObjectType.objects.all()
     )
     )
     assigned_object = serializers.SerializerMethodField(read_only=True)
     assigned_object = serializers.SerializerMethodField(read_only=True)
     created_by = serializers.PrimaryKeyRelatedField(
     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 rest_framework import serializers
 
 
-from core.models import ContentType
+from core.models import ObjectType
 from netbox.api.serializers import BaseModelSerializer
 from netbox.api.serializers import BaseModelSerializer
 
 
 __all__ = (
 __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:
     class Meta:
-        model = ContentType
+        model = ObjectType
         fields = ['id', 'url', 'display', 'app_label', 'model']
         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 rest_framework import serializers
 
 
-from core.models import ContentType
+from core.models import ObjectType
 from extras.models import SavedFilter
 from extras.models import SavedFilter
 from netbox.api.fields import ContentTypeField
 from netbox.api.fields import ContentTypeField
 from netbox.api.serializers import ValidatedModelSerializer
 from netbox.api.serializers import ValidatedModelSerializer
@@ -12,15 +12,15 @@ __all__ = (
 
 
 class SavedFilterSerializer(ValidatedModelSerializer):
 class SavedFilterSerializer(ValidatedModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='extras-api:savedfilter-detail')
     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
         many=True
     )
     )
 
 
     class Meta:
     class Meta:
         model = SavedFilter
         model = SavedFilter
         fields = [
         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',
             'shared', 'parameters', 'created', 'last_updated',
         ]
         ]
         brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description')
         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 rest_framework import serializers
 
 
-from core.models import ContentType
+from core.models import ObjectType
 from extras.models import Tag
 from extras.models import Tag
 from netbox.api.fields import ContentTypeField, RelatedObjectCountField
 from netbox.api.fields import ContentTypeField, RelatedObjectCountField
 from netbox.api.serializers import ValidatedModelSerializer
 from netbox.api.serializers import ValidatedModelSerializer
@@ -13,7 +13,7 @@ __all__ = (
 class TagSerializer(ValidatedModelSerializer):
 class TagSerializer(ValidatedModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='extras-api:tag-detail')
     url = serializers.HyperlinkedIdentityField(view_name='extras-api:tag-detail')
     object_types = ContentTypeField(
     object_types = ContentTypeField(
-        queryset=ContentType.objects.with_feature('tags'),
+        queryset=ObjectType.objects.with_feature('tags'),
         many=True,
         many=True,
         required=False
         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('config-templates', views.ConfigTemplateViewSet)
 router.register('scripts', views.ScriptViewSet, basename='script')
 router.register('scripts', views.ScriptViewSet, basename='script')
 router.register('object-changes', views.ObjectChangeViewSet)
 router.register('object-changes', views.ObjectChangeViewSet)
-router.register('content-types', views.ContentTypeViewSet)
+router.register('object-types', views.ObjectTypeViewSet)
 
 
 app_name = 'extras-api'
 app_name = 'extras-api'
 urlpatterns = [
 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.shortcuts import get_object_or_404
 from django_rq.queues import get_connection
 from django_rq.queues import get_connection
 from rest_framework import status
 from rest_framework import status
@@ -11,7 +10,7 @@ from rest_framework.routers import APIRootView
 from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
 from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
 from rq import Worker
 from rq import Worker
 
 
-from core.models import Job
+from core.models import Job, ObjectType
 from extras import filtersets
 from extras import filtersets
 from extras.models import *
 from extras.models import *
 from extras.scripts import run_script
 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]
     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.urls import NoReverseMatch, resolve, reverse
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
-from core.models import ContentType
+from core.models import ObjectType
 from extras.choices import BookmarkOrderingChoices
 from extras.choices import BookmarkOrderingChoices
 from utilities.choices import ButtonColorChoices
 from utilities.choices import ButtonColorChoices
 from utilities.permissions import get_permission_for_model
 from utilities.permissions import get_permission_for_model
@@ -34,14 +34,14 @@ __all__ = (
 def get_object_type_choices():
 def get_object_type_choices():
     return [
     return [
         (content_type_identifier(ct), content_type_name(ct))
         (content_type_identifier(ct), content_type_name(ct))
-        for ct in ContentType.objects.public().order_by('app_label', 'model')
+        for ct in ObjectType.objects.public().order_by('app_label', 'model')
     ]
     ]
 
 
 
 
 def get_bookmarks_object_type_choices():
 def get_bookmarks_object_type_choices():
     return [
     return [
         (content_type_identifier(ct), content_type_name(ct))
         (content_type_identifier(ct), content_type_name(ct))
-        for ct in ContentType.objects.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 = []
     models = []
     for content_type_id in content_types:
     for content_type_id in content_types:
         app_label, model_name = content_type_id.split('.')
         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())
         models.append(content_type.model_class())
     return models
     return models
 
 
@@ -238,7 +238,7 @@ class ObjectListWidget(DashboardWidget):
 
 
     def render(self, request):
     def render(self, request):
         app_label, model_name = self.config['model'].split('.')
         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')
         viewname = get_viewname(model, action='list')
 
 
         # Evaluate user's permission. Note that this controls only whether the HTMX element is
         # 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'])
             bookmarks = Bookmark.objects.filter(user=request.user).order_by(self.config['order_by'])
             if object_types := self.config.get('object_types'):
             if object_types := self.config.get('object_types'):
                 models = get_models_from_content_types(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)
                 bookmarks = bookmarks.filter(object_type__in=conent_types)
             if max_items := self.config.get('max_items'):
             if max_items := self.config.get('max_items'):
                 bookmarks = bookmarks[: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]:
         if content_type not in events_cache[action_flag]:
             events_cache[action_flag][content_type] = EventRule.objects.filter(
             events_cache[action_flag][content_type] = EventRule.objects.filter(
                 **{action_flag: True},
                 **{action_flag: True},
-                content_types=content_type,
+                object_types=content_type,
                 enabled=True
                 enabled=True
             )
             )
         event_rules = events_cache[action_flag][content_type]
         event_rules = events_cache[action_flag][content_type]

+ 40 - 26
netbox/extras/filtersets.py

@@ -4,7 +4,7 @@ from django.contrib.contenttypes.models import ContentType
 from django.db.models import Q
 from django.db.models import Q
 from django.utils.translation import gettext as _
 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 dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
 from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet
 from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
@@ -18,7 +18,6 @@ __all__ = (
     'BookmarkFilterSet',
     'BookmarkFilterSet',
     'ConfigContextFilterSet',
     'ConfigContextFilterSet',
     'ConfigTemplateFilterSet',
     'ConfigTemplateFilterSet',
-    'ContentTypeFilterSet',
     'CustomFieldChoiceSetFilterSet',
     'CustomFieldChoiceSetFilterSet',
     'CustomFieldFilterSet',
     'CustomFieldFilterSet',
     'CustomLinkFilterSet',
     'CustomLinkFilterSet',
@@ -28,6 +27,7 @@ __all__ = (
     'JournalEntryFilterSet',
     'JournalEntryFilterSet',
     'LocalConfigContextFilterSet',
     'LocalConfigContextFilterSet',
     'ObjectChangeFilterSet',
     'ObjectChangeFilterSet',
+    'ObjectTypeFilterSet',
     'SavedFilterFilterSet',
     'SavedFilterFilterSet',
     'ScriptFilterSet',
     'ScriptFilterSet',
     'TagFilterSet',
     'TagFilterSet',
@@ -89,10 +89,12 @@ class EventRuleFilterSet(NetBoxModelFilterSet):
         method='search',
         method='search',
         label=_('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(
     action_type = django_filters.MultipleChoiceFilter(
         choices=EventRuleActionChoices
         choices=EventRuleActionChoices
     )
     )
@@ -124,10 +126,16 @@ class CustomFieldFilterSet(BaseFilterSet):
     type = django_filters.MultipleChoiceFilter(
     type = django_filters.MultipleChoiceFilter(
         choices=CustomFieldTypeChoices
         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()
+    related_object_type_id = MultiValueNumberFilter(
+        field_name='related_object_type__id'
+    )
+    related_object_type = ContentTypeFilter()
     choice_set_id = django_filters.ModelMultipleChoiceFilter(
     choice_set_id = django_filters.ModelMultipleChoiceFilter(
         queryset=CustomFieldChoiceSet.objects.all()
         queryset=CustomFieldChoiceSet.objects.all()
     )
     )
@@ -140,8 +148,8 @@ class CustomFieldFilterSet(BaseFilterSet):
     class Meta:
     class Meta:
         model = CustomField
         model = CustomField
         fields = [
         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):
     def search(self, queryset, name, value):
@@ -188,15 +196,17 @@ class CustomLinkFilterSet(BaseFilterSet):
         method='search',
         method='search',
         label=_('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:
     class Meta:
         model = CustomLink
         model = CustomLink
         fields = [
         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):
     def search(self, queryset, name, value):
@@ -215,10 +225,12 @@ class ExportTemplateFilterSet(BaseFilterSet):
         method='search',
         method='search',
         label=_('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(
     data_source_id = django_filters.ModelMultipleChoiceFilter(
         queryset=DataSource.objects.all(),
         queryset=DataSource.objects.all(),
         label=_('Data source (ID)'),
         label=_('Data source (ID)'),
@@ -230,7 +242,7 @@ class ExportTemplateFilterSet(BaseFilterSet):
 
 
     class Meta:
     class Meta:
         model = ExportTemplate
         model = ExportTemplate
-        fields = ['id', 'content_types', 'name', 'description', 'data_synced']
+        fields = ['id', 'name', 'description', 'data_synced']
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -246,10 +258,12 @@ class SavedFilterFilterSet(BaseFilterSet):
         method='search',
         method='search',
         label=_('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(
     user_id = django_filters.ModelMultipleChoiceFilter(
         queryset=get_user_model().objects.all(),
         queryset=get_user_model().objects.all(),
         label=_('User (ID)'),
         label=_('User (ID)'),
@@ -266,7 +280,7 @@ class SavedFilterFilterSet(BaseFilterSet):
 
 
     class Meta:
     class Meta:
         model = SavedFilter
         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):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -316,11 +330,11 @@ class ImageAttachmentFilterSet(BaseFilterSet):
         label=_('Search'),
         label=_('Search'),
     )
     )
     created = django_filters.DateTimeFilter()
     created = django_filters.DateTimeFilter()
-    content_type = ContentTypeFilter()
+    object_type = ContentTypeFilter()
 
 
     class Meta:
     class Meta:
         model = ImageAttachment
         model = ImageAttachment
-        fields = ['id', 'content_type_id', 'object_id', 'name']
+        fields = ['id', 'object_type_id', 'object_id', 'name']
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -660,14 +674,14 @@ class ObjectChangeFilterSet(BaseFilterSet):
 # ContentTypes
 # ContentTypes
 #
 #
 
 
-class ContentTypeFilterSet(django_filters.FilterSet):
+class ObjectTypeFilterSet(django_filters.FilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',
         label=_('Search'),
         label=_('Search'),
     )
     )
 
 
     class Meta:
     class Meta:
-        model = ContentType
+        model = ObjectType
         fields = ['id', 'app_label', 'model']
         fields = ['id', 'app_label', 'model']
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):

+ 25 - 25
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.safestring import mark_safe
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
-from core.models import ContentType
+from core.models import ObjectType
 from extras.choices import *
 from extras.choices import *
 from extras.models import *
 from extras.models import *
 from netbox.forms import NetBoxModelImportForm
 from netbox.forms import NetBoxModelImportForm
@@ -30,9 +30,9 @@ __all__ = (
 
 
 
 
 class CustomFieldImportForm(CSVModelForm):
 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")
         help_text=_("One or more assigned object types")
     )
     )
     type = CSVChoiceField(
     type = CSVChoiceField(
@@ -40,9 +40,9 @@ class CustomFieldImportForm(CSVModelForm):
         choices=CustomFieldTypeChoices,
         choices=CustomFieldTypeChoices,
         help_text=_('Field data type (e.g. text, integer, etc.)')
         help_text=_('Field data type (e.g. text, integer, etc.)')
     )
     )
-    object_type = CSVContentTypeField(
+    related_object_type = CSVContentTypeField(
         label=_('Object type'),
         label=_('Object type'),
-        queryset=ContentType.objects.public(),
+        queryset=ObjectType.objects.public(),
         required=False,
         required=False,
         help_text=_("Object type (for object or multi-object fields)")
         help_text=_("Object type (for object or multi-object fields)")
     )
     )
@@ -69,7 +69,7 @@ class CustomFieldImportForm(CSVModelForm):
     class Meta:
     class Meta:
         model = CustomField
         model = CustomField
         fields = (
         fields = (
-            'name', 'label', 'group_name', 'type', 'content_types', 'object_type', 'required', 'description',
+            'name', 'label', 'group_name', 'type', 'object_types', 'related_object_type', 'required', 'description',
             'search_weight', 'filter_logic', 'default', 'choice_set', 'weight', 'validation_minimum',
             'search_weight', 'filter_logic', 'default', 'choice_set', 'weight', 'validation_minimum',
             'validation_maximum', 'validation_regex', 'ui_visible', 'ui_editable', 'is_cloneable',
             'validation_maximum', 'validation_regex', 'ui_visible', 'ui_editable', 'is_cloneable',
         )
         )
@@ -111,31 +111,31 @@ class CustomFieldChoiceSetImportForm(CSVModelForm):
 
 
 
 
 class CustomLinkImportForm(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")
         help_text=_("One or more assigned object types")
     )
     )
 
 
     class Meta:
     class Meta:
         model = CustomLink
         model = CustomLink
         fields = (
         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',
             'link_url',
         )
         )
 
 
 
 
 class ExportTemplateImportForm(CSVModelForm):
 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")
         help_text=_("One or more assigned object types")
     )
     )
 
 
     class Meta:
     class Meta:
         model = ExportTemplate
         model = ExportTemplate
         fields = (
         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):
 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")
         help_text=_("One or more assigned object types")
     )
     )
 
 
     class Meta:
     class Meta:
         model = SavedFilter
         model = SavedFilter
         fields = (
         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):
 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")
         help_text=_("One or more assigned object types")
     )
     )
     action_object = forms.CharField(
     action_object = forms.CharField(
@@ -187,7 +187,7 @@ class EventRuleImportForm(NetBoxModelImportForm):
     class Meta:
     class Meta:
         model = EventRule
         model = EventRule
         fields = (
         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'
             'type_delete', 'type_job_start', 'type_job_end', 'action_type', 'action_object', 'comments', 'tags'
         )
         )
 
 
@@ -213,7 +213,7 @@ class EventRuleImportForm(NetBoxModelImportForm):
                 except ObjectDoesNotExist:
                 except ObjectDoesNotExist:
                     raise forms.ValidationError(_("Script {name} not found").format(name=action_object))
                     raise forms.ValidationError(_("Script {name} not found").format(name=action_object))
                 self.instance.action_object = script
                 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):
 class TagImportForm(CSVModelForm):
@@ -229,7 +229,7 @@ class TagImportForm(CSVModelForm):
 
 
 class JournalEntryImportForm(NetBoxModelImportForm):
 class JournalEntryImportForm(NetBoxModelImportForm):
     assigned_object_type = CSVContentTypeField(
     assigned_object_type = CSVContentTypeField(
-        queryset=ContentType.objects.all(),
+        queryset=ObjectType.objects.all(),
         label=_('Assigned object type'),
         label=_('Assigned object type'),
     )
     )
     kind = CSVChoiceField(
     kind = CSVChoiceField(

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

@@ -2,7 +2,7 @@ from django import forms
 from django.contrib.auth import get_user_model
 from django.contrib.auth import get_user_model
 from django.utils.translation import gettext_lazy as _
 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 dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
 from extras.choices import *
 from extras.choices import *
 from extras.models import *
 from extras.models import *
@@ -38,14 +38,14 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
     fieldsets = (
     fieldsets = (
         (None, ('q', 'filter_id')),
         (None, ('q', 'filter_id')),
         (_('Attributes'), (
         (_('Attributes'), (
-            'type', 'content_type_id', 'group_name', 'weight', 'required', 'choice_set_id', 'ui_visible', 'ui_editable',
-            'is_cloneable',
+            'type', 'related_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'),
+    related_object_type_id = ContentTypeMultipleChoiceField(
+        queryset=ObjectType.objects.with_feature('custom_fields'),
         required=False,
         required=False,
-        label=_('Object type')
+        label=_('Related object type')
     )
     )
     type = forms.MultipleChoiceField(
     type = forms.MultipleChoiceField(
         choices=CustomFieldTypeChoices,
         choices=CustomFieldTypeChoices,
@@ -108,11 +108,11 @@ class CustomFieldChoiceSetFilterForm(SavedFiltersMixin, FilterForm):
 class CustomLinkFilterForm(SavedFiltersMixin, FilterForm):
 class CustomLinkFilterForm(SavedFiltersMixin, FilterForm):
     fieldsets = (
     fieldsets = (
         (None, ('q', 'filter_id')),
         (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
         required=False
     )
     )
     enabled = forms.NullBooleanField(
     enabled = forms.NullBooleanField(
@@ -139,7 +139,7 @@ class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
     fieldsets = (
     fieldsets = (
         (None, ('q', 'filter_id')),
         (None, ('q', 'filter_id')),
         (_('Data'), ('data_source_id', 'data_file_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(
     data_source_id = DynamicModelMultipleChoiceField(
         queryset=DataSource.objects.all(),
         queryset=DataSource.objects.all(),
@@ -154,8 +154,8 @@ class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
             'source_id': '$data_source_id'
             '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,
         required=False,
         label=_('Content types')
         label=_('Content types')
     )
     )
@@ -179,11 +179,11 @@ class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
 class ImageAttachmentFilterForm(SavedFiltersMixin, FilterForm):
 class ImageAttachmentFilterForm(SavedFiltersMixin, FilterForm):
     fieldsets = (
     fieldsets = (
         (None, ('q', 'filter_id')),
         (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
         required=False
     )
     )
     name = forms.CharField(
     name = forms.CharField(
@@ -195,11 +195,11 @@ class ImageAttachmentFilterForm(SavedFiltersMixin, FilterForm):
 class SavedFilterFilterForm(SavedFiltersMixin, FilterForm):
 class SavedFilterFilterForm(SavedFiltersMixin, FilterForm):
     fieldsets = (
     fieldsets = (
         (None, ('q', 'filter_id')),
         (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
         required=False
     )
     )
     enabled = forms.NullBooleanField(
     enabled = forms.NullBooleanField(
@@ -250,11 +250,11 @@ class EventRuleFilterForm(NetBoxModelFilterSetForm):
 
 
     fieldsets = (
     fieldsets = (
         (None, ('q', 'filter_id', 'tag')),
         (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')),
         (_('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,
         required=False,
         label=_('Object type')
         label=_('Object type')
     )
     )
@@ -310,12 +310,12 @@ class EventRuleFilterForm(NetBoxModelFilterSetForm):
 class TagFilterForm(SavedFiltersMixin, FilterForm):
 class TagFilterForm(SavedFiltersMixin, FilterForm):
     model = Tag
     model = Tag
     content_type_id = ContentTypeMultipleChoiceField(
     content_type_id = ContentTypeMultipleChoiceField(
-        queryset=ContentType.objects.with_feature('tags'),
+        queryset=ObjectType.objects.with_feature('tags'),
         required=False,
         required=False,
         label=_('Tagged object type')
         label=_('Tagged object type')
     )
     )
     for_object_type_id = ContentTypeChoiceField(
     for_object_type_id = ContentTypeChoiceField(
-        queryset=ContentType.objects.with_feature('tags'),
+        queryset=ObjectType.objects.with_feature('tags'),
         required=False,
         required=False,
         label=_('Allowed object type')
         label=_('Allowed object type')
     )
     )
@@ -464,7 +464,7 @@ class JournalEntryFilterForm(NetBoxModelFilterSetForm):
         label=_('User')
         label=_('User')
     )
     )
     assigned_object_type_id = DynamicModelMultipleChoiceField(
     assigned_object_type_id = DynamicModelMultipleChoiceField(
-        queryset=ContentType.objects.all(),
+        queryset=ObjectType.objects.all(),
         required=False,
         required=False,
         label=_('Object Type'),
         label=_('Object Type'),
         widget=APISelectMultiple(
         widget=APISelectMultiple(
@@ -507,7 +507,7 @@ class ObjectChangeFilterForm(SavedFiltersMixin, FilterForm):
         label=_('User')
         label=_('User')
     )
     )
     changed_object_type_id = DynamicModelMultipleChoiceField(
     changed_object_type_id = DynamicModelMultipleChoiceField(
-        queryset=ContentType.objects.all(),
+        queryset=ObjectType.objects.all(),
         required=False,
         required=False,
         label=_('Object Type'),
         label=_('Object Type'),
         widget=APISelectMultiple(
         widget=APISelectMultiple(

+ 29 - 30
netbox/extras/forms/model_forms.py

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

+ 5 - 5
netbox/extras/graphql/filters.py

@@ -39,7 +39,7 @@ class ConfigTemplateFilter(filtersets.ConfigTemplateFilterSet):
 @strawberry_django.filter(models.CustomField, lookups=True)
 @strawberry_django.filter(models.CustomField, lookups=True)
 class CustomFieldFilter(filtersets.CustomFieldFilterSet):
 class CustomFieldFilter(filtersets.CustomFieldFilterSet):
     id: auto
     id: auto
-    content_types: auto
+    object_types: auto
     name: auto
     name: auto
     group_name: auto
     group_name: auto
     required: auto
     required: auto
@@ -62,7 +62,7 @@ class CustomFieldChoiceSetFilter(filtersets.CustomFieldChoiceSetFilterSet):
 @strawberry_django.filter(models.CustomLink, lookups=True)
 @strawberry_django.filter(models.CustomLink, lookups=True)
 class CustomLinkFilter(filtersets.CustomLinkFilterSet):
 class CustomLinkFilter(filtersets.CustomLinkFilterSet):
     id: auto
     id: auto
-    content_types: auto
+    object_types: auto
     name: auto
     name: auto
     enabled: auto
     enabled: auto
     link_text: auto
     link_text: auto
@@ -75,7 +75,7 @@ class CustomLinkFilter(filtersets.CustomLinkFilterSet):
 @strawberry_django.filter(models.ExportTemplate, lookups=True)
 @strawberry_django.filter(models.ExportTemplate, lookups=True)
 class ExportTemplateFilter(filtersets.ExportTemplateFilterSet):
 class ExportTemplateFilter(filtersets.ExportTemplateFilterSet):
     id: auto
     id: auto
-    content_types: auto
+    object_types: auto
     name: auto
     name: auto
     description: auto
     description: auto
     data_synced: auto
     data_synced: auto
@@ -84,7 +84,7 @@ class ExportTemplateFilter(filtersets.ExportTemplateFilterSet):
 @strawberry_django.filter(models.ImageAttachment, lookups=True)
 @strawberry_django.filter(models.ImageAttachment, lookups=True)
 class ImageAttachmentFilter(filtersets.ImageAttachmentFilterSet):
 class ImageAttachmentFilter(filtersets.ImageAttachmentFilterSet):
     id: auto
     id: auto
-    content_type_id: auto
+    object_type_id: auto
     object_id: auto
     object_id: auto
     name: auto
     name: auto
 
 
@@ -113,7 +113,7 @@ class ObjectChangeFilter(filtersets.ObjectChangeFilterSet):
 @strawberry_django.filter(models.SavedFilter, lookups=True)
 @strawberry_django.filter(models.SavedFilter, lookups=True)
 class SavedFilterFilter(filtersets.SavedFilterFilterSet):
 class SavedFilterFilter(filtersets.SavedFilterFilterSet):
     id: auto
     id: auto
-    content_types: auto
+    object_types: auto
     name: auto
     name: auto
     slug: auto
     slug: auto
     description: auto
     description: auto

+ 0 - 3
netbox/extras/migrations/0108_convert_reports_to_scripts.py

@@ -25,7 +25,4 @@ class Migration(migrations.Migration):
         migrations.DeleteModel(
         migrations.DeleteModel(
             name='Report',
             name='Report',
         ),
         ),
-        migrations.DeleteModel(
-            name='ReportModule',
-        ),
     ]
     ]

+ 12 - 4
netbox/extras/migrations/0109_script_model.py

@@ -82,10 +82,12 @@ def update_scripts(apps, schema_editor):
     ContentType = apps.get_model('contenttypes', 'ContentType')
     ContentType = apps.get_model('contenttypes', 'ContentType')
     Script = apps.get_model('extras', 'Script')
     Script = apps.get_model('extras', 'Script')
     ScriptModule = apps.get_model('extras', 'ScriptModule')
     ScriptModule = apps.get_model('extras', 'ScriptModule')
+    ReportModule = apps.get_model('extras', 'ReportModule')
     Job = apps.get_model('core', 'Job')
     Job = apps.get_model('core', 'Job')
 
 
-    script_ct = ContentType.objects.get_for_model(Script)
-    scriptmodule_ct = ContentType.objects.get_for_model(ScriptModule)
+    script_ct = ContentType.objects.get_for_model(Script, for_concrete_model=False)
+    scriptmodule_ct = ContentType.objects.get_for_model(ScriptModule, for_concrete_model=False)
+    reportmodule_ct = ContentType.objects.get_for_model(ReportModule, for_concrete_model=False)
 
 
     for module in ScriptModule.objects.all():
     for module in ScriptModule.objects.all():
         for script_name in get_module_scripts(module):
         for script_name in get_module_scripts(module):
@@ -96,10 +98,16 @@ def update_scripts(apps, schema_editor):
 
 
             # Update all Jobs associated with this ScriptModule & script name to point to the new Script object
             # Update all Jobs associated with this ScriptModule & script name to point to the new Script object
             Job.objects.filter(
             Job.objects.filter(
-                object_type=scriptmodule_ct,
+                object_type_id=scriptmodule_ct.id,
+                object_id=module.pk,
+                name=script_name
+            ).update(object_type_id=script_ct.id, object_id=script.pk)
+            # Update all Jobs associated with this ScriptModule & script name to point to the new Script object
+            Job.objects.filter(
+                object_type_id=reportmodule_ct.id,
                 object_id=module.pk,
                 object_id=module.pk,
                 name=script_name
                 name=script_name
-            ).update(object_type=script_ct, object_id=script.pk)
+            ).update(object_type_id=script_ct.id, object_id=script.pk)
 
 
 
 
 def update_event_rules(apps, schema_editor):
 def update_event_rules(apps, schema_editor):

+ 3 - 0
netbox/extras/migrations/0110_remove_eventrule_action_parameters.py

@@ -12,4 +12,7 @@ class Migration(migrations.Migration):
             model_name='eventrule',
             model_name='eventrule',
             name='action_parameters',
             name='action_parameters',
         ),
         ),
+        migrations.DeleteModel(
+            name='ReportModule',
+        ),
     ]
     ]

+ 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'),
+        ),
+    ]

+ 16 - 0
netbox/extras/migrations/0113_customfield_rename_object_type.py

@@ -0,0 +1,16 @@
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('extras', '0112_tag_update_object_types'),
+    ]
+
+    operations = [
+        migrations.RenameField(
+            model_name='customfield',
+            old_name='object_type',
+            new_name='related_object_type',
+        ),
+    ]

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

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

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

@@ -12,7 +12,7 @@ from django.urls import reverse
 from django.utils.safestring import mark_safe
 from django.utils.safestring import mark_safe
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
-from core.models import ContentType
+from core.models import ObjectType
 from extras.choices import *
 from extras.choices import *
 from extras.data import CHOICE_SETS
 from extras.data import CHOICE_SETS
 from netbox.models import ChangeLoggedModel
 from netbox.models import ChangeLoggedModel
@@ -52,8 +52,8 @@ class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)):
         """
         """
         Return all CustomFields assigned to the given model.
         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):
     def get_defaults_for_model(self, model):
         """
         """
@@ -66,8 +66,8 @@ class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)):
 
 
 
 
 class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
 class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
-    content_types = models.ManyToManyField(
-        to='contenttypes.ContentType',
+    object_types = models.ManyToManyField(
+        to='core.ObjectType',
         related_name='custom_fields',
         related_name='custom_fields',
         help_text=_('The object(s) to which this field applies.')
         help_text=_('The object(s) to which this field applies.')
     )
     )
@@ -78,8 +78,8 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
         default=CustomFieldTypeChoices.TYPE_TEXT,
         default=CustomFieldTypeChoices.TYPE_TEXT,
         help_text=_('The type of data this custom field holds')
         help_text=_('The type of data this custom field holds')
     )
     )
-    object_type = models.ForeignKey(
-        to='contenttypes.ContentType',
+    related_object_type = models.ForeignKey(
+        to='core.ObjectType',
         on_delete=models.PROTECT,
         on_delete=models.PROTECT,
         blank=True,
         blank=True,
         null=True,
         null=True,
@@ -209,7 +209,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
     objects = CustomFieldManager()
     objects = CustomFieldManager()
 
 
     clone_fields = (
     clone_fields = (
-        'content_types', 'type', 'object_type', 'group_name', 'description', 'required', 'search_weight',
+        'object_types', 'type', 'related_object_type', 'group_name', 'description', 'required', 'search_weight',
         'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex',
         'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex',
         'choice_set', 'ui_visible', 'ui_editable', 'is_cloneable',
         '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.
         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()
             model = ct.model_class()
             params = {f'custom_field_data__{old_name}__isnull': False}
             params = {f'custom_field_data__{old_name}__isnull': False}
             instances = model.objects.filter(**params)
             instances = model.objects.filter(**params)
@@ -344,11 +344,11 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
 
 
         # Object fields must define an object_type; other fields must not
         # Object fields must define an object_type; other fields must not
         if self.type in (CustomFieldTypeChoices.TYPE_OBJECT, CustomFieldTypeChoices.TYPE_MULTIOBJECT):
         if self.type in (CustomFieldTypeChoices.TYPE_OBJECT, CustomFieldTypeChoices.TYPE_MULTIOBJECT):
-            if not self.object_type:
+            if not self.related_object_type:
                 raise ValidationError({
                 raise ValidationError({
                     'object_type': _("Object fields must define an object type.")
                     'object_type': _("Object fields must define an object type.")
                 })
                 })
-        elif self.object_type:
+        elif self.related_object_type:
             raise ValidationError({
             raise ValidationError({
                 'object_type': _(
                 'object_type': _(
                     "{type} fields may not define an object type.")
                     "{type} fields may not define an object type.")
@@ -388,10 +388,10 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
             except ValueError:
             except ValueError:
                 return value
                 return value
         if self.type == CustomFieldTypeChoices.TYPE_OBJECT:
         if self.type == CustomFieldTypeChoices.TYPE_OBJECT:
-            model = self.object_type.model_class()
+            model = self.related_object_type.model_class()
             return model.objects.filter(pk=value).first()
             return model.objects.filter(pk=value).first()
         if self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
         if self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
-            model = self.object_type.model_class()
+            model = self.related_object_type.model_class()
             return model.objects.filter(pk__in=value)
             return model.objects.filter(pk__in=value)
         return value
         return value
 
 
@@ -488,7 +488,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
 
 
         # Object
         # Object
         elif self.type == CustomFieldTypeChoices.TYPE_OBJECT:
         elif self.type == CustomFieldTypeChoices.TYPE_OBJECT:
-            model = self.object_type.model_class()
+            model = self.related_object_type.model_class()
             field_class = CSVModelChoiceField if for_csv_import else DynamicModelChoiceField
             field_class = CSVModelChoiceField if for_csv_import else DynamicModelChoiceField
             field = field_class(
             field = field_class(
                 queryset=model.objects.all(),
                 queryset=model.objects.all(),
@@ -498,7 +498,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
 
 
         # Multiple objects
         # Multiple objects
         elif self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
         elif self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
-            model = self.object_type.model_class()
+            model = self.related_object_type.model_class()
             field_class = CSVModelMultipleChoiceField if for_csv_import else DynamicModelMultipleChoiceField
             field_class = CSVModelMultipleChoiceField if for_csv_import else DynamicModelMultipleChoiceField
             field = field_class(
             field = field_class(
                 queryset=model.objects.all(),
                 queryset=model.objects.all(),

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

+ 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.utils.translation import gettext_lazy as _
 from django_prometheus.models import model_deletes, model_inserts, model_updates
 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 core.signals import job_end, job_start
 from extras.constants import EVENT_JOB_END, EVENT_JOB_START
 from extras.constants import EVENT_JOB_END, EVENT_JOB_START
 from extras.events import process_event_rules
 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.
     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)
 post_save.connect(handle_cf_renamed, sender=CustomField)
 pre_delete.connect(handle_cf_deleted, 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':
     if action != 'pre_add':
         return
         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'):
     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():
         if ct not in tag.object_types.all():
             raise AbortRequest(f"Tag {tag} cannot be assigned to {ct.model} objects.")
             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.
     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
     username = sender.user.username if sender.user else None
     process_event_rules(event_rules, sender.object_type.model, EVENT_JOB_START, sender.data, username)
     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.
     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
     username = sender.user.username if sender.user else None
     process_event_rules(event_rules, sender.object_type.model, EVENT_JOB_END, sender.data, username)
     process_event_rules(event_rules, sender.object_type.model, EVENT_JOB_END, sender.data, username)

+ 90 - 27
netbox/extras/tables/tables.py

@@ -5,7 +5,7 @@ from django.conf import settings
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
 from extras.models import *
 from extras.models import *
-from netbox.tables import NetBoxTable, columns
+from netbox.tables import BaseTable, NetBoxTable, columns
 from .template_code import *
 from .template_code import *
 
 
 __all__ = (
 __all__ = (
@@ -21,6 +21,8 @@ __all__ = (
     'JournalEntryTable',
     'JournalEntryTable',
     'ObjectChangeTable',
     'ObjectChangeTable',
     'SavedFilterTable',
     'SavedFilterTable',
+    'ReportResultsTable',
+    'ScriptResultsTable',
     'TaggedItemTable',
     'TaggedItemTable',
     'TagTable',
     'TagTable',
     'WebhookTable',
     'WebhookTable',
@@ -40,8 +42,8 @@ class CustomFieldTable(NetBoxTable):
         verbose_name=_('Name'),
         verbose_name=_('Name'),
         linkify=True
         linkify=True
     )
     )
-    content_types = columns.ContentTypesColumn(
-        verbose_name=_('Content Types')
+    object_types = columns.ContentTypesColumn(
+        verbose_name=_('Object Types')
     )
     )
     required = columns.BooleanColumn(
     required = columns.BooleanColumn(
         verbose_name=_('Required')
         verbose_name=_('Required')
@@ -55,6 +57,9 @@ class CustomFieldTable(NetBoxTable):
     description = columns.MarkdownColumn(
     description = columns.MarkdownColumn(
         verbose_name=_('Description')
         verbose_name=_('Description')
     )
     )
+    related_object_type = columns.ContentTypeColumn(
+        verbose_name=_('Related Object Type')
+    )
     choice_set = tables.Column(
     choice_set = tables.Column(
         linkify=True,
         linkify=True,
         verbose_name=_('Choice Set')
         verbose_name=_('Choice Set')
@@ -71,11 +76,11 @@ class CustomFieldTable(NetBoxTable):
     class Meta(NetBoxTable.Meta):
     class Meta(NetBoxTable.Meta):
         model = CustomField
         model = CustomField
         fields = (
         fields = (
-            'pk', 'id', 'name', 'content_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',
+            'pk', 'id', 'name', 'object_types', 'label', 'type', 'related_object_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):
 class CustomFieldChoiceSetTable(NetBoxTable):
@@ -115,8 +120,8 @@ class CustomLinkTable(NetBoxTable):
         verbose_name=_('Name'),
         verbose_name=_('Name'),
         linkify=True
         linkify=True
     )
     )
-    content_types = columns.ContentTypesColumn(
-        verbose_name=_('Content Types'),
+    object_types = columns.ContentTypesColumn(
+        verbose_name=_('Object Types'),
     )
     )
     enabled = columns.BooleanColumn(
     enabled = columns.BooleanColumn(
         verbose_name=_('Enabled'),
         verbose_name=_('Enabled'),
@@ -128,10 +133,10 @@ class CustomLinkTable(NetBoxTable):
     class Meta(NetBoxTable.Meta):
     class Meta(NetBoxTable.Meta):
         model = CustomLink
         model = CustomLink
         fields = (
         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',
             '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):
 class ExportTemplateTable(NetBoxTable):
@@ -139,8 +144,8 @@ class ExportTemplateTable(NetBoxTable):
         verbose_name=_('Name'),
         verbose_name=_('Name'),
         linkify=True
         linkify=True
     )
     )
-    content_types = columns.ContentTypesColumn(
-        verbose_name=_('Content Types'),
+    object_types = columns.ContentTypesColumn(
+        verbose_name=_('Object Types'),
     )
     )
     as_attachment = columns.BooleanColumn(
     as_attachment = columns.BooleanColumn(
         verbose_name=_('As Attachment'),
         verbose_name=_('As Attachment'),
@@ -161,11 +166,11 @@ class ExportTemplateTable(NetBoxTable):
     class Meta(NetBoxTable.Meta):
     class Meta(NetBoxTable.Meta):
         model = ExportTemplate
         model = ExportTemplate
         fields = (
         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',
             'data_source', 'data_file', 'data_synced', 'created', 'last_updated',
         )
         )
         default_columns = (
         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 +179,8 @@ class ImageAttachmentTable(NetBoxTable):
         verbose_name=_('ID'),
         verbose_name=_('ID'),
         linkify=False
         linkify=False
     )
     )
-    content_type = columns.ContentTypeColumn(
-        verbose_name=_('Content Type'),
+    object_type = columns.ContentTypeColumn(
+        verbose_name=_('Object Type'),
     )
     )
     parent = tables.Column(
     parent = tables.Column(
         verbose_name=_('Parent'),
         verbose_name=_('Parent'),
@@ -193,10 +198,10 @@ class ImageAttachmentTable(NetBoxTable):
     class Meta(NetBoxTable.Meta):
     class Meta(NetBoxTable.Meta):
         model = ImageAttachment
         model = ImageAttachment
         fields = (
         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',
             'last_updated',
         )
         )
-        default_columns = ('content_type', 'parent', 'image', 'name', 'size', 'created')
+        default_columns = ('object_type', 'parent', 'image', 'name', 'size', 'created')
 
 
 
 
 class SavedFilterTable(NetBoxTable):
 class SavedFilterTable(NetBoxTable):
@@ -204,8 +209,8 @@ class SavedFilterTable(NetBoxTable):
         verbose_name=_('Name'),
         verbose_name=_('Name'),
         linkify=True
         linkify=True
     )
     )
-    content_types = columns.ContentTypesColumn(
-        verbose_name=_('Content Types'),
+    object_types = columns.ContentTypesColumn(
+        verbose_name=_('Object Types'),
     )
     )
     enabled = columns.BooleanColumn(
     enabled = columns.BooleanColumn(
         verbose_name=_('Enabled'),
         verbose_name=_('Enabled'),
@@ -220,11 +225,11 @@ class SavedFilterTable(NetBoxTable):
     class Meta(NetBoxTable.Meta):
     class Meta(NetBoxTable.Meta):
         model = SavedFilter
         model = SavedFilter
         fields = (
         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'
             'created', 'last_updated', 'parameters'
         )
         )
         default_columns = (
         default_columns = (
-            'pk', 'name', 'content_types', 'user', 'description', 'enabled', 'shared',
+            'pk', 'name', 'object_types', 'user', 'description', 'enabled', 'shared',
         )
         )
 
 
 
 
@@ -281,8 +286,8 @@ class EventRuleTable(NetBoxTable):
         linkify=True,
         linkify=True,
         verbose_name=_('Object'),
         verbose_name=_('Object'),
     )
     )
-    content_types = columns.ContentTypesColumn(
-        verbose_name=_('Content Types'),
+    object_types = columns.ContentTypesColumn(
+        verbose_name=_('Object Types'),
     )
     )
     enabled = columns.BooleanColumn(
     enabled = columns.BooleanColumn(
         verbose_name=_('Enabled'),
         verbose_name=_('Enabled'),
@@ -309,12 +314,12 @@ class EventRuleTable(NetBoxTable):
     class Meta(NetBoxTable.Meta):
     class Meta(NetBoxTable.Meta):
         model = EventRule
         model = EventRule
         fields = (
         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',
             'type_create', 'type_update', 'type_delete', 'type_job_start', 'type_job_end', 'tags', 'created',
             'last_updated',
             'last_updated',
         )
         )
         default_columns = (
         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',
             'type_delete', 'type_job_start', 'type_job_end',
         )
         )
 
 
@@ -507,3 +512,61 @@ class JournalEntryTable(NetBoxTable):
         default_columns = (
         default_columns = (
             'pk', 'created', 'created_by', 'assigned_object_type', 'assigned_object', 'kind', 'comments'
             'pk', 'created', 'created_by', 'assigned_object_type', 'assigned_object', 'kind', 'comments'
         )
         )
+
+
+class ScriptResultsTable(BaseTable):
+    index = tables.Column(
+        verbose_name=_('Line')
+    )
+    time = tables.Column(
+        verbose_name=_('Time')
+    )
+    status = tables.TemplateColumn(
+        template_code="""{% load log_levels %}{% log_level record.status %}""",
+        verbose_name=_('Level')
+    )
+    message = tables.Column(
+        verbose_name=_('Message')
+    )
+
+    class Meta(BaseTable.Meta):
+        empty_text = _('No results found')
+        fields = (
+            'index', 'time', 'status', 'message',
+        )
+
+
+class ReportResultsTable(BaseTable):
+    index = tables.Column(
+        verbose_name=_('Line')
+    )
+    method = tables.Column(
+        verbose_name=_('Method')
+    )
+    time = tables.Column(
+        verbose_name=_('Time')
+    )
+    status = tables.Column(
+        empty_values=(),
+        verbose_name=_('Level')
+    )
+    status = tables.TemplateColumn(
+        template_code="""{% load log_levels %}{% log_level record.status %}""",
+        verbose_name=_('Level')
+    )
+
+    object = tables.Column(
+        verbose_name=_('Object')
+    )
+    url = tables.Column(
+        verbose_name=_('URL')
+    )
+    message = tables.Column(
+        verbose_name=_('Message')
+    )
+
+    class Meta(BaseTable.Meta):
+        empty_text = _('No results found')
+        fields = (
+            'index', 'method', 'time', 'status', 'object', 'url', 'message',
+        )

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

@@ -1,7 +1,7 @@
 from django import template
 from django import template
-from django.contrib.contenttypes.models import ContentType
 from django.utils.safestring import mark_safe
 from django.utils.safestring import mark_safe
 
 
+from core.models import ObjectType
 from extras.models import CustomLink
 from extras.models import CustomLink
 
 
 
 
@@ -32,8 +32,8 @@ def custom_links(context, obj):
     """
     """
     Render all applicable links for the given object.
     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:
     if not custom_links:
         return ''
         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 rest_framework import status
 
 
 from core.choices import ManagedFileRootPathChoices
 from core.choices import ManagedFileRootPathChoices
+from core.models import ObjectType
 from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, Location, RackRole, Site
 from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, Location, RackRole, Site
 from extras.choices import *
 from extras.choices import *
 from extras.models import *
 from extras.models import *
-from extras.reports import Report
 from extras.scripts import BooleanVar, IntegerVar, Script as PythonClass, StringVar
 from extras.scripts import BooleanVar, IntegerVar, Script as PythonClass, StringVar
 from utilities.testing import APITestCase, APIViewTestCases
 from utilities.testing import APITestCase, APIViewTestCases
 
 
@@ -122,7 +122,7 @@ class EventRuleTest(APIViewTestCases.APIViewTestCase):
         cls.create_data = [
         cls.create_data = [
             {
             {
                 'name': 'EventRule 4',
                 'name': 'EventRule 4',
-                'content_types': ['dcim.device', 'dcim.devicetype'],
+                'object_types': ['dcim.device', 'dcim.devicetype'],
                 'type_create': True,
                 'type_create': True,
                 'action_type': EventRuleActionChoices.WEBHOOK,
                 'action_type': EventRuleActionChoices.WEBHOOK,
                 'action_object_type': 'extras.webhook',
                 'action_object_type': 'extras.webhook',
@@ -130,7 +130,7 @@ class EventRuleTest(APIViewTestCases.APIViewTestCase):
             },
             },
             {
             {
                 'name': 'EventRule 5',
                 'name': 'EventRule 5',
-                'content_types': ['dcim.device', 'dcim.devicetype'],
+                'object_types': ['dcim.device', 'dcim.devicetype'],
                 'type_create': True,
                 'type_create': True,
                 'action_type': EventRuleActionChoices.WEBHOOK,
                 'action_type': EventRuleActionChoices.WEBHOOK,
                 'action_object_type': 'extras.webhook',
                 'action_object_type': 'extras.webhook',
@@ -138,7 +138,7 @@ class EventRuleTest(APIViewTestCases.APIViewTestCase):
             },
             },
             {
             {
                 'name': 'EventRule 6',
                 'name': 'EventRule 6',
-                'content_types': ['dcim.device', 'dcim.devicetype'],
+                'object_types': ['dcim.device', 'dcim.devicetype'],
                 'type_create': True,
                 'type_create': True,
                 'action_type': EventRuleActionChoices.WEBHOOK,
                 'action_type': EventRuleActionChoices.WEBHOOK,
                 'action_object_type': 'extras.webhook',
                 'action_object_type': 'extras.webhook',
@@ -152,17 +152,17 @@ class CustomFieldTest(APIViewTestCases.APIViewTestCase):
     brief_fields = ['description', 'display', 'id', 'name', 'url']
     brief_fields = ['description', 'display', 'id', 'name', 'url']
     create_data = [
     create_data = [
         {
         {
-            'content_types': ['dcim.site'],
+            'object_types': ['dcim.site'],
             'name': 'cf4',
             'name': 'cf4',
             'type': 'date',
             'type': 'date',
         },
         },
         {
         {
-            'content_types': ['dcim.site'],
+            'object_types': ['dcim.site'],
             'name': 'cf5',
             'name': 'cf5',
             'type': 'url',
             'type': 'url',
         },
         },
         {
         {
-            'content_types': ['dcim.site'],
+            'object_types': ['dcim.site'],
             'name': 'cf6',
             'name': 'cf6',
             'type': 'text',
             'type': 'text',
         },
         },
@@ -171,14 +171,14 @@ class CustomFieldTest(APIViewTestCases.APIViewTestCase):
         'description': 'New description',
         'description': 'New description',
     }
     }
     update_data = {
     update_data = {
-        'content_types': ['dcim.device'],
+        'object_types': ['dcim.device'],
         'name': 'New_Name',
         'name': 'New_Name',
         'description': 'New description',
         'description': 'New description',
     }
     }
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
-        site_ct = ContentType.objects.get_for_model(Site)
+        site_ct = ObjectType.objects.get_for_model(Site)
 
 
         custom_fields = (
         custom_fields = (
             CustomField(
             CustomField(
@@ -196,7 +196,7 @@ class CustomFieldTest(APIViewTestCases.APIViewTestCase):
         )
         )
         CustomField.objects.bulk_create(custom_fields)
         CustomField.objects.bulk_create(custom_fields)
         for cf in custom_fields:
         for cf in custom_fields:
-            cf.content_types.add(site_ct)
+            cf.object_types.add(site_ct)
 
 
 
 
 class CustomFieldChoiceSetTest(APIViewTestCases.APIViewTestCase):
 class CustomFieldChoiceSetTest(APIViewTestCases.APIViewTestCase):
@@ -273,21 +273,21 @@ class CustomLinkTest(APIViewTestCases.APIViewTestCase):
     brief_fields = ['display', 'id', 'name', 'url']
     brief_fields = ['display', 'id', 'name', 'url']
     create_data = [
     create_data = [
         {
         {
-            'content_types': ['dcim.site'],
+            'object_types': ['dcim.site'],
             'name': 'Custom Link 4',
             'name': 'Custom Link 4',
             'enabled': True,
             'enabled': True,
             'link_text': 'Link 4',
             'link_text': 'Link 4',
             'link_url': 'http://example.com/?4',
             'link_url': 'http://example.com/?4',
         },
         },
         {
         {
-            'content_types': ['dcim.site'],
+            'object_types': ['dcim.site'],
             'name': 'Custom Link 5',
             'name': 'Custom Link 5',
             'enabled': True,
             'enabled': True,
             'link_text': 'Link 5',
             'link_text': 'Link 5',
             'link_url': 'http://example.com/?5',
             'link_url': 'http://example.com/?5',
         },
         },
         {
         {
-            'content_types': ['dcim.site'],
+            'object_types': ['dcim.site'],
             'name': 'Custom Link 6',
             'name': 'Custom Link 6',
             'enabled': False,
             'enabled': False,
             'link_text': 'Link 6',
             'link_text': 'Link 6',
@@ -301,7 +301,7 @@ class CustomLinkTest(APIViewTestCases.APIViewTestCase):
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
-        site_ct = ContentType.objects.get_for_model(Site)
+        site_type = ObjectType.objects.get_for_model(Site)
 
 
         custom_links = (
         custom_links = (
             CustomLink(
             CustomLink(
@@ -325,7 +325,7 @@ class CustomLinkTest(APIViewTestCases.APIViewTestCase):
         )
         )
         CustomLink.objects.bulk_create(custom_links)
         CustomLink.objects.bulk_create(custom_links)
         for i, custom_link in enumerate(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):
 class SavedFilterTest(APIViewTestCases.APIViewTestCase):
@@ -333,7 +333,7 @@ class SavedFilterTest(APIViewTestCases.APIViewTestCase):
     brief_fields = ['description', 'display', 'id', 'name', 'slug', 'url']
     brief_fields = ['description', 'display', 'id', 'name', 'slug', 'url']
     create_data = [
     create_data = [
         {
         {
-            'content_types': ['dcim.site'],
+            'object_types': ['dcim.site'],
             'name': 'Saved Filter 4',
             'name': 'Saved Filter 4',
             'slug': 'saved-filter-4',
             'slug': 'saved-filter-4',
             'weight': 100,
             'weight': 100,
@@ -342,7 +342,7 @@ class SavedFilterTest(APIViewTestCases.APIViewTestCase):
             'parameters': {'status': ['active']},
             'parameters': {'status': ['active']},
         },
         },
         {
         {
-            'content_types': ['dcim.site'],
+            'object_types': ['dcim.site'],
             'name': 'Saved Filter 5',
             'name': 'Saved Filter 5',
             'slug': 'saved-filter-5',
             'slug': 'saved-filter-5',
             'weight': 200,
             'weight': 200,
@@ -351,7 +351,7 @@ class SavedFilterTest(APIViewTestCases.APIViewTestCase):
             'parameters': {'status': ['planned']},
             'parameters': {'status': ['planned']},
         },
         },
         {
         {
-            'content_types': ['dcim.site'],
+            'object_types': ['dcim.site'],
             'name': 'Saved Filter 6',
             'name': 'Saved Filter 6',
             'slug': 'saved-filter-6',
             'slug': 'saved-filter-6',
             'weight': 300,
             'weight': 300,
@@ -368,7 +368,7 @@ class SavedFilterTest(APIViewTestCases.APIViewTestCase):
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
-        site_ct = ContentType.objects.get_for_model(Site)
+        site_type = ObjectType.objects.get_for_model(Site)
 
 
         saved_filters = (
         saved_filters = (
             SavedFilter(
             SavedFilter(
@@ -398,7 +398,7 @@ class SavedFilterTest(APIViewTestCases.APIViewTestCase):
         )
         )
         SavedFilter.objects.bulk_create(saved_filters)
         SavedFilter.objects.bulk_create(saved_filters)
         for i, savedfilter in enumerate(saved_filters):
         for i, savedfilter in enumerate(saved_filters):
-            savedfilter.content_types.set([site_ct])
+            savedfilter.object_types.set([site_type])
 
 
 
 
 class BookmarkTest(
 class BookmarkTest(
@@ -458,17 +458,17 @@ class ExportTemplateTest(APIViewTestCases.APIViewTestCase):
     brief_fields = ['description', 'display', 'id', 'name', 'url']
     brief_fields = ['description', 'display', 'id', 'name', 'url']
     create_data = [
     create_data = [
         {
         {
-            'content_types': ['dcim.device'],
+            'object_types': ['dcim.device'],
             'name': 'Test Export Template 4',
             'name': 'Test Export Template 4',
             'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
             'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
         },
         },
         {
         {
-            'content_types': ['dcim.device'],
+            'object_types': ['dcim.device'],
             'name': 'Test Export Template 5',
             'name': 'Test Export Template 5',
             'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
             'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
         },
         },
         {
         {
-            'content_types': ['dcim.device'],
+            'object_types': ['dcim.device'],
             'name': 'Test Export Template 6',
             'name': 'Test Export Template 6',
             'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
             'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
         },
         },
@@ -495,7 +495,7 @@ class ExportTemplateTest(APIViewTestCases.APIViewTestCase):
         )
         )
         ExportTemplate.objects.bulk_create(export_templates)
         ExportTemplate.objects.bulk_create(export_templates)
         for et in 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):
 class TagTest(APIViewTestCases.APIViewTestCase):
@@ -548,7 +548,7 @@ class ImageAttachmentTest(
 
 
         image_attachments = (
         image_attachments = (
             ImageAttachment(
             ImageAttachment(
-                content_type=ct,
+                object_type=ct,
                 object_id=site.pk,
                 object_id=site.pk,
                 name='Image Attachment 1',
                 name='Image Attachment 1',
                 image='http://example.com/image1.png',
                 image='http://example.com/image1.png',
@@ -556,7 +556,7 @@ class ImageAttachmentTest(
                 image_width=100
                 image_width=100
             ),
             ),
             ImageAttachment(
             ImageAttachment(
-                content_type=ct,
+                object_type=ct,
                 object_id=site.pk,
                 object_id=site.pk,
                 name='Image Attachment 2',
                 name='Image Attachment 2',
                 image='http://example.com/image2.png',
                 image='http://example.com/image2.png',
@@ -564,7 +564,7 @@ class ImageAttachmentTest(
                 image_width=100
                 image_width=100
             ),
             ),
             ImageAttachment(
             ImageAttachment(
-                content_type=ct,
+                object_type=ct,
                 object_id=site.pk,
                 object_id=site.pk,
                 name='Image Attachment 3',
                 name='Image Attachment 3',
                 image='http://example.com/image3.png',
                 image='http://example.com/image3.png',
@@ -876,17 +876,17 @@ class CreatedUpdatedFilterTest(APITestCase):
         self.assertEqual(response.data['results'][0]['id'], rack2.pk)
         self.assertEqual(response.data['results'][0]['id'], rack2.pk)
 
 
 
 
-class ContentTypeTest(APITestCase):
+class ObjectTypeTest(APITestCase):
 
 
     def test_list_objects(self):
     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.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):
     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)
         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 django.urls import reverse
 from rest_framework import status
 from rest_framework import status
 
 
+from core.models import ObjectType
 from dcim.choices import SiteStatusChoices
 from dcim.choices import SiteStatusChoices
 from dcim.models import Site
 from dcim.models import Site
 from extras.choices import *
 from extras.choices import *
@@ -23,14 +24,14 @@ class ChangeLogViewTest(ModelViewTestCase):
         )
         )
 
 
         # Create a custom field on the Site model
         # 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(
         cf = CustomField(
             type=CustomFieldTypeChoices.TYPE_TEXT,
             type=CustomFieldTypeChoices.TYPE_TEXT,
             name='cf1',
             name='cf1',
             required=False
             required=False
         )
         )
         cf.save()
         cf.save()
-        cf.content_types.set([ct])
+        cf.object_types.set([site_type])
 
 
         # Create a select custom field on the Site model
         # Create a select custom field on the Site model
         cf_select = CustomField(
         cf_select = CustomField(
@@ -40,7 +41,7 @@ class ChangeLogViewTest(ModelViewTestCase):
             choice_set=choice_set
             choice_set=choice_set
         )
         )
         cf_select.save()
         cf_select.save()
-        cf_select.content_types.set([ct])
+        cf_select.object_types.set([site_type])
 
 
     def test_create_object(self):
     def test_create_object(self):
         tags = create_tags('Tag 1', 'Tag 2')
         tags = create_tags('Tag 1', 'Tag 2')
@@ -275,14 +276,14 @@ class ChangeLogAPITest(APITestCase):
     def setUpTestData(cls):
     def setUpTestData(cls):
 
 
         # Create a custom field on the Site model
         # 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(
         cf = CustomField(
             type=CustomFieldTypeChoices.TYPE_TEXT,
             type=CustomFieldTypeChoices.TYPE_TEXT,
             name='cf1',
             name='cf1',
             required=False
             required=False
         )
         )
         cf.save()
         cf.save()
-        cf.content_types.set([ct])
+        cf.object_types.set([site_type])
 
 
         # Create a select custom field on the Site model
         # Create a select custom field on the Site model
         choice_set = CustomFieldChoiceSet.objects.create(
         choice_set = CustomFieldChoiceSet.objects.create(
@@ -296,7 +297,7 @@ class ChangeLogAPITest(APITestCase):
             choice_set=choice_set
             choice_set=choice_set
         )
         )
         cf_select.save()
         cf_select.save()
-        cf_select.content_types.set([ct])
+        cf_select.object_types.set([site_type])
 
 
         # Create some tags
         # Create some tags
         tags = (
         tags = (

+ 62 - 50
netbox/extras/tests/test_customfields.py

@@ -1,11 +1,11 @@
 import datetime
 import datetime
 from decimal import Decimal
 from decimal import Decimal
 
 
-from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
 from django.urls import reverse
 from django.urls import reverse
 from rest_framework import status
 from rest_framework import status
 
 
+from core.models import ObjectType
 from dcim.filtersets import SiteFilterSet
 from dcim.filtersets import SiteFilterSet
 from dcim.forms import SiteImportForm
 from dcim.forms import SiteImportForm
 from dcim.models import Manufacturer, Rack, Site
 from dcim.models import Manufacturer, Rack, Site
@@ -28,7 +28,7 @@ class CustomFieldTest(TestCase):
             Site(name='Site C', slug='site-c'),
             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):
     def test_invalid_name(self):
         """
         """
@@ -50,7 +50,7 @@ class CustomFieldTest(TestCase):
             type=CustomFieldTypeChoices.TYPE_TEXT,
             type=CustomFieldTypeChoices.TYPE_TEXT,
             required=False
             required=False
         )
         )
-        cf.content_types.set([self.object_type])
+        cf.object_types.set([self.object_type])
         instance = Site.objects.first()
         instance = Site.objects.first()
         self.assertIsNone(instance.custom_field_data[cf.name])
         self.assertIsNone(instance.custom_field_data[cf.name])
 
 
@@ -75,7 +75,7 @@ class CustomFieldTest(TestCase):
             type=CustomFieldTypeChoices.TYPE_LONGTEXT,
             type=CustomFieldTypeChoices.TYPE_LONGTEXT,
             required=False
             required=False
         )
         )
-        cf.content_types.set([self.object_type])
+        cf.object_types.set([self.object_type])
         instance = Site.objects.first()
         instance = Site.objects.first()
         self.assertIsNone(instance.custom_field_data[cf.name])
         self.assertIsNone(instance.custom_field_data[cf.name])
 
 
@@ -99,7 +99,7 @@ class CustomFieldTest(TestCase):
             type=CustomFieldTypeChoices.TYPE_INTEGER,
             type=CustomFieldTypeChoices.TYPE_INTEGER,
             required=False
             required=False
         )
         )
-        cf.content_types.set([self.object_type])
+        cf.object_types.set([self.object_type])
         instance = Site.objects.first()
         instance = Site.objects.first()
         self.assertIsNone(instance.custom_field_data[cf.name])
         self.assertIsNone(instance.custom_field_data[cf.name])
 
 
@@ -125,7 +125,7 @@ class CustomFieldTest(TestCase):
             type=CustomFieldTypeChoices.TYPE_DECIMAL,
             type=CustomFieldTypeChoices.TYPE_DECIMAL,
             required=False
             required=False
         )
         )
-        cf.content_types.set([self.object_type])
+        cf.object_types.set([self.object_type])
         instance = Site.objects.first()
         instance = Site.objects.first()
         self.assertIsNone(instance.custom_field_data[cf.name])
         self.assertIsNone(instance.custom_field_data[cf.name])
 
 
@@ -151,7 +151,7 @@ class CustomFieldTest(TestCase):
             type=CustomFieldTypeChoices.TYPE_INTEGER,
             type=CustomFieldTypeChoices.TYPE_INTEGER,
             required=False
             required=False
         )
         )
-        cf.content_types.set([self.object_type])
+        cf.object_types.set([self.object_type])
         instance = Site.objects.first()
         instance = Site.objects.first()
         self.assertIsNone(instance.custom_field_data[cf.name])
         self.assertIsNone(instance.custom_field_data[cf.name])
 
 
@@ -178,7 +178,7 @@ class CustomFieldTest(TestCase):
             type=CustomFieldTypeChoices.TYPE_DATE,
             type=CustomFieldTypeChoices.TYPE_DATE,
             required=False
             required=False
         )
         )
-        cf.content_types.set([self.object_type])
+        cf.object_types.set([self.object_type])
         instance = Site.objects.first()
         instance = Site.objects.first()
         self.assertIsNone(instance.custom_field_data[cf.name])
         self.assertIsNone(instance.custom_field_data[cf.name])
 
 
@@ -203,7 +203,7 @@ class CustomFieldTest(TestCase):
             type=CustomFieldTypeChoices.TYPE_DATETIME,
             type=CustomFieldTypeChoices.TYPE_DATETIME,
             required=False
             required=False
         )
         )
-        cf.content_types.set([self.object_type])
+        cf.object_types.set([self.object_type])
         instance = Site.objects.first()
         instance = Site.objects.first()
         self.assertIsNone(instance.custom_field_data[cf.name])
         self.assertIsNone(instance.custom_field_data[cf.name])
 
 
@@ -228,7 +228,7 @@ class CustomFieldTest(TestCase):
             type=CustomFieldTypeChoices.TYPE_URL,
             type=CustomFieldTypeChoices.TYPE_URL,
             required=False
             required=False
         )
         )
-        cf.content_types.set([self.object_type])
+        cf.object_types.set([self.object_type])
         instance = Site.objects.first()
         instance = Site.objects.first()
         self.assertIsNone(instance.custom_field_data[cf.name])
         self.assertIsNone(instance.custom_field_data[cf.name])
 
 
@@ -253,7 +253,7 @@ class CustomFieldTest(TestCase):
             type=CustomFieldTypeChoices.TYPE_JSON,
             type=CustomFieldTypeChoices.TYPE_JSON,
             required=False
             required=False
         )
         )
-        cf.content_types.set([self.object_type])
+        cf.object_types.set([self.object_type])
         instance = Site.objects.first()
         instance = Site.objects.first()
         self.assertIsNone(instance.custom_field_data[cf.name])
         self.assertIsNone(instance.custom_field_data[cf.name])
 
 
@@ -290,7 +290,7 @@ class CustomFieldTest(TestCase):
             required=False,
             required=False,
             choice_set=choice_set
             choice_set=choice_set
         )
         )
-        cf.content_types.set([self.object_type])
+        cf.object_types.set([self.object_type])
         instance = Site.objects.first()
         instance = Site.objects.first()
         self.assertIsNone(instance.custom_field_data[cf.name])
         self.assertIsNone(instance.custom_field_data[cf.name])
 
 
@@ -327,7 +327,7 @@ class CustomFieldTest(TestCase):
             required=False,
             required=False,
             choice_set=choice_set
             choice_set=choice_set
         )
         )
-        cf.content_types.set([self.object_type])
+        cf.object_types.set([self.object_type])
         instance = Site.objects.first()
         instance = Site.objects.first()
         self.assertIsNone(instance.custom_field_data[cf.name])
         self.assertIsNone(instance.custom_field_data[cf.name])
 
 
@@ -350,10 +350,10 @@ class CustomFieldTest(TestCase):
         cf = CustomField.objects.create(
         cf = CustomField.objects.create(
             name='object_field',
             name='object_field',
             type=CustomFieldTypeChoices.TYPE_OBJECT,
             type=CustomFieldTypeChoices.TYPE_OBJECT,
-            object_type=ContentType.objects.get_for_model(VLAN),
+            related_object_type=ObjectType.objects.get_for_model(VLAN),
             required=False
             required=False
         )
         )
-        cf.content_types.set([self.object_type])
+        cf.object_types.set([self.object_type])
         instance = Site.objects.first()
         instance = Site.objects.first()
         self.assertIsNone(instance.custom_field_data[cf.name])
         self.assertIsNone(instance.custom_field_data[cf.name])
 
 
@@ -382,10 +382,10 @@ class CustomFieldTest(TestCase):
         cf = CustomField.objects.create(
         cf = CustomField.objects.create(
             name='object_field',
             name='object_field',
             type=CustomFieldTypeChoices.TYPE_MULTIOBJECT,
             type=CustomFieldTypeChoices.TYPE_MULTIOBJECT,
-            object_type=ContentType.objects.get_for_model(VLAN),
+            related_object_type=ObjectType.objects.get_for_model(VLAN),
             required=False
             required=False
         )
         )
-        cf.content_types.set([self.object_type])
+        cf.object_types.set([self.object_type])
         instance = Site.objects.first()
         instance = Site.objects.first()
         self.assertIsNone(instance.custom_field_data[cf.name])
         self.assertIsNone(instance.custom_field_data[cf.name])
 
 
@@ -402,13 +402,13 @@ class CustomFieldTest(TestCase):
         self.assertIsNone(instance.custom_field_data.get(cf.name))
         self.assertIsNone(instance.custom_field_data.get(cf.name))
 
 
     def test_rename_customfield(self):
     def test_rename_customfield(self):
-        obj_type = ContentType.objects.get_for_model(Site)
+        obj_type = ObjectType.objects.get_for_model(Site)
         FIELD_DATA = 'abc'
         FIELD_DATA = 'abc'
 
 
         # Create a custom field
         # Create a custom field
         cf = CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='field1')
         cf = CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='field1')
         cf.save()
         cf.save()
-        cf.content_types.set([obj_type])
+        cf.object_types.set([obj_type])
 
 
         # Assign custom field data to an object
         # Assign custom field data to an object
         site = Site.objects.create(
         site = Site.objects.create(
@@ -437,7 +437,7 @@ class CustomFieldTest(TestCase):
             )
             )
         )
         )
         site = Site.objects.create(name='Site 1', slug='site-1')
         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
         # Text
         CustomField(name='test', type='text', required=True, default="Default text").full_clean()
         CustomField(name='test', type='text', required=True, default="Default text").full_clean()
@@ -498,16 +498,28 @@ class CustomFieldTest(TestCase):
             ).full_clean()
             ).full_clean()
 
 
         # Object
         # Object
-        CustomField(name='test', type='object', required=True, object_type=object_type, default=site.pk).full_clean()
-        with self.assertRaises(ValidationError):
-            CustomField(name='test', type='object', required=True, object_type=object_type, default="xxx").full_clean()
+        CustomField(
+            name='test',
+            type='object',
+            required=True,
+            related_object_type=object_type,
+            default=site.pk
+        ).full_clean()
+        with (self.assertRaises(ValidationError)):
+            CustomField(
+                name='test',
+                type='object',
+                required=True,
+                related_object_type=object_type,
+                default="xxx"
+            ).full_clean()
 
 
         # Multi-object
         # Multi-object
         CustomField(
         CustomField(
             name='test',
             name='test',
             type='multiobject',
             type='multiobject',
             required=True,
             required=True,
-            object_type=object_type,
+            related_object_type=object_type,
             default=[site.pk]
             default=[site.pk]
         ).full_clean()
         ).full_clean()
         with self.assertRaises(ValidationError):
         with self.assertRaises(ValidationError):
@@ -515,7 +527,7 @@ class CustomFieldTest(TestCase):
                 name='test',
                 name='test',
                 type='multiobject',
                 type='multiobject',
                 required=True,
                 required=True,
-                object_type=object_type,
+                related_object_type=object_type,
                 default=["xxx"]
                 default=["xxx"]
             ).full_clean()
             ).full_clean()
 
 
@@ -524,10 +536,10 @@ class CustomFieldManagerTest(TestCase):
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     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 = CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='text_field', default='foo')
         custom_field.save()
         custom_field.save()
-        custom_field.content_types.set([content_type])
+        custom_field.object_types.set([object_type])
 
 
     def test_get_for_model(self):
     def test_get_for_model(self):
         self.assertEqual(CustomField.objects.get_for_model(Site).count(), 1)
         self.assertEqual(CustomField.objects.get_for_model(Site).count(), 1)
@@ -538,7 +550,7 @@ class CustomFieldAPITest(APITestCase):
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
-        content_type = ContentType.objects.get_for_model(Site)
+        object_type = ObjectType.objects.get_for_model(Site)
 
 
         # Create some VLANs
         # Create some VLANs
         vlans = (
         vlans = (
@@ -581,19 +593,19 @@ class CustomFieldAPITest(APITestCase):
             CustomField(
             CustomField(
                 type=CustomFieldTypeChoices.TYPE_OBJECT,
                 type=CustomFieldTypeChoices.TYPE_OBJECT,
                 name='object_field',
                 name='object_field',
-                object_type=ContentType.objects.get_for_model(VLAN),
+                related_object_type=ObjectType.objects.get_for_model(VLAN),
                 default=vlans[0].pk,
                 default=vlans[0].pk,
             ),
             ),
             CustomField(
             CustomField(
                 type=CustomFieldTypeChoices.TYPE_MULTIOBJECT,
                 type=CustomFieldTypeChoices.TYPE_MULTIOBJECT,
                 name='multiobject_field',
                 name='multiobject_field',
-                object_type=ContentType.objects.get_for_model(VLAN),
+                related_object_type=ObjectType.objects.get_for_model(VLAN),
                 default=[vlans[0].pk, vlans[1].pk],
                 default=[vlans[0].pk, vlans[1].pk],
             ),
             ),
         )
         )
         for cf in custom_fields:
         for cf in custom_fields:
             cf.save()
             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
         # Create some sites *after* creating the custom fields. This ensures that
         # default values are not set for the assigned objects.
         # default values are not set for the assigned objects.
@@ -1163,7 +1175,7 @@ class CustomFieldImportTest(TestCase):
         )
         )
         for cf in custom_fields:
         for cf in custom_fields:
             cf.save()
             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):
     def test_import(self):
         """
         """
@@ -1256,11 +1268,11 @@ class CustomFieldModelTest(TestCase):
     def setUpTestData(cls):
     def setUpTestData(cls):
         cf1 = CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='foo')
         cf1 = CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='foo')
         cf1.save()
         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 = CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='bar')
         cf2.save()
         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):
     def test_cf_data(self):
         """
         """
@@ -1299,7 +1311,7 @@ class CustomFieldModelTest(TestCase):
         """
         """
         cf3 = CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='baz', required=True)
         cf3 = CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='baz', required=True)
         cf3.save()
         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')
         site = Site(name='Test Site', slug='test-site')
 
 
@@ -1318,7 +1330,7 @@ class CustomFieldModelFilterTest(TestCase):
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
-        obj_type = ContentType.objects.get_for_model(Site)
+        object_type = ObjectType.objects.get_for_model(Site)
 
 
         manufacturers = Manufacturer.objects.bulk_create((
         manufacturers = Manufacturer.objects.bulk_create((
             Manufacturer(name='Manufacturer 1', slug='manufacturer-1'),
             Manufacturer(name='Manufacturer 1', slug='manufacturer-1'),
@@ -1335,17 +1347,17 @@ class CustomFieldModelFilterTest(TestCase):
         # Integer filtering
         # Integer filtering
         cf = CustomField(name='cf1', type=CustomFieldTypeChoices.TYPE_INTEGER)
         cf = CustomField(name='cf1', type=CustomFieldTypeChoices.TYPE_INTEGER)
         cf.save()
         cf.save()
-        cf.content_types.set([obj_type])
+        cf.object_types.set([object_type])
 
 
         # Decimal filtering
         # Decimal filtering
         cf = CustomField(name='cf2', type=CustomFieldTypeChoices.TYPE_DECIMAL)
         cf = CustomField(name='cf2', type=CustomFieldTypeChoices.TYPE_DECIMAL)
         cf.save()
         cf.save()
-        cf.content_types.set([obj_type])
+        cf.object_types.set([object_type])
 
 
         # Boolean filtering
         # Boolean filtering
         cf = CustomField(name='cf3', type=CustomFieldTypeChoices.TYPE_BOOLEAN)
         cf = CustomField(name='cf3', type=CustomFieldTypeChoices.TYPE_BOOLEAN)
         cf.save()
         cf.save()
-        cf.content_types.set([obj_type])
+        cf.object_types.set([object_type])
 
 
         # Exact text filtering
         # Exact text filtering
         cf = CustomField(
         cf = CustomField(
@@ -1354,7 +1366,7 @@ class CustomFieldModelFilterTest(TestCase):
             filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT
             filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT
         )
         )
         cf.save()
         cf.save()
-        cf.content_types.set([obj_type])
+        cf.object_types.set([object_type])
 
 
         # Loose text filtering
         # Loose text filtering
         cf = CustomField(
         cf = CustomField(
@@ -1363,12 +1375,12 @@ class CustomFieldModelFilterTest(TestCase):
             filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE
             filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE
         )
         )
         cf.save()
         cf.save()
-        cf.content_types.set([obj_type])
+        cf.object_types.set([object_type])
 
 
         # Date filtering
         # Date filtering
         cf = CustomField(name='cf6', type=CustomFieldTypeChoices.TYPE_DATE)
         cf = CustomField(name='cf6', type=CustomFieldTypeChoices.TYPE_DATE)
         cf.save()
         cf.save()
-        cf.content_types.set([obj_type])
+        cf.object_types.set([object_type])
 
 
         # Exact URL filtering
         # Exact URL filtering
         cf = CustomField(
         cf = CustomField(
@@ -1377,7 +1389,7 @@ class CustomFieldModelFilterTest(TestCase):
             filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT
             filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT
         )
         )
         cf.save()
         cf.save()
-        cf.content_types.set([obj_type])
+        cf.object_types.set([object_type])
 
 
         # Loose URL filtering
         # Loose URL filtering
         cf = CustomField(
         cf = CustomField(
@@ -1386,7 +1398,7 @@ class CustomFieldModelFilterTest(TestCase):
             filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE
             filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE
         )
         )
         cf.save()
         cf.save()
-        cf.content_types.set([obj_type])
+        cf.object_types.set([object_type])
 
 
         # Selection filtering
         # Selection filtering
         cf = CustomField(
         cf = CustomField(
@@ -1395,7 +1407,7 @@ class CustomFieldModelFilterTest(TestCase):
             choice_set=choice_set
             choice_set=choice_set
         )
         )
         cf.save()
         cf.save()
-        cf.content_types.set([obj_type])
+        cf.object_types.set([object_type])
 
 
         # Multiselect filtering
         # Multiselect filtering
         cf = CustomField(
         cf = CustomField(
@@ -1404,25 +1416,25 @@ class CustomFieldModelFilterTest(TestCase):
             choice_set=choice_set
             choice_set=choice_set
         )
         )
         cf.save()
         cf.save()
-        cf.content_types.set([obj_type])
+        cf.object_types.set([object_type])
 
 
         # Object filtering
         # Object filtering
         cf = CustomField(
         cf = CustomField(
             name='cf11',
             name='cf11',
             type=CustomFieldTypeChoices.TYPE_OBJECT,
             type=CustomFieldTypeChoices.TYPE_OBJECT,
-            object_type=ContentType.objects.get_for_model(Manufacturer)
+            related_object_type=ObjectType.objects.get_for_model(Manufacturer)
         )
         )
         cf.save()
         cf.save()
-        cf.content_types.set([obj_type])
+        cf.object_types.set([object_type])
 
 
         # Multi-object filtering
         # Multi-object filtering
         cf = CustomField(
         cf = CustomField(
             name='cf12',
             name='cf12',
             type=CustomFieldTypeChoices.TYPE_MULTIOBJECT,
             type=CustomFieldTypeChoices.TYPE_MULTIOBJECT,
-            object_type=ContentType.objects.get_for_model(Manufacturer)
+            related_object_type=ObjectType.objects.get_for_model(Manufacturer)
         )
         )
         cf.save()
         cf.save()
-        cf.content_types.set([obj_type])
+        cf.object_types.set([object_type])
 
 
         Site.objects.bulk_create([
         Site.objects.bulk_create([
             Site(name='Site 1', slug='site-1', custom_field_data={
             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
 from unittest.mock import patch
 
 
 import django_rq
 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.http import HttpResponse
 from django.urls import reverse
 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.choices import EventRuleActionChoices, ObjectChangeActionChoices
 from extras.events import enqueue_object, flush_events, serialize_for_event
 from extras.events import enqueue_object, flush_events, serialize_for_event
 from extras.models import EventRule, Tag, Webhook
 from extras.models import EventRule, Tag, Webhook
 from extras.webhooks import generate_signature, send_webhook
 from extras.webhooks import generate_signature, send_webhook
-from requests import Session
-from rest_framework import status
 from utilities.testing import APITestCase
 from utilities.testing import APITestCase
 
 
 
 
@@ -29,7 +30,7 @@ class EventRuleTest(APITestCase):
     @classmethod
     @classmethod
     def setUpTestData(cls):
     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_URL = 'http://localhost:9000/'
         DUMMY_SECRET = 'LOOKATMEIMASECRETSTRING'
         DUMMY_SECRET = 'LOOKATMEIMASECRETSTRING'
 
 
@@ -39,32 +40,32 @@ class EventRuleTest(APITestCase):
             Webhook(name='Webhook 3', payload_url=DUMMY_URL, secret=DUMMY_SECRET),
             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((
         event_rules = EventRule.objects.bulk_create((
             EventRule(
             EventRule(
                 name='Webhook Event 1',
                 name='Webhook Event 1',
                 type_create=True,
                 type_create=True,
                 action_type=EventRuleActionChoices.WEBHOOK,
                 action_type=EventRuleActionChoices.WEBHOOK,
-                action_object_type=ct,
+                action_object_type=webhook_type,
                 action_object_id=webhooks[0].id
                 action_object_id=webhooks[0].id
             ),
             ),
             EventRule(
             EventRule(
                 name='Webhook Event 2',
                 name='Webhook Event 2',
                 type_update=True,
                 type_update=True,
                 action_type=EventRuleActionChoices.WEBHOOK,
                 action_type=EventRuleActionChoices.WEBHOOK,
-                action_object_type=ct,
+                action_object_type=webhook_type,
                 action_object_id=webhooks[0].id
                 action_object_id=webhooks[0].id
             ),
             ),
             EventRule(
             EventRule(
                 name='Webhook Event 3',
                 name='Webhook Event 3',
                 type_delete=True,
                 type_delete=True,
                 action_type=EventRuleActionChoices.WEBHOOK,
                 action_type=EventRuleActionChoices.WEBHOOK,
-                action_object_type=ct,
+                action_object_type=webhook_type,
                 action_object_id=webhooks[0].id
                 action_object_id=webhooks[0].id
             ),
             ),
         ))
         ))
         for event_rule in event_rules:
         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.objects.bulk_create((
             Tag(name='Foo', slug='foo'),
             Tag(name='Foo', slug='foo'),

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

@@ -7,6 +7,7 @@ from django.test import TestCase
 
 
 from circuits.models import Provider
 from circuits.models import Provider
 from core.choices import ManagedFileRootPathChoices
 from core.choices import ManagedFileRootPathChoices
+from core.models import ObjectType
 from dcim.filtersets import SiteFilterSet
 from dcim.filtersets import SiteFilterSet
 from dcim.models import DeviceRole, DeviceType, Manufacturer, Platform, Rack, Region, Site, SiteGroup
 from dcim.models import DeviceRole, DeviceType, Manufacturer, Platform, Rack, Region, Site, SiteGroup
 from dcim.models import Location
 from dcim.models import Location
@@ -85,13 +86,23 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests):
                 ui_editable=CustomFieldUIEditableChoices.HIDDEN,
                 ui_editable=CustomFieldUIEditableChoices.HIDDEN,
                 choice_set=choice_sets[1]
                 choice_set=choice_sets[1]
             ),
             ),
+            CustomField(
+                name='Custom Field 6',
+                type=CustomFieldTypeChoices.TYPE_OBJECT,
+                related_object_type=ObjectType.objects.get_by_natural_key('dcim', 'site'),
+                required=False,
+                weight=600,
+                filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED,
+                ui_visible=CustomFieldUIVisibleChoices.HIDDEN,
+                ui_editable=CustomFieldUIEditableChoices.HIDDEN
+            ),
         )
         )
         CustomField.objects.bulk_create(custom_fields)
         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):
     def test_q(self):
         params = {'q': 'foobar1'}
         params = {'q': 'foobar1'}
@@ -101,10 +112,16 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests):
         params = {'name': ['Custom Field 1', 'Custom Field 2']}
         params = {'name': ['Custom Field 1', 'Custom Field 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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)
         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_related_object_type(self):
+        params = {'related_object_type': 'dcim.site'}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+        params = {'related_object_type_id': [ObjectType.objects.get_by_natural_key('dcim', 'site').pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
 
     def test_required(self):
     def test_required(self):
@@ -174,8 +191,6 @@ class WebhookTestCase(TestCase, BaseFilterSetTests):
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
-        content_types = ContentType.objects.filter(model__in=['region', 'site', 'rack', 'location', 'device'])
-
         webhooks = (
         webhooks = (
             Webhook(
             Webhook(
                 name='Webhook 1',
                 name='Webhook 1',
@@ -240,7 +255,7 @@ class EventRuleTestCase(TestCase, BaseFilterSetTests):
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
-        content_types = ContentType.objects.filter(
+        object_types = ObjectType.objects.filter(
             model__in=['region', 'site', 'rack', 'location', 'device']
             model__in=['region', 'site', 'rack', 'location', 'device']
         )
         )
 
 
@@ -333,11 +348,11 @@ class EventRuleTestCase(TestCase, BaseFilterSetTests):
             ),
             ),
         )
         )
         EventRule.objects.bulk_create(event_rules)
         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):
     def test_q(self):
         params = {'q': 'foobar1'}
         params = {'q': 'foobar1'}
@@ -351,10 +366,10 @@ class EventRuleTestCase(TestCase, BaseFilterSetTests):
         params = {'description': ['foobar1', 'foobar2']}
         params = {'description': ['foobar1', 'foobar2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         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)
         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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
 
     def test_action_type(self):
     def test_action_type(self):
@@ -396,7 +411,7 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests):
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     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 = (
         custom_links = (
             CustomLink(
             CustomLink(
@@ -426,7 +441,7 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests):
         )
         )
         CustomLink.objects.bulk_create(custom_links)
         CustomLink.objects.bulk_create(custom_links)
         for i, custom_link in enumerate(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):
     def test_q(self):
         params = {'q': 'Custom Link 1'}
         params = {'q': 'Custom Link 1'}
@@ -436,10 +451,10 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests):
         params = {'name': ['Custom Link 1', 'Custom Link 2']}
         params = {'name': ['Custom Link 1', 'Custom Link 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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)
         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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
 
     def test_weight(self):
     def test_weight(self):
@@ -465,7 +480,7 @@ class SavedFilterTestCase(TestCase, BaseFilterSetTests):
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
-        content_types = ContentType.objects.filter(model__in=['site', 'rack', 'device'])
+        object_types = ObjectType.objects.filter(model__in=['site', 'rack', 'device'])
 
 
         users = (
         users = (
             User(username='User 1'),
             User(username='User 1'),
@@ -508,7 +523,7 @@ class SavedFilterTestCase(TestCase, BaseFilterSetTests):
         )
         )
         SavedFilter.objects.bulk_create(saved_filters)
         SavedFilter.objects.bulk_create(saved_filters)
         for i, savedfilter in enumerate(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):
     def test_q(self):
         params = {'q': 'foobar1'}
         params = {'q': 'foobar1'}
@@ -526,10 +541,10 @@ class SavedFilterTestCase(TestCase, BaseFilterSetTests):
         params = {'description': ['foobar1', 'foobar2']}
         params = {'description': ['foobar1', 'foobar2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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)
         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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
 
     def test_user(self):
     def test_user(self):
@@ -638,7 +653,7 @@ class ExportTemplateTestCase(TestCase, BaseFilterSetTests):
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     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 = (
         export_templates = (
             ExportTemplate(name='Export Template 1', template_code='TESTING', description='foobar1'),
             ExportTemplate(name='Export Template 1', template_code='TESTING', description='foobar1'),
@@ -647,7 +662,7 @@ class ExportTemplateTestCase(TestCase, BaseFilterSetTests):
         )
         )
         ExportTemplate.objects.bulk_create(export_templates)
         ExportTemplate.objects.bulk_create(export_templates)
         for i, et in enumerate(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):
     def test_q(self):
         params = {'q': 'foobar1'}
         params = {'q': 'foobar1'}
@@ -657,10 +672,10 @@ class ExportTemplateTestCase(TestCase, BaseFilterSetTests):
         params = {'name': ['Export Template 1', 'Export Template 2']}
         params = {'name': ['Export Template 1', 'Export Template 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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)
         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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
 
     def test_description(self):
     def test_description(self):
@@ -692,7 +707,7 @@ class ImageAttachmentTestCase(TestCase, BaseFilterSetTests):
 
 
         image_attachments = (
         image_attachments = (
             ImageAttachment(
             ImageAttachment(
-                content_type=site_ct,
+                object_type=site_ct,
                 object_id=sites[0].pk,
                 object_id=sites[0].pk,
                 name='Image Attachment 1',
                 name='Image Attachment 1',
                 image='http://example.com/image1.png',
                 image='http://example.com/image1.png',
@@ -700,7 +715,7 @@ class ImageAttachmentTestCase(TestCase, BaseFilterSetTests):
                 image_width=100
                 image_width=100
             ),
             ),
             ImageAttachment(
             ImageAttachment(
-                content_type=site_ct,
+                object_type=site_ct,
                 object_id=sites[1].pk,
                 object_id=sites[1].pk,
                 name='Image Attachment 2',
                 name='Image Attachment 2',
                 image='http://example.com/image2.png',
                 image='http://example.com/image2.png',
@@ -708,7 +723,7 @@ class ImageAttachmentTestCase(TestCase, BaseFilterSetTests):
                 image_width=100
                 image_width=100
             ),
             ),
             ImageAttachment(
             ImageAttachment(
-                content_type=rack_ct,
+                object_type=rack_ct,
                 object_id=racks[0].pk,
                 object_id=racks[0].pk,
                 name='Image Attachment 3',
                 name='Image Attachment 3',
                 image='http://example.com/image3.png',
                 image='http://example.com/image3.png',
@@ -716,7 +731,7 @@ class ImageAttachmentTestCase(TestCase, BaseFilterSetTests):
                 image_width=100
                 image_width=100
             ),
             ),
             ImageAttachment(
             ImageAttachment(
-                content_type=rack_ct,
+                object_type=rack_ct,
                 object_id=racks[1].pk,
                 object_id=racks[1].pk,
                 name='Image Attachment 4',
                 name='Image Attachment 4',
                 image='http://example.com/image4.png',
                 image='http://example.com/image4.png',
@@ -734,13 +749,13 @@ class ImageAttachmentTestCase(TestCase, BaseFilterSetTests):
         params = {'name': ['Image Attachment 1', 'Image Attachment 2']}
         params = {'name': ['Image Attachment 1', 'Image Attachment 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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)
         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 = {
         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],
             'object_id': [Site.objects.first().pk],
         }
         }
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
@@ -1113,9 +1128,9 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     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 = (
         tags = (
@@ -1124,8 +1139,8 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
             Tag(name='Tag 3', slug='tag-3', color='0000ff'),
             Tag(name='Tag 3', slug='tag-3', color='0000ff'),
         )
         )
         Tag.objects.bulk_create(tags)
         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
         # Apply some tags so we can filter by content type
         site = Site.objects.create(name='Site 1', slug='site-1')
         site = Site.objects.create(name='Site 1', slug='site-1')
@@ -1163,12 +1178,12 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
     def test_object_types(self):
     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(
         self.assertEqual(
             list(self.filterset(params, self.queryset).qs.values_list('name', flat=True)),
             list(self.filterset(params, self.queryset).qs.values_list('name', flat=True)),
             ['Tag 1', 'Tag 3']
             ['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(
         self.assertEqual(
             list(self.filterset(params, self.queryset).qs.values_list('name', flat=True)),
             list(self.filterset(params, self.queryset).qs.values_list('name', flat=True)),
             ['Tag 2', 'Tag 3']
             ['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 django.test import TestCase
 
 
+from core.models import ObjectType
 from dcim.forms import SiteForm
 from dcim.forms import SiteForm
 from dcim.models import Site
 from dcim.models import Site
 from extras.choices import CustomFieldTypeChoices
 from extras.choices import CustomFieldTypeChoices
@@ -12,66 +12,66 @@ class CustomFieldModelFormTest(TestCase):
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
-        obj_type = ContentType.objects.get_for_model(Site)
+        object_type = ObjectType.objects.get_for_model(Site)
         choice_set = CustomFieldChoiceSet.objects.create(
         choice_set = CustomFieldChoiceSet.objects.create(
             name='Choice Set 1',
             name='Choice Set 1',
             extra_choices=(('a', 'A'), ('b', 'B'), ('c', 'C'))
             extra_choices=(('a', 'A'), ('b', 'B'), ('c', 'C'))
         )
         )
 
 
         cf_text = CustomField.objects.create(name='text', type=CustomFieldTypeChoices.TYPE_TEXT)
         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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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(
         cf_select = CustomField.objects.create(
             name='select',
             name='select',
             type=CustomFieldTypeChoices.TYPE_SELECT,
             type=CustomFieldTypeChoices.TYPE_SELECT,
             choice_set=choice_set
             choice_set=choice_set
         )
         )
-        cf_select.content_types.set([obj_type])
+        cf_select.object_types.set([object_type])
 
 
         cf_multiselect = CustomField.objects.create(
         cf_multiselect = CustomField.objects.create(
             name='multiselect',
             name='multiselect',
             type=CustomFieldTypeChoices.TYPE_MULTISELECT,
             type=CustomFieldTypeChoices.TYPE_MULTISELECT,
             choice_set=choice_set
             choice_set=choice_set
         )
         )
-        cf_multiselect.content_types.set([obj_type])
+        cf_multiselect.object_types.set([object_type])
 
 
         cf_object = CustomField.objects.create(
         cf_object = CustomField.objects.create(
             name='object',
             name='object',
             type=CustomFieldTypeChoices.TYPE_OBJECT,
             type=CustomFieldTypeChoices.TYPE_OBJECT,
-            object_type=ContentType.objects.get_for_model(Site)
+            related_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(
         cf_multiobject = CustomField.objects.create(
             name='multiobject',
             name='multiobject',
             type=CustomFieldTypeChoices.TYPE_MULTIOBJECT,
             type=CustomFieldTypeChoices.TYPE_MULTIOBJECT,
-            object_type=ContentType.objects.get_for_model(Site)
+            related_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):
     def test_empty_values(self):
         """
         """
@@ -99,7 +99,7 @@ class SavedFilterFormTest(TestCase):
         form = SavedFilterForm({
         form = SavedFilterForm({
             'name': 'test-sf',
             'name': 'test-sf',
             'slug': '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,
             'weight': 100,
             'parameters': {
             'parameters': {
                 "status": [
                 "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 django.test import TestCase
 
 
+from core.models import ObjectType
 from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Platform, Region, Site, SiteGroup
 from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Platform, Region, Site, SiteGroup
 from extras.models import ConfigContext, Tag
 from extras.models import ConfigContext, Tag
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
@@ -22,7 +22,7 @@ class TagTest(TestCase):
 
 
         # Create a Tag that can only be applied to Regions
         # Create a Tag that can only be applied to Regions
         tag = Tag.objects.create(name='Tag 1', slug='tag-1')
         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
         # Apply the Tag to a Region
         region.tags.add(tag)
         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.contrib.contenttypes.models import ContentType
 from django.urls import reverse
 from django.urls import reverse
 
 
+from core.models import ObjectType
 from dcim.models import DeviceType, Manufacturer, Site
 from dcim.models import DeviceType, Manufacturer, Site
 from extras.choices import *
 from extras.choices import *
 from extras.models import *
 from extras.models import *
@@ -19,7 +20,7 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
 
 
-        site_ct = ContentType.objects.get_for_model(Site)
+        site_type = ObjectType.objects.get_for_model(Site)
         CustomFieldChoiceSet.objects.create(
         CustomFieldChoiceSet.objects.create(
             name='Choice Set 1',
             name='Choice Set 1',
             extra_choices=(
             extra_choices=(
@@ -36,13 +37,13 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         )
         )
         for customfield in custom_fields:
         for customfield in custom_fields:
             customfield.save()
             customfield.save()
-            customfield.content_types.add(site_ct)
+            customfield.object_types.add(site_type)
 
 
         cls.form_data = {
         cls.form_data = {
             'name': 'field_x',
             'name': 'field_x',
             'label': 'Field X',
             'label': 'Field X',
             'type': 'text',
             'type': 'text',
-            'content_types': [site_ct.pk],
+            'object_types': [site_type.pk],
             'search_weight': 2000,
             'search_weight': 2000,
             'filter_logic': CustomFieldFilterLogicChoices.FILTER_EXACT,
             'filter_logic': CustomFieldFilterLogicChoices.FILTER_EXACT,
             'default': None,
             'default': None,
@@ -53,7 +54,7 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         }
         }
 
 
         cls.csv_data = (
         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,related_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',
             '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',
             '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',
             'field6,Field 6,select,dcim.site,,100,3000,exact,Choice Set 1,,,,always,yes',
@@ -137,7 +138,7 @@ class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
-        site_ct = ContentType.objects.get_for_model(Site)
+        site_type = ObjectType.objects.get_for_model(Site)
         custom_links = (
         custom_links = (
             CustomLink(name='Custom Link 1', enabled=True, link_text='Link 1', link_url='http://example.com/?1'),
             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'),
             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)
         CustomLink.objects.bulk_create(custom_links)
         for i, custom_link in enumerate(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 = {
         cls.form_data = {
             'name': 'Custom Link X',
             'name': 'Custom Link X',
-            'content_types': [site_ct.pk],
+            'object_types': [site_type.pk],
             'enabled': False,
             'enabled': False,
             'weight': 100,
             'weight': 100,
             'button_class': CustomLinkButtonClassChoices.DEFAULT,
             'button_class': CustomLinkButtonClassChoices.DEFAULT,
@@ -158,7 +159,7 @@ class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         }
         }
 
 
         cls.csv_data = (
         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 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 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",
             "Custom Link 6,dcim.site,False,100,blue,Link 6,http://exmaple.com/?6",
@@ -183,7 +184,7 @@ class SavedFilterTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
-        site_ct = ContentType.objects.get_for_model(Site)
+        site_type = ObjectType.objects.get_for_model(Site)
 
 
         users = (
         users = (
             User(username='User 1'),
             User(username='User 1'),
@@ -217,12 +218,12 @@ class SavedFilterTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         )
         )
         SavedFilter.objects.bulk_create(saved_filters)
         SavedFilter.objects.bulk_create(saved_filters)
         for i, savedfilter in enumerate(saved_filters):
         for i, savedfilter in enumerate(saved_filters):
-            savedfilter.content_types.set([site_ct])
+            savedfilter.object_types.set([site_type])
 
 
         cls.form_data = {
         cls.form_data = {
             'name': 'Saved Filter X',
             'name': 'Saved Filter X',
             'slug': 'saved-filter-x',
             'slug': 'saved-filter-x',
-            'content_types': [site_ct.pk],
+            'object_types': [site_type.pk],
             'description': 'Foo',
             'description': 'Foo',
             'weight': 1000,
             'weight': 1000,
             'enabled': True,
             'enabled': True,
@@ -231,7 +232,7 @@ class SavedFilterTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         }
         }
 
 
         cls.csv_data = (
         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 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 5,saved-filter-5,dcim.device,500,True,True,{"foo": "b"}',
             'Saved Filter 6,saved-filter-6,dcim.device,600,True,True,{"foo": "c"}',
             'Saved Filter 6,saved-filter-6,dcim.device,600,True,True,{"foo": "c"}',
@@ -302,7 +303,7 @@ class ExportTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     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 %}"""
         TEMPLATE_CODE = """{% for object in queryset %}{{ object }}{% endfor %}"""
 
 
         export_templates = (
         export_templates = (
@@ -312,16 +313,16 @@ class ExportTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         )
         )
         ExportTemplate.objects.bulk_create(export_templates)
         ExportTemplate.objects.bulk_create(export_templates)
         for et in export_templates:
         for et in export_templates:
-            et.content_types.set([site_ct])
+            et.object_types.set([site_type])
 
 
         cls.form_data = {
         cls.form_data = {
             'name': 'Export Template X',
             'name': 'Export Template X',
-            'content_types': [site_ct.pk],
+            'object_types': [site_type.pk],
             'template_code': TEMPLATE_CODE,
             'template_code': TEMPLATE_CODE,
         }
         }
 
 
         cls.csv_data = (
         cls.csv_data = (
-            "name,content_types,template_code",
+            "name,object_types,template_code",
             f"Export Template 4,dcim.site,{TEMPLATE_CODE}",
             f"Export Template 4,dcim.site,{TEMPLATE_CODE}",
             f"Export Template 5,dcim.site,{TEMPLATE_CODE}",
             f"Export Template 5,dcim.site,{TEMPLATE_CODE}",
             f"Export Template 6,dcim.site,{TEMPLATE_CODE}",
             f"Export Template 6,dcim.site,{TEMPLATE_CODE}",
@@ -396,7 +397,7 @@ class EventRulesTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         for webhook in webhooks:
         for webhook in webhooks:
             webhook.save()
             webhook.save()
 
 
-        site_ct = ContentType.objects.get_for_model(Site)
+        site_type = ObjectType.objects.get_for_model(Site)
         event_rules = (
         event_rules = (
             EventRule(name='EventRule 1', type_create=True, action_object=webhooks[0]),
             EventRule(name='EventRule 1', type_create=True, action_object=webhooks[0]),
             EventRule(name='EventRule 2', type_create=True, action_object=webhooks[1]),
             EventRule(name='EventRule 2', type_create=True, action_object=webhooks[1]),
@@ -404,12 +405,12 @@ class EventRulesTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         )
         )
         for event in event_rules:
         for event in event_rules:
             event.save()
             event.save()
-            event.content_types.add(site_ct)
+            event.object_types.add(site_type)
 
 
         webhook_ct = ContentType.objects.get_for_model(Webhook)
         webhook_ct = ContentType.objects.get_for_model(Webhook)
         cls.form_data = {
         cls.form_data = {
             'name': 'Event X',
             'name': 'Event X',
-            'content_types': [site_ct.pk],
+            'object_types': [site_type.pk],
             'type_create': False,
             'type_create': False,
             'type_update': True,
             'type_update': True,
             'type_delete': True,
             'type_delete': True,
@@ -422,7 +423,7 @@ class EventRulesTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         }
         }
 
 
         cls.csv_data = (
         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",
             "Webhook 4,dcim.site,True,webhook,Webhook 1",
         )
         )
 
 
@@ -651,7 +652,7 @@ class CustomLinkTest(TestCase):
             new_window=False
             new_window=False
         )
         )
         customlink.save()
         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 = Site(name='Test Site', slug='test-site')
         site.save()
         site.save()

+ 1 - 1
netbox/extras/utils.py

@@ -24,7 +24,7 @@ def image_upload(instance, filename):
     elif instance.name:
     elif instance.name:
         filename = 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):
 def is_script(obj):

+ 61 - 6
netbox/extras/views.py

@@ -17,6 +17,7 @@ from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm
 from extras.dashboard.utils import get_widget_class
 from extras.dashboard.utils import get_widget_class
 from netbox.constants import DEFAULT_ACTION_PERMISSIONS
 from netbox.constants import DEFAULT_ACTION_PERMISSIONS
 from netbox.views import generic
 from netbox.views import generic
+from netbox.views.generic.mixins import TableMixin
 from utilities.forms import ConfirmationForm, get_field_value
 from utilities.forms import ConfirmationForm, get_field_value
 from utilities.paginator import EnhancedPaginator, get_paginate_count
 from utilities.paginator import EnhancedPaginator, get_paginate_count
 from utilities.rqworker import get_workers_for_queue
 from utilities.rqworker import get_workers_for_queue
@@ -26,6 +27,7 @@ from utilities.views import ContentTypePermissionRequiredMixin, register_model_v
 from . import filtersets, forms, tables
 from . import filtersets, forms, tables
 from .models import *
 from .models import *
 from .scripts import run_script
 from .scripts import run_script
+from .tables import ReportResultsTable, ScriptResultsTable
 
 
 
 
 #
 #
@@ -46,9 +48,9 @@ class CustomFieldView(generic.ObjectView):
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
         related_models = ()
         related_models = ()
 
 
-        for content_type in instance.content_types.all():
+        for object_type in instance.object_types.all():
             related_models += (
             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}': ''}) |
                     Q(**{f'custom_field_data__{instance.name}': None})
                     Q(**{f'custom_field_data__{instance.name}': None})
                 ),
                 ),
@@ -762,8 +764,8 @@ class ImageAttachmentEditView(generic.ObjectEditView):
     def alter_object(self, instance, request, args, kwargs):
     def alter_object(self, instance, request, args, kwargs):
         if not instance.pk:
         if not instance.pk:
             # Assign the parent object based on URL kwargs
             # 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
         return instance
 
 
     def get_return_url(self, request, obj=None):
     def get_return_url(self, request, obj=None):
@@ -771,7 +773,7 @@ class ImageAttachmentEditView(generic.ObjectEditView):
 
 
     def get_extra_addanother_params(self, request):
     def get_extra_addanother_params(self, request):
         return {
         return {
-            'content_type': request.GET.get('content_type'),
+            'object_type': request.GET.get('object_type'),
             'object_id': request.GET.get('object_id'),
             'object_id': request.GET.get('object_id'),
         }
         }
 
 
@@ -1143,19 +1145,72 @@ class LegacyScriptRedirectView(ContentTypePermissionRequiredMixin, View):
         return redirect(f'{url}{path}')
         return redirect(f'{url}{path}')
 
 
 
 
-class ScriptResultView(generic.ObjectView):
+class ScriptResultView(TableMixin, generic.ObjectView):
     queryset = Job.objects.all()
     queryset = Job.objects.all()
 
 
     def get_required_permission(self):
     def get_required_permission(self):
         return 'extras.view_script'
         return 'extras.view_script'
 
 
+    def get_table(self, job, request, bulk_actions=True):
+        data = []
+        tests = None
+        table = None
+        index = 0
+        if job.data:
+            if 'log' in job.data:
+                if 'tests' in job.data:
+                    tests = job.data['tests']
+
+                for log in job.data['log']:
+                    index += 1
+                    result = {
+                        'index': index,
+                        'time': log.get('time'),
+                        'status': log.get('status'),
+                        'message': log.get('message'),
+                    }
+                    data.append(result)
+
+                table = ScriptResultsTable(data, user=request.user)
+                table.configure(request)
+            else:
+                # for legacy reports
+                tests = job.data
+
+        if tests:
+            for method, test_data in tests.items():
+                if 'log' in test_data:
+                    for time, status, obj, url, message in test_data['log']:
+                        index += 1
+                        result = {
+                            'index': index,
+                            'method': method,
+                            'time': time,
+                            'status': status,
+                            'object': obj,
+                            'url': url,
+                            'message': message,
+                        }
+                        data.append(result)
+
+            table = ReportResultsTable(data, user=request.user)
+            table.configure(request)
+
+        return table
+
     def get(self, request, **kwargs):
     def get(self, request, **kwargs):
+        table = None
         job = get_object_or_404(Job.objects.all(), pk=kwargs.get('job_pk'))
         job = get_object_or_404(Job.objects.all(), pk=kwargs.get('job_pk'))
 
 
+        if job.completed:
+            table = self.get_table(job, request, bulk_actions=False)
+
         context = {
         context = {
             'script': job.object,
             'script': job.object,
             'job': job,
             'job': job,
+            'table': table,
         }
         }
+
         if job.data and 'log' in job.data:
         if job.data and 'log' in job.data:
             # Script
             # Script
             context['tests'] = job.data.get('tests', {})
             context['tests'] = job.data.get('tests', {})

+ 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.functional import cached_property
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
-from core.models import ContentType
+from core.models import ObjectType
 from ipam.choices import *
 from ipam.choices import *
 from ipam.constants import *
 from ipam.constants import *
 from ipam.fields import IPNetworkField, IPAddressField
 from ipam.fields import IPNetworkField, IPAddressField
@@ -861,7 +861,7 @@ class IPAddress(PrimaryModel):
 
 
         if self._original_assigned_object_id and self._original_assigned_object_type_id:
         if self._original_assigned_object_id and self._original_assigned_object_type_id:
             parent = getattr(self.assigned_object, 'parent_object', None)
             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_assigned_object = ct.get_object_for_this_type(pk=self._original_assigned_object_id)
             original_parent = getattr(original_assigned_object, 'parent_object', None)
             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 import serializers
 from rest_framework.fields import CreateOnlyDefault
 from rest_framework.fields import CreateOnlyDefault
 
 
 from extras.api.customfields import CustomFieldsDataField, CustomFieldDefaultValues
 from extras.api.customfields import CustomFieldsDataField, CustomFieldDefaultValues
-from extras.models import CustomField
 from .nested import NestedTagSerializer
 from .nested import NestedTagSerializer
 
 
 __all__ = (
 __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.core.exceptions import ObjectDoesNotExist
 from django.db import transaction
 from django.db import transaction
 from django.http import Http404
 from django.http import Http404
 from rest_framework import status
 from rest_framework import status
 from rest_framework.response import Response
 from rest_framework.response import Response
 
 
+from core.models import ObjectType
 from extras.models import ExportTemplate
 from extras.models import ExportTemplate
 from netbox.api.serializers import BulkOperationSerializer
 from netbox.api.serializers import BulkOperationSerializer
 
 
@@ -26,9 +26,9 @@ class CustomFieldsMixin:
         context = super().get_serializer_context()
         context = super().get_serializer_context()
 
 
         if hasattr(self.queryset.model, 'custom_fields'):
         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({
             context.update({
-                'custom_fields': content_type.custom_fields.all(),
+                'custom_fields': object_type.custom_fields.all(),
             })
             })
 
 
         return context
         return context
@@ -40,8 +40,8 @@ class ExportTemplatesMixin:
     """
     """
     def list(self, request, *args, **kwargs):
     def list(self, request, *args, **kwargs):
         if 'export' in request.GET:
         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:
             if et is None:
                 raise Http404
                 raise Http404
             queryset = self.filter_queryset(self.get_queryset())
             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
         # Dynamically add a Filter for each CustomField applicable to the parent model
         custom_fields = CustomField.objects.filter(
         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(
         ).exclude(
             filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED
             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.db.models import Q
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
+from core.models import ObjectType
 from extras.choices import *
 from extras.choices import *
 from extras.models import CustomField, Tag
 from extras.models import CustomField, Tag
 from utilities.forms import CSVModelForm
 from utilities.forms import CSVModelForm
@@ -88,7 +89,7 @@ class NetBoxModelImportForm(CSVModelForm, NetBoxModelForm):
 
 
     def _get_custom_fields(self, content_type):
     def _get_custom_fields(self, content_type):
         return CustomField.objects.filter(
         return CustomField.objects.filter(
-            content_types=content_type,
+            object_types=content_type,
             ui_editable=CustomFieldUIEditableChoices.YES
             ui_editable=CustomFieldUIEditableChoices.YES
         )
         )
 
 
@@ -129,9 +130,9 @@ class NetBoxModelBulkEditForm(CustomFieldsMixin, forms.Form):
         self.fields['pk'].queryset = self.model.objects.all()
         self.fields['pk'].queryset = self.model.objects.all()
 
 
         # Restrict tag fields by model
         # 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()
         self._extend_nullable_fields()
 
 
@@ -169,9 +170,9 @@ class NetBoxModelFilterSetForm(CustomFieldsMixin, SavedFiltersMixin, forms.Form)
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
 
 
         # Limit saved filters to those applicable to the form's model
         # 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({
         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):
     def _get_custom_fields(self, content_type):

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

@@ -1,7 +1,7 @@
 from django import forms
 from django import forms
-from django.contrib.contenttypes.models import ContentType
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
+from core.models import ObjectType
 from extras.choices import *
 from extras.choices import *
 from extras.models import *
 from extras.models import *
 from utilities.forms.fields import DynamicModelMultipleChoiceField
 from utilities.forms.fields import DynamicModelMultipleChoiceField
@@ -32,16 +32,16 @@ class CustomFieldsMixin:
 
 
     def _get_content_type(self):
     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):
         if not getattr(self, 'model', None):
             raise NotImplementedError(_("{class_name} must specify a model class.").format(
             raise NotImplementedError(_("{class_name} must specify a model class.").format(
                 class_name=self.__class__.__name__
                 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):
     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
             ui_editable=CustomFieldUIEditableChoices.HIDDEN
         )
         )
 
 
@@ -85,6 +85,6 @@ class TagsMixin(forms.Form):
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
 
 
         # Limit tags to those applicable to the object type
         # 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)

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

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

@@ -11,6 +11,7 @@ from django.utils.module_loading import import_string
 import netaddr
 import netaddr
 from netaddr.core import AddrFormatError
 from netaddr.core import AddrFormatError
 
 
+from core.models import ObjectType
 from extras.models import CachedValue, CustomField
 from extras.models import CachedValue, CustomField
 from netbox.registry import registry
 from netbox.registry import registry
 from utilities.querysets import RestrictedPrefetch
 from utilities.querysets import RestrictedPrefetch
@@ -130,11 +131,11 @@ class CachedValueSearchBackend(SearchBackend):
             )
             )
         )[:MAX_RESULTS]
         )[: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
         # objects). This must be done before generating the final results list, which returns
         # a RawQuerySet.
         # 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
         # Construct a Prefetch to pre-fetch only those related objects for which the
         # user has permission to view.
         # user has permission to view.
@@ -151,11 +152,11 @@ class CachedValueSearchBackend(SearchBackend):
             params
             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).
         # 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)):
             if not (display_attrs := getattr(indexer, 'display_attrs', None)):
                 continue
                 continue
 
 
@@ -169,7 +170,7 @@ class CachedValueSearchBackend(SearchBackend):
             # Compile a list of all CachedValues referencing this object type, and prefetch
             # Compile a list of all CachedValues referencing this object type, and prefetch
             # any related objects
             # any related objects
             if prefetch_fields:
             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)
                 prefetch_related_objects(objects, *prefetch_fields)
 
 
         # Omit any results pertaining to an object the user does not have permission to view
         # Omit any results pertaining to an object the user does not have permission to view
@@ -182,7 +183,7 @@ class CachedValueSearchBackend(SearchBackend):
         return ret
         return ret
 
 
     def cache(self, instances, indexer=None, remove_existing=True):
     def cache(self, instances, indexer=None, remove_existing=True):
-        content_type = None
+        object_type = None
         custom_fields = None
         custom_fields = None
 
 
         # Convert a single instance to an iterable
         # Convert a single instance to an iterable
@@ -204,8 +205,8 @@ class CachedValueSearchBackend(SearchBackend):
                         break
                         break
 
 
                 # Prefetch any associated custom fields
                 # 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
             # Wipe out any previously cached values for the object
             if remove_existing:
             if remove_existing:
@@ -215,7 +216,7 @@ class CachedValueSearchBackend(SearchBackend):
             for field in indexer.to_cache(instance, custom_fields=custom_fields):
             for field in indexer.to_cache(instance, custom_fields=custom_fields):
                 buffer.append(
                 buffer.append(
                     CachedValue(
                     CachedValue(
-                        object_type=content_type,
+                        object_type=object_type,
                         object_id=instance.pk,
                         object_id=instance.pk,
                         field=field.name,
                         field=field.name,
                         type=field.type,
                         type=field.type,

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

@@ -3,7 +3,6 @@ from copy import deepcopy
 import django_tables2 as tables
 import django_tables2 as tables
 from django.contrib.auth.models import AnonymousUser
 from django.contrib.auth.models import AnonymousUser
 from django.contrib.contenttypes.fields import GenericForeignKey
 from django.contrib.contenttypes.fields import GenericForeignKey
-from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import FieldDoesNotExist
 from django.core.exceptions import FieldDoesNotExist
 from django.db.models.fields.related import RelatedField
 from django.db.models.fields.related import RelatedField
 from django.urls import reverse
 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.utils.translation import gettext_lazy as _
 from django_tables2.data import TableQuerysetData
 from django_tables2.data import TableQuerysetData
 
 
+from core.models import ObjectType
 from extras.choices import *
 from extras.choices import *
 from extras.models import CustomField, CustomLink
 from extras.models import CustomField, CustomLink
 from netbox.registry import registry
 from netbox.registry import registry
@@ -201,14 +201,14 @@ class NetBoxTable(BaseTable):
             ])
             ])
 
 
         # Add custom field & custom link columns
         # 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(
         custom_fields = CustomField.objects.filter(
-            content_types=content_type
+            object_types=object_type
         ).exclude(ui_visible=CustomFieldUIVisibleChoices.HIDDEN)
         ).exclude(ui_visible=CustomFieldUIVisibleChoices.HIDDEN)
         extra_columns.extend([
         extra_columns.extend([
             (f'cf_{cf.name}', columns.CustomFieldColumn(cf)) for cf in custom_fields
             (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([
         extra_columns.extend([
             (f'cl_{cl.name}', columns.CustomLinkColumn(cl)) for cl in custom_links
             (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.conf import settings
 from django.contrib.auth import get_user_model
 from django.contrib.auth import get_user_model
-from django.contrib.contenttypes.models import ContentType
 from django.test import Client
 from django.test import Client
 from django.test.utils import override_settings
 from django.test.utils import override_settings
 from django.urls import reverse
 from django.urls import reverse
 from netaddr import IPNetwork
 from netaddr import IPNetwork
 from rest_framework.test import APIClient
 from rest_framework.test import APIClient
 
 
+from core.models import ObjectType
 from dcim.models import Site
 from dcim.models import Site
 from ipam.models import Prefix
 from ipam.models import Prefix
 from users.models import Group, ObjectPermission, Token
 from users.models import Group, ObjectPermission, Token
@@ -452,7 +452,7 @@ class ObjectPermissionAPIViewTestCase(TestCase):
         )
         )
         obj_perm.save()
         obj_perm.save()
         obj_perm.users.add(self.user)
         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
         # Retrieve permitted object
         url = reverse('ipam-api:prefix-detail',
         url = reverse('ipam-api:prefix-detail',
@@ -482,7 +482,7 @@ class ObjectPermissionAPIViewTestCase(TestCase):
         )
         )
         obj_perm.save()
         obj_perm.save()
         obj_perm.users.add(self.user)
         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.
         # Retrieve all objects. Only permitted objects should be returned.
         response = self.client.get(url, **self.header)
         response = self.client.get(url, **self.header)
@@ -510,7 +510,7 @@ class ObjectPermissionAPIViewTestCase(TestCase):
         )
         )
         obj_perm.save()
         obj_perm.save()
         obj_perm.users.add(self.user)
         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
         # Attempt to create a non-permitted object
         response = self.client.post(url, data, format='json', **self.header)
         response = self.client.post(url, data, format='json', **self.header)
@@ -541,7 +541,7 @@ class ObjectPermissionAPIViewTestCase(TestCase):
         )
         )
         obj_perm.save()
         obj_perm.save()
         obj_perm.users.add(self.user)
         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
         # Attempt to edit a non-permitted object
         data = {'site': self.sites[0].pk}
         data = {'site': self.sites[0].pk}
@@ -581,7 +581,7 @@ class ObjectPermissionAPIViewTestCase(TestCase):
         )
         )
         obj_perm.save()
         obj_perm.save()
         obj_perm.users.add(self.user)
         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
         # Attempt to delete a non-permitted object
         url = reverse('ipam-api:prefix-detail',
         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 django.test import override_settings
 
 
+from core.models import ObjectType
 from dcim.models import *
 from dcim.models import *
 from users.models import ObjectPermission
 from users.models import ObjectPermission
 from utilities.choices import CSVDelimiterChoices, ImportFormatChoices
 from utilities.choices import CSVDelimiterChoices, ImportFormatChoices
@@ -67,7 +67,7 @@ class CSVImportTestCase(ModelViewTestCase):
         obj_perm = ObjectPermission(name='Test permission', actions=['add'])
         obj_perm = ObjectPermission(name='Test permission', actions=['add'])
         obj_perm.save()
         obj_perm.save()
         obj_perm.users.add(self.user)
         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
         # Try GET with model-level permission
         self.assertHttpStatus(self.client.get(self._get_url('import')), 200)
         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 = ObjectPermission(name='Test permission', actions=['add'])
         obj_perm.save()
         obj_perm.save()
         obj_perm.users.add(self.user)
         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
         # Try GET with model-level permission
         self.assertHttpStatus(self.client.get(self._get_url('import')), 200)
         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 django.test import TransactionTestCase
 
 
 from circuits.models import Provider, Circuit, CircuitType
 from circuits.models import Provider, Circuit, CircuitType
 from extras.choices import ChangeActionChoices
 from extras.choices import ChangeActionChoices
 from extras.models import Branch, StagedChange, Tag
 from extras.models import Branch, StagedChange, Tag
 from ipam.models import ASN, RIR
 from ipam.models import ASN, RIR
+from netbox.search.backends import search_backend
 from netbox.staging import checkout
 from netbox.staging import checkout
 from utilities.testing import create_tags
 from utilities.testing import create_tags
 
 
@@ -11,6 +13,10 @@ from utilities.testing import create_tags
 class StagingTestCase(TransactionTestCase):
 class StagingTestCase(TransactionTestCase):
 
 
     def setUp(self):
     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')
         create_tags('Alpha', 'Bravo', 'Charlie')
 
 
         rir = RIR.objects.create(name='RIR 1', slug='rir-1')
         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 import messages
 from django.contrib.contenttypes.fields import GenericRel
 from django.contrib.contenttypes.fields import GenericRel
-from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist, ValidationError
 from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist, ValidationError
 from django.db import transaction, IntegrityError
 from django.db import transaction, IntegrityError
 from django.db.models import ManyToManyField, ProtectedError, RestrictedError
 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.utils.translation import gettext as _
 from django_tables2.export import TableExport
 from django_tables2.export import TableExport
 
 
+from core.models import ObjectType
 from extras.models import ExportTemplate
 from extras.models import ExportTemplate
 from extras.signals import clear_events
 from extras.signals import clear_events
 from utilities.error_handlers import handle_protectederror
 from utilities.error_handlers import handle_protectederror
@@ -124,7 +124,7 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
             request: The current request
             request: The current request
         """
         """
         model = self.queryset.model
         model = self.queryset.model
-        content_type = ContentType.objects.get_for_model(model)
+        object_type = ObjectType.objects.get_for_model(model)
 
 
         if self.filterset:
         if self.filterset:
             self.queryset = self.filterset(request.GET, self.queryset, request=request).qs
             self.queryset = self.filterset(request.GET, self.queryset, request=request).qs
@@ -143,7 +143,7 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
 
 
             # Render an ExportTemplate
             # Render an ExportTemplate
             elif request.GET['export']:
             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)
                 return self.export_template(template, request)
 
 
             # Check for YAML export support on the model
             # Check for YAML export support on the model

+ 4 - 2
netbox/templates/extras/customfield.html

@@ -17,7 +17,9 @@
           <th scope="row">Type</th>
           <th scope="row">Type</th>
           <td>
           <td>
             {{ object.get_type_display }}
             {{ object.get_type_display }}
-            {% if object.object_type %}({{ object.object_type.model|bettertitle }}){% endif %}
+            {% if object.related_object_type %}
+              ({{ object.related_object_type.model|bettertitle }})
+            {% endif %}
           </td>
           </td>
         </tr>
         </tr>
         <tr>
         <tr>
@@ -89,7 +91,7 @@
     <div class="card">
     <div class="card">
       <h5 class="card-header">{% trans "Object Types" %}</h5>
       <h5 class="card-header">{% trans "Object Types" %}</h5>
       <table class="table table-hover attr-table">
       <table class="table table-hover attr-table">
-        {% for ct in object.content_types.all %}
+        {% for ct in object.object_types.all %}
           <tr>
           <tr>
             <td>{{ ct }}</td>
             <td>{{ ct }}</td>
           </tr>
           </tr>

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

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

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

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

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

@@ -5,11 +5,6 @@
 
 
 {% block title %}{{ object.name }}{% endblock %}
 {% 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 %}
 {% block content %}
   <div class="row mb-3">
   <div class="row mb-3">
     <div class="col col-md-5">
     <div class="col col-md-5">
@@ -70,9 +65,9 @@
       <div class="card">
       <div class="card">
         <h5 class="card-header">{% trans "Assigned Models" %}</h5>
         <h5 class="card-header">{% trans "Assigned Models" %}</h5>
         <table class="table table-hover attr-table">
         <table class="table table-hover attr-table">
-          {% for ct in object.content_types.all %}
+          {% for object_type in object.object_types.all %}
             <tr>
             <tr>
-              <td>{{ ct }}</td>
+              <td>{{ object_type }}</td>
             </tr>
             </tr>
           {% endfor %}
           {% endfor %}
         </table>
         </table>

+ 49 - 110
netbox/templates/extras/htmx/script_result.html

@@ -3,124 +3,63 @@
 {% load log_levels %}
 {% load log_levels %}
 {% load i18n %}
 {% load i18n %}
 
 
-<p>
-  {% if job.started %}
-    {% trans "Started" %}: <strong>{{ job.started|annotated_date }}</strong>
-  {% elif job.scheduled %}
-    {% trans "Scheduled for" %}: <strong>{{ job.scheduled|annotated_date }}</strong> ({{ job.scheduled|naturaltime }})
-  {% else %}
-    {% trans "Created" %}: <strong>{{ job.created|annotated_date }}</strong>
-  {% endif %}
+<div class="htmx-container">
+  <p>
+    {% if job.started %}
+      {% trans "Started" %}: <strong>{{ job.started|annotated_date }}</strong>
+    {% elif job.scheduled %}
+      {% trans "Scheduled for" %}: <strong>{{ job.scheduled|annotated_date }}</strong> ({{ job.scheduled|naturaltime }})
+    {% else %}
+      {% trans "Created" %}: <strong>{{ job.created|annotated_date }}</strong>
+    {% endif %}
+    {% if job.completed %}
+      {% trans "Duration" %}: <strong>{{ job.duration }}</strong>
+    {% endif %}
+    <span id="pending-result-label">{% badge job.get_status_display job.get_status_color %}</span>
+  </p>
   {% if job.completed %}
   {% if job.completed %}
-    {% trans "Duration" %}: <strong>{{ job.duration }}</strong>
-  {% endif %}
-  <span id="pending-result-label">{% badge job.get_status_display job.get_status_color %}</span>
-</p>
-{% if job.completed %}
-
-  {# Script log. Legacy reports will not have this. #}
-  {% if 'log' in job.data %}
-    <div class="card mb-3">
-      <h5 class="card-header">{% trans "Log" %}</h5>
-      {% if job.data.log %}
-        <table class="table table-hover panel-body">
-          <tr>
-            <th>{% trans "Line" %}</th>
-            <th>{% trans "Time" %}</th>
-            <th>{% trans "Level" %}</th>
-            <th>{% trans "Message" %}</th>
-          </tr>
-          {% for log in job.data.log %}
+    {% if tests %}
+      {# Summary of test methods #}
+      <div class="card">
+        <h5 class="card-header">{% trans "Test Summary" %}</h5>
+        <table class="table table-hover">
+          {% for test, data in tests.items %}
             <tr>
             <tr>
-              <td>{{ forloop.counter }}</td>
-              <td>{{ log.time|placeholder }}</td>
-              <td>{% log_level log.status %}</td>
-              <td>{{ log.message|markdown }}</td>
+              <td class="font-monospace"><a href="#{{ test }}">{{ test }}</a></td>
+              <td class="text-end report-stats">
+                <span class="badge text-bg-success">{{ data.success }}</span>
+                <span class="badge text-bg-info">{{ data.info }}</span>
+                <span class="badge text-bg-warning">{{ data.warning }}</span>
+                <span class="badge text-bg-danger">{{ data.failure }}</span>
+              </td>
             </tr>
             </tr>
           {% endfor %}
           {% endfor %}
         </table>
         </table>
-      {% else %}
-        <div class="card-body text-muted">{% trans "None" %}</div>
-      {% endif %}
-    </div>
-  {% endif %}
-
-  {# Script output. Legacy reports will not have this. #}
-  {% if 'output' in job.data %}
-    <div class="card mb-3">
-    <h5 class="card-header">{% trans "Output" %}</h5>
-      {% if job.data.output %}
-        <pre class="card-body font-monospace">{{ job.data.output }}</pre>
-      {% else %}
-        <div class="card-body text-muted">{% trans "None" %}</div>
-      {% endif %}
-    </div>
-  {% endif %}
-
-  {# Test method logs (for legacy Reports) #}
-  {% if tests %}
+      </div>
+    {% endif %}
 
 
-    {# Summary of test methods #}
+    {% if table %}
     <div class="card">
     <div class="card">
-      <h5 class="card-header">{% trans "Test Summary" %}</h5>
-      <table class="table table-hover">
-        {% for test, data in tests.items %}
-          <tr>
-            <td class="font-monospace"><a href="#{{ test }}">{{ test }}</a></td>
-            <td class="text-end report-stats">
-              <span class="badge text-bg-success">{{ data.success }}</span>
-              <span class="badge text-bg-info">{{ data.info }}</span>
-              <span class="badge text-bg-warning">{{ data.warning }}</span>
-              <span class="badge text-bg-danger">{{ data.failure }}</span>
-            </td>
-          </tr>
-        {% endfor %}
-      </table>
+      <div class="table-responsive" id="object_list">
+        <h5 class="card-header">{% trans "Log" %}</h5>
+        {% include 'htmx/table.html' %}
+      </div>
     </div>
     </div>
+    {% endif %}
 
 
-    {# Detailed results for individual tests #}
-    <div class="card">
-      <h5 class="card-header">{% trans "Test Details" %}</h5>
-      <table class="table table-hover report">
-        <thead>
-          <tr class="table-headings">
-            <th>{% trans "Time" %}</th>
-            <th>{% trans "Level" %}</th>
-            <th>{% trans "Object" %}</th>
-            <th>{% trans "Message" %}</th>
-          </tr>
-        </thead>
-        <tbody>
-          {% for test, data in tests.items %}
-            <tr>
-              <th colspan="4" style="font-family: monospace">
-                <a name="{{ test }}"></a>{{ test }}
-              </th>
-            </tr>
-            {% for time, level, obj, url, message in data.log %}
-              <tr class="{% if level == 'failure' %}danger{% elif level %}{{ level }}{% endif %}">
-                <td>{{ time }}</td>
-                <td>
-                  <label class="badge text-bg-{% if level == 'failure' %}danger{% else %}{{ level }}{% endif %}">{{ level|title }}</label>
-                </td>
-                <td>
-                  {% if obj and url %}
-                    <a href="{{ url }}">{{ obj }}</a>
-                  {% elif obj %}
-                    {{ obj }}
-                  {% else %}
-                    {{ ''|placeholder }}
-                  {% endif %}
-                </td>
-                <td class="rendered-markdown">{{ message|markdown }}</td>
-              </tr>
-            {% endfor %}
-          {% endfor %}
-        </tbody>
-      </table>
-    </div>
+    {# Script output. Legacy reports will not have this. #}
+    {% if 'output' in job.data %}
+      <div class="card mb-3">
+      <h5 class="card-header">{% trans "Output" %}</h5>
+        {% if job.data.output %}
+          <pre class="card-body font-monospace">{{ job.data.output }}</pre>
+        {% else %}
+          <div class="card-body text-muted">{% trans "None" %}</div>
+        {% endif %}
+      </div>
+    {% endif %}
 
 
+  {% elif job.started %}
+    {% include 'extras/inc/result_pending.html' %}
   {% endif %}
   {% endif %}
-{% elif job.started %}
-  {% include 'extras/inc/result_pending.html' %}
-{% endif %}
+</div>

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

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

+ 60 - 14
netbox/templates/extras/script_result.html

@@ -32,28 +32,74 @@
 {% block tabs %}
 {% block tabs %}
   <ul class="nav nav-tabs" role="tablist">
   <ul class="nav nav-tabs" role="tablist">
     <li class="nav-item" role="presentation">
     <li class="nav-item" role="presentation">
-      <a href="#log" role="tab" data-bs-toggle="tab" class="nav-link active">{% trans "Log" %}</a>
-    </li>
-    <li class="nav-item" role="presentation">
-      <a href="#source" role="tab" data-bs-toggle="tab" class="nav-link">{% trans "Source" %}</a>
+      <a href="#results" role="tab" data-bs-toggle="tab" class="nav-link active">{% trans "Results" %}</a>
     </li>
     </li>
   </ul>
   </ul>
 {% endblock %}
 {% endblock %}
 
 
 {% block content %}
 {% block content %}
-  <div role="tabpanel" class="tab-pane active" id="log">
-    <div class="row">
-      <div class="col col-md-12"{% if not job.completed %} hx-get="{% url 'extras:script_result' job_pk=job.pk %}" hx-trigger="load delay:0.5s, every 5s"{% endif %}>
-        {% include 'extras/htmx/script_result.html' %}
+    {# Object list tab #}
+    <div class="tab-pane show active" id="results" role="tabpanel" aria-labelledby="results-tab">
+
+      {# Object table controls #}
+      <div class="row mb-3">
+        <div class="col-auto ms-auto d-print-none">
+          {% if request.user.is_authenticated %}
+            <div class="table-configure input-group">
+              <button type="button" data-bs-toggle="modal" title="{% trans "Configure Table" %}" data-bs-target="#ObjectTable_config"
+                class="btn">
+                <i class="mdi mdi-cog"></i> {% trans "Configure Table" %}
+              </button>
+            </div>
+          {% endif %}
+        </div>
       </div>
       </div>
+
+      <form method="post" class="form form-horizontal">
+        {% csrf_token %}
+        {# "Select all" form #}
+        {% if table.paginator.num_pages > 1 %}
+          <div id="select-all-box" class="d-none card d-print-none">
+            <div class="form col-md-12">
+              <div class="card-body">
+                <div class="form-check">
+                  <input type="checkbox" id="select-all" name="_all" class="form-check-input" />
+                  <label for="select-all" class="form-check-label">
+                    {% blocktrans trimmed with count=table.rows|length object_type_plural=table.data.verbose_name_plural %}
+                      Select <strong>all {{ count }} {{ object_type_plural }}</strong> matching query
+                    {% endblocktrans %}
+                  </label>
+                </div>
+              </div>
+            </div>
+          </div>
+        {% endif %}
+
+        <div class="form form-horizontal">
+          {% csrf_token %}
+          <input type="hidden" name="return_url" value="{% if return_url %}{{ return_url }}{% else %}{{ request.path }}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}{% endif %}" />
+
+          {# Objects table #}
+            <div class="col col-md-12"{% if not job.completed %} hx-get="{% url 'extras:script_result' job_pk=job.pk %}" hx-trigger="load delay:0.5s, every 5s"{% endif %}>
+              {% include 'extras/htmx/script_result.html' %}
+            </div>
+          {# /Objects table #}
+
+        </div>
+      </form>
     </div>
     </div>
-  </div>
-  <div role="tabpanel" class="tab-pane" id="source">
-    <p><code>{{ script.filename }}</code></p>
-    <pre class="block">{{ script.source }}</pre>
-  </div>
+    {# /Object list tab #}
+
+    {# Filters tab #}
+    {% if filter_form %}
+      <div class="tab-pane show" id="filters-form" role="tabpanel" aria-labelledby="filters-form-tab">
+        {% include 'inc/filter_list.html' %}
+      </div>
+    {% endif %}
+    {# /Filters tab #}
+
 {% endblock content %}
 {% endblock content %}
 
 
 {% block modals %}
 {% block modals %}
-  {% include 'inc/htmx_modal.html' %}
+  {% table_config_form table table_name="ObjectTable" %}
 {% endblock modals %}
 {% endblock modals %}

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

@@ -6,7 +6,7 @@
     {% trans "Images" %}
     {% trans "Images" %}
     {% if perms.extras.add_imageattachment %}
     {% if perms.extras.add_imageattachment %}
       <div class="card-actions">
       <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" %}
           <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Attach an image" %}
         </a>
         </a>
       </div>
       </div>

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

@@ -5,7 +5,7 @@
 {% block extra_controls %}
 {% block extra_controls %}
   {% if perms.tenancy.add_contactassignment %}
   {% if perms.tenancy.add_contactassignment %}
     {% with viewname=object|viewname:"contacts" %}
     {% 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" %}
         <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add a contact" %}
       </a>
       </a>
     {% endwith %}
     {% endwith %}

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

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

+ 30 - 4
netbox/tenancy/filtersets.py

@@ -26,12 +26,25 @@ __all__ = (
 class ContactGroupFilterSet(OrganizationalModelFilterSet):
 class ContactGroupFilterSet(OrganizationalModelFilterSet):
     parent_id = django_filters.ModelMultipleChoiceFilter(
     parent_id = django_filters.ModelMultipleChoiceFilter(
         queryset=ContactGroup.objects.all(),
         queryset=ContactGroup.objects.all(),
-        label=_('Contact group (ID)'),
+        label=_('Parent contact group (ID)'),
     )
     )
     parent = django_filters.ModelMultipleChoiceFilter(
     parent = django_filters.ModelMultipleChoiceFilter(
         field_name='parent__slug',
         field_name='parent__slug',
         queryset=ContactGroup.objects.all(),
         queryset=ContactGroup.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
+        label=_('Parent contact group (slug)'),
+    )
+    ancestor_id = TreeNodeMultipleChoiceFilter(
+        queryset=ContactGroup.objects.all(),
+        field_name='parent',
+        lookup_expr='in',
+        label=_('Contact group (ID)'),
+    )
+    ancestor = TreeNodeMultipleChoiceFilter(
+        queryset=ContactGroup.objects.all(),
+        field_name='parent',
+        lookup_expr='in',
+        to_field_name='slug',
         label=_('Contact group (slug)'),
         label=_('Contact group (slug)'),
     )
     )
 
 
@@ -86,7 +99,7 @@ class ContactAssignmentFilterSet(NetBoxModelFilterSet):
         method='search',
         method='search',
         label=_('Search'),
         label=_('Search'),
     )
     )
-    content_type = ContentTypeFilter()
+    object_type = ContentTypeFilter()
     contact_id = django_filters.ModelMultipleChoiceFilter(
     contact_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Contact.objects.all(),
         queryset=Contact.objects.all(),
         label=_('Contact (ID)'),
         label=_('Contact (ID)'),
@@ -118,7 +131,7 @@ class ContactAssignmentFilterSet(NetBoxModelFilterSet):
 
 
     class Meta:
     class Meta:
         model = ContactAssignment
         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):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -155,12 +168,25 @@ class ContactModelFilterSet(django_filters.FilterSet):
 class TenantGroupFilterSet(OrganizationalModelFilterSet):
 class TenantGroupFilterSet(OrganizationalModelFilterSet):
     parent_id = django_filters.ModelMultipleChoiceFilter(
     parent_id = django_filters.ModelMultipleChoiceFilter(
         queryset=TenantGroup.objects.all(),
         queryset=TenantGroup.objects.all(),
-        label=_('Tenant group (ID)'),
+        label=_('Parent tenant group (ID)'),
     )
     )
     parent = django_filters.ModelMultipleChoiceFilter(
     parent = django_filters.ModelMultipleChoiceFilter(
         field_name='parent__slug',
         field_name='parent__slug',
         queryset=TenantGroup.objects.all(),
         queryset=TenantGroup.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
+        label=_('Parent tenant group (slug)'),
+    )
+    ancestor_id = TreeNodeMultipleChoiceFilter(
+        queryset=TenantGroup.objects.all(),
+        field_name='parent',
+        lookup_expr='in',
+        label=_('Tenant group (ID)'),
+    )
+    ancestor = TreeNodeMultipleChoiceFilter(
+        queryset=TenantGroup.objects.all(),
+        field_name='parent',
+        lookup_expr='in',
+        to_field_name='slug',
         label=_('Tenant group (slug)'),
         label=_('Tenant group (slug)'),
     )
     )
 
 

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

@@ -91,7 +91,7 @@ class ContactImportForm(NetBoxModelImportForm):
 
 
 
 
 class ContactAssignmentImportForm(NetBoxModelImportForm):
 class ContactAssignmentImportForm(NetBoxModelImportForm):
-    content_type = CSVContentTypeField(
+    object_type = CSVContentTypeField(
         queryset=ContentType.objects.all(),
         queryset=ContentType.objects.all(),
         help_text=_("One or more assigned object types")
         help_text=_("One or more assigned object types")
     )
     )
@@ -108,4 +108,4 @@ class ContactAssignmentImportForm(NetBoxModelImportForm):
 
 
     class Meta:
     class Meta:
         model = ContactAssignment
         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 import forms
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
-from core.models import ContentType
+from core.models import ObjectType
 from netbox.forms import NetBoxModelFilterSetForm
 from netbox.forms import NetBoxModelFilterSetForm
 from tenancy.choices import *
 from tenancy.choices import *
 from tenancy.models import *
 from tenancy.models import *
@@ -83,10 +83,10 @@ class ContactAssignmentFilterForm(NetBoxModelFilterSetForm):
     model = ContactAssignment
     model = ContactAssignment
     fieldsets = (
     fieldsets = (
         (None, ('q', 'filter_id', 'tag')),
         (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,
         required=False,
         label=_('Object type')
         label=_('Object type')
     )
     )

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

@@ -143,9 +143,9 @@ class ContactAssignmentForm(NetBoxModelForm):
     class Meta:
     class Meta:
         model = ContactAssignment
         model = ContactAssignment
         fields = (
         fields = (
-            'content_type', 'object_id', 'group', 'contact', 'role', 'priority', 'tags'
+            'object_type', 'object_id', 'group', 'contact', 'role', 'priority', 'tags'
         )
         )
         widgets = {
         widgets = {
-            'content_type': forms.HiddenInput(),
+            'object_type': forms.HiddenInput(),
             'object_id': 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.urls import reverse
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
-from core.models import ContentType
+from core.models import ObjectType
 from netbox.models import ChangeLoggedModel, NestedGroupModel, OrganizationalModel, PrimaryModel
 from netbox.models import ChangeLoggedModel, NestedGroupModel, OrganizationalModel, PrimaryModel
 from netbox.models.features import CustomFieldsMixin, ExportTemplatesMixin, TagsMixin
 from netbox.models.features import CustomFieldsMixin, ExportTemplatesMixin, TagsMixin
 from tenancy.choices import *
 from tenancy.choices import *
@@ -111,13 +111,13 @@ class Contact(PrimaryModel):
 
 
 
 
 class ContactAssignment(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel):
 class ContactAssignment(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel):
-    content_type = models.ForeignKey(
+    object_type = models.ForeignKey(
         to='contenttypes.ContentType',
         to='contenttypes.ContentType',
         on_delete=models.CASCADE
         on_delete=models.CASCADE
     )
     )
     object_id = models.PositiveBigIntegerField()
     object_id = models.PositiveBigIntegerField()
     object = GenericForeignKey(
     object = GenericForeignKey(
-        ct_field='content_type',
+        ct_field='object_type',
         fk_field='object_id'
         fk_field='object_id'
     )
     )
     contact = models.ForeignKey(
     contact = models.ForeignKey(
@@ -137,16 +137,16 @@ class ContactAssignment(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, Chan
         blank=True
         blank=True
     )
     )
 
 
-    clone_fields = ('content_type', 'object_id', 'role', 'priority')
+    clone_fields = ('object_type', 'object_id', 'role', 'priority')
 
 
     class Meta:
     class Meta:
         ordering = ('contact', 'priority', 'role', 'pk')
         ordering = ('contact', 'priority', 'role', 'pk')
         indexes = (
         indexes = (
-            models.Index(fields=('content_type', 'object_id')),
+            models.Index(fields=('object_type', 'object_id')),
         )
         )
         constraints = (
         constraints = (
             models.UniqueConstraint(
             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'
                 name='%(app_label)s_%(class)s_unique_object_contact_role'
             ),
             ),
         )
         )
@@ -165,9 +165,9 @@ class ContactAssignment(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, Chan
         super().clean()
         super().clean()
 
 
         # Validate the assigned object type
         # 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(
             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):
     def to_objectchange(self, action):

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

@@ -86,7 +86,7 @@ class ContactTable(NetBoxTable):
 
 
 
 
 class ContactAssignmentTable(NetBoxTable):
 class ContactAssignmentTable(NetBoxTable):
-    content_type = columns.ContentTypeColumn(
+    object_type = columns.ContentTypeColumn(
         verbose_name=_('Object Type')
         verbose_name=_('Object Type')
     )
     )
     object = tables.Column(
     object = tables.Column(
@@ -141,10 +141,10 @@ class ContactAssignmentTable(NetBoxTable):
     class Meta(NetBoxTable.Meta):
     class Meta(NetBoxTable.Meta):
         model = ContactAssignment
         model = ContactAssignment
         fields = (
         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',
             'contact_email', 'contact_address', 'contact_link', 'contact_description', 'contact_group', 'tags',
             'actions'
             'actions'
         )
         )
         default_columns = (
         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 = [
         cls.create_data = [
             {
             {
-                'content_type': 'dcim.site',
+                'object_type': 'dcim.site',
                 'object_id': sites[1].pk,
                 'object_id': sites[1].pk,
                 'contact': contacts[3].pk,
                 'contact': contacts[3].pk,
                 'role': contact_roles[0].pk,
                 'role': contact_roles[0].pk,
                 'priority': ContactPriorityChoices.PRIORITY_PRIMARY,
                 'priority': ContactPriorityChoices.PRIORITY_PRIMARY,
             },
             },
             {
             {
-                'content_type': 'dcim.site',
+                'object_type': 'dcim.site',
                 'object_id': sites[1].pk,
                 'object_id': sites[1].pk,
                 'contact': contacts[4].pk,
                 'contact': contacts[4].pk,
                 'role': contact_roles[1].pk,
                 'role': contact_roles[1].pk,
                 'priority': ContactPriorityChoices.PRIORITY_SECONDARY,
                 'priority': ContactPriorityChoices.PRIORITY_SECONDARY,
             },
             },
             {
             {
-                'content_type': 'dcim.site',
+                'object_type': 'dcim.site',
                 'object_id': sites[1].pk,
                 'object_id': sites[1].pk,
                 'contact': contacts[5].pk,
                 'contact': contacts[5].pk,
                 'role': contact_roles[2].pk,
                 'role': contact_roles[2].pk,

+ 65 - 35
netbox/tenancy/tests/test_filtersets.py

@@ -1,6 +1,6 @@
-from django.contrib.contenttypes.models import ContentType
 from django.test import TestCase
 from django.test import TestCase
 
 
+from core.models import ObjectType
 from dcim.models import Manufacturer, Site
 from dcim.models import Manufacturer, Site
 from tenancy.filtersets import *
 from tenancy.filtersets import *
 from tenancy.models import *
 from tenancy.models import *
@@ -15,35 +15,43 @@ class TenantGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
     def setUpTestData(cls):
     def setUpTestData(cls):
 
 
         parent_tenant_groups = (
         parent_tenant_groups = (
-            TenantGroup(name='Parent Tenant Group 1', slug='parent-tenant-group-1'),
-            TenantGroup(name='Parent Tenant Group 2', slug='parent-tenant-group-2'),
-            TenantGroup(name='Parent Tenant Group 3', slug='parent-tenant-group-3'),
+            TenantGroup(name='Tenant Group 1', slug='tenant-group-1'),
+            TenantGroup(name='Tenant Group 2', slug='tenant-group-2'),
+            TenantGroup(name='Tenant Group 3', slug='tenant-group-3'),
         )
         )
-        for tenantgroup in parent_tenant_groups:
-            tenantgroup.save()
+        for tenant_group in parent_tenant_groups:
+            tenant_group.save()
 
 
         tenant_groups = (
         tenant_groups = (
             TenantGroup(
             TenantGroup(
-                name='Tenant Group 1',
-                slug='tenant-group-1',
+                name='Tenant Group 1A',
+                slug='tenant-group-1a',
                 parent=parent_tenant_groups[0],
                 parent=parent_tenant_groups[0],
                 description='foobar1'
                 description='foobar1'
             ),
             ),
             TenantGroup(
             TenantGroup(
-                name='Tenant Group 2',
-                slug='tenant-group-2',
+                name='Tenant Group 2A',
+                slug='tenant-group-2a',
                 parent=parent_tenant_groups[1],
                 parent=parent_tenant_groups[1],
                 description='foobar2'
                 description='foobar2'
             ),
             ),
             TenantGroup(
             TenantGroup(
-                name='Tenant Group 3',
-                slug='tenant-group-3',
+                name='Tenant Group 3A',
+                slug='tenant-group-3a',
                 parent=parent_tenant_groups[2],
                 parent=parent_tenant_groups[2],
                 description='foobar3'
                 description='foobar3'
             ),
             ),
         )
         )
-        for tenantgroup in tenant_groups:
-            tenantgroup.save()
+        for tenant_group in tenant_groups:
+            tenant_group.save()
+
+        child_tenant_groups = (
+            TenantGroup(name='Tenant Group 1A1', slug='tenant-group-1a1', parent=tenant_groups[0]),
+            TenantGroup(name='Tenant Group 2A1', slug='tenant-group-2a1', parent=tenant_groups[1]),
+            TenantGroup(name='Tenant Group 3A1', slug='tenant-group-3a1', parent=tenant_groups[2]),
+        )
+        for tenant_group in child_tenant_groups:
+            tenant_group.save()
 
 
     def test_q(self):
     def test_q(self):
         params = {'q': 'foobar1'}
         params = {'q': 'foobar1'}
@@ -62,12 +70,19 @@ class TenantGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
     def test_parent(self):
     def test_parent(self):
-        parent_groups = TenantGroup.objects.filter(name__startswith='Parent')[:2]
-        params = {'parent_id': [parent_groups[0].pk, parent_groups[1].pk]}
+        tenant_groups = TenantGroup.objects.filter(parent__isnull=True)[:2]
+        params = {'parent_id': [tenant_groups[0].pk, tenant_groups[1].pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-        params = {'parent': [parent_groups[0].slug, parent_groups[1].slug]}
+        params = {'parent': [tenant_groups[0].slug, tenant_groups[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
+    def test_ancestor(self):
+        tenant_groups = TenantGroup.objects.filter(parent__isnull=True)[:2]
+        params = {'ancestor_id': [tenant_groups[0].pk, tenant_groups[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+        params = {'ancestor': [tenant_groups[0].slug, tenant_groups[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+
 
 
 class TenantTestCase(TestCase, ChangeLoggedFilterSetTests):
 class TenantTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = Tenant.objects.all()
     queryset = Tenant.objects.all()
@@ -123,35 +138,43 @@ class ContactGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
     def setUpTestData(cls):
     def setUpTestData(cls):
 
 
         parent_contact_groups = (
         parent_contact_groups = (
-            ContactGroup(name='Parent Contact Group 1', slug='parent-contact-group-1'),
-            ContactGroup(name='Parent Contact Group 2', slug='parent-contact-group-2'),
-            ContactGroup(name='Parent Contact Group 3', slug='parent-contact-group-3'),
+            ContactGroup(name='Contact Group 1', slug='contact-group-1'),
+            ContactGroup(name='Contact Group 2', slug='contact-group-2'),
+            ContactGroup(name='Contact Group 3', slug='contact-group-3'),
         )
         )
-        for contactgroup in parent_contact_groups:
-            contactgroup.save()
+        for contact_group in parent_contact_groups:
+            contact_group.save()
 
 
         contact_groups = (
         contact_groups = (
             ContactGroup(
             ContactGroup(
-                name='Contact Group 1',
-                slug='contact-group-1',
+                name='Contact Group 1A',
+                slug='contact-group-1a',
                 parent=parent_contact_groups[0],
                 parent=parent_contact_groups[0],
                 description='foobar1'
                 description='foobar1'
             ),
             ),
             ContactGroup(
             ContactGroup(
-                name='Contact Group 2',
-                slug='contact-group-2',
+                name='Contact Group 2A',
+                slug='contact-group-2a',
                 parent=parent_contact_groups[1],
                 parent=parent_contact_groups[1],
                 description='foobar2'
                 description='foobar2'
             ),
             ),
             ContactGroup(
             ContactGroup(
-                name='Contact Group 3',
-                slug='contact-group-3',
+                name='Contact Group 3A',
+                slug='contact-group-3a',
                 parent=parent_contact_groups[2],
                 parent=parent_contact_groups[2],
                 description='foobar3'
                 description='foobar3'
             ),
             ),
         )
         )
-        for contactgroup in contact_groups:
-            contactgroup.save()
+        for contact_group in contact_groups:
+            contact_group.save()
+
+        child_contact_groups = (
+            ContactGroup(name='Contact Group 1A1', slug='contact-group-1a1', parent=contact_groups[0]),
+            ContactGroup(name='Contact Group 2A1', slug='contact-group-2a1', parent=contact_groups[1]),
+            ContactGroup(name='Contact Group 3A1', slug='contact-group-3a1', parent=contact_groups[2]),
+        )
+        for contact_group in child_contact_groups:
+            contact_group.save()
 
 
     def test_q(self):
     def test_q(self):
         params = {'q': 'foobar1'}
         params = {'q': 'foobar1'}
@@ -170,12 +193,19 @@ class ContactGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
     def test_parent(self):
     def test_parent(self):
-        parent_groups = ContactGroup.objects.filter(parent__isnull=True)[:2]
-        params = {'parent_id': [parent_groups[0].pk, parent_groups[1].pk]}
+        contact_groups = ContactGroup.objects.filter(parent__isnull=True)[:2]
+        params = {'parent_id': [contact_groups[0].pk, contact_groups[1].pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-        params = {'parent': [parent_groups[0].slug, parent_groups[1].slug]}
+        params = {'parent': [contact_groups[0].slug, contact_groups[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
+    def test_ancestor(self):
+        contact_groups = ContactGroup.objects.filter(parent__isnull=True)[:2]
+        params = {'ancestor_id': [contact_groups[0].pk, contact_groups[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+        params = {'ancestor': [contact_groups[0].slug, contact_groups[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+
 
 
 class ContactRoleTestCase(TestCase, ChangeLoggedFilterSetTests):
 class ContactRoleTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = ContactRole.objects.all()
     queryset = ContactRole.objects.all()
@@ -295,8 +325,8 @@ class ContactAssignmentTestCase(TestCase, ChangeLoggedFilterSetTests):
         )
         )
         ContactAssignment.objects.bulk_create(assignments)
         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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
 
 
     def test_contact(self):
     def test_contact(self):

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

@@ -292,7 +292,7 @@ class ContactAssignmentTestCase(
         tags = create_tags('Alpha', 'Bravo', 'Charlie')
         tags = create_tags('Alpha', 'Bravo', 'Charlie')
 
 
         cls.form_data = {
         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,
             'object_id': sites[3].pk,
             'contact': contacts[3].pk,
             'contact': contacts[3].pk,
             'role': contact_roles[3].pk,
             'role': contact_roles[3].pk,
@@ -306,11 +306,11 @@ class ContactAssignmentTestCase(
         }
         }
 
 
     def _get_url(self, action, instance=None):
     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':
         if action == 'add':
             url = reverse('tenancy:contactassignment_add')
             url = reverse('tenancy:contactassignment_add')
             content_type = ContentType.objects.get_for_model(Site).pk
             content_type = ContentType.objects.get_for_model(Site).pk
             object_id = Site.objects.first().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)
         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):
     def get_children(self, request, parent):
         return ContactAssignment.objects.restrict(request.user, 'view').filter(
         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
             object_id=parent.pk
         ).order_by('priority', 'contact', 'role')
         ).order_by('priority', 'contact', 'role')
 
 
@@ -31,7 +31,7 @@ class ObjectContactsView(generic.ObjectChildrenView):
         table = super().get_table(*args, **kwargs)
         table = super().get_table(*args, **kwargs)
 
 
         # Hide object columns
         # Hide object columns
-        table.columns.hide('content_type')
+        table.columns.hide('object_type')
         table.columns.hide('object')
         table.columns.hide('object')
 
 
         return table
         return table
@@ -374,8 +374,8 @@ class ContactAssignmentEditView(generic.ObjectEditView):
     def alter_object(self, instance, request, args, kwargs):
     def alter_object(self, instance, request, args, kwargs):
         if not instance.pk:
         if not instance.pk:
             # Assign the object based on URL kwargs
             # 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
         return instance
 
 
     def get_extra_addanother_params(self, request):
     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.auth import get_user_model
-from django.contrib.contenttypes.models import ContentType
 from drf_spectacular.utils import extend_schema_field
 from drf_spectacular.utils import extend_schema_field
 from drf_spectacular.types import OpenApiTypes
 from drf_spectacular.types import OpenApiTypes
 from rest_framework import serializers
 from rest_framework import serializers
 
 
+from core.models import ObjectType
 from netbox.api.fields import ContentTypeField
 from netbox.api.fields import ContentTypeField
 from netbox.api.serializers import WritableNestedSerializer
 from netbox.api.serializers import WritableNestedSerializer
 from users.models import Group, ObjectPermission, Token
 from users.models import Group, ObjectPermission, Token
@@ -49,7 +49,7 @@ class NestedTokenSerializer(WritableNestedSerializer):
 class NestedObjectPermissionSerializer(WritableNestedSerializer):
 class NestedObjectPermissionSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='users-api:objectpermission-detail')
     url = serializers.HyperlinkedIdentityField(view_name='users-api:objectpermission-detail')
     object_types = ContentTypeField(
     object_types = ContentTypeField(
-        queryset=ContentType.objects.all(),
+        queryset=ObjectType.objects.all(),
         many=True
         many=True
     )
     )
     groups = serializers.SerializerMethodField(read_only=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.auth import get_user_model
-from django.contrib.contenttypes.models import ContentType
 from rest_framework import serializers
 from rest_framework import serializers
 
 
+from core.models import ObjectType
 from netbox.api.fields import ContentTypeField, SerializedPKRelatedField
 from netbox.api.fields import ContentTypeField, SerializedPKRelatedField
 from netbox.api.serializers import ValidatedModelSerializer
 from netbox.api.serializers import ValidatedModelSerializer
 from users.models import Group, ObjectPermission
 from users.models import Group, ObjectPermission
@@ -15,7 +15,7 @@ __all__ = (
 class ObjectPermissionSerializer(ValidatedModelSerializer):
 class ObjectPermissionSerializer(ValidatedModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='users-api:objectpermission-detail')
     url = serializers.HyperlinkedIdentityField(view_name='users-api:objectpermission-detail')
     object_types = ContentTypeField(
     object_types = ContentTypeField(
-        queryset=ContentType.objects.all(),
+        queryset=ObjectType.objects.all(),
         many=True
         many=True
     )
     )
     groups = SerializedPKRelatedField(
     groups = SerializedPKRelatedField(

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

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

+ 1 - 1
netbox/users/migrations/0005_alter_user_table.py

@@ -14,7 +14,7 @@ def update_content_types(apps, schema_editor):
     if netboxuser_ct:
     if netboxuser_ct:
         user_ct = ContentType.objects.filter(app_label='users', model='user').first()
         user_ct = ContentType.objects.filter(app_label='users', model='user').first()
         CustomField = apps.get_model('extras', 'CustomField')
         CustomField = apps.get_model('extras', 'CustomField')
-        CustomField.objects.filter(object_type_id=netboxuser_ct.id).update(object_type_id=user_ct.id)
+        CustomField.objects.filter(related_object_type_id=netboxuser_ct.id).update(related_object_type_id=user_ct.id)
         netboxuser_ct.delete()
         netboxuser_ct.delete()
 
 
 
 

+ 1 - 1
netbox/users/migrations/0006_custom_group_model.py

@@ -12,7 +12,7 @@ def update_custom_fields(apps, schema_editor):
 
 
     if old_ct := ContentType.objects.filter(app_label='users', model='netboxgroup').first():
     if old_ct := ContentType.objects.filter(app_label='users', model='netboxgroup').first():
         new_ct = ContentType.objects.get_for_model(Group)
         new_ct = ContentType.objects.get_for_model(Group)
-        CustomField.objects.filter(object_type=old_ct).update(object_type=new_ct)
+        CustomField.objects.filter(related_object_type=old_ct).update(related_object_type=new_ct)
 
 
 
 
 class Migration(migrations.Migration):
 class Migration(migrations.Migration):

+ 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 django.utils.translation import gettext_lazy as _
 from netaddr import IPNetwork
 from netaddr import IPNetwork
 
 
-from core.models import ContentType
+from core.models import ObjectType
 from ipam.fields import IPNetworkField
 from ipam.fields import IPNetworkField
 from netbox.config import get_config
 from netbox.config import get_config
 from utilities.querysets import RestrictedQuerySet
 from utilities.querysets import RestrictedQuerySet
@@ -383,7 +383,7 @@ class ObjectPermission(models.Model):
         default=True
         default=True
     )
     )
     object_types = models.ManyToManyField(
     object_types = models.ManyToManyField(
-        to='contenttypes.ContentType',
+        to='core.ObjectType',
         limit_choices_to=OBJECTPERMISSION_OBJECT_TYPES,
         limit_choices_to=OBJECTPERMISSION_OBJECT_TYPES,
         related_name='object_permissions'
         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.auth import get_user_model
-from django.contrib.contenttypes.models import ContentType
 from django.urls import reverse
 from django.urls import reverse
 
 
+from core.models import ObjectType
 from users.models import Group, ObjectPermission, Token
 from users.models import Group, ObjectPermission, Token
 from utilities.testing import APIViewTestCases, APITestCase, create_test_user
 from utilities.testing import APIViewTestCases, APITestCase, create_test_user
 from utilities.utils import deepmerge
 from utilities.utils import deepmerge
@@ -64,7 +64,7 @@ class UserTest(APIViewTestCases.APIViewTestCase):
         )
         )
         obj_perm.save()
         obj_perm.save()
         obj_perm.users.add(self.user)
         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 = {
         user_credentials = {
             'username': 'user1',
             'username': 'user1',
@@ -261,7 +261,7 @@ class ObjectPermissionTest(
         )
         )
         User.objects.bulk_create(users)
         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):
         for i in range(3):
             objectpermission = ObjectPermission(
             objectpermission = ObjectPermission(

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

@@ -1,10 +1,10 @@
 import datetime
 import datetime
 
 
 from django.contrib.auth import get_user_model
 from django.contrib.auth import get_user_model
-from django.contrib.contenttypes.models import ContentType
 from django.test import TestCase
 from django.test import TestCase
 from django.utils.timezone import make_aware
 from django.utils.timezone import make_aware
 
 
+from core.models import ObjectType
 from users import filtersets
 from users import filtersets
 from users.models import Group, ObjectPermission, Token
 from users.models import Group, ObjectPermission, Token
 from utilities.testing import BaseFilterSetTests
 from utilities.testing import BaseFilterSetTests
@@ -151,9 +151,9 @@ class ObjectPermissionTestCase(TestCase, BaseFilterSetTests):
         User.objects.bulk_create(users)
         User.objects.bulk_create(users)
 
 
         object_types = (
         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 = (
         permissions = (
@@ -198,7 +198,7 @@ class ObjectPermissionTestCase(TestCase, BaseFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
     def test_object_types(self):
     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]}
         params = {'object_types': [object_types[0].pk, object_types[1].pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         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 users.models import *
 from utilities.testing import ViewTestCases, create_test_user
 from utilities.testing import ViewTestCases, create_test_user
 
 
@@ -115,7 +114,7 @@ class ObjectPermissionTestCase(
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
-        ct = ContentType.objects.get_by_natural_key('dcim', 'site')
+        object_type = ObjectType.objects.get_by_natural_key('dcim', 'site')
 
 
         permissions = (
         permissions = (
             ObjectPermission(name='Permission 1', actions=['view', 'add', 'delete']),
             ObjectPermission(name='Permission 1', actions=['view', 'add', 'delete']),
@@ -127,7 +126,7 @@ class ObjectPermissionTestCase(
         cls.form_data = {
         cls.form_data = {
             'name': 'Permission X',
             'name': 'Permission X',
             'description': 'A new permission',
             'description': 'A new permission',
-            'object_types': [ct.pk],
+            'object_types': [object_type.pk],
             'actions': 'view,edit,delete',
             'actions': 'view,edit,delete',
         }
         }
 
 

Неке датотеке нису приказане због велике количине промена