Arthur 1 год назад
Родитель
Сommit
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`        |
 | 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.
 

+ 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)
 * [#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
 * [#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
@@ -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
 * [#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
+* [#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(
         label=_('Object Type'),
-        queryset=ContentType.objects.with_feature('jobs'),
+        queryset=ObjectType.objects.with_feature('jobs'),
         required=False,
     )
     status = forms.MultipleChoiceField(

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

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

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

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

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

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

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

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

+ 38 - 2
netbox/dcim/filtersets.py

@@ -89,6 +89,19 @@ class RegionFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet):
         to_field_name='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:
         model = Region
@@ -106,6 +119,19 @@ class SiteGroupFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet):
         to_field_name='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:
         model = SiteGroup
@@ -214,13 +240,23 @@ class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, OrganizationalM
         to_field_name='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(),
         field_name='parent',
         lookup_expr='in',
         label=_('Location (ID)'),
     )
-    parent = TreeNodeMultipleChoiceFilter(
+    ancestor = TreeNodeMultipleChoiceFilter(
         queryset=Location.objects.all(),
         field_name='parent',
         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.utils.translation import gettext_lazy as _
 
-from core.models import ContentType
+from core.models import ObjectType
 from dcim.choices import *
 from dcim.constants import *
 from dcim.fields import PathField
@@ -481,13 +481,13 @@ class CablePath(models.Model):
     def origin_type(self):
         if self.path:
             ct_id, _ = decompile_path_node(self.path[0][0])
-            return ContentType.objects.get_for_id(ct_id)
+            return ObjectType.objects.get_for_id(ct_id)
 
     @property
     def destination_type(self):
         if self.is_complete:
             ct_id, _ = decompile_path_node(self.path[-1][0])
-            return ContentType.objects.get_for_id(ct_id)
+            return ObjectType.objects.get_for_id(ct_id)
 
     @property
     def path_objects(self):
@@ -594,7 +594,7 @@ class CablePath(models.Model):
 
             # Step 6: Determine the far-end terminations
             if isinstance(links[0], Cable):
-                termination_type = ContentType.objects.get_for_model(terminations[0])
+                termination_type = ObjectType.objects.get_for_model(terminations[0])
                 local_cable_terminations = CableTermination.objects.filter(
                     termination_type=termination_type,
                     termination_id__in=[t.pk for t in terminations]
@@ -747,7 +747,7 @@ class CablePath(models.Model):
         # Prefetch path objects using one query per model type. Prefetch related devices where appropriate.
         prefetched = {}
         for ct_id, object_ids in to_prefetch.items():
-            model_class = ContentType.objects.get_for_id(ct_id).model_class()
+            model_class = ObjectType.objects.get_for_id(ct_id).model_class()
             queryset = model_class.objects.filter(pk__in=object_ids)
             if hasattr(model_class, 'device'):
                 queryset = queryset.prefetch_related('device')
@@ -774,7 +774,7 @@ class CablePath(models.Model):
         """
         Return all Cable IDs within the path.
         """
-        cable_ct = ContentType.objects.get_for_model(Cable).pk
+        cable_ct = ObjectType.objects.get_for_model(Cable).pk
         cable_ids = []
 
         for node in self._nodes:

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

@@ -64,21 +64,32 @@ class RegionTestCase(TestCase, ChangeLoggedFilterSetTests):
     @classmethod
     def setUpTestData(cls):
 
-        regions = (
+        parent_regions = (
             Region(name='Region 1', slug='region-1', description='foobar1'),
             Region(name='Region 2', slug='region-2', description='foobar2'),
             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:
             region.save()
 
         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:
             region.save()
@@ -100,12 +111,19 @@ class RegionTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
     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)
-        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)
 
+    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):
     queryset = SiteGroup.objects.all()
@@ -114,24 +132,35 @@ class SiteGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
     @classmethod
     def setUpTestData(cls):
 
-        sitegroups = (
+        parent_groups = (
             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 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):
         params = {'q': 'foobar1'}
@@ -150,12 +179,19 @@ class SiteGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
     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)
-        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)
 
+    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):
     queryset = Site.objects.all()
@@ -314,21 +350,29 @@ class LocationTestCase(TestCase, ChangeLoggedFilterSetTests):
         Site.objects.bulk_create(sites)
 
         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:
             location.save()
 
         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:
             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):
         params = {'q': 'foobar1'}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
@@ -352,31 +396,38 @@ class LocationTestCase(TestCase, ChangeLoggedFilterSetTests):
     def test_region(self):
         regions = Region.objects.all()[:2]
         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]}
-        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):
         site_groups = SiteGroup.objects.all()[:2]
         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]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
 
     def test_site(self):
         sites = Site.objects.all()[:2]
         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]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
 
     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)
-        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)
 
+    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):
     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.test import TestCase
 
 from circuits.models import *
+from core.models import ObjectType
 from dcim.choices import *
 from dcim.models import *
 from extras.models import CustomField
@@ -293,8 +293,8 @@ class DeviceTestCase(TestCase):
 
         # Create a CustomField with a default value & assign it to all component models
         cf1 = CustomField.objects.create(name='cf1', default='foo')
-        cf1.content_types.set(
-            ContentType.objects.filter(app_label='dcim', model__in=[
+        cf1.object_types.set(
+            ObjectType.objects.filter(app_label='dcim', model__in=[
                 'consoleport',
                 'consoleserverport',
                 'powerport',

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

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

+ 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 drf_spectacular.types import OpenApiTypes
 from drf_spectacular.utils import extend_schema_field
 from rest_framework.fields import Field
 from rest_framework.serializers import ValidationError
 
+from core.models import ObjectType
 from extras.choices import CustomFieldTypeChoices
 from extras.models import CustomField
 from utilities.api import get_serializer_for_model
@@ -24,8 +24,8 @@ class CustomFieldDefaultValues:
         self.model = serializer_field.parent.Meta.model
 
         # Retrieve the CustomFields for the parent model
-        content_type = ContentType.objects.get_for_model(self.model)
-        fields = CustomField.objects.filter(content_types=content_type)
+        object_type = ObjectType.objects.get_for_model(self.model)
+        fields = CustomField.objects.filter(object_types=object_type)
 
         # Populate the default value for each CustomField
         value = {}
@@ -46,8 +46,8 @@ class CustomFieldsDataField(Field):
         Cache CustomFields assigned to this model to avoid redundant database queries
         """
         if not hasattr(self, '_custom_fields'):
-            content_type = ContentType.objects.get_for_model(self.parent.Meta.model)
-            self._custom_fields = CustomField.objects.filter(content_types=content_type)
+            object_type = ObjectType.objects.get_for_model(self.parent.Meta.model)
+            self._custom_fields = CustomField.objects.filter(object_types=object_type)
         return self._custom_fields
 
     def to_representation(self, obj):
@@ -57,10 +57,10 @@ class CustomFieldsDataField(Field):
         for cf in self._get_custom_fields():
             value = cf.deserialize(obj.get(cf.name))
             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
             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
             data[cf.name] = value
 
@@ -79,7 +79,7 @@ class CustomFieldsDataField(Field):
                     CustomFieldTypeChoices.TYPE_OBJECT,
                     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
                 serializer = serializer_class(data=data[cf.name], nested=True, many=many, context=self.parent.context)
                 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_.bookmarks import *
 from .serializers_.change_logging import *
-from .serializers_.contenttypes import *
 from .serializers_.customfields import *
 from .serializers_.customlinks import *
 from .serializers_.dashboard import *

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 1 - 1
netbox/extras/events.py

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

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

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

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

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

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

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

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

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

@@ -25,7 +25,4 @@ class Migration(migrations.Migration):
         migrations.DeleteModel(
             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')
     Script = apps.get_model('extras', 'Script')
     ScriptModule = apps.get_model('extras', 'ScriptModule')
+    ReportModule = apps.get_model('extras', 'ReportModule')
     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 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
             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,
                 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):

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

@@ -12,4 +12,7 @@ class Migration(migrations.Migration):
             model_name='eventrule',
             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.utils.translation import gettext_lazy as _
 
-from core.models import ContentType
+from core.models import ObjectType
 from extras.choices import *
 from ..querysets import ObjectChangeQuerySet
 
@@ -113,7 +113,7 @@ class ObjectChange(models.Model):
         super().clean()
 
         # Validate the assigned object type
-        if self.changed_object_type not in ContentType.objects.with_feature('change_logging'):
+        if self.changed_object_type not in ObjectType.objects.with_feature('change_logging'):
             raise ValidationError(
                 _("Change logging is not supported for this object type ({type}).").format(
                     type=self.changed_object_type

+ 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.translation import gettext_lazy as _
 
-from core.models import ContentType
+from core.models import ObjectType
 from extras.choices import *
 from extras.data import CHOICE_SETS
 from netbox.models import ChangeLoggedModel
@@ -52,8 +52,8 @@ class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)):
         """
         Return all CustomFields assigned to the given model.
         """
-        content_type = ContentType.objects.get_for_model(model._meta.concrete_model)
-        return self.get_queryset().filter(content_types=content_type)
+        content_type = ObjectType.objects.get_for_model(model._meta.concrete_model)
+        return self.get_queryset().filter(object_types=content_type)
 
     def get_defaults_for_model(self, model):
         """
@@ -66,8 +66,8 @@ class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)):
 
 
 class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
-    content_types = models.ManyToManyField(
-        to='contenttypes.ContentType',
+    object_types = models.ManyToManyField(
+        to='core.ObjectType',
         related_name='custom_fields',
         help_text=_('The object(s) to which this field applies.')
     )
@@ -78,8 +78,8 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
         default=CustomFieldTypeChoices.TYPE_TEXT,
         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,
         blank=True,
         null=True,
@@ -209,7 +209,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
     objects = CustomFieldManager()
 
     clone_fields = (
-        'content_types', 'type', 'object_type', 'group_name', 'description', 'required', 'search_weight',
+        'object_types', 'type', 'related_object_type', 'group_name', 'description', 'required', 'search_weight',
         'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex',
         'choice_set', 'ui_visible', 'ui_editable', 'is_cloneable',
     )
@@ -284,7 +284,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
         """
         Called when a CustomField has been renamed. Updates all assigned object data.
         """
-        for ct in self.content_types.all():
+        for ct in self.object_types.all():
             model = ct.model_class()
             params = {f'custom_field_data__{old_name}__isnull': False}
             instances = model.objects.filter(**params)
@@ -344,11 +344,11 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
 
         # Object fields must define an object_type; other fields must not
         if self.type in (CustomFieldTypeChoices.TYPE_OBJECT, CustomFieldTypeChoices.TYPE_MULTIOBJECT):
-            if not self.object_type:
+            if not self.related_object_type:
                 raise ValidationError({
                     'object_type': _("Object fields must define an object type.")
                 })
-        elif self.object_type:
+        elif self.related_object_type:
             raise ValidationError({
                 'object_type': _(
                     "{type} fields may not define an object type.")
@@ -388,10 +388,10 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
             except ValueError:
                 return value
         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()
         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 value
 
@@ -488,7 +488,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
 
         # 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 = field_class(
                 queryset=model.objects.all(),
@@ -498,7 +498,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
 
         # Multiple objects
         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 = field_class(
                 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 rest_framework.utils.encoders import JSONEncoder
 
-from core.models import ContentType
+from core.models import ObjectType
 from extras.choices import *
 from extras.conditions import ConditionSet
 from extras.constants import *
@@ -43,9 +43,9 @@ class EventRule(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLogged
     specific type of object is created, modified, or deleted. The action to be taken might entail transmitting a
     webhook or executing a custom script.
     """
-    content_types = models.ManyToManyField(
-        to='contenttypes.ContentType',
-        related_name='eventrules',
+    object_types = models.ManyToManyField(
+        to='core.ObjectType',
+        related_name='event_rules',
         verbose_name=_('object types'),
         help_text=_("The object(s) to which this rule applies.")
     )
@@ -313,8 +313,8 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
     A custom link to an external representation of a NetBox object. The link text and URL fields accept Jinja2 template
     code to be rendered with an object as context.
     """
-    content_types = models.ManyToManyField(
-        to='contenttypes.ContentType',
+    object_types = models.ManyToManyField(
+        to='core.ObjectType',
         related_name='custom_links',
         help_text=_('The object type(s) to which this link applies.')
     )
@@ -359,7 +359,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
     )
 
     clone_fields = (
-        'content_types', 'enabled', 'weight', 'group_name', 'button_class', 'new_window',
+        'object_types', 'enabled', 'weight', 'group_name', 'button_class', 'new_window',
     )
 
     class Meta:
@@ -409,8 +409,8 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
 
 
 class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
-    content_types = models.ManyToManyField(
-        to='contenttypes.ContentType',
+    object_types = models.ManyToManyField(
+        to='core.ObjectType',
         related_name='export_templates',
         help_text=_('The object type(s) to which this template applies.')
     )
@@ -448,7 +448,7 @@ class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, Change
     )
 
     clone_fields = (
-        'content_types', 'template_code', 'mime_type', 'file_extension', 'as_attachment',
+        'object_types', 'template_code', 'mime_type', 'file_extension', 'as_attachment',
     )
 
     class Meta:
@@ -518,8 +518,8 @@ class SavedFilter(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
     """
     A set of predefined keyword parameters that can be reused to filter for specific objects.
     """
-    content_types = models.ManyToManyField(
-        to='contenttypes.ContentType',
+    object_types = models.ManyToManyField(
+        to='core.ObjectType',
         related_name='saved_filters',
         help_text=_('The object type(s) to which this filter applies.')
     )
@@ -561,7 +561,7 @@ class SavedFilter(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
     )
 
     clone_fields = (
-        'content_types', 'weight', 'enabled', 'parameters',
+        'object_types', 'weight', 'enabled', 'parameters',
     )
 
     class Meta:
@@ -598,13 +598,13 @@ class ImageAttachment(ChangeLoggedModel):
     """
     An uploaded image which is associated with an object.
     """
-    content_type = models.ForeignKey(
+    object_type = models.ForeignKey(
         to='contenttypes.ContentType',
         on_delete=models.CASCADE
     )
     object_id = models.PositiveBigIntegerField()
     parent = GenericForeignKey(
-        ct_field='content_type',
+        ct_field='object_type',
         fk_field='object_id'
     )
     image = models.ImageField(
@@ -626,12 +626,12 @@ class ImageAttachment(ChangeLoggedModel):
 
     objects = RestrictedQuerySet.as_manager()
 
-    clone_fields = ('content_type', 'object_id')
+    clone_fields = ('object_type', 'object_id')
 
     class Meta:
         ordering = ('name', 'pk')  # name may be non-unique
         indexes = (
-            models.Index(fields=('content_type', 'object_id')),
+            models.Index(fields=('object_type', 'object_id')),
         )
         verbose_name = _('image attachment')
         verbose_name_plural = _('image attachments')
@@ -646,9 +646,9 @@ class ImageAttachment(ChangeLoggedModel):
         super().clean()
 
         # Validate the assigned object type
-        if self.content_type not in ContentType.objects.with_feature('image_attachments'):
+        if self.object_type not in ObjectType.objects.with_feature('image_attachments'):
             raise ValidationError(
-                _("Image attachments cannot be assigned to this object type ({type}).").format(type=self.content_type)
+                _("Image attachments cannot be assigned to this object type ({type}).").format(type=self.object_type)
             )
 
     def delete(self, *args, **kwargs):
@@ -739,7 +739,7 @@ class JournalEntry(CustomFieldsMixin, CustomLinksMixin, TagsMixin, ExportTemplat
         super().clean()
 
         # Validate the assigned object type
-        if self.assigned_object_type not in ContentType.objects.with_feature('journaling'):
+        if self.assigned_object_type not in ObjectType.objects.with_feature('journaling'):
             raise ValidationError(
                 _("Journaling is not supported for this object type ({type}).").format(type=self.assigned_object_type)
             )
@@ -795,7 +795,7 @@ class Bookmark(models.Model):
         super().clean()
 
         # Validate the assigned object type
-        if self.object_type not in ContentType.objects.with_feature('bookmarks'):
+        if self.object_type not in ObjectType.objects.with_feature('bookmarks'):
             raise ValidationError(
                 _("Bookmarks cannot be assigned to this object type ({type}).").format(type=self.object_type)
             )

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

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

+ 8 - 7
netbox/extras/signals.py

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

+ 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 extras.models import *
-from netbox.tables import NetBoxTable, columns
+from netbox.tables import BaseTable, NetBoxTable, columns
 from .template_code import *
 
 __all__ = (
@@ -21,6 +21,8 @@ __all__ = (
     'JournalEntryTable',
     'ObjectChangeTable',
     'SavedFilterTable',
+    'ReportResultsTable',
+    'ScriptResultsTable',
     'TaggedItemTable',
     'TagTable',
     'WebhookTable',
@@ -40,8 +42,8 @@ class CustomFieldTable(NetBoxTable):
         verbose_name=_('Name'),
         linkify=True
     )
-    content_types = columns.ContentTypesColumn(
-        verbose_name=_('Content Types')
+    object_types = columns.ContentTypesColumn(
+        verbose_name=_('Object Types')
     )
     required = columns.BooleanColumn(
         verbose_name=_('Required')
@@ -55,6 +57,9 @@ class CustomFieldTable(NetBoxTable):
     description = columns.MarkdownColumn(
         verbose_name=_('Description')
     )
+    related_object_type = columns.ContentTypeColumn(
+        verbose_name=_('Related Object Type')
+    )
     choice_set = tables.Column(
         linkify=True,
         verbose_name=_('Choice Set')
@@ -71,11 +76,11 @@ class CustomFieldTable(NetBoxTable):
     class Meta(NetBoxTable.Meta):
         model = CustomField
         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):
@@ -115,8 +120,8 @@ class CustomLinkTable(NetBoxTable):
         verbose_name=_('Name'),
         linkify=True
     )
-    content_types = columns.ContentTypesColumn(
-        verbose_name=_('Content Types'),
+    object_types = columns.ContentTypesColumn(
+        verbose_name=_('Object Types'),
     )
     enabled = columns.BooleanColumn(
         verbose_name=_('Enabled'),
@@ -128,10 +133,10 @@ class CustomLinkTable(NetBoxTable):
     class Meta(NetBoxTable.Meta):
         model = CustomLink
         fields = (
-            'pk', 'id', 'name', 'content_types', 'enabled', 'link_text', 'link_url', 'weight', 'group_name',
+            'pk', 'id', 'name', 'object_types', 'enabled', 'link_text', 'link_url', 'weight', 'group_name',
             'button_class', 'new_window', 'created', 'last_updated',
         )
-        default_columns = ('pk', 'name', 'content_types', 'enabled', 'group_name', 'button_class', 'new_window')
+        default_columns = ('pk', 'name', 'object_types', 'enabled', 'group_name', 'button_class', 'new_window')
 
 
 class ExportTemplateTable(NetBoxTable):
@@ -139,8 +144,8 @@ class ExportTemplateTable(NetBoxTable):
         verbose_name=_('Name'),
         linkify=True
     )
-    content_types = columns.ContentTypesColumn(
-        verbose_name=_('Content Types'),
+    object_types = columns.ContentTypesColumn(
+        verbose_name=_('Object Types'),
     )
     as_attachment = columns.BooleanColumn(
         verbose_name=_('As Attachment'),
@@ -161,11 +166,11 @@ class ExportTemplateTable(NetBoxTable):
     class Meta(NetBoxTable.Meta):
         model = ExportTemplate
         fields = (
-            'pk', 'id', 'name', 'content_types', 'description', 'mime_type', 'file_extension', 'as_attachment',
+            'pk', 'id', 'name', 'object_types', 'description', 'mime_type', 'file_extension', 'as_attachment',
             'data_source', 'data_file', 'data_synced', 'created', 'last_updated',
         )
         default_columns = (
-            'pk', 'name', 'content_types', 'description', 'mime_type', 'file_extension', 'as_attachment', 'is_synced',
+            'pk', 'name', 'object_types', 'description', 'mime_type', 'file_extension', 'as_attachment', 'is_synced',
         )
 
 
@@ -174,8 +179,8 @@ class ImageAttachmentTable(NetBoxTable):
         verbose_name=_('ID'),
         linkify=False
     )
-    content_type = columns.ContentTypeColumn(
-        verbose_name=_('Content Type'),
+    object_type = columns.ContentTypeColumn(
+        verbose_name=_('Object Type'),
     )
     parent = tables.Column(
         verbose_name=_('Parent'),
@@ -193,10 +198,10 @@ class ImageAttachmentTable(NetBoxTable):
     class Meta(NetBoxTable.Meta):
         model = ImageAttachment
         fields = (
-            'pk', 'content_type', 'parent', 'image', 'name', 'image_height', 'image_width', 'size', 'created',
+            'pk', 'object_type', 'parent', 'image', 'name', 'image_height', 'image_width', 'size', 'created',
             'last_updated',
         )
-        default_columns = ('content_type', 'parent', 'image', 'name', 'size', 'created')
+        default_columns = ('object_type', 'parent', 'image', 'name', 'size', 'created')
 
 
 class SavedFilterTable(NetBoxTable):
@@ -204,8 +209,8 @@ class SavedFilterTable(NetBoxTable):
         verbose_name=_('Name'),
         linkify=True
     )
-    content_types = columns.ContentTypesColumn(
-        verbose_name=_('Content Types'),
+    object_types = columns.ContentTypesColumn(
+        verbose_name=_('Object Types'),
     )
     enabled = columns.BooleanColumn(
         verbose_name=_('Enabled'),
@@ -220,11 +225,11 @@ class SavedFilterTable(NetBoxTable):
     class Meta(NetBoxTable.Meta):
         model = SavedFilter
         fields = (
-            'pk', 'id', 'name', 'slug', 'content_types', 'description', 'user', 'weight', 'enabled', 'shared',
+            'pk', 'id', 'name', 'slug', 'object_types', 'description', 'user', 'weight', 'enabled', 'shared',
             'created', 'last_updated', 'parameters'
         )
         default_columns = (
-            'pk', 'name', 'content_types', 'user', 'description', 'enabled', 'shared',
+            'pk', 'name', 'object_types', 'user', 'description', 'enabled', 'shared',
         )
 
 
@@ -281,8 +286,8 @@ class EventRuleTable(NetBoxTable):
         linkify=True,
         verbose_name=_('Object'),
     )
-    content_types = columns.ContentTypesColumn(
-        verbose_name=_('Content Types'),
+    object_types = columns.ContentTypesColumn(
+        verbose_name=_('Object Types'),
     )
     enabled = columns.BooleanColumn(
         verbose_name=_('Enabled'),
@@ -309,12 +314,12 @@ class EventRuleTable(NetBoxTable):
     class Meta(NetBoxTable.Meta):
         model = EventRule
         fields = (
-            'pk', 'id', 'name', 'enabled', 'description', 'action_type', 'action_object', 'content_types',
+            'pk', 'id', 'name', 'enabled', 'description', 'action_type', 'action_object', 'object_types',
             'type_create', 'type_update', 'type_delete', 'type_job_start', 'type_job_end', 'tags', 'created',
             'last_updated',
         )
         default_columns = (
-            'pk', 'name', 'enabled', 'action_type', 'action_object', 'content_types', 'type_create', 'type_update',
+            'pk', 'name', 'enabled', 'action_type', 'action_object', 'object_types', 'type_create', 'type_update',
             'type_delete', 'type_job_start', 'type_job_end',
         )
 
@@ -507,3 +512,61 @@ class JournalEntryTable(NetBoxTable):
         default_columns = (
             '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.contrib.contenttypes.models import ContentType
 from django.utils.safestring import mark_safe
 
+from core.models import ObjectType
 from extras.models import CustomLink
 
 
@@ -32,8 +32,8 @@ def custom_links(context, obj):
     """
     Render all applicable links for the given object.
     """
-    content_type = ContentType.objects.get_for_model(obj)
-    custom_links = CustomLink.objects.filter(content_types=content_type, enabled=True)
+    object_type = ObjectType.objects.get_for_model(obj)
+    custom_links = CustomLink.objects.filter(object_types=object_type, enabled=True)
     if not custom_links:
         return ''
 

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

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

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

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

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

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

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

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

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

@@ -7,6 +7,7 @@ from django.test import TestCase
 
 from circuits.models import Provider
 from core.choices import ManagedFileRootPathChoices
+from core.models import ObjectType
 from dcim.filtersets import SiteFilterSet
 from dcim.models import DeviceRole, DeviceType, Manufacturer, Platform, Rack, Region, Site, SiteGroup
 from dcim.models import Location
@@ -85,13 +86,23 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests):
                 ui_editable=CustomFieldUIEditableChoices.HIDDEN,
                 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)
-        custom_fields[0].content_types.add(ContentType.objects.get_by_natural_key('dcim', 'site'))
-        custom_fields[1].content_types.add(ContentType.objects.get_by_natural_key('dcim', 'rack'))
-        custom_fields[2].content_types.add(ContentType.objects.get_by_natural_key('dcim', 'device'))
-        custom_fields[3].content_types.add(ContentType.objects.get_by_natural_key('dcim', 'device'))
-        custom_fields[4].content_types.add(ContentType.objects.get_by_natural_key('dcim', 'device'))
+        custom_fields[0].object_types.add(ObjectType.objects.get_by_natural_key('dcim', 'site'))
+        custom_fields[1].object_types.add(ObjectType.objects.get_by_natural_key('dcim', 'rack'))
+        custom_fields[2].object_types.add(ObjectType.objects.get_by_natural_key('dcim', 'device'))
+        custom_fields[3].object_types.add(ObjectType.objects.get_by_natural_key('dcim', 'device'))
+        custom_fields[4].object_types.add(ObjectType.objects.get_by_natural_key('dcim', 'device'))
 
     def test_q(self):
         params = {'q': 'foobar1'}
@@ -101,10 +112,16 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests):
         params = {'name': ['Custom Field 1', 'Custom Field 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
-    def test_content_types(self):
-        params = {'content_types': 'dcim.site'}
+    def test_object_type(self):
+        params = {'object_type': 'dcim.site'}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
-        params = {'content_type_id': [ContentType.objects.get_by_natural_key('dcim', 'site').pk]}
+        params = {'object_type_id': [ObjectType.objects.get_by_natural_key('dcim', 'site').pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+    def test_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)
 
     def test_required(self):
@@ -174,8 +191,6 @@ class WebhookTestCase(TestCase, BaseFilterSetTests):
 
     @classmethod
     def setUpTestData(cls):
-        content_types = ContentType.objects.filter(model__in=['region', 'site', 'rack', 'location', 'device'])
-
         webhooks = (
             Webhook(
                 name='Webhook 1',
@@ -240,7 +255,7 @@ class EventRuleTestCase(TestCase, BaseFilterSetTests):
 
     @classmethod
     def setUpTestData(cls):
-        content_types = ContentType.objects.filter(
+        object_types = ObjectType.objects.filter(
             model__in=['region', 'site', 'rack', 'location', 'device']
         )
 
@@ -333,11 +348,11 @@ class EventRuleTestCase(TestCase, BaseFilterSetTests):
             ),
         )
         EventRule.objects.bulk_create(event_rules)
-        event_rules[0].content_types.add(content_types[0])
-        event_rules[1].content_types.add(content_types[1])
-        event_rules[2].content_types.add(content_types[2])
-        event_rules[3].content_types.add(content_types[3])
-        event_rules[4].content_types.add(content_types[4])
+        event_rules[0].object_types.add(object_types[0])
+        event_rules[1].object_types.add(object_types[1])
+        event_rules[2].object_types.add(object_types[2])
+        event_rules[3].object_types.add(object_types[3])
+        event_rules[4].object_types.add(object_types[4])
 
     def test_q(self):
         params = {'q': 'foobar1'}
@@ -351,10 +366,10 @@ class EventRuleTestCase(TestCase, BaseFilterSetTests):
         params = {'description': ['foobar1', 'foobar2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
-    def test_content_types(self):
-        params = {'content_types': 'dcim.region'}
+    def test_object_type(self):
+        params = {'object_type': 'dcim.region'}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
-        params = {'content_type_id': [ContentType.objects.get_for_model(Region).pk]}
+        params = {'object_type_id': [ObjectType.objects.get_for_model(Region).pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
     def test_action_type(self):
@@ -396,7 +411,7 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests):
 
     @classmethod
     def setUpTestData(cls):
-        content_types = ContentType.objects.filter(model__in=['site', 'rack', 'device'])
+        object_types = ObjectType.objects.filter(model__in=['site', 'rack', 'device'])
 
         custom_links = (
             CustomLink(
@@ -426,7 +441,7 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests):
         )
         CustomLink.objects.bulk_create(custom_links)
         for i, custom_link in enumerate(custom_links):
-            custom_link.content_types.set([content_types[i]])
+            custom_link.object_types.set([object_types[i]])
 
     def test_q(self):
         params = {'q': 'Custom Link 1'}
@@ -436,10 +451,10 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests):
         params = {'name': ['Custom Link 1', 'Custom Link 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
-    def test_content_types(self):
-        params = {'content_types': 'dcim.site'}
+    def test_object_type(self):
+        params = {'object_type': 'dcim.site'}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
-        params = {'content_type_id': [ContentType.objects.get_for_model(Site).pk]}
+        params = {'object_type_id': [ObjectType.objects.get_for_model(Site).pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
     def test_weight(self):
@@ -465,7 +480,7 @@ class SavedFilterTestCase(TestCase, BaseFilterSetTests):
 
     @classmethod
     def setUpTestData(cls):
-        content_types = ContentType.objects.filter(model__in=['site', 'rack', 'device'])
+        object_types = ObjectType.objects.filter(model__in=['site', 'rack', 'device'])
 
         users = (
             User(username='User 1'),
@@ -508,7 +523,7 @@ class SavedFilterTestCase(TestCase, BaseFilterSetTests):
         )
         SavedFilter.objects.bulk_create(saved_filters)
         for i, savedfilter in enumerate(saved_filters):
-            savedfilter.content_types.set([content_types[i]])
+            savedfilter.object_types.set([object_types[i]])
 
     def test_q(self):
         params = {'q': 'foobar1'}
@@ -526,10 +541,10 @@ class SavedFilterTestCase(TestCase, BaseFilterSetTests):
         params = {'description': ['foobar1', 'foobar2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
-    def test_content_types(self):
-        params = {'content_types': 'dcim.site'}
+    def test_object_type(self):
+        params = {'object_type': 'dcim.site'}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
-        params = {'content_type_id': [ContentType.objects.get_for_model(Site).pk]}
+        params = {'object_type_id': [ObjectType.objects.get_for_model(Site).pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
     def test_user(self):
@@ -638,7 +653,7 @@ class ExportTemplateTestCase(TestCase, BaseFilterSetTests):
 
     @classmethod
     def setUpTestData(cls):
-        content_types = ContentType.objects.filter(model__in=['site', 'rack', 'device'])
+        object_types = ObjectType.objects.filter(model__in=['site', 'rack', 'device'])
 
         export_templates = (
             ExportTemplate(name='Export Template 1', template_code='TESTING', description='foobar1'),
@@ -647,7 +662,7 @@ class ExportTemplateTestCase(TestCase, BaseFilterSetTests):
         )
         ExportTemplate.objects.bulk_create(export_templates)
         for i, et in enumerate(export_templates):
-            et.content_types.set([content_types[i]])
+            et.object_types.set([object_types[i]])
 
     def test_q(self):
         params = {'q': 'foobar1'}
@@ -657,10 +672,10 @@ class ExportTemplateTestCase(TestCase, BaseFilterSetTests):
         params = {'name': ['Export Template 1', 'Export Template 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
-    def test_content_types(self):
-        params = {'content_types': 'dcim.site'}
+    def test_object_type(self):
+        params = {'object_type': 'dcim.site'}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
-        params = {'content_type_id': [ContentType.objects.get_for_model(Site).pk]}
+        params = {'object_type_id': [ObjectType.objects.get_for_model(Site).pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
     def test_description(self):
@@ -692,7 +707,7 @@ class ImageAttachmentTestCase(TestCase, BaseFilterSetTests):
 
         image_attachments = (
             ImageAttachment(
-                content_type=site_ct,
+                object_type=site_ct,
                 object_id=sites[0].pk,
                 name='Image Attachment 1',
                 image='http://example.com/image1.png',
@@ -700,7 +715,7 @@ class ImageAttachmentTestCase(TestCase, BaseFilterSetTests):
                 image_width=100
             ),
             ImageAttachment(
-                content_type=site_ct,
+                object_type=site_ct,
                 object_id=sites[1].pk,
                 name='Image Attachment 2',
                 image='http://example.com/image2.png',
@@ -708,7 +723,7 @@ class ImageAttachmentTestCase(TestCase, BaseFilterSetTests):
                 image_width=100
             ),
             ImageAttachment(
-                content_type=rack_ct,
+                object_type=rack_ct,
                 object_id=racks[0].pk,
                 name='Image Attachment 3',
                 image='http://example.com/image3.png',
@@ -716,7 +731,7 @@ class ImageAttachmentTestCase(TestCase, BaseFilterSetTests):
                 image_width=100
             ),
             ImageAttachment(
-                content_type=rack_ct,
+                object_type=rack_ct,
                 object_id=racks[1].pk,
                 name='Image Attachment 4',
                 image='http://example.com/image4.png',
@@ -734,13 +749,13 @@ class ImageAttachmentTestCase(TestCase, BaseFilterSetTests):
         params = {'name': ['Image Attachment 1', 'Image Attachment 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
-    def test_content_type(self):
-        params = {'content_type': 'dcim.site'}
+    def test_object_type(self):
+        params = {'object_type': 'dcim.site'}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
-    def test_content_type_id_and_object_id(self):
+    def test_object_type_id_and_object_id(self):
         params = {
-            'content_type_id': ContentType.objects.get(app_label='dcim', model='site').pk,
+            'object_type_id': ContentType.objects.get(app_label='dcim', model='site').pk,
             'object_id': [Site.objects.first().pk],
         }
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
@@ -1113,9 +1128,9 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
 
     @classmethod
     def setUpTestData(cls):
-        content_types = {
-            'site': ContentType.objects.get_by_natural_key('dcim', 'site'),
-            'provider': ContentType.objects.get_by_natural_key('circuits', 'provider'),
+        object_types = {
+            'site': ObjectType.objects.get_by_natural_key('dcim', 'site'),
+            'provider': ObjectType.objects.get_by_natural_key('circuits', 'provider'),
         }
 
         tags = (
@@ -1124,8 +1139,8 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
             Tag(name='Tag 3', slug='tag-3', color='0000ff'),
         )
         Tag.objects.bulk_create(tags)
-        tags[0].object_types.add(content_types['site'])
-        tags[1].object_types.add(content_types['provider'])
+        tags[0].object_types.add(object_types['site'])
+        tags[1].object_types.add(object_types['provider'])
 
         # Apply some tags so we can filter by content type
         site = Site.objects.create(name='Site 1', slug='site-1')
@@ -1163,12 +1178,12 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
     def test_object_types(self):
-        params = {'for_object_type_id': [ContentType.objects.get_by_natural_key('dcim', 'site').pk]}
+        params = {'for_object_type_id': [ObjectType.objects.get_by_natural_key('dcim', 'site').pk]}
         self.assertEqual(
             list(self.filterset(params, self.queryset).qs.values_list('name', flat=True)),
             ['Tag 1', 'Tag 3']
         )
-        params = {'for_object_type_id': [ContentType.objects.get_by_natural_key('circuits', 'provider').pk]}
+        params = {'for_object_type_id': [ObjectType.objects.get_by_natural_key('circuits', 'provider').pk]}
         self.assertEqual(
             list(self.filterset(params, self.queryset).qs.values_list('name', flat=True)),
             ['Tag 2', 'Tag 3']

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

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

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

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

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

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

+ 1 - 1
netbox/extras/utils.py

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

+ 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 netbox.constants import DEFAULT_ACTION_PERMISSIONS
 from netbox.views import generic
+from netbox.views.generic.mixins import TableMixin
 from utilities.forms import ConfirmationForm, get_field_value
 from utilities.paginator import EnhancedPaginator, get_paginate_count
 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 .models import *
 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):
         related_models = ()
 
-        for content_type in instance.content_types.all():
+        for object_type in instance.object_types.all():
             related_models += (
-                content_type.model_class().objects.restrict(request.user, 'view').exclude(
+                object_type.model_class().objects.restrict(request.user, 'view').exclude(
                     Q(**{f'custom_field_data__{instance.name}': ''}) |
                     Q(**{f'custom_field_data__{instance.name}': None})
                 ),
@@ -762,8 +764,8 @@ class ImageAttachmentEditView(generic.ObjectEditView):
     def alter_object(self, instance, request, args, kwargs):
         if not instance.pk:
             # Assign the parent object based on URL kwargs
-            content_type = get_object_or_404(ContentType, pk=request.GET.get('content_type'))
-            instance.parent = get_object_or_404(content_type.model_class(), pk=request.GET.get('object_id'))
+            object_type = get_object_or_404(ContentType, pk=request.GET.get('object_type'))
+            instance.parent = get_object_or_404(object_type.model_class(), pk=request.GET.get('object_id'))
         return instance
 
     def get_return_url(self, request, obj=None):
@@ -771,7 +773,7 @@ class ImageAttachmentEditView(generic.ObjectEditView):
 
     def get_extra_addanother_params(self, request):
         return {
-            'content_type': request.GET.get('content_type'),
+            'object_type': request.GET.get('object_type'),
             'object_id': request.GET.get('object_id'),
         }
 
@@ -1143,19 +1145,72 @@ class LegacyScriptRedirectView(ContentTypePermissionRequiredMixin, View):
         return redirect(f'{url}{path}')
 
 
-class ScriptResultView(generic.ObjectView):
+class ScriptResultView(TableMixin, generic.ObjectView):
     queryset = Job.objects.all()
 
     def get_required_permission(self):
         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):
+        table = None
         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 = {
             'script': job.object,
             'job': job,
+            'table': table,
         }
+
         if job.data and 'log' in job.data:
             # Script
             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.translation import gettext_lazy as _
 
-from core.models import ContentType
+from core.models import ObjectType
 from ipam.choices import *
 from ipam.constants import *
 from ipam.fields import IPNetworkField, IPAddressField
@@ -861,7 +861,7 @@ class IPAddress(PrimaryModel):
 
         if self._original_assigned_object_id and self._original_assigned_object_type_id:
             parent = getattr(self.assigned_object, 'parent_object', None)
-            ct = ContentType.objects.get_for_id(self._original_assigned_object_type_id)
+            ct = ObjectType.objects.get_for_id(self._original_assigned_object_type_id)
             original_assigned_object = ct.get_object_for_this_type(pk=self._original_assigned_object_id)
             original_parent = getattr(original_assigned_object, 'parent_object', None)
 

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

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

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

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

+ 1 - 1
netbox/netbox/filtersets.py

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -17,7 +17,9 @@
           <th scope="row">Type</th>
           <td>
             {{ 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>
         </tr>
         <tr>
@@ -89,7 +91,7 @@
     <div class="card">
       <h5 class="card-header">{% trans "Object Types" %}</h5>
       <table class="table table-hover attr-table">
-        {% for ct in object.content_types.all %}
+        {% for ct in object.object_types.all %}
           <tr>
             <td>{{ ct }}</td>
           </tr>

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

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

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

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

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

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

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

@@ -3,124 +3,63 @@
 {% load log_levels %}
 {% 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 %}
-    {% 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>
-              <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>
           {% endfor %}
         </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">
-      <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>
+    {% 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 %}
-{% 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">
       <h5 class="card-header">{% trans "Assigned Models" %}</h5>
       <table class="table table-hover attr-table">
-        {% for ct in object.content_types.all %}
+        {% for object_type in object.object_types.all %}
           <tr>
-            <td>{{ ct }}</td>
+            <td>{{ object_type }}</td>
           </tr>
         {% endfor %}
       </table>

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

@@ -32,28 +32,74 @@
 {% block tabs %}
   <ul class="nav nav-tabs" role="tablist">
     <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>
   </ul>
 {% endblock %}
 
 {% 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>
+
+      <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 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 %}
 
 {% block modals %}
-  {% include 'inc/htmx_modal.html' %}
+  {% table_config_form table table_name="ObjectTable" %}
 {% endblock modals %}

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

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

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

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

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

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

+ 30 - 4
netbox/tenancy/filtersets.py

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 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 core.models import ObjectType
 from dcim.models import Manufacturer, Site
 from tenancy.filtersets import *
 from tenancy.models import *
@@ -15,35 +15,43 @@ class TenantGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
     def setUpTestData(cls):
 
         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 = (
             TenantGroup(
-                name='Tenant Group 1',
-                slug='tenant-group-1',
+                name='Tenant Group 1A',
+                slug='tenant-group-1a',
                 parent=parent_tenant_groups[0],
                 description='foobar1'
             ),
             TenantGroup(
-                name='Tenant Group 2',
-                slug='tenant-group-2',
+                name='Tenant Group 2A',
+                slug='tenant-group-2a',
                 parent=parent_tenant_groups[1],
                 description='foobar2'
             ),
             TenantGroup(
-                name='Tenant Group 3',
-                slug='tenant-group-3',
+                name='Tenant Group 3A',
+                slug='tenant-group-3a',
                 parent=parent_tenant_groups[2],
                 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):
         params = {'q': 'foobar1'}
@@ -62,12 +70,19 @@ class TenantGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
     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)
-        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)
 
+    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):
     queryset = Tenant.objects.all()
@@ -123,35 +138,43 @@ class ContactGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
     def setUpTestData(cls):
 
         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 = (
             ContactGroup(
-                name='Contact Group 1',
-                slug='contact-group-1',
+                name='Contact Group 1A',
+                slug='contact-group-1a',
                 parent=parent_contact_groups[0],
                 description='foobar1'
             ),
             ContactGroup(
-                name='Contact Group 2',
-                slug='contact-group-2',
+                name='Contact Group 2A',
+                slug='contact-group-2a',
                 parent=parent_contact_groups[1],
                 description='foobar2'
             ),
             ContactGroup(
-                name='Contact Group 3',
-                slug='contact-group-3',
+                name='Contact Group 3A',
+                slug='contact-group-3a',
                 parent=parent_contact_groups[2],
                 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):
         params = {'q': 'foobar1'}
@@ -170,12 +193,19 @@ class ContactGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
     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)
-        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)
 
+    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):
     queryset = ContactRole.objects.all()
@@ -295,8 +325,8 @@ class ContactAssignmentTestCase(TestCase, ChangeLoggedFilterSetTests):
         )
         ContactAssignment.objects.bulk_create(assignments)
 
-    def test_content_type(self):
-        params = {'content_type_id': ContentType.objects.get_by_natural_key('dcim', 'site')}
+    def test_object_type(self):
+        params = {'object_type_id': ObjectType.objects.get_by_natural_key('dcim', 'site')}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
 
     def test_contact(self):

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

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

+ 4 - 4
netbox/tenancy/views.py

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

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

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

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

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

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

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

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

@@ -14,7 +14,7 @@ def update_content_types(apps, schema_editor):
     if netboxuser_ct:
         user_ct = ContentType.objects.filter(app_label='users', model='user').first()
         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()
 
 

+ 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():
         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):

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

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

+ 2 - 2
netbox/users/models.py

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

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

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

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

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

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

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

Некоторые файлы не были показаны из-за большого количества измененных файлов