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

Closes #16388: Move change logging resources from `extras` to `core` (#16545)

* Initial work on #16388

* Misc cleanup
Jeremy Stretch 1 год назад
Родитель
Сommit
853d990c03
63 измененных файлов с 644 добавлено и 522 удалено
  1. 4 2
      netbox/account/views.py
  2. 1 0
      netbox/core/api/serializers.py
  3. 3 3
      netbox/core/api/serializers_/change_logging.py
  4. 1 3
      netbox/core/api/urls.py
  5. 11 0
      netbox/core/api/views.py
  6. 17 0
      netbox/core/choices.py
  7. 41 0
      netbox/core/filtersets.py
  8. 39 2
      netbox/core/forms/filtersets.py
  9. 7 0
      netbox/core/graphql/filters.py
  10. 24 0
      netbox/core/graphql/mixins.py
  11. 10 0
      netbox/core/graphql/types.py
  12. 45 0
      netbox/core/migrations/0011_move_objectchange.py
  13. 2 1
      netbox/core/models/__init__.py
  14. 4 4
      netbox/core/models/change_logging.py
  15. 26 0
      netbox/core/querysets.py
  16. 1 0
      netbox/core/tables/__init__.py
  17. 53 0
      netbox/core/tables/change_logging.py
  18. 16 0
      netbox/core/tables/template_code.py
  19. 3 2
      netbox/core/tests/test_changelog.py
  20. 103 1
      netbox/core/tests/test_filtersets.py
  21. 1 1
      netbox/core/tests/test_models.py
  22. 42 2
      netbox/core/tests/test_views.py
  23. 4 0
      netbox/core/urls.py
  24. 70 0
      netbox/core/views.py
  25. 2 6
      netbox/dcim/graphql/types.py
  26. 0 1
      netbox/extras/api/serializers.py
  27. 0 1
      netbox/extras/api/urls.py
  28. 0 14
      netbox/extras/api/views.py
  29. 0 17
      netbox/extras/choices.py
  30. 1 1
      netbox/extras/constants.py
  31. 4 1
      netbox/extras/events.py
  32. 0 38
      netbox/extras/filtersets.py
  33. 1 36
      netbox/extras/forms/filtersets.py
  34. 0 7
      netbox/extras/graphql/filters.py
  35. 1 18
      netbox/extras/graphql/mixins.py
  36. 0 10
      netbox/extras/graphql/types.py
  37. 1 2
      netbox/extras/management/commands/housekeeping.py
  38. 1 1
      netbox/extras/management/commands/runscript.py
  39. 57 0
      netbox/extras/migrations/0116_move_objectchange.py
  40. 0 1
      netbox/extras/models/__init__.py
  41. 1 2
      netbox/extras/models/models.py
  42. 0 20
      netbox/extras/querysets.py
  43. 1 1
      netbox/extras/scripts.py
  44. 3 3
      netbox/extras/signals.py
  45. 0 44
      netbox/extras/tables/tables.py
  46. 0 17
      netbox/extras/tables/template_code.py
  47. 3 2
      netbox/extras/tests/test_event_rules.py
  48. 2 99
      netbox/extras/tests/test_filtersets.py
  49. 0 40
      netbox/extras/tests/test_views.py
  50. 0 4
      netbox/extras/urls.py
  51. 0 70
      netbox/extras/views.py
  52. 1 1
      netbox/netbox/context_managers.py
  53. 4 2
      netbox/netbox/filtersets.py
  54. 3 10
      netbox/netbox/graphql/types.py
  55. 1 1
      netbox/netbox/middleware.py
  56. 3 2
      netbox/netbox/models/features.py
  57. 1 1
      netbox/netbox/navigation/menu.py
  58. 8 7
      netbox/netbox/views/generic/feature_views.py
  59. 5 5
      netbox/templates/core/objectchange.html
  60. 0 0
      netbox/templates/core/objectchange_list.html
  61. 2 2
      netbox/users/views.py
  62. 8 11
      netbox/utilities/testing/api.py
  63. 2 3
      netbox/utilities/testing/views.py

+ 4 - 2
netbox/account/views.py

@@ -19,8 +19,10 @@ from django.views.generic import View
 from social_core.backends.utils import load_backends
 
 from account.models import UserToken
-from extras.models import Bookmark, ObjectChange
-from extras.tables import BookmarkTable, ObjectChangeTable
+from core.models import ObjectChange
+from core.tables import ObjectChangeTable
+from extras.models import Bookmark
+from extras.tables import BookmarkTable
 from netbox.authentication import get_auth_backend_display, get_saml_idps
 from netbox.config import get_config
 from netbox.views import generic

+ 1 - 0
netbox/core/api/serializers.py

@@ -1,3 +1,4 @@
+from .serializers_.change_logging import *
 from .serializers_.data import *
 from .serializers_.jobs import *
 from .nested_serializers import *

+ 3 - 3
netbox/extras/api/serializers_/change_logging.py → netbox/core/api/serializers_/change_logging.py

@@ -1,8 +1,8 @@
 from drf_spectacular.utils import extend_schema_field
 from rest_framework import serializers
 
-from extras.choices import *
-from extras.models import ObjectChange
+from core.choices import *
+from core.models import ObjectChange
 from netbox.api.exceptions import SerializerNotFound
 from netbox.api.fields import ChoiceField, ContentTypeField
 from netbox.api.serializers import BaseModelSerializer
@@ -15,7 +15,7 @@ __all__ = (
 
 
 class ObjectChangeSerializer(BaseModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='extras-api:objectchange-detail')
+    url = serializers.HyperlinkedIdentityField(view_name='core-api:objectchange-detail')
     user = UserSerializer(
         nested=True,
         read_only=True

+ 1 - 3
netbox/core/api/urls.py

@@ -5,12 +5,10 @@ from . import views
 router = NetBoxRouter()
 router.APIRootView = views.CoreRootView
 
-# Data sources
 router.register('data-sources', views.DataSourceViewSet)
 router.register('data-files', views.DataFileViewSet)
-
-# Jobs
 router.register('jobs', views.JobViewSet)
+router.register('object-changes', views.ObjectChangeViewSet)
 
 app_name = 'core-api'
 urlpatterns = router.urls

+ 11 - 0
netbox/core/api/views.py

@@ -8,6 +8,7 @@ from rest_framework.viewsets import ReadOnlyModelViewSet
 
 from core import filtersets
 from core.models import *
+from netbox.api.metadata import ContentTypeMetadata
 from netbox.api.viewsets import NetBoxModelViewSet, NetBoxReadOnlyModelViewSet
 from . import serializers
 
@@ -54,3 +55,13 @@ class JobViewSet(ReadOnlyModelViewSet):
     queryset = Job.objects.all()
     serializer_class = serializers.JobSerializer
     filterset_class = filtersets.JobFilterSet
+
+
+class ObjectChangeViewSet(ReadOnlyModelViewSet):
+    """
+    Retrieve a list of recent changes.
+    """
+    metadata_class = ContentTypeMetadata
+    queryset = ObjectChange.objects.valid_models()
+    serializer_class = serializers.ObjectChangeSerializer
+    filterset_class = filtersets.ObjectChangeFilterSet

+ 17 - 0
netbox/core/choices.py

@@ -64,3 +64,20 @@ class JobStatusChoices(ChoiceSet):
         STATUS_ERRORED,
         STATUS_FAILED,
     )
+
+
+#
+# ObjectChanges
+#
+
+class ObjectChangeActionChoices(ChoiceSet):
+
+    ACTION_CREATE = 'create'
+    ACTION_UPDATE = 'update'
+    ACTION_DELETE = 'delete'
+
+    CHOICES = (
+        (ACTION_CREATE, _('Created'), 'green'),
+        (ACTION_UPDATE, _('Updated'), 'blue'),
+        (ACTION_DELETE, _('Deleted'), 'red'),
+    )

+ 41 - 0
netbox/core/filtersets.py

@@ -1,3 +1,5 @@
+from django.contrib.auth import get_user_model
+from django.contrib.contenttypes.models import ContentType
 from django.db.models import Q
 from django.utils.translation import gettext as _
 
@@ -5,6 +7,7 @@ import django_filters
 
 from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet
 from netbox.utils import get_data_backend_choices
+from utilities.filters import ContentTypeFilter
 from .choices import *
 from .models import *
 
@@ -13,6 +16,7 @@ __all__ = (
     'DataFileFilterSet',
     'DataSourceFilterSet',
     'JobFilterSet',
+    'ObjectChangeFilterSet',
 )
 
 
@@ -126,6 +130,43 @@ class JobFilterSet(BaseFilterSet):
         )
 
 
