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

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 social_core.backends.utils import load_backends
 
 
 from account.models import UserToken
 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.authentication import get_auth_backend_display, get_saml_idps
 from netbox.config import get_config
 from netbox.config import get_config
 from netbox.views import generic
 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_.data import *
 from .serializers_.jobs import *
 from .serializers_.jobs import *
 from .nested_serializers 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 drf_spectacular.utils import extend_schema_field
 from rest_framework import serializers
 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.exceptions import SerializerNotFound
 from netbox.api.fields import ChoiceField, ContentTypeField
 from netbox.api.fields import ChoiceField, ContentTypeField
 from netbox.api.serializers import BaseModelSerializer
 from netbox.api.serializers import BaseModelSerializer
@@ -15,7 +15,7 @@ __all__ = (
 
 
 
 
 class ObjectChangeSerializer(BaseModelSerializer):
 class ObjectChangeSerializer(BaseModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='extras-api:objectchange-detail')
+    url = serializers.HyperlinkedIdentityField(view_name='core-api:objectchange-detail')
     user = UserSerializer(
     user = UserSerializer(
         nested=True,
         nested=True,
         read_only=True
         read_only=True

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

@@ -5,12 +5,10 @@ from . import views
 router = NetBoxRouter()
 router = NetBoxRouter()
 router.APIRootView = views.CoreRootView
 router.APIRootView = views.CoreRootView
 
 
-# Data sources
 router.register('data-sources', views.DataSourceViewSet)
 router.register('data-sources', views.DataSourceViewSet)
 router.register('data-files', views.DataFileViewSet)
 router.register('data-files', views.DataFileViewSet)
-
-# Jobs
 router.register('jobs', views.JobViewSet)
 router.register('jobs', views.JobViewSet)
+router.register('object-changes', views.ObjectChangeViewSet)
 
 
 app_name = 'core-api'
 app_name = 'core-api'
 urlpatterns = router.urls
 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 import filtersets
 from core.models import *
 from core.models import *
+from netbox.api.metadata import ContentTypeMetadata
 from netbox.api.viewsets import NetBoxModelViewSet, NetBoxReadOnlyModelViewSet
 from netbox.api.viewsets import NetBoxModelViewSet, NetBoxReadOnlyModelViewSet
 from . import serializers
 from . import serializers
 
 
@@ -54,3 +55,13 @@ class JobViewSet(ReadOnlyModelViewSet):
     queryset = Job.objects.all()
     queryset = Job.objects.all()
     serializer_class = serializers.JobSerializer
     serializer_class = serializers.JobSerializer
     filterset_class = filtersets.JobFilterSet
     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_ERRORED,
         STATUS_FAILED,
         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.db.models import Q
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
@@ -5,6 +7,7 @@ import django_filters
 
 
 from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet
 from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet
 from netbox.utils import get_data_backend_choices
 from netbox.utils import get_data_backend_choices
+from utilities.filters import ContentTypeFilter
 from .choices import *
 from .choices import *
 from .models import *
 from .models import *
 
 
@@ -13,6 +16,7 @@ __all__ = (
     'DataFileFilterSet',
     'DataFileFilterSet',
     'DataSourceFilterSet',
     'DataSourceFilterSet',
     'JobFilterSet',
     '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):
 class ConfigRevisionFilterSet(BaseFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         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 import NetBoxModelFilterSetForm
 from netbox.forms.mixins import SavedFiltersMixin
 from netbox.forms.mixins import SavedFiltersMixin
 from netbox.utils import get_data_backend_choices
 from netbox.utils import get_data_backend_choices
-from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm
-from utilities.forms.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.rendering import FieldSet
 from utilities.forms.widgets import DateTimePicker
 from utilities.forms.widgets import DateTimePicker
 
 
@@ -17,6 +19,7 @@ __all__ = (
     'DataFileFilterForm',
     'DataFileFilterForm',
     'DataSourceFilterForm',
     'DataSourceFilterForm',
     'JobFilterForm',
     '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):
 class ConfigRevisionFilterForm(SavedFiltersMixin, FilterForm):
     fieldsets = (
     fieldsets = (
         FieldSet('q', 'filter_id'),
         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__ = (
 __all__ = (
     'DataFileFilter',
     'DataFileFilter',
     'DataSourceFilter',
     'DataSourceFilter',
+    'ObjectChangeFilter',
 )
 )
 
 
 
 
@@ -19,3 +20,9 @@ class DataFileFilter(BaseFilterMixin):
 @autotype_decorator(filtersets.DataSourceFilterSet)
 @autotype_decorator(filtersets.DataSourceFilterSet)
 class DataSourceFilter(BaseFilterMixin):
 class DataSourceFilter(BaseFilterMixin):
     pass
     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__ = (
 __all__ = (
     'DataFileType',
     'DataFileType',
     'DataSourceType',
     'DataSourceType',
+    'ObjectChangeType',
 )
 )
 
 
 
 
@@ -30,3 +31,12 @@ class DataFileType(BaseObjectType):
 class DataSourceType(NetBoxObjectType):
 class DataSourceType(NetBoxObjectType):
 
 
     datafiles: List[Annotated["DataFileType", strawberry.lazy('core.graphql.types')]]
     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 .contenttypes import *
+from .change_logging import *
+from .config import *
 from .data import *
 from .data import *
 from .files import *
 from .files import *
 from .jobs 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 django.utils.translation import gettext_lazy as _
 from mptt.models import MPTTModel
 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 netbox.models.features import ChangeLoggingMixin
 from utilities.data import shallow_compare_dict
 from utilities.data import shallow_compare_dict
-from ..querysets import ObjectChangeQuerySet
+from .contenttypes import ObjectType
 
 
 __all__ = (
 __all__ = (
     'ObjectChange',
     'ObjectChange',
@@ -136,7 +136,7 @@ class ObjectChange(models.Model):
         return super().save(*args, **kwargs)
         return super().save(*args, **kwargs)
 
 
     def get_absolute_url(self):
     def get_absolute_url(self):
-        return reverse('extras:objectchange', args=[self.pk])
+        return reverse('core:objectchange', args=[self.pk])
 
 
     def get_action_color(self):
     def get_action_color(self):
         return ObjectChangeActionChoices.colors.get(self.action)
         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 .config import *
 from .data import *
 from .data import *
 from .jobs 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 django.urls import reverse
 from rest_framework import status
 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.choices import SiteStatusChoices
 from dcim.models import Site
 from dcim.models import Site
 from extras.choices import *
 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 import APITestCase
 from utilities.testing.utils import create_tags, post_data
 from utilities.testing.utils import create_tags, post_data
 from utilities.testing.views import ModelViewTestCase
 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 datetime import datetime, timezone
 
 
+from django.contrib.contenttypes.models import ContentType
 from django.test import TestCase
 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 ..choices import *
 from ..filtersets import *
 from ..filtersets import *
 from ..models import *
 from ..models import *
@@ -132,3 +138,99 @@ class DataFileTestCase(TestCase, ChangeLoggedFilterSetTests):
             'a78168c7c97115bafd96450ed03ea43acec495094c5caa28f0d02e20e3a76cc2',
             'a78168c7c97115bafd96450ed03ea43acec495094c5caa28f0d02e20e3a76cc2',
         ]}
         ]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         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 django.test import TestCase
 
 
 from core.models import DataSource
 from core.models import DataSource
-from extras.choices import ObjectChangeActionChoices
+from core.choices import ObjectChangeActionChoices
 from netbox.constants import CENSOR_TOKEN, CENSOR_TOKEN_CHANGED
 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
 import uuid
 from datetime import datetime
 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.job import Job as RQ_Job, JobStatus
 from rq.registry import DeferredJobRegistry, FailedJobRegistry, FinishedJobRegistry, StartedJobRegistry
 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 utilities.testing import TestCase, ViewTestCases, create_tags
-from ..models import *
 
 
 
 
 class DataSourceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 class DataSourceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
@@ -99,6 +102,43 @@ class DataFileTestCase(
         DataFile.objects.bulk_create(data_files)
         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):
 class BackgroundTaskTestCase(TestCase):
     user_permissions = ()
     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>/', views.JobView.as_view(), name='job'),
     path('jobs/<int:pk>/delete/', views.JobDeleteView.as_view(), name='job_delete'),
     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
     # Background Tasks
     path('background-queues/', views.BackgroundQueueListView.as_view(), name='background_queue_list'),
     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'),
     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 import generic
 from netbox.views.generic.base import BaseObjectView
 from netbox.views.generic.base import BaseObjectView
 from netbox.views.generic.mixins import TableMixin
 from netbox.views.generic.mixins import TableMixin
+from utilities.data import shallow_compare_dict
 from utilities.forms import ConfirmationForm
 from utilities.forms import ConfirmationForm
 from utilities.htmx import htmx_partial
 from utilities.htmx import htmx_partial
 from utilities.query import count_related
 from utilities.query import count_related
@@ -176,6 +177,75 @@ class JobBulkDeleteView(generic.BulkDeleteView):
     table = tables.JobTable
     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
 # Config Revisions
 #
 #

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

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

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

@@ -1,7 +1,6 @@
 from .serializers_.objecttypes import *
 from .serializers_.objecttypes import *
 from .serializers_.attachments import *
 from .serializers_.attachments import *
 from .serializers_.bookmarks import *
 from .serializers_.bookmarks import *
-from .serializers_.change_logging import *
 from .serializers_.customfields import *
 from .serializers_.customfields import *
 from .serializers_.customlinks import *
 from .serializers_.customlinks import *
 from .serializers_.dashboard 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-contexts', views.ConfigContextViewSet)
 router.register('config-templates', views.ConfigTemplateViewSet)
 router.register('config-templates', views.ConfigTemplateViewSet)
 router.register('scripts', views.ScriptViewSet, basename='script')
 router.register('scripts', views.ScriptViewSet, basename='script')
-router.register('object-changes', views.ObjectChangeViewSet)
 router.register('object-types', views.ObjectTypeViewSet)
 router.register('object-types', views.ObjectTypeViewSet)
 
 
 app_name = 'extras-api'
 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)
         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
 # Object types
 #
 #

+ 0 - 17
netbox/extras/choices.py

@@ -123,23 +123,6 @@ class BookmarkOrderingChoices(ChoiceSet):
         (ORDERING_OLDEST, _('Oldest')),
         (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
 # Journal entries

+ 1 - 1
netbox/extras/constants.py

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

+ 4 - 1
netbox/extras/events.py

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

+ 0 - 38
netbox/extras/filtersets.py

@@ -26,7 +26,6 @@ __all__ = (
     'ImageAttachmentFilterSet',
     'ImageAttachmentFilterSet',
     'JournalEntryFilterSet',
     'JournalEntryFilterSet',
     'LocalConfigContextFilterSet',
     'LocalConfigContextFilterSet',
-    'ObjectChangeFilterSet',
     'ObjectTypeFilterSet',
     'ObjectTypeFilterSet',
     'SavedFilterFilterSet',
     'SavedFilterFilterSet',
     'ScriptFilterSet',
     'ScriptFilterSet',
@@ -645,43 +644,6 @@ class LocalConfigContextFilterSet(django_filters.FilterSet):
         return queryset.exclude(local_context_data__isnull=value)
         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
 # ContentTypes
 #
 #

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

@@ -14,7 +14,7 @@ from utilities.forms.fields import (
     ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, TagFilterField,
     ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, TagFilterField,
 )
 )
 from utilities.forms.rendering import FieldSet
 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
 from virtualization.models import Cluster, ClusterGroup, ClusterType
 
 
 __all__ = (
 __all__ = (
@@ -28,7 +28,6 @@ __all__ = (
     'ImageAttachmentFilterForm',
     'ImageAttachmentFilterForm',
     'JournalEntryFilterForm',
     'JournalEntryFilterForm',
     'LocalConfigContextFilterForm',
     'LocalConfigContextFilterForm',
-    'ObjectChangeFilterForm',
     'SavedFilterFilterForm',
     'SavedFilterFilterForm',
     'TagFilterForm',
     'TagFilterForm',
     'WebhookFilterForm',
     'WebhookFilterForm',
@@ -475,37 +474,3 @@ class JournalEntryFilterForm(NetBoxModelFilterSetForm):
         required=False
         required=False
     )
     )
     tag = TagFilterField(model)
     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',
     'ExportTemplateFilter',
     'ImageAttachmentFilter',
     'ImageAttachmentFilter',
     'JournalEntryFilter',
     'JournalEntryFilter',
-    'ObjectChangeFilter',
     'SavedFilterFilter',
     'SavedFilterFilter',
     'TagFilter',
     'TagFilter',
     'WebhookFilter',
     'WebhookFilter',
@@ -68,12 +67,6 @@ class JournalEntryFilter(BaseFilterMixin):
     pass
     pass
 
 
 
 
-@strawberry_django.filter(models.ObjectChange, lookups=True)
-@autotype_decorator(filtersets.ObjectChangeFilterSet)
-class ObjectChangeFilter(BaseFilterMixin):
-    pass
-
-
 @strawberry_django.filter(models.SavedFilter, lookups=True)
 @strawberry_django.filter(models.SavedFilter, lookups=True)
 @autotype_decorator(filtersets.SavedFilterFilterSet)
 @autotype_decorator(filtersets.SavedFilterFilterSet)
 class SavedFilterFilter(BaseFilterMixin):
 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
 import strawberry_django
 import strawberry_django
-from django.contrib.contenttypes.models import ContentType
-
-from extras.models import ObjectChange
 
 
 __all__ = (
 __all__ = (
-    'ChangelogMixin',
     'ConfigContextMixin',
     'ConfigContextMixin',
     'ContactsMixin',
     'ContactsMixin',
     'CustomFieldsMixin',
     'CustomFieldsMixin',
@@ -17,23 +13,10 @@ __all__ = (
 )
 )
 
 
 if TYPE_CHECKING:
 if TYPE_CHECKING:
-    from .types import ImageAttachmentType, JournalEntryType, ObjectChangeType, TagType
+    from .types import ImageAttachmentType, JournalEntryType, TagType
     from tenancy.graphql.types import ContactAssignmentType
     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
 @strawberry.type
 class ConfigContextMixin:
 class ConfigContextMixin:
 
 

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

@@ -18,7 +18,6 @@ __all__ = (
     'ExportTemplateType',
     'ExportTemplateType',
     'ImageAttachmentType',
     'ImageAttachmentType',
     'JournalEntryType',
     'JournalEntryType',
-    'ObjectChangeType',
     'SavedFilterType',
     'SavedFilterType',
     'TagType',
     'TagType',
     'WebhookType',
     'WebhookType',
@@ -123,15 +122,6 @@ class JournalEntryType(CustomFieldsMixin, TagsMixin, ObjectType):
     created_by: Annotated["UserType", strawberry.lazy('users.graphql.types')] | None
     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(
 @strawberry_django.type(
     models.SavedFilter,
     models.SavedFilter,
     exclude=['content_types',],
     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 django.utils import timezone
 from packaging import version
 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
 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.choices import JobStatusChoices
 from core.models import Job
 from core.models import Job
-from extras.context_managers import event_tracking
 from extras.scripts import get_module_and_script
 from extras.scripts import get_module_and_script
 from extras.signals import clear_events
 from extras.signals import clear_events
+from netbox.context_managers import event_tracking
 from utilities.exceptions import AbortTransaction
 from utilities.exceptions import AbortTransaction
 from utilities.request import NetBoxFakeRequest
 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 .configs import *
 from .customfields import *
 from .customfields import *
 from .dashboard 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.http import HttpResponse
 from django.urls import reverse
 from django.urls import reverse
 from django.utils import timezone
 from django.utils import timezone
-from django.utils.formats import date_format
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 from rest_framework.utils.encoders import JSONEncoder
 from rest_framework.utils.encoders import JSONEncoder
 
 
@@ -23,9 +22,9 @@ from netbox.models.features import (
     CloningMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin,
     CloningMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin,
 )
 )
 from utilities.html import clean_html
 from utilities.html import clean_html
+from utilities.jinja2 import render_jinja2
 from utilities.querydict import dict_to_querydict
 from utilities.querydict import dict_to_querydict
 from utilities.querysets import RestrictedQuerySet
 from utilities.querysets import RestrictedQuerySet
-from utilities.jinja2 import render_jinja2
 
 
 __all__ = (
 __all__ = (
     'Bookmark',
     '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.contrib.postgres.aggregates import JSONBAgg
 from django.db.models import OuterRef, Subquery, Q
 from django.db.models import OuterRef, Subquery, Q
-from django.db.utils import ProgrammingError
 
 
 from extras.models.tags import TaggedItem
 from extras.models.tags import TaggedItem
 from utilities.query_functions import EmptyGroupByJSONBAgg
 from utilities.query_functions import EmptyGroupByJSONBAgg
@@ -148,20 +145,3 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
         )
         )
 
 
         return base_query
         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 extras.signals import clear_events
 from ipam.formfields import IPAddressFormField, IPNetworkFormField
 from ipam.formfields import IPAddressFormField, IPNetworkFormField
 from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
 from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
+from netbox.context_managers import event_tracking
 from utilities.exceptions import AbortScript, AbortTransaction
 from utilities.exceptions import AbortScript, AbortTransaction
 from utilities.forms import add_blank_choice
 from utilities.forms import add_blank_choice
 from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField
 from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField
 from utilities.forms.widgets import DatePicker, DateTimePicker
 from utilities.forms.widgets import DatePicker, DateTimePicker
-from .context_managers import event_tracking
 from .forms import ScriptForm
 from .forms import ScriptForm
 from .utils import is_report
 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.utils.translation import gettext_lazy as _
 from django_prometheus.models import model_deletes, model_inserts, model_updates
 from django_prometheus.models import model_deletes, model_inserts, model_updates
 
 
-from core.models import ObjectType
+from core.choices import ObjectChangeActionChoices
+from core.models import ObjectChange, ObjectType
 from core.signals import job_end, job_start
 from core.signals import job_end, job_start
 from extras.constants import EVENT_JOB_END, EVENT_JOB_START
 from extras.constants import EVENT_JOB_END, EVENT_JOB_START
 from extras.events import process_event_rules
 from extras.events import process_event_rules
@@ -19,9 +20,8 @@ from netbox.context import current_request, events_queue
 from netbox.models.features import ChangeLoggingMixin
 from netbox.models.features import ChangeLoggingMixin
 from netbox.signals import post_clean
 from netbox.signals import post_clean
 from utilities.exceptions import AbortRequest
 from utilities.exceptions import AbortRequest
-from .choices import ObjectChangeActionChoices
 from .events import enqueue_object, get_snapshots, serialize_for_event
 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
 from .validators import CustomValidator
 
 
 
 

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

@@ -19,7 +19,6 @@ __all__ = (
     'ExportTemplateTable',
     'ExportTemplateTable',
     'ImageAttachmentTable',
     'ImageAttachmentTable',
     'JournalEntryTable',
     'JournalEntryTable',
-    'ObjectChangeTable',
     'SavedFilterTable',
     'SavedFilterTable',
     'ReportResultsTable',
     'ReportResultsTable',
     'ScriptResultsTable',
     '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):
 class JournalEntryTable(NetBoxTable):
     created = columns.DateTimeColumn(
     created = columns.DateTimeColumn(
         verbose_name=_('Created'),
         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>
     <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 %}
 {% 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 requests import Session
 from rest_framework import status
 from rest_framework import status
 
 
+from core.choices import ObjectChangeActionChoices
 from core.models import ObjectType
 from core.models import ObjectType
 from dcim.choices import SiteStatusChoices
 from dcim.choices import SiteStatusChoices
 from dcim.models import Site
 from dcim.models import Site
-from extras.choices import 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.events import enqueue_object, flush_events, serialize_for_event
 from extras.models import EventRule, Tag, Webhook
 from extras.models import EventRule, Tag, Webhook
 from extras.webhooks import generate_signature, send_webhook
 from extras.webhooks import generate_signature, send_webhook
+from netbox.context_managers import event_tracking
 from utilities.testing import APITestCase
 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 django.test import TestCase
 
 
 from circuits.models import Provider
 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.filtersets import SiteFilterSet
 from dcim.models import DeviceRole, DeviceType, Manufacturer, Platform, Rack, Region, Site, SiteGroup
 from dcim.models import DeviceRole, DeviceType, Manufacturer, Platform, Rack, Region, Site, SiteGroup
 from dcim.models import Location
 from dcim.models import Location
 from extras.choices import *
 from extras.choices import *
 from extras.filtersets import *
 from extras.filtersets import *
 from extras.models import *
 from extras.models import *
-from ipam.models import IPAddress
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
 from utilities.testing import BaseFilterSetTests, ChangeLoggedFilterSetTests, create_tags
 from utilities.testing import BaseFilterSetTests, ChangeLoggedFilterSetTests, create_tags
 from virtualization.models import Cluster, ClusterGroup, ClusterType
 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):
 class ChangeLoggedFilterSetTestCase(TestCase):
     """
     """
     Evaluate base ChangeLoggedFilterSet filters using the Site model.
     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.auth import get_user_model
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.urls import reverse
 from django.urls import reverse
@@ -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(
 class JournalEntryTestCase(
     # ViewTestCases.GetObjectViewTestCase,
     # ViewTestCases.GetObjectViewTestCase,
     ViewTestCases.CreateObjectViewTestCase,
     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/import/', views.JournalEntryBulkImportView.as_view(), name='journalentry_import'),
     path('journal-entries/<int:pk>/', include(get_model_urls('extras', 'journalentry'))),
     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
     # User dashboard
     path('dashboard/reset/', views.DashboardResetView.as_view(), name='dashboard_reset'),
     path('dashboard/reset/', views.DashboardResetView.as_view(), name='dashboard_reset'),
     path('dashboard/widgets/add/', views.DashboardWidgetAddView.as_view(), name='dashboardwidget_add'),
     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.constants import DEFAULT_ACTION_PERMISSIONS
 from netbox.views import generic
 from netbox.views import generic
 from netbox.views.generic.mixins import TableMixin
 from netbox.views.generic.mixins import TableMixin
-from utilities.data import shallow_compare_dict
 from utilities.forms import ConfirmationForm, get_field_value
 from utilities.forms import ConfirmationForm, get_field_value
 from utilities.htmx import htmx_partial
 from utilities.htmx import htmx_partial
 from utilities.paginator import EnhancedPaginator, get_paginate_count
 from utilities.paginator import EnhancedPaginator, get_paginate_count
@@ -683,75 +682,6 @@ class ConfigTemplateBulkSyncDataView(generic.BulkSyncDataView):
     queryset = ConfigTemplate.objects.all()
     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
 # Image attachments
 #
 #

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

@@ -1,7 +1,7 @@
 from contextlib import contextmanager
 from contextlib import contextmanager
 
 
 from netbox.context import current_request, events_queue
 from netbox.context import current_request, events_queue
-from .events import flush_events
+from extras.events import flush_events
 
 
 
 
 @contextmanager
 @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_filters.utils import get_model_field, resolve_field
 from django.utils.translation import gettext as _
 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.filters import TagFilter
-from extras.models import CustomField, ObjectChange, SavedFilter
+from extras.models import CustomField, SavedFilter
 from utilities.constants import (
 from utilities.constants import (
     FILTER_CHAR_BASED_LOOKUP_MAP, FILTER_NEGATION_LOOKUP_MAP, FILTER_TREENODE_NEGATION_LOOKUP_MAP,
     FILTER_CHAR_BASED_LOOKUP_MAP, FILTER_NEGATION_LOOKUP_MAP, FILTER_TREENODE_NEGATION_LOOKUP_MAP,
     FILTER_NUMERIC_BASED_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
 import strawberry
-from strawberry import auto
 import strawberry_django
 import strawberry_django
+from django.contrib.contenttypes.models import ContentType
 
 
+from core.graphql.mixins import ChangelogMixin
 from core.models import ObjectType as ObjectType_
 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__ = (
 __all__ = (
     'BaseObjectType',
     '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.db.utils import InternalError
 from django.http import Http404, HttpResponseRedirect
 from django.http import Http404, HttpResponseRedirect
 
 
-from extras.context_managers import event_tracking
 from netbox.config import clear_config, get_config
 from netbox.config import clear_config, get_config
+from netbox.context_managers import event_tracking
 from netbox.views import handler_500
 from netbox.views import handler_500
 from utilities.api import is_api_request
 from utilities.api import is_api_request
 from utilities.error_handlers import handle_rest_api_exception
 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 django.utils.translation import gettext_lazy as _
 from taggit.managers import TaggableManager
 from taggit.managers import TaggableManager
 
 
-from core.choices import JobStatusChoices
+from core.choices import JobStatusChoices, ObjectChangeActionChoices
 from core.models import ObjectType
 from core.models import ObjectType
 from extras.choices import *
 from extras.choices import *
 from extras.utils import is_taggable
 from extras.utils import is_taggable
@@ -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
         Return a new ObjectChange representing a change made to this object. This will typically be called automatically
         by ChangeLoggingMiddleware.
         by ChangeLoggingMiddleware.
         """
         """
-        from extras.models import ObjectChange
+        # TODO: Fix circular import
+        from core.models import ObjectChange
 
 
         exclude = []
         exclude = []
         if get_config().CHANGELOG_SKIP_EMPTY_CHANGES:
         if get_config().CHANGELOG_SKIP_EMPTY_CHANGES:

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

@@ -356,7 +356,7 @@ OPERATIONS_MENU = Menu(
             label=_('Logging'),
             label=_('Logging'),
             items=(
             items=(
                 get_model_item('extras', 'journalentry', _('Journal Entries'), actions=['import']),
                 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.utils.translation import gettext as _
 from django.views.generic import View
 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.permissions import get_permission_for_model
 from utilities.views import GetReturnURLMixin, ViewTab
 from utilities.views import GetReturnURLMixin, ViewTab
 from .base import BaseMultiObjectView
 from .base import BaseMultiObjectView
@@ -56,7 +57,7 @@ class ObjectChangeLogView(View):
             Q(changed_object_type=content_type, changed_object_id=obj.pk) |
             Q(changed_object_type=content_type, changed_object_id=obj.pk) |
             Q(related_object_type=content_type, related_object_id=obj.pk)
             Q(related_object_type=content_type, related_object_id=obj.pk)
         )
         )
-        objectchanges_table = tables.ObjectChangeTable(
+        objectchanges_table = ObjectChangeTable(
             data=objectchanges,
             data=objectchanges,
             orderable=False,
             orderable=False,
             user=request.user
             user=request.user
@@ -108,13 +109,13 @@ class ObjectJournalView(View):
             assigned_object_type=content_type,
             assigned_object_type=content_type,
             assigned_object_id=obj.pk
             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.configure(request)
         journalentry_table.columns.hide('assigned_object_type')
         journalentry_table.columns.hide('assigned_object_type')
         journalentry_table.columns.hide('assigned_object')
         journalentry_table.columns.hide('assigned_object')
 
 
         if request.user.has_perm('extras.add_journalentry'):
         if request.user.has_perm('extras.add_journalentry'):
-            form = forms.JournalEntryForm(
+            form = JournalEntryForm(
                 initial={
                 initial={
                     'assigned_object_type': ContentType.objects.get_for_model(obj),
                     'assigned_object_type': ContentType.objects.get_for_model(obj),
                     'assigned_object_id': obj.pk
                     '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 title %}{{ object }}{% endblock %}
 
 
 {% block breadcrumbs %}
 {% 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 %}
   {% 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>
     <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 %}
   {% elif object.changed_object and object.changed_object.get_absolute_url %}
@@ -78,10 +78,10 @@
             <h5 class="card-header d-flex justify-content-between">
             <h5 class="card-header d-flex justify-content-between">
               {% trans "Difference" %}
               {% trans "Difference" %}
               <div class="btn-group btn-group-sm d-print-none">
               <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" %}
                   <i class="mdi mdi-chevron-left" aria-hidden="true"></i> {% trans "Previous" %}
                 </a>
                 </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>
                   {% trans "Next" %} <i class="mdi mdi-chevron-right" aria-hidden="true"></i>
                 </a>
                 </a>
               </div>
               </div>
@@ -119,7 +119,7 @@
                 </pre>
                 </pre>
               {% endspaceless %}
               {% endspaceless %}
             {% elif non_atomic_change %}
             {% 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 %}
             {% else %}
               <span class="text-muted">{% trans "None" %}</span>
               <span class="text-muted">{% trans "None" %}</span>
             {% endif %}
             {% endif %}
@@ -158,7 +158,7 @@
         {% include 'inc/panel_table.html' with table=related_changes_table heading='Related Changes' panel_class='default' %}
         {% 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 %}
         {% if related_changes_count > related_changes_table.rows|length %}
             <div class="float-end">
             <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" %}
                   {% blocktrans trimmed with count=related_changes_count|add:"1" %}
                     See All {{ count }} Changes
                     See All {{ count }} Changes
                   {% endblocktrans %}
                   {% 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 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 netbox.views import generic
 from utilities.views import register_model_view
 from utilities.views import register_model_view
 from . import filtersets, forms, tables
 from . import filtersets, forms, tables

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

@@ -1,29 +1,26 @@
 import inspect
 import inspect
 import json
 import json
-import strawberry_django
 
 
+import strawberry_django
 from django.conf import settings
 from django.conf import settings
 from django.contrib.auth import get_user_model
 from django.contrib.auth import get_user_model
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
-from django.urls import reverse
 from django.test import override_settings
 from django.test import override_settings
+from django.urls import reverse
 from rest_framework import status
 from rest_framework import status
 from rest_framework.test import APIClient
 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 users.models import ObjectPermission, Token
 from utilities.api import get_graphql_type_for_model
 from utilities.api import get_graphql_type_for_model
 from .base import ModelTestCase
 from .base import ModelTestCase
 from .utils import disable_warnings
 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__ = (
 __all__ = (
     'APITestCase',
     'APITestCase',
     'APIViewTestCases',
     '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.urls import reverse
 from django.utils.translation import gettext as _
 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.choices import CSVDelimiterChoices, ImportFormatChoices
 from netbox.models.features import ChangeLoggingMixin, CustomFieldsMixin
 from netbox.models.features import ChangeLoggingMixin, CustomFieldsMixin
 from users.models import ObjectPermission
 from users.models import ObjectPermission