+class ObjectChangeFilterSet(BaseFilterSet):
+    q = django_filters.CharFilter(
+        method='search',
+        label=_('Search'),
+    )
+    time = django_filters.DateTimeFromToRangeFilter()
+    changed_object_type = ContentTypeFilter()
+    changed_object_type_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=ContentType.objects.all()
+    )
+    user_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=get_user_model().objects.all(),
+        label=_('User (ID)'),
+    )
+    user = django_filters.ModelMultipleChoiceFilter(
+        field_name='user__username',
+        queryset=get_user_model().objects.all(),
+        to_field_name='username',
+        label=_('User name'),
+    )
+
+    class Meta:
+        model = ObjectChange
+        fields = (
+            'id', 'user', 'user_name', 'request_id', 'action', 'changed_object_type_id', 'changed_object_id',
+            'related_object_type', 'related_object_id', 'object_repr',
+        )
+
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        return queryset.filter(
+            Q(user_name__icontains=value) |
+            Q(object_repr__icontains=value)
+        )
+
+
 class ConfigRevisionFilterSet(BaseFilterSet):
     q = django_filters.CharFilter(
         method='search',

+ 39 - 2
netbox/core/forms/filtersets.py

@@ -7,8 +7,10 @@ from core.models import *
 from netbox.forms import NetBoxModelFilterSetForm
 from netbox.forms.mixins import SavedFiltersMixin
 from netbox.utils import get_data_backend_choices
-from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm
-from utilities.forms.fields import ContentTypeChoiceField, DynamicModelMultipleChoiceField
+from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice
+from utilities.forms.fields import (
+    ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField,
+)
 from utilities.forms.rendering import FieldSet
 from utilities.forms.widgets import DateTimePicker
 
@@ -17,6 +19,7 @@ __all__ = (
     'DataFileFilterForm',
     'DataSourceFilterForm',
     'JobFilterForm',
+    'ObjectChangeFilterForm',
 )
 
 
@@ -124,6 +127,40 @@ class JobFilterForm(SavedFiltersMixin, FilterForm):
     )
 
 
+class ObjectChangeFilterForm(SavedFiltersMixin, FilterForm):
+    model = ObjectChange
+    fieldsets = (
+        FieldSet('q', 'filter_id'),
+        FieldSet('time_before', 'time_after', name=_('Time')),
+        FieldSet('action', 'user_id', 'changed_object_type_id', name=_('Attributes')),
+    )
+    time_after = forms.DateTimeField(
+        required=False,
+        label=_('After'),
+        widget=DateTimePicker()
+    )
+    time_before = forms.DateTimeField(
+        required=False,
+        label=_('Before'),
+        widget=DateTimePicker()
+    )
+    action = forms.ChoiceField(
+        label=_('Action'),
+        choices=add_blank_choice(ObjectChangeActionChoices),
+        required=False
+    )
+    user_id = DynamicModelMultipleChoiceField(
+        queryset=get_user_model().objects.all(),
+        required=False,
+        label=_('User')
+    )
+    changed_object_type_id = ContentTypeMultipleChoiceField(
+        queryset=ObjectType.objects.with_feature('change_logging'),
+        required=False,
+        label=_('Object Type'),
+    )
+
+
 class ConfigRevisionFilterForm(SavedFiltersMixin, FilterForm):
     fieldsets = (
         FieldSet('q', 'filter_id'),

+ 7 - 0
netbox/core/graphql/filters.py

@@ -6,6 +6,7 @@ from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin
 __all__ = (
     'DataFileFilter',
     'DataSourceFilter',
+    'ObjectChangeFilter',
 )
 
 
@@ -19,3 +20,9 @@ class DataFileFilter(BaseFilterMixin):
 @autotype_decorator(filtersets.DataSourceFilterSet)
 class DataSourceFilter(BaseFilterMixin):
     pass
+
+
+@strawberry_django.filter(models.ObjectChange, lookups=True)
+@autotype_decorator(filtersets.ObjectChangeFilterSet)
+class ObjectChangeFilter(BaseFilterMixin):
+    pass

+ 24 - 0
netbox/core/graphql/mixins.py

@@ -0,0 +1,24 @@
+from typing import Annotated, List
+
+import strawberry
+import strawberry_django
+from django.contrib.contenttypes.models import ContentType
+
+from core.models import ObjectChange
+
+__all__ = (
+    'ChangelogMixin',
+)
+
+
+@strawberry.type
+class ChangelogMixin:
+
+    @strawberry_django.field
+    def changelog(self, info) -> List[Annotated["ObjectChangeType", strawberry.lazy('.types')]]:
+        content_type = ContentType.objects.get_for_model(self)
+        object_changes = ObjectChange.objects.filter(
+            changed_object_type=content_type,
+            changed_object_id=self.pk
+        )
+        return object_changes.restrict(info.context.request.user, 'view')

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

@@ -10,6 +10,7 @@ from .filters import *
 __all__ = (
     'DataFileType',
     'DataSourceType',
+    'ObjectChangeType',
 )
 
 
@@ -30,3 +31,12 @@ class DataFileType(BaseObjectType):
 class DataSourceType(NetBoxObjectType):
 
     datafiles: List[Annotated["DataFileType", strawberry.lazy('core.graphql.types')]]
+
+
+@strawberry_django.type(
+    models.ObjectChange,
+    fields='__all__',
+    filters=ObjectChangeFilter
+)
+class ObjectChangeType(BaseObjectType):
+    pass

+ 45 - 0
netbox/core/migrations/0011_move_objectchange.py

@@ -0,0 +1,45 @@
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('contenttypes', '0002_remove_content_type_name'),
+        ('core', '0010_gfk_indexes'),
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+    ]
+
+    operations = [
+        migrations.SeparateDatabaseAndState(
+            state_operations=[
+                migrations.CreateModel(
+                    name='ObjectChange',
+                    fields=[
+                        ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
+                        ('time', models.DateTimeField(auto_now_add=True, db_index=True)),
+                        ('user_name', models.CharField(editable=False, max_length=150)),
+                        ('request_id', models.UUIDField(db_index=True, editable=False)),
+                        ('action', models.CharField(max_length=50)),
+                        ('changed_object_id', models.PositiveBigIntegerField()),
+                        ('related_object_id', models.PositiveBigIntegerField(blank=True, null=True)),
+                        ('object_repr', models.CharField(editable=False, max_length=200)),
+                        ('prechange_data', models.JSONField(blank=True, editable=False, null=True)),
+                        ('postchange_data', models.JSONField(blank=True, editable=False, null=True)),
+                        ('changed_object_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype')),
+                        ('related_object_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype')),
+                        ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='changes', to=settings.AUTH_USER_MODEL)),
+                    ],
+                    options={
+                        'verbose_name': 'object change',
+                        'verbose_name_plural': 'object changes',
+                        'ordering': ['-time'],
+                        'indexes': [models.Index(fields=['changed_object_type', 'changed_object_id'], name='core_object_changed_c227ce_idx'), models.Index(fields=['related_object_type', 'related_object_id'], name='core_object_related_3375d6_idx')],
+                    },
+                ),
+            ],
+            # Table has been renamed from 'extras' app
+            database_operations=[],
+        ),
+    ]

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

@@ -1,5 +1,6 @@
-from .config import *
 from .contenttypes import *
+from .change_logging import *
+from .config import *
 from .data import *
 from .files import *
 from .jobs import *

+ 4 - 4
netbox/extras/models/change_logging.py → netbox/core/models/change_logging.py

@@ -8,11 +8,11 @@ from django.urls import reverse
 from django.utils.translation import gettext_lazy as _
 from mptt.models import MPTTModel
 
-from core.models import ObjectType
-from extras.choices import *
+from core.choices import ObjectChangeActionChoices
+from core.querysets import ObjectChangeQuerySet
 from netbox.models.features import ChangeLoggingMixin
 from utilities.data import shallow_compare_dict
-from ..querysets import ObjectChangeQuerySet
+from .contenttypes import ObjectType
 
 __all__ = (
     'ObjectChange',
@@ -136,7 +136,7 @@ class ObjectChange(models.Model):
         return super().save(*args, **kwargs)
 
     def get_absolute_url(self):
-        return reverse('extras:objectchange', args=[self.pk])
+        return reverse('core:objectchange', args=[self.pk])
 
     def get_action_color(self):
         return ObjectChangeActionChoices.colors.get(self.action)

+ 26 - 0
netbox/core/querysets.py

@@ -0,0 +1,26 @@
+from django.apps import apps
+from django.contrib.contenttypes.models import ContentType
+from django.db.utils import ProgrammingError
+
+from utilities.querysets import RestrictedQuerySet
+
+__all__ = (
+    'ObjectChangeQuerySet',
+)
+
+
+class ObjectChangeQuerySet(RestrictedQuerySet):
+
+    def valid_models(self):
+        # Exclude any change records which refer to an instance of a model that's no longer installed. This
+        # can happen when a plugin is removed but its data remains in the database, for example.
+        try:
+            content_types = ContentType.objects.get_for_models(*apps.get_models()).values()
+        except ProgrammingError:
+            # Handle the case where the database schema has not yet been initialized
+            content_types = ContentType.objects.none()
+
+        content_type_ids = set(
+            ct.pk for ct in content_types
+        )
+        return self.filter(changed_object_type_id__in=content_type_ids)

+ 1 - 0
netbox/core/tables/__init__.py

@@ -1,3 +1,4 @@
+from .change_logging import *
 from .config import *
 from .data import *
 from .jobs import *

+ 53 - 0
netbox/core/tables/change_logging.py

@@ -0,0 +1,53 @@
+import django_tables2 as tables
+from django.utils.translation import gettext_lazy as _
+
+from core.models import ObjectChange
+from netbox.tables import NetBoxTable, columns
+from .template_code import *
+
+__all__ = (
+    'ObjectChangeTable',
+)
+
+
+class ObjectChangeTable(NetBoxTable):
+    time = columns.DateTimeColumn(
+        verbose_name=_('Time'),
+        timespec='minutes',
+        linkify=True
+    )
+    user_name = tables.Column(
+        verbose_name=_('Username')
+    )
+    full_name = tables.TemplateColumn(
+        accessor=tables.A('user'),
+        template_code=OBJECTCHANGE_FULL_NAME,
+        verbose_name=_('Full Name'),
+        orderable=False
+    )
+    action = columns.ChoiceFieldColumn(
+        verbose_name=_('Action'),
+    )
+    changed_object_type = columns.ContentTypeColumn(
+        verbose_name=_('Type')
+    )
+    object_repr = tables.TemplateColumn(
+        accessor=tables.A('changed_object'),
+        template_code=OBJECTCHANGE_OBJECT,
+        verbose_name=_('Object'),
+        orderable=False
+    )
+    request_id = tables.TemplateColumn(
+        template_code=OBJECTCHANGE_REQUEST_ID,
+        verbose_name=_('Request ID')
+    )
+    actions = columns.ActionsColumn(
+        actions=()
+    )
+
+    class Meta(NetBoxTable.Meta):
+        model = ObjectChange
+        fields = (
+            'pk', 'id', 'time', 'user_name', 'full_name', 'action', 'changed_object_type', 'object_repr', 'request_id',
+            'actions',
+        )

+ 16 - 0
netbox/core/tables/template_code.py

@@ -0,0 +1,16 @@
+OBJECTCHANGE_FULL_NAME = """
+{% load helpers %}
+{{ value.get_full_name|placeholder }}
+"""
+
+OBJECTCHANGE_OBJECT = """
+{% if value and value.get_absolute_url %}
+    <a href="{{ value.get_absolute_url }}">{{ record.object_repr }}</a>
+{% else %}
+    {{ record.object_repr }}
+{% endif %}
+"""
+
+OBJECTCHANGE_REQUEST_ID = """
+<a href="{% url 'core:objectchange_list' %}?request_id={{ value }}">{{ value }}</a>
+"""

+ 3 - 2
netbox/extras/tests/test_changelog.py → netbox/core/tests/test_changelog.py

@@ -3,11 +3,12 @@ from django.test import override_settings
 from django.urls import reverse
 from rest_framework import status
 
-from core.models import ObjectType
+from core.choices import ObjectChangeActionChoices
+from core.models import ObjectChange, ObjectType
 from dcim.choices import SiteStatusChoices
 from dcim.models import Site
 from extras.choices import *
-from extras.models import CustomField, CustomFieldChoiceSet, ObjectChange, Tag
+from extras.models import CustomField, CustomFieldChoiceSet, Tag
 from utilities.testing import APITestCase
 from utilities.testing.utils import create_tags, post_data
 from utilities.testing.views import ModelViewTestCase

+ 103 - 1
netbox/core/tests/test_filtersets.py

@@ -1,7 +1,13 @@
+import uuid
 from datetime import datetime, timezone
 
+from django.contrib.contenttypes.models import ContentType
 from django.test import TestCase
-from utilities.testing import ChangeLoggedFilterSetTests
+
+from dcim.models import Site
+from ipam.models import IPAddress
+from users.models import User
+from utilities.testing import BaseFilterSetTests, ChangeLoggedFilterSetTests
 from ..choices import *
 from ..filtersets import *
 from ..models import *
@@ -132,3 +138,99 @@ class DataFileTestCase(TestCase, ChangeLoggedFilterSetTests):
             'a78168c7c97115bafd96450ed03ea43acec495094c5caa28f0d02e20e3a76cc2',
         ]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+
+class ObjectChangeTestCase(TestCase, BaseFilterSetTests):
+    queryset = ObjectChange.objects.all()
+    filterset = ObjectChangeFilterSet
+    ignore_fields = ('prechange_data', 'postchange_data')
+
+    @classmethod
+    def setUpTestData(cls):
+        users = (
+            User(username='user1'),
+            User(username='user2'),
+            User(username='user3'),
+        )
+        User.objects.bulk_create(users)
+
+        site = Site.objects.create(name='Test Site 1', slug='test-site-1')
+        ipaddress = IPAddress.objects.create(address='192.0.2.1/24')
+
+        object_changes = (
+            ObjectChange(
+                user=users[0],
+                user_name=users[0].username,
+                request_id=uuid.uuid4(),
+                action=ObjectChangeActionChoices.ACTION_CREATE,
+                changed_object=site,
+                object_repr=str(site),
+                postchange_data={'name': site.name, 'slug': site.slug}
+            ),
+            ObjectChange(
+                user=users[0],
+                user_name=users[0].username,
+                request_id=uuid.uuid4(),
+                action=ObjectChangeActionChoices.ACTION_UPDATE,
+                changed_object=site,
+                object_repr=str(site),
+                postchange_data={'name': site.name, 'slug': site.slug}
+            ),
+            ObjectChange(
+                user=users[1],
+                user_name=users[1].username,
+                request_id=uuid.uuid4(),
+                action=ObjectChangeActionChoices.ACTION_DELETE,
+                changed_object=site,
+                object_repr=str(site),
+                postchange_data={'name': site.name, 'slug': site.slug}
+            ),
+            ObjectChange(
+                user=users[1],
+                user_name=users[1].username,
+                request_id=uuid.uuid4(),
+                action=ObjectChangeActionChoices.ACTION_CREATE,
+                changed_object=ipaddress,
+                object_repr=str(ipaddress),
+                postchange_data={'address': ipaddress.address, 'status': ipaddress.status}
+            ),
+            ObjectChange(
+                user=users[2],
+                user_name=users[2].username,
+                request_id=uuid.uuid4(),
+                action=ObjectChangeActionChoices.ACTION_UPDATE,
+                changed_object=ipaddress,
+                object_repr=str(ipaddress),
+                postchange_data={'address': ipaddress.address, 'status': ipaddress.status}
+            ),
+            ObjectChange(
+                user=users[2],
+                user_name=users[2].username,
+                request_id=uuid.uuid4(),
+                action=ObjectChangeActionChoices.ACTION_DELETE,
+                changed_object=ipaddress,
+                object_repr=str(ipaddress),
+                postchange_data={'address': ipaddress.address, 'status': ipaddress.status}
+            ),
+        )
+        ObjectChange.objects.bulk_create(object_changes)
+
+    def test_q(self):
+        params = {'q': 'Site 1'}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
+
+    def test_user(self):
+        params = {'user_id': User.objects.filter(username__in=['user1', 'user2']).values_list('pk', flat=True)}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+        params = {'user': ['user1', 'user2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+
+    def test_user_name(self):
+        params = {'user_name': ['user1', 'user2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+
+    def test_changed_object_type(self):
+        params = {'changed_object_type': 'dcim.site'}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
+        params = {'changed_object_type_id': [ContentType.objects.get(app_label='dcim', model='site').pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)

+ 1 - 1
netbox/core/tests/test_models.py

@@ -1,7 +1,7 @@
 from django.test import TestCase
 
 from core.models import DataSource
-from extras.choices import ObjectChangeActionChoices
+from core.choices import ObjectChangeActionChoices
 from netbox.constants import CENSOR_TOKEN, CENSOR_TOKEN_CHANGED
 
 

+ 42 - 2
netbox/core/tests/test_views.py

@@ -1,4 +1,4 @@
-import logging
+import urllib.parse
 import uuid
 from datetime import datetime
 
@@ -10,8 +10,11 @@ from django_rq.workers import get_worker
 from rq.job import Job as RQ_Job, JobStatus
 from rq.registry import DeferredJobRegistry, FailedJobRegistry, FinishedJobRegistry, StartedJobRegistry
 
+from core.choices import ObjectChangeActionChoices
+from core.models import *
+from dcim.models import Site
+from users.models import User
 from utilities.testing import TestCase, ViewTestCases, create_tags
-from ..models import *
 
 
 class DataSourceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
@@ -99,6 +102,43 @@ class DataFileTestCase(
         DataFile.objects.bulk_create(data_files)
 
 
+# TODO: Convert to StandardTestCases.Views
+class ObjectChangeTestCase(TestCase):
+    user_permissions = (
+        'core.view_objectchange',
+    )
+
+    @classmethod
+    def setUpTestData(cls):
+
+        site = Site(name='Site 1', slug='site-1')
+        site.save()
+
+        # Create three ObjectChanges
+        user = User.objects.create_user(username='testuser2')
+        for i in range(1, 4):
+            oc = site.to_objectchange(action=ObjectChangeActionChoices.ACTION_UPDATE)
+            oc.user = user
+            oc.request_id = uuid.uuid4()
+            oc.save()
+
+    def test_objectchange_list(self):
+
+        url = reverse('core:objectchange_list')
+        params = {
+            "user": User.objects.first().pk,
+        }
+
+        response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
+        self.assertHttpStatus(response, 200)
+
+    def test_objectchange(self):
+
+        objectchange = ObjectChange.objects.first()
+        response = self.client.get(objectchange.get_absolute_url())
+        self.assertHttpStatus(response, 200)
+
+
 class BackgroundTaskTestCase(TestCase):
     user_permissions = ()
 

+ 4 - 0
netbox/core/urls.py

@@ -25,6 +25,10 @@ urlpatterns = (
     path('jobs/<int:pk>/', views.JobView.as_view(), name='job'),
     path('jobs/<int:pk>/delete/', views.JobDeleteView.as_view(), name='job_delete'),
 
+    # Change logging
+    path('changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'),
+    path('changelog/<int:pk>/', include(get_model_urls('core', 'objectchange'))),
+
     # Background Tasks
     path('background-queues/', views.BackgroundQueueListView.as_view(), name='background_queue_list'),
     path('background-queues/<int:queue_index>/<str:status>/', views.BackgroundTaskListView.as_view(), name='background_task_list'),

+ 70 - 0
netbox/core/views.py

@@ -29,6 +29,7 @@ from netbox.config import get_config, PARAMS
 from netbox.views import generic
 from netbox.views.generic.base import BaseObjectView
 from netbox.views.generic.mixins import TableMixin
+from utilities.data import shallow_compare_dict
 from utilities.forms import ConfirmationForm
 from utilities.htmx import htmx_partial
 from utilities.query import count_related
@@ -176,6 +177,75 @@ class JobBulkDeleteView(generic.BulkDeleteView):
     table = tables.JobTable
 
 
+#
+# Change logging
+#
+
+class ObjectChangeListView(generic.ObjectListView):
+    queryset = ObjectChange.objects.valid_models()
+    filterset = filtersets.ObjectChangeFilterSet
+    filterset_form = forms.ObjectChangeFilterForm
+    table = tables.ObjectChangeTable
+    template_name = 'core/objectchange_list.html'
+    actions = {
+        'export': {'view'},
+    }
+
+
+@register_model_view(ObjectChange)
+class ObjectChangeView(generic.ObjectView):
+    queryset = ObjectChange.objects.valid_models()
+
+    def get_extra_context(self, request, instance):
+        related_changes = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter(
+            request_id=instance.request_id
+        ).exclude(
+            pk=instance.pk
+        )
+        related_changes_table = tables.ObjectChangeTable(
+            data=related_changes[:50],
+            orderable=False
+        )
+
+        objectchanges = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter(
+            changed_object_type=instance.changed_object_type,
+            changed_object_id=instance.changed_object_id,
+        )
+
+        next_change = objectchanges.filter(time__gt=instance.time).order_by('time').first()
+        prev_change = objectchanges.filter(time__lt=instance.time).order_by('-time').first()
+
+        if not instance.prechange_data and instance.action in ['update', 'delete'] and prev_change:
+            non_atomic_change = True
+            prechange_data = prev_change.postchange_data_clean
+        else:
+            non_atomic_change = False
+            prechange_data = instance.prechange_data_clean
+
+        if prechange_data and instance.postchange_data:
+            diff_added = shallow_compare_dict(
+                prechange_data or dict(),
+                instance.postchange_data_clean or dict(),
+                exclude=['last_updated'],
+            )
+            diff_removed = {
+                x: prechange_data.get(x) for x in diff_added
+            } if prechange_data else {}
+        else:
+            diff_added = None
+            diff_removed = None
+
+        return {
+            'diff_added': diff_added,
+            'diff_removed': diff_removed,
+            'next_change': next_change,
+            'prev_change': prev_change,
+            'related_changes_table': related_changes_table,
+            'related_changes_count': related_changes.count(),
+            'non_atomic_change': non_atomic_change
+        }
+
+
 #
 # Config Revisions
 #

+ 2 - 6
netbox/dcim/graphql/types.py

@@ -3,14 +3,10 @@ from typing import Annotated, List, Union
 import strawberry
 import strawberry_django
 
+from core.graphql.mixins import ChangelogMixin
 from dcim import models
 from extras.graphql.mixins import (
-    ChangelogMixin,
-    ConfigContextMixin,
-    ContactsMixin,
-    CustomFieldsMixin,
-    ImageAttachmentsMixin,
-    TagsMixin,
+    ConfigContextMixin, ContactsMixin, CustomFieldsMixin, ImageAttachmentsMixin, TagsMixin,
 )
 from ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin
 from netbox.graphql.scalars import BigInt

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

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

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

@@ -21,7 +21,6 @@ router.register('journal-entries', views.JournalEntryViewSet)
 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('object-types', views.ObjectTypeViewSet)
 
 app_name = 'extras-api'

+ 0 - 14
netbox/extras/api/views.py

@@ -271,20 +271,6 @@ class ScriptViewSet(ModelViewSet):
         return Response(input_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
 
 
-#
-# Change logging
-#
-
-class ObjectChangeViewSet(ReadOnlyModelViewSet):
-    """
-    Retrieve a list of recent changes.
-    """
-    metadata_class = ContentTypeMetadata
-    queryset = ObjectChange.objects.valid_models()
-    serializer_class = serializers.ObjectChangeSerializer
-    filterset_class = filtersets.ObjectChangeFilterSet
-
-
 #
 # Object types
 #

+ 0 - 17
netbox/extras/choices.py

@@ -123,23 +123,6 @@ class BookmarkOrderingChoices(ChoiceSet):
         (ORDERING_OLDEST, _('Oldest')),
     )
 
-#
-# ObjectChanges
-#
-
-
-class ObjectChangeActionChoices(ChoiceSet):
-
-    ACTION_CREATE = 'create'
-    ACTION_UPDATE = 'update'
-    ACTION_DELETE = 'delete'
-
-    CHOICES = (
-        (ACTION_CREATE, _('Created'), 'green'),
-        (ACTION_UPDATE, _('Updated'), 'blue'),
-        (ACTION_DELETE, _('Deleted'), 'red'),
-    )
-
 
 #
 # Journal entries

+ 1 - 1
netbox/extras/constants.py

@@ -128,7 +128,7 @@ DEFAULT_DASHBOARD = [
         'title': 'Change Log',
         'color': 'blue',
         'config': {
-            'model': 'extras.objectchange',
+            'model': 'core.objectchange',
             'page_size': 25,
         }
     },

+ 4 - 1
netbox/extras/events.py

@@ -1,3 +1,5 @@
+import logging
+
 from django.conf import settings
 from django.contrib.auth import get_user_model
 from django.contrib.contenttypes.models import ContentType
@@ -6,6 +8,7 @@ from django.utils.module_loading import import_string
 from django.utils.translation import gettext as _
 from django_rq import get_queue
 
+from core.choices import ObjectChangeActionChoices
 from core.models import Job
 from netbox.config import get_config
 from netbox.constants import RQ_QUEUE_DEFAULT
@@ -13,7 +16,7 @@ from netbox.registry import registry
 from utilities.api import get_serializer_for_model
 from utilities.rqworker import get_rq_retry
 from utilities.serialization import serialize_object
-from .choices import *
+from .choices import EventRuleActionChoices
 from .models import EventRule
 
 logger = logging.getLogger('netbox.events_processor')

+ 0 - 38
netbox/extras/filtersets.py

@@ -26,7 +26,6 @@ __all__ = (
     'ImageAttachmentFilterSet',
     'JournalEntryFilterSet',
     'LocalConfigContextFilterSet',
-    'ObjectChangeFilterSet',
     'ObjectTypeFilterSet',
     'SavedFilterFilterSet',
     'ScriptFilterSet',
@@ -645,43 +644,6 @@ class LocalConfigContextFilterSet(django_filters.FilterSet):
         return queryset.exclude(local_context_data__isnull=value)
 
 
-class ObjectChangeFilterSet(BaseFilterSet):
-    q = django_filters.CharFilter(
-        method='search',
-        label=_('Search'),
-    )
-    time = django_filters.DateTimeFromToRangeFilter()
-    changed_object_type = ContentTypeFilter()
-    changed_object_type_id = django_filters.ModelMultipleChoiceFilter(
-        queryset=ContentType.objects.all()
-    )
-    user_id = django_filters.ModelMultipleChoiceFilter(
-        queryset=get_user_model().objects.all(),
-        label=_('User (ID)'),
-    )
-    user = django_filters.ModelMultipleChoiceFilter(
-        field_name='user__username',
-        queryset=get_user_model().objects.all(),
-        to_field_name='username',
-        label=_('User name'),
-    )
-
-    class Meta:
-        model = ObjectChange
-        fields = (
-            'id', 'user', 'user_name', 'request_id', 'action', 'changed_object_type_id', 'changed_object_id',
-            'related_object_type', 'related_object_id', 'object_repr',
-        )
-
-    def search(self, queryset, name, value):
-        if not value.strip():
-            return queryset
-        return queryset.filter(
-            Q(user_name__icontains=value) |
-            Q(object_repr__icontains=value)
-        )
-
-
 #
 # ContentTypes
 #

+ 1 - 36
netbox/extras/forms/filtersets.py

@@ -14,7 +14,7 @@ from utilities.forms.fields import (
     ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, TagFilterField,
 )
 from utilities.forms.rendering import FieldSet
-from utilities.forms.widgets import APISelectMultiple, DateTimePicker
+from utilities.forms.widgets import DateTimePicker
 from virtualization.models import Cluster, ClusterGroup, ClusterType
 
 __all__ = (
@@ -28,7 +28,6 @@ __all__ = (
     'ImageAttachmentFilterForm',
     'JournalEntryFilterForm',
     'LocalConfigContextFilterForm',
-    'ObjectChangeFilterForm',
     'SavedFilterFilterForm',
     'TagFilterForm',
     'WebhookFilterForm',
@@ -475,37 +474,3 @@ class JournalEntryFilterForm(NetBoxModelFilterSetForm):
         required=False
     )
     tag = TagFilterField(model)
-
-
-class ObjectChangeFilterForm(SavedFiltersMixin, FilterForm):
-    model = ObjectChange
-    fieldsets = (
-        FieldSet('q', 'filter_id'),
-        FieldSet('time_before', 'time_after', name=_('Time')),
-        FieldSet('action', 'user_id', 'changed_object_type_id', name=_('Attributes')),
-    )
-    time_after = forms.DateTimeField(
-        required=False,
-        label=_('After'),
-        widget=DateTimePicker()
-    )
-    time_before = forms.DateTimeField(
-        required=False,
-        label=_('Before'),
-        widget=DateTimePicker()
-    )
-    action = forms.ChoiceField(
-        label=_('Action'),
-        choices=add_blank_choice(ObjectChangeActionChoices),
-        required=False
-    )
-    user_id = DynamicModelMultipleChoiceField(
-        queryset=get_user_model().objects.all(),
-        required=False,
-        label=_('User')
-    )
-    changed_object_type_id = ContentTypeMultipleChoiceField(
-        queryset=ObjectType.objects.with_feature('change_logging'),
-        required=False,
-        label=_('Object Type'),
-    )

+ 0 - 7
netbox/extras/graphql/filters.py

@@ -13,7 +13,6 @@ __all__ = (
     'ExportTemplateFilter',
     'ImageAttachmentFilter',
     'JournalEntryFilter',
-    'ObjectChangeFilter',
     'SavedFilterFilter',
     'TagFilter',
     'WebhookFilter',
@@ -68,12 +67,6 @@ class JournalEntryFilter(BaseFilterMixin):
     pass
 
 
-@strawberry_django.filter(models.ObjectChange, lookups=True)
-@autotype_decorator(filtersets.ObjectChangeFilterSet)
-class ObjectChangeFilter(BaseFilterMixin):
-    pass
-
-
 @strawberry_django.filter(models.SavedFilter, lookups=True)
 @autotype_decorator(filtersets.SavedFilterFilterSet)
 class SavedFilterFilter(BaseFilterMixin):

+ 1 - 18
netbox/extras/graphql/mixins.py

@@ -2,12 +2,8 @@ from typing import TYPE_CHECKING, Annotated, List
 
 import strawberry
 import strawberry_django
-from django.contrib.contenttypes.models import ContentType
-
-from extras.models import ObjectChange
 
 __all__ = (
-    'ChangelogMixin',
     'ConfigContextMixin',
     'ContactsMixin',
     'CustomFieldsMixin',
@@ -17,23 +13,10 @@ __all__ = (
 )
 
 if TYPE_CHECKING:
-    from .types import ImageAttachmentType, JournalEntryType, ObjectChangeType, TagType
+    from .types import ImageAttachmentType, JournalEntryType, TagType
     from tenancy.graphql.types import ContactAssignmentType
 
 
-@strawberry.type
-class ChangelogMixin:
-
-    @strawberry_django.field
-    def changelog(self, info) -> List[Annotated["ObjectChangeType", strawberry.lazy('.types')]]:
-        content_type = ContentType.objects.get_for_model(self)
-        object_changes = ObjectChange.objects.filter(
-            changed_object_type=content_type,
-            changed_object_id=self.pk
-        )
-        return object_changes.restrict(info.context.request.user, 'view')
-
-
 @strawberry.type
 class ConfigContextMixin:
 

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

@@ -18,7 +18,6 @@ __all__ = (
     'ExportTemplateType',
     'ImageAttachmentType',
     'JournalEntryType',
-    'ObjectChangeType',
     'SavedFilterType',
     'TagType',
     'WebhookType',
@@ -123,15 +122,6 @@ class JournalEntryType(CustomFieldsMixin, TagsMixin, ObjectType):
     created_by: Annotated["UserType", strawberry.lazy('users.graphql.types')] | None
 
 
-@strawberry_django.type(
-    models.ObjectChange,
-    fields='__all__',
-    filters=ObjectChangeFilter
-)
-class ObjectChangeType(BaseObjectType):
-    pass
-
-
 @strawberry_django.type(
     models.SavedFilter,
     exclude=['content_types',],

+ 1 - 2
netbox/extras/management/commands/housekeeping.py

@@ -9,8 +9,7 @@ from django.db import DEFAULT_DB_ALIAS
 from django.utils import timezone
 from packaging import version
 
-from core.models import Job
-from extras.models import ObjectChange
+from core.models import Job, ObjectChange
 from netbox.config import Config
 
 

+ 1 - 1
netbox/extras/management/commands/runscript.py

@@ -10,9 +10,9 @@ from django.db import transaction
 
 from core.choices import JobStatusChoices
 from core.models import Job
-from extras.context_managers import event_tracking
 from extras.scripts import get_module_and_script
 from extras.signals import clear_events
+from netbox.context_managers import event_tracking
 from utilities.exceptions import AbortTransaction
 from utilities.request import NetBoxFakeRequest
 

+ 57 - 0
netbox/extras/migrations/0116_move_objectchange.py

@@ -0,0 +1,57 @@
+from django.db import migrations
+
+
+def update_content_types(apps, schema_editor):
+    ContentType = apps.get_model('contenttypes', 'ContentType')
+
+    # Delete the new ContentTypes effected by the new model in the core app
+    ContentType.objects.filter(app_label='core', model='objectchange').delete()
+
+    # Update the app labels of the original ContentTypes for extras.ObjectChange to ensure that any
+    # foreign key references are preserved
+    ContentType.objects.filter(app_label='extras', model='objectchange').update(app_label='core')
+
+
+def update_dashboard_widgets(apps, schema_editor):
+    Dashboard = apps.get_model('extras', 'Dashboard')
+
+    for dashboard in Dashboard.objects.all():
+        for key, widget in dashboard.config.items():
+            if getattr(widget['config'], 'model') == 'extras.objectchange':
+                widget['config']['model'] = 'core.objectchange'
+            elif models := widget['config'].get('models'):
+                models = list(map(lambda x: x.replace('extras.objectchange', 'core.objectchange'), models))
+                dashboard.config[key]['config']['models'] = models
+        dashboard.save()
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('extras', '0115_convert_dashboard_widgets'),
+        ('core', '0011_move_objectchange'),
+    ]
+
+    operations = [
+        migrations.SeparateDatabaseAndState(
+            state_operations=[
+                migrations.DeleteModel(
+                    name='ObjectChange',
+                ),
+            ],
+            database_operations=[
+                migrations.AlterModelTable(
+                    name='ObjectChange',
+                    table='core_objectchange',
+                ),
+            ],
+        ),
+        migrations.RunPython(
+            code=update_content_types,
+            reverse_code=migrations.RunPython.noop
+        ),
+        migrations.RunPython(
+            code=update_dashboard_widgets,
+            reverse_code=migrations.RunPython.noop
+        ),
+    ]

+ 0 - 1
netbox/extras/models/__init__.py

@@ -1,4 +1,3 @@
-from .change_logging import *
 from .configs import *
 from .customfields import *
 from .dashboard import *

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

@@ -8,7 +8,6 @@ from django.db import models
 from django.http import HttpResponse
 from django.urls import reverse
 from django.utils import timezone
-from django.utils.formats import date_format
 from django.utils.translation import gettext_lazy as _
 from rest_framework.utils.encoders import JSONEncoder
 
@@ -23,9 +22,9 @@ from netbox.models.features import (
     CloningMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin,
 )
 from utilities.html import clean_html
+from utilities.jinja2 import render_jinja2
 from utilities.querydict import dict_to_querydict
 from utilities.querysets import RestrictedQuerySet
-from utilities.jinja2 import render_jinja2
 
 __all__ = (
     'Bookmark',

+ 0 - 20
netbox/extras/querysets.py

@@ -1,8 +1,5 @@
-from django.apps import apps
-from django.contrib.contenttypes.models import ContentType
 from django.contrib.postgres.aggregates import JSONBAgg
 from django.db.models import OuterRef, Subquery, Q
-from django.db.utils import ProgrammingError
 
 from extras.models.tags import TaggedItem
 from utilities.query_functions import EmptyGroupByJSONBAgg
@@ -148,20 +145,3 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
         )
 
         return base_query
-
-
-class ObjectChangeQuerySet(RestrictedQuerySet):
-
-    def valid_models(self):
-        # Exclude any change records which refer to an instance of a model that's no longer installed. This
-        # can happen when a plugin is removed but its data remains in the database, for example.
-        try:
-            content_types = ContentType.objects.get_for_models(*apps.get_models()).values()
-        except ProgrammingError:
-            # Handle the case where the database schema has not yet been initialized
-            content_types = ContentType.objects.none()
-
-        content_type_ids = set(
-            ct.pk for ct in content_types
-        )
-        return self.filter(changed_object_type_id__in=content_type_ids)

+ 1 - 1
netbox/extras/scripts.py

@@ -21,11 +21,11 @@ from extras.models import ScriptModule, Script as ScriptModel
 from extras.signals import clear_events
 from ipam.formfields import IPAddressFormField, IPNetworkFormField
 from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
+from netbox.context_managers import event_tracking
 from utilities.exceptions import AbortScript, AbortTransaction
 from utilities.forms import add_blank_choice
 from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField
 from utilities.forms.widgets import DatePicker, DateTimePicker
-from .context_managers import event_tracking
 from .forms import ScriptForm
 from .utils import is_report
 

+ 3 - 3
netbox/extras/signals.py

@@ -9,7 +9,8 @@ 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.choices import ObjectChangeActionChoices
+from core.models import ObjectChange, 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
@@ -19,9 +20,8 @@ from netbox.context import current_request, events_queue
 from netbox.models.features import ChangeLoggingMixin
 from netbox.signals import post_clean
 from utilities.exceptions import AbortRequest
-from .choices import ObjectChangeActionChoices
 from .events import enqueue_object, get_snapshots, serialize_for_event
-from .models import CustomField, ObjectChange, TaggedItem
+from .models import CustomField, TaggedItem
 from .validators import CustomValidator
 
 

+ 0 - 44
netbox/extras/tables/tables.py

@@ -19,7 +19,6 @@ __all__ = (
     'ExportTemplateTable',
     'ImageAttachmentTable',
     'JournalEntryTable',
-    'ObjectChangeTable',
     'SavedFilterTable',
     'ReportResultsTable',
     'ScriptResultsTable',
@@ -451,49 +450,6 @@ class ConfigTemplateTable(NetBoxTable):
         )
 
 
-class ObjectChangeTable(NetBoxTable):
-    time = columns.DateTimeColumn(
-        verbose_name=_('Time'),
-        timespec='minutes',
-        linkify=True
-    )
-    user_name = tables.Column(
-        verbose_name=_('Username')
-    )
-    full_name = tables.TemplateColumn(
-        accessor=tables.A('user'),
-        template_code=OBJECTCHANGE_FULL_NAME,
-        verbose_name=_('Full Name'),
-        orderable=False
-    )
-    action = columns.ChoiceFieldColumn(
-        verbose_name=_('Action'),
-    )
-    changed_object_type = columns.ContentTypeColumn(
-        verbose_name=_('Type')
-    )
-    object_repr = tables.TemplateColumn(
-        accessor=tables.A('changed_object'),
-        template_code=OBJECTCHANGE_OBJECT,
-        verbose_name=_('Object'),
-        orderable=False
-    )
-    request_id = tables.TemplateColumn(
-        template_code=OBJECTCHANGE_REQUEST_ID,
-        verbose_name=_('Request ID')
-    )
-    actions = columns.ActionsColumn(
-        actions=()
-    )
-
-    class Meta(NetBoxTable.Meta):
-        model = ObjectChange
-        fields = (
-            'pk', 'id', 'time', 'user_name', 'full_name', 'action', 'changed_object_type', 'object_repr', 'request_id',
-            'actions',
-        )
-
-
 class JournalEntryTable(NetBoxTable):
     created = columns.DateTimeColumn(
         verbose_name=_('Created'),

+ 0 - 17
netbox/extras/tables/template_code.py

@@ -6,20 +6,3 @@ CONFIGCONTEXT_ACTIONS = """
     <a href="{% url 'extras:configcontext_delete' pk=record.pk %}" class="btn btn-sm btn-danger"><i class="mdi mdi-trash-can-outline" aria-hidden="true"></i></a>
 {% endif %}
 """
-
-OBJECTCHANGE_FULL_NAME = """
-{% load helpers %}
-{{ value.get_full_name|placeholder }}
-"""
-
-OBJECTCHANGE_OBJECT = """
-{% if value and value.get_absolute_url %}
-    <a href="{{ value.get_absolute_url }}">{{ record.object_repr }}</a>
-{% else %}
-    {{ record.object_repr }}
-{% endif %}
-"""
-
-OBJECTCHANGE_REQUEST_ID = """
-<a href="{% url 'extras:objectchange_list' %}?request_id={{ value }}">{{ value }}</a>
-"""

+ 3 - 2
netbox/extras/tests/test_event_rules.py

@@ -9,14 +9,15 @@ from django.urls import reverse
 from requests import Session
 from rest_framework import status
 
+from core.choices import ObjectChangeActionChoices
 from core.models import ObjectType
 from dcim.choices import SiteStatusChoices
 from dcim.models import Site
-from extras.choices import EventRuleActionChoices, ObjectChangeActionChoices
-from extras.context_managers import event_tracking
+from extras.choices import EventRuleActionChoices
 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 netbox.context_managers import event_tracking
 from utilities.testing import APITestCase
 
 

+ 2 - 99
netbox/extras/tests/test_filtersets.py

@@ -6,15 +6,14 @@ from django.contrib.contenttypes.models import ContentType
 from django.test import TestCase
 
 from circuits.models import Provider
-from core.choices import ManagedFileRootPathChoices
-from core.models import ObjectType
+from core.choices import ManagedFileRootPathChoices, ObjectChangeActionChoices
+from core.models import ObjectChange, ObjectType
 from dcim.filtersets import SiteFilterSet
 from dcim.models import DeviceRole, DeviceType, Manufacturer, Platform, Rack, Region, Site, SiteGroup
 from dcim.models import Location
 from extras.choices import *
 from extras.filtersets import *
 from extras.models import *
-from ipam.models import IPAddress
 from tenancy.models import Tenant, TenantGroup
 from utilities.testing import BaseFilterSetTests, ChangeLoggedFilterSetTests, create_tags
 from virtualization.models import Cluster, ClusterGroup, ClusterType
@@ -1280,102 +1279,6 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
         )
 
 
-class ObjectChangeTestCase(TestCase, BaseFilterSetTests):
-    queryset = ObjectChange.objects.all()
-    filterset = ObjectChangeFilterSet
-    ignore_fields = ('prechange_data', 'postchange_data')
-
-    @classmethod
-    def setUpTestData(cls):
-        users = (
-            User(username='user1'),
-            User(username='user2'),
-            User(username='user3'),
-        )
-        User.objects.bulk_create(users)
-
-        site = Site.objects.create(name='Test Site 1', slug='test-site-1')
-        ipaddress = IPAddress.objects.create(address='192.0.2.1/24')
-
-        object_changes = (
-            ObjectChange(
-                user=users[0],
-                user_name=users[0].username,
-                request_id=uuid.uuid4(),
-                action=ObjectChangeActionChoices.ACTION_CREATE,
-                changed_object=site,
-                object_repr=str(site),
-                postchange_data={'name': site.name, 'slug': site.slug}
-            ),
-            ObjectChange(
-                user=users[0],
-                user_name=users[0].username,
-                request_id=uuid.uuid4(),
-                action=ObjectChangeActionChoices.ACTION_UPDATE,
-                changed_object=site,
-                object_repr=str(site),
-                postchange_data={'name': site.name, 'slug': site.slug}
-            ),
-            ObjectChange(
-                user=users[1],
-                user_name=users[1].username,
-                request_id=uuid.uuid4(),
-                action=ObjectChangeActionChoices.ACTION_DELETE,
-                changed_object=site,
-                object_repr=str(site),
-                postchange_data={'name': site.name, 'slug': site.slug}
-            ),
-            ObjectChange(
-                user=users[1],
-                user_name=users[1].username,
-                request_id=uuid.uuid4(),
-                action=ObjectChangeActionChoices.ACTION_CREATE,
-                changed_object=ipaddress,
-                object_repr=str(ipaddress),
-                postchange_data={'address': ipaddress.address, 'status': ipaddress.status}
-            ),
-            ObjectChange(
-                user=users[2],
-                user_name=users[2].username,
-                request_id=uuid.uuid4(),
-                action=ObjectChangeActionChoices.ACTION_UPDATE,
-                changed_object=ipaddress,
-                object_repr=str(ipaddress),
-                postchange_data={'address': ipaddress.address, 'status': ipaddress.status}
-            ),
-            ObjectChange(
-                user=users[2],
-                user_name=users[2].username,
-                request_id=uuid.uuid4(),
-                action=ObjectChangeActionChoices.ACTION_DELETE,
-                changed_object=ipaddress,
-                object_repr=str(ipaddress),
-                postchange_data={'address': ipaddress.address, 'status': ipaddress.status}
-            ),
-        )
-        ObjectChange.objects.bulk_create(object_changes)
-
-    def test_q(self):
-        params = {'q': 'Site 1'}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
-
-    def test_user(self):
-        params = {'user_id': User.objects.filter(username__in=['user1', 'user2']).values_list('pk', flat=True)}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
-        params = {'user': ['user1', 'user2']}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
-
-    def test_user_name(self):
-        params = {'user_name': ['user1', 'user2']}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
-
-    def test_changed_object_type(self):
-        params = {'changed_object_type': 'dcim.site'}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
-        params = {'changed_object_type_id': [ContentType.objects.get(app_label='dcim', model='site').pk]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
-
-
 class ChangeLoggedFilterSetTestCase(TestCase):
     """
     Evaluate base ChangeLoggedFilterSet filters using the Site model.

+ 0 - 40
netbox/extras/tests/test_views.py

@@ -1,6 +1,3 @@
-import urllib.parse
-import uuid
-
 from django.contrib.auth import get_user_model
 from django.contrib.contenttypes.models import ContentType
 from django.urls import reverse
@@ -567,43 +564,6 @@ class ConfigTemplateTestCase(
         }
 
 
-# TODO: Convert to StandardTestCases.Views
-class ObjectChangeTestCase(TestCase):
-    user_permissions = (
-        'extras.view_objectchange',
-    )
-
-    @classmethod
-    def setUpTestData(cls):
-
-        site = Site(name='Site 1', slug='site-1')
-        site.save()
-
-        # Create three ObjectChanges
-        user = User.objects.create_user(username='testuser2')
-        for i in range(1, 4):
-            oc = site.to_objectchange(action=ObjectChangeActionChoices.ACTION_UPDATE)
-            oc.user = user
-            oc.request_id = uuid.uuid4()
-            oc.save()
-
-    def test_objectchange_list(self):
-
-        url = reverse('extras:objectchange_list')
-        params = {
-            "user": User.objects.first().pk,
-        }
-
-        response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
-        self.assertHttpStatus(response, 200)
-
-    def test_objectchange(self):
-
-        objectchange = ObjectChange.objects.first()
-        response = self.client.get(objectchange.get_absolute_url())
-        self.assertHttpStatus(response, 200)
-
-
 class JournalEntryTestCase(
     # ViewTestCases.GetObjectViewTestCase,
     ViewTestCases.CreateObjectViewTestCase,

+ 0 - 4
netbox/extras/urls.py

@@ -106,10 +106,6 @@ urlpatterns = [
     path('journal-entries/import/', views.JournalEntryBulkImportView.as_view(), name='journalentry_import'),
     path('journal-entries/<int:pk>/', include(get_model_urls('extras', 'journalentry'))),
 
-    # Change logging
-    path('changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'),
-    path('changelog/<int:pk>/', include(get_model_urls('extras', 'objectchange'))),
-
     # User dashboard
     path('dashboard/reset/', views.DashboardResetView.as_view(), name='dashboard_reset'),
     path('dashboard/widgets/add/', views.DashboardWidgetAddView.as_view(), name='dashboardwidget_add'),

+ 0 - 70
netbox/extras/views.py

@@ -19,7 +19,6 @@ 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.data import shallow_compare_dict
 from utilities.forms import ConfirmationForm, get_field_value
 from utilities.htmx import htmx_partial
 from utilities.paginator import EnhancedPaginator, get_paginate_count
@@ -683,75 +682,6 @@ class ConfigTemplateBulkSyncDataView(generic.BulkSyncDataView):
     queryset = ConfigTemplate.objects.all()
 
 
-#
-# Change logging
-#
-
-class ObjectChangeListView(generic.ObjectListView):
-    queryset = ObjectChange.objects.valid_models()
-    filterset = filtersets.ObjectChangeFilterSet
-    filterset_form = forms.ObjectChangeFilterForm
-    table = tables.ObjectChangeTable
-    template_name = 'extras/objectchange_list.html'
-    actions = {
-        'export': {'view'},
-    }
-
-
-@register_model_view(ObjectChange)
-class ObjectChangeView(generic.ObjectView):
-    queryset = ObjectChange.objects.valid_models()
-
-    def get_extra_context(self, request, instance):
-        related_changes = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter(
-            request_id=instance.request_id
-        ).exclude(
-            pk=instance.pk
-        )
-        related_changes_table = tables.ObjectChangeTable(
-            data=related_changes[:50],
-            orderable=False
-        )
-
-        objectchanges = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter(
-            changed_object_type=instance.changed_object_type,
-            changed_object_id=instance.changed_object_id,
-        )
-
-        next_change = objectchanges.filter(time__gt=instance.time).order_by('time').first()
-        prev_change = objectchanges.filter(time__lt=instance.time).order_by('-time').first()
-
-        if not instance.prechange_data and instance.action in ['update', 'delete'] and prev_change:
-            non_atomic_change = True
-            prechange_data = prev_change.postchange_data_clean
-        else:
-            non_atomic_change = False
-            prechange_data = instance.prechange_data_clean
-
-        if prechange_data and instance.postchange_data:
-            diff_added = shallow_compare_dict(
-                prechange_data or dict(),
-                instance.postchange_data_clean or dict(),
-                exclude=['last_updated'],
-            )
-            diff_removed = {
-                x: prechange_data.get(x) for x in diff_added
-            } if prechange_data else {}
-        else:
-            diff_added = None
-            diff_removed = None
-
-        return {
-            'diff_added': diff_added,
-            'diff_removed': diff_removed,
-            'next_change': next_change,
-            'prev_change': prev_change,
-            'related_changes_table': related_changes_table,
-            'related_changes_count': related_changes.count(),
-            'non_atomic_change': non_atomic_change
-        }
-
-
 #
 # Image attachments
 #

+ 1 - 1
netbox/extras/context_managers.py → netbox/netbox/context_managers.py

@@ -1,7 +1,7 @@
 from contextlib import contextmanager
 
 from netbox.context import current_request, events_queue
-from .events import flush_events
+from extras.events import flush_events
 
 
 @contextmanager

+ 4 - 2
netbox/netbox/filtersets.py

@@ -7,9 +7,11 @@ from django_filters.exceptions import FieldLookupError
 from django_filters.utils import get_model_field, resolve_field
 from django.utils.translation import gettext as _
 
-from extras.choices import CustomFieldFilterLogicChoices, ObjectChangeActionChoices
+from core.choices import ObjectChangeActionChoices
+from core.models import ObjectChange
+from extras.choices import CustomFieldFilterLogicChoices
 from extras.filters import TagFilter
-from extras.models import CustomField, ObjectChange, SavedFilter
+from extras.models import CustomField, SavedFilter
 from utilities.constants import (
     FILTER_CHAR_BASED_LOOKUP_MAP, FILTER_NEGATION_LOOKUP_MAP, FILTER_TREENODE_NEGATION_LOOKUP_MAP,
     FILTER_NUMERIC_BASED_LOOKUP_MAP

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

@@ -1,17 +1,10 @@
-from typing import Annotated, List
-
 import strawberry
-from strawberry import auto
 import strawberry_django
+from django.contrib.contenttypes.models import ContentType
 
+from core.graphql.mixins import ChangelogMixin
 from core.models import ObjectType as ObjectType_
-from django.contrib.contenttypes.models import ContentType
-from extras.graphql.mixins import (
-    ChangelogMixin,
-    CustomFieldsMixin,
-    JournalEntriesMixin,
-    TagsMixin,
-)
+from extras.graphql.mixins import CustomFieldsMixin, JournalEntriesMixin, TagsMixin
 
 __all__ = (
     'BaseObjectType',

+ 1 - 1
netbox/netbox/middleware.py

@@ -10,8 +10,8 @@ from django.db import connection, ProgrammingError
 from django.db.utils import InternalError
 from django.http import Http404, HttpResponseRedirect
 
-from extras.context_managers import event_tracking
 from netbox.config import clear_config, get_config
+from netbox.context_managers import event_tracking
 from netbox.views import handler_500
 from utilities.api import is_api_request
 from utilities.error_handlers import handle_rest_api_exception

+ 3 - 2
netbox/netbox/models/features.py

@@ -9,7 +9,7 @@ from django.utils import timezone
 from django.utils.translation import gettext_lazy as _
 from taggit.managers import TaggableManager
 
-from core.choices import JobStatusChoices
+from core.choices import JobStatusChoices, ObjectChangeActionChoices
 from core.models import ObjectType
 from extras.choices import *
 from extras.utils import is_taggable
@@ -90,7 +90,8 @@ class ChangeLoggingMixin(models.Model):
         Return a new ObjectChange representing a change made to this object. This will typically be called automatically
         by ChangeLoggingMiddleware.
         """
-        from extras.models import ObjectChange
+        # TODO: Fix circular import
+        from core.models import ObjectChange
 
         exclude = []
         if get_config().CHANGELOG_SKIP_EMPTY_CHANGES:

+ 1 - 1
netbox/netbox/navigation/menu.py

@@ -356,7 +356,7 @@ OPERATIONS_MENU = Menu(
             label=_('Logging'),
             items=(
                 get_model_item('extras', 'journalentry', _('Journal Entries'), actions=['import']),
-                get_model_item('extras', 'objectchange', _('Change Log'), actions=[]),
+                get_model_item('core', 'objectchange', _('Change Log'), actions=[]),
             ),
         ),
     ),

+ 8 - 7
netbox/netbox/views/generic/feature_views.py

@@ -6,10 +6,11 @@ from django.shortcuts import get_object_or_404, redirect, render
 from django.utils.translation import gettext as _
 from django.views.generic import View
 
-from core.models import Job
-from core.tables import JobTable
-from extras import forms, tables
-from extras.models import *
+from core.models import Job, ObjectChange
+from core.tables import JobTable, ObjectChangeTable
+from extras.forms import JournalEntryForm
+from extras.models import JournalEntry
+from extras.tables import JournalEntryTable
 from utilities.permissions import get_permission_for_model
 from utilities.views import GetReturnURLMixin, ViewTab
 from .base import BaseMultiObjectView
@@ -56,7 +57,7 @@ class ObjectChangeLogView(View):
             Q(changed_object_type=content_type, changed_object_id=obj.pk) |
             Q(related_object_type=content_type, related_object_id=obj.pk)
         )
-        objectchanges_table = tables.ObjectChangeTable(
+        objectchanges_table = ObjectChangeTable(
             data=objectchanges,
             orderable=False,
             user=request.user
@@ -108,13 +109,13 @@ class ObjectJournalView(View):
             assigned_object_type=content_type,
             assigned_object_id=obj.pk
         )
-        journalentry_table = tables.JournalEntryTable(journalentries, user=request.user)
+        journalentry_table = JournalEntryTable(journalentries, user=request.user)
         journalentry_table.configure(request)
         journalentry_table.columns.hide('assigned_object_type')
         journalentry_table.columns.hide('assigned_object')
 
         if request.user.has_perm('extras.add_journalentry'):
-            form = forms.JournalEntryForm(
+            form = JournalEntryForm(
                 initial={
                     'assigned_object_type': ContentType.objects.get_for_model(obj),
                     'assigned_object_id': obj.pk

+ 5 - 5
netbox/templates/extras/objectchange.html → netbox/templates/core/objectchange.html

@@ -6,7 +6,7 @@
 {% block title %}{{ object }}{% endblock %}
 
 {% block breadcrumbs %}
-  <li class="breadcrumb-item"><a href="{% url 'extras:objectchange_list' %}">{% trans "Change Log" %}</a></li>
+  <li class="breadcrumb-item"><a href="{% url 'core:objectchange_list' %}">{% trans "Change Log" %}</a></li>
   {% if object.related_object and object.related_object.get_absolute_url %}
     <li class="breadcrumb-item"><a href="{{ object.related_object.get_absolute_url }}changelog/">{{ object.related_object }}</a></li>
   {% elif object.changed_object and object.changed_object.get_absolute_url %}
@@ -78,10 +78,10 @@
             <h5 class="card-header d-flex justify-content-between">
               {% trans "Difference" %}
               <div class="btn-group btn-group-sm d-print-none">
-                <a {% if prev_change %}href="{% url 'extras:objectchange' pk=prev_change.pk %}"{% else %}disabled{% endif %} class="btn btn-outline-secondary">
+                <a {% if prev_change %}href="{% url 'core:objectchange' pk=prev_change.pk %}"{% else %}disabled{% endif %} class="btn btn-outline-secondary">
                   <i class="mdi mdi-chevron-left" aria-hidden="true"></i> {% trans "Previous" %}
                 </a>
-                <a {% if next_change %}href="{% url 'extras:objectchange' pk=next_change.pk %}"{% else %}disabled{% endif %} class="btn btn-outline-secondary">
+                <a {% if next_change %}href="{% url 'core:objectchange' pk=next_change.pk %}"{% else %}disabled{% endif %} class="btn btn-outline-secondary">
                   {% trans "Next" %} <i class="mdi mdi-chevron-right" aria-hidden="true"></i>
                 </a>
               </div>
@@ -119,7 +119,7 @@
                 </pre>
               {% endspaceless %}
             {% elif non_atomic_change %}
-              {% trans "Warning: Comparing non-atomic change to previous change record" %} (<a href="{% url 'extras:objectchange' pk=prev_change.pk %}">{{ prev_change.pk }}</a>)
+              {% trans "Warning: Comparing non-atomic change to previous change record" %} (<a href="{% url 'core:objectchange' pk=prev_change.pk %}">{{ prev_change.pk }}</a>)
             {% else %}
               <span class="text-muted">{% trans "None" %}</span>
             {% endif %}
@@ -158,7 +158,7 @@
         {% include 'inc/panel_table.html' with table=related_changes_table heading='Related Changes' panel_class='default' %}
         {% if related_changes_count > related_changes_table.rows|length %}
             <div class="float-end">
-                <a href="{% url 'extras:objectchange_list' %}?request_id={{ object.request_id }}" class="btn btn-primary">
+                <a href="{% url 'core:objectchange_list' %}?request_id={{ object.request_id }}" class="btn btn-primary">
                   {% blocktrans trimmed with count=related_changes_count|add:"1" %}
                     See All {{ count }} Changes
                   {% endblocktrans %}

+ 0 - 0
netbox/templates/extras/objectchange_list.html → netbox/templates/core/objectchange_list.html


+ 2 - 2
netbox/users/views.py

@@ -1,7 +1,7 @@
 from django.db.models import Count
 
-from extras.models import ObjectChange
-from extras.tables import ObjectChangeTable
+from core.models import ObjectChange
+from core.tables import ObjectChangeTable
 from netbox.views import generic
 from utilities.views import register_model_view
 from . import filtersets, forms, tables

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

@@ -1,29 +1,26 @@
 import inspect
 import json
-import strawberry_django
 
+import strawberry_django
 from django.conf import settings
 from django.contrib.auth import get_user_model
 from django.contrib.contenttypes.models import ContentType
-from django.urls import reverse
 from django.test import override_settings
+from django.urls import reverse
 from rest_framework import status
 from rest_framework.test import APIClient
+from strawberry.lazy_type import LazyType
+from strawberry.type import StrawberryList, StrawberryOptional
+from strawberry.union import StrawberryUnion
 
-from core.models import ObjectType
-from extras.choices import ObjectChangeActionChoices
-from extras.models import ObjectChange
+from core.choices import ObjectChangeActionChoices
+from core.models import ObjectChange, ObjectType
+from ipam.graphql.types import IPAddressFamilyType
 from users.models import ObjectPermission, Token
 from utilities.api import get_graphql_type_for_model
 from .base import ModelTestCase
 from .utils import disable_warnings
 
-from ipam.graphql.types import IPAddressFamilyType
-from strawberry.field import StrawberryField
-from strawberry.lazy_type import LazyType
-from strawberry.type import StrawberryList, StrawberryOptional
-from strawberry.union import StrawberryUnion
-
 __all__ = (
     'APITestCase',
     'APIViewTestCases',

+ 2 - 3
netbox/utilities/testing/views.py

@@ -8,9 +8,8 @@ from django.test import override_settings
 from django.urls import reverse
 from django.utils.translation import gettext as _
 
-from core.models import ObjectType
-from extras.choices import ObjectChangeActionChoices
-from extras.models import ObjectChange
+from core.choices import ObjectChangeActionChoices
+from core.models import ObjectChange, ObjectType
 from netbox.choices import CSVDelimiterChoices, ImportFormatChoices
 from netbox.models.features import ChangeLoggingMixin, CustomFieldsMixin
 from users.models import ObjectPermission