diff --git a/netbox/account/views.py b/netbox/account/views.py
index 40ce780392..fa6970c6e9 100644
--- a/netbox/account/views.py
+++ b/netbox/account/views.py
@@ -19,8 +19,10 @@
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
diff --git a/netbox/core/api/serializers.py b/netbox/core/api/serializers.py
index 8553bb91c4..d59745ccd7 100644
--- a/netbox/core/api/serializers.py
+++ b/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 *
diff --git a/netbox/extras/api/serializers_/change_logging.py b/netbox/core/api/serializers_/change_logging.py
similarity index 92%
rename from netbox/extras/api/serializers_/change_logging.py
rename to netbox/core/api/serializers_/change_logging.py
index 46fb901fff..ceb0c9b9a8 100644
--- a/netbox/extras/api/serializers_/change_logging.py
+++ b/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 @@
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
diff --git a/netbox/core/api/urls.py b/netbox/core/api/urls.py
index 7017c3739b..95ee1896ec 100644
--- a/netbox/core/api/urls.py
+++ b/netbox/core/api/urls.py
@@ -5,12 +5,10 @@
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
diff --git a/netbox/core/api/views.py b/netbox/core/api/views.py
index 6338523d24..ff488e3cde 100644
--- a/netbox/core/api/views.py
+++ b/netbox/core/api/views.py
@@ -8,6 +8,7 @@
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
diff --git a/netbox/core/choices.py b/netbox/core/choices.py
index 8d70504145..ee0febaff5 100644
--- a/netbox/core/choices.py
+++ b/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'),
+ )
diff --git a/netbox/core/filtersets.py b/netbox/core/filtersets.py
index c5d332b68b..f622e789cb 100644
--- a/netbox/core/filtersets.py
+++ b/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 @@
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 @@
'DataFileFilterSet',
'DataSourceFilterSet',
'JobFilterSet',
+ 'ObjectChangeFilterSet',
)
@@ -126,6 +130,43 @@ def search(self, queryset, name, 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)
+ )
+
+
class ConfigRevisionFilterSet(BaseFilterSet):
q = django_filters.CharFilter(
method='search',
diff --git a/netbox/core/forms/filtersets.py b/netbox/core/forms/filtersets.py
index 60a3acc44d..c629841ae3 100644
--- a/netbox/core/forms/filtersets.py
+++ b/netbox/core/forms/filtersets.py
@@ -7,8 +7,10 @@
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 @@
'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'),
diff --git a/netbox/core/graphql/filters.py b/netbox/core/graphql/filters.py
index 64b4d0de28..82da685a59 100644
--- a/netbox/core/graphql/filters.py
+++ b/netbox/core/graphql/filters.py
@@ -6,6 +6,7 @@
__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
diff --git a/netbox/core/graphql/mixins.py b/netbox/core/graphql/mixins.py
new file mode 100644
index 0000000000..43f8761d18
--- /dev/null
+++ b/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')
diff --git a/netbox/core/graphql/types.py b/netbox/core/graphql/types.py
index 8287bfa317..09385d7c12 100644
--- a/netbox/core/graphql/types.py
+++ b/netbox/core/graphql/types.py
@@ -10,6 +10,7 @@
__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
diff --git a/netbox/core/migrations/0011_move_objectchange.py b/netbox/core/migrations/0011_move_objectchange.py
new file mode 100644
index 0000000000..2b41133ecc
--- /dev/null
+++ b/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=[],
+ ),
+ ]
diff --git a/netbox/core/models/__init__.py b/netbox/core/models/__init__.py
index 2c30ce02b9..db00e67aa8 100644
--- a/netbox/core/models/__init__.py
+++ b/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 *
diff --git a/netbox/extras/models/change_logging.py b/netbox/core/models/change_logging.py
similarity index 97%
rename from netbox/extras/models/change_logging.py
rename to netbox/core/models/change_logging.py
index 8451a0d150..1d1bbc07c8 100644
--- a/netbox/extras/models/change_logging.py
+++ b/netbox/core/models/change_logging.py
@@ -8,11 +8,11 @@
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 @@ def save(self, *args, **kwargs):
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)
diff --git a/netbox/core/querysets.py b/netbox/core/querysets.py
new file mode 100644
index 0000000000..6e646cc879
--- /dev/null
+++ b/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)
diff --git a/netbox/core/tables/__init__.py b/netbox/core/tables/__init__.py
index 8f219afa45..cec7342f9e 100644
--- a/netbox/core/tables/__init__.py
+++ b/netbox/core/tables/__init__.py
@@ -1,3 +1,4 @@
+from .change_logging import *
from .config import *
from .data import *
from .jobs import *
diff --git a/netbox/core/tables/change_logging.py b/netbox/core/tables/change_logging.py
new file mode 100644
index 0000000000..423e459e5f
--- /dev/null
+++ b/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',
+ )
diff --git a/netbox/core/tables/template_code.py b/netbox/core/tables/template_code.py
new file mode 100644
index 0000000000..c8f0058e71
--- /dev/null
+++ b/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 %}
+ {{ record.object_repr }}
+{% else %}
+ {{ record.object_repr }}
+{% endif %}
+"""
+
+OBJECTCHANGE_REQUEST_ID = """
+{{ value }}
+"""
diff --git a/netbox/extras/tests/test_changelog.py b/netbox/core/tests/test_changelog.py
similarity index 99%
rename from netbox/extras/tests/test_changelog.py
rename to netbox/core/tests/test_changelog.py
index aac526e0f7..c58968ee8a 100644
--- a/netbox/extras/tests/test_changelog.py
+++ b/netbox/core/tests/test_changelog.py
@@ -3,11 +3,12 @@
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
diff --git a/netbox/core/tests/test_filtersets.py b/netbox/core/tests/test_filtersets.py
index aefb9eed0d..310be1d0ef 100644
--- a/netbox/core/tests/test_filtersets.py
+++ b/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 @@ def test_hash(self):
'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)
diff --git a/netbox/core/tests/test_models.py b/netbox/core/tests/test_models.py
index 0eeb66984d..ff71c2e88f 100644
--- a/netbox/core/tests/test_models.py
+++ b/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
diff --git a/netbox/core/tests/test_views.py b/netbox/core/tests/test_views.py
index b7a951a0fe..3c847e4efd 100644
--- a/netbox/core/tests/test_views.py
+++ b/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 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 @@ def setUpTestData(cls):
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 = ()
diff --git a/netbox/core/urls.py b/netbox/core/urls.py
index 59eead615d..58e96d735b 100644
--- a/netbox/core/urls.py
+++ b/netbox/core/urls.py
@@ -25,6 +25,10 @@
path('jobs//', views.JobView.as_view(), name='job'),
path('jobs//delete/', views.JobDeleteView.as_view(), name='job_delete'),
+ # Change logging
+ path('changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'),
+ path('changelog//', include(get_model_urls('core', 'objectchange'))),
+
# Background Tasks
path('background-queues/', views.BackgroundQueueListView.as_view(), name='background_queue_list'),
path('background-queues///', views.BackgroundTaskListView.as_view(), name='background_task_list'),
diff --git a/netbox/core/views.py b/netbox/core/views.py
index af705c8d16..a9fb89b35d 100644
--- a/netbox/core/views.py
+++ b/netbox/core/views.py
@@ -29,6 +29,7 @@
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
#
diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py
index 99a9106cbb..8b4613f143 100644
--- a/netbox/dcim/graphql/types.py
+++ b/netbox/dcim/graphql/types.py
@@ -3,14 +3,10 @@
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
diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py
index bd19b3184a..ddd13815a9 100644
--- a/netbox/extras/api/serializers.py
+++ b/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 *
diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py
index 301cc1b0a5..bc68103b70 100644
--- a/netbox/extras/api/urls.py
+++ b/netbox/extras/api/urls.py
@@ -21,7 +21,6 @@
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'
diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py
index 05087b2d5e..34565384b4 100644
--- a/netbox/extras/api/views.py
+++ b/netbox/extras/api/views.py
@@ -271,20 +271,6 @@ def post(self, request, pk):
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
#
diff --git a/netbox/extras/choices.py b/netbox/extras/choices.py
index 2c9d5836a6..4a699cc3bb 100644
--- a/netbox/extras/choices.py
+++ b/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
diff --git a/netbox/extras/constants.py b/netbox/extras/constants.py
index 48b44fb453..7e6ca9f846 100644
--- a/netbox/extras/constants.py
+++ b/netbox/extras/constants.py
@@ -128,7 +128,7 @@
'title': 'Change Log',
'color': 'blue',
'config': {
- 'model': 'extras.objectchange',
+ 'model': 'core.objectchange',
'page_size': 25,
}
},
diff --git a/netbox/extras/events.py b/netbox/extras/events.py
index 22ce26ba96..170a6a2ed2 100644
--- a/netbox/extras/events.py
+++ b/netbox/extras/events.py
@@ -6,6 +6,7 @@
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
diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py
index c3ac3e6abc..12dd8042fb 100644
--- a/netbox/extras/filtersets.py
+++ b/netbox/extras/filtersets.py
@@ -26,7 +26,6 @@
'ImageAttachmentFilterSet',
'JournalEntryFilterSet',
'LocalConfigContextFilterSet',
- 'ObjectChangeFilterSet',
'ObjectTypeFilterSet',
'SavedFilterFilterSet',
'ScriptFilterSet',
@@ -645,43 +644,6 @@ def _local_context_data(self, queryset, name, 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
#
diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py
index e6b001f2c9..658aae83b8 100644
--- a/netbox/extras/forms/filtersets.py
+++ b/netbox/extras/forms/filtersets.py
@@ -14,7 +14,7 @@
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 @@
'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'),
- )
diff --git a/netbox/extras/graphql/filters.py b/netbox/extras/graphql/filters.py
index af3a93588e..7451eef8a9 100644
--- a/netbox/extras/graphql/filters.py
+++ b/netbox/extras/graphql/filters.py
@@ -13,7 +13,6 @@
'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):
diff --git a/netbox/extras/graphql/mixins.py b/netbox/extras/graphql/mixins.py
index 456c6daa5c..542bbcc85b 100644
--- a/netbox/extras/graphql/mixins.py
+++ b/netbox/extras/graphql/mixins.py
@@ -2,12 +2,8 @@
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 @@
)
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:
diff --git a/netbox/extras/graphql/types.py b/netbox/extras/graphql/types.py
index 6bb7ce411e..1f3bfcdb91 100644
--- a/netbox/extras/graphql/types.py
+++ b/netbox/extras/graphql/types.py
@@ -18,7 +18,6 @@
'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',],
diff --git a/netbox/extras/management/commands/housekeeping.py b/netbox/extras/management/commands/housekeeping.py
index 467518fef9..2ba0cbd720 100644
--- a/netbox/extras/management/commands/housekeeping.py
+++ b/netbox/extras/management/commands/housekeeping.py
@@ -9,8 +9,7 @@
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
diff --git a/netbox/extras/management/commands/runscript.py b/netbox/extras/management/commands/runscript.py
index ef1bd51419..dbfbb40d90 100644
--- a/netbox/extras/management/commands/runscript.py
+++ b/netbox/extras/management/commands/runscript.py
@@ -10,9 +10,9 @@
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
diff --git a/netbox/extras/migrations/0116_move_objectchange.py b/netbox/extras/migrations/0116_move_objectchange.py
new file mode 100644
index 0000000000..c546a1aa47
--- /dev/null
+++ b/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
+ ),
+ ]
diff --git a/netbox/extras/models/__init__.py b/netbox/extras/models/__init__.py
index ebed3b1fbb..0413d1b91c 100644
--- a/netbox/extras/models/__init__.py
+++ b/netbox/extras/models/__init__.py
@@ -1,4 +1,3 @@
-from .change_logging import *
from .configs import *
from .customfields import *
from .dashboard import *
diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py
index 49249eaa06..cf43959431 100644
--- a/netbox/extras/models/models.py
+++ b/netbox/extras/models/models.py
@@ -8,7 +8,6 @@
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 @@
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',
diff --git a/netbox/extras/querysets.py b/netbox/extras/querysets.py
index b3bdc0a3e4..3ee9d73e8b 100644
--- a/netbox/extras/querysets.py
+++ b/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 @@ def _get_config_context_filters(self):
)
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)
diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py
index 0e74c3f0de..b4a1d6de1d 100644
--- a/netbox/extras/scripts.py
+++ b/netbox/extras/scripts.py
@@ -21,11 +21,11 @@
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
diff --git a/netbox/extras/signals.py b/netbox/extras/signals.py
index 9d439ace9a..53ec39cac8 100644
--- a/netbox/extras/signals.py
+++ b/netbox/extras/signals.py
@@ -9,7 +9,8 @@
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.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
diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py
index 8c78ad0dec..d04a67e070 100644
--- a/netbox/extras/tables/tables.py
+++ b/netbox/extras/tables/tables.py
@@ -19,7 +19,6 @@
'ExportTemplateTable',
'ImageAttachmentTable',
'JournalEntryTable',
- 'ObjectChangeTable',
'SavedFilterTable',
'ReportResultsTable',
'ScriptResultsTable',
@@ -451,49 +450,6 @@ class Meta(NetBoxTable.Meta):
)
-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'),
diff --git a/netbox/extras/tables/template_code.py b/netbox/extras/tables/template_code.py
index 2c62484696..fe1a5685de 100644
--- a/netbox/extras/tables/template_code.py
+++ b/netbox/extras/tables/template_code.py
@@ -6,20 +6,3 @@
{% endif %}
"""
-
-OBJECTCHANGE_FULL_NAME = """
-{% load helpers %}
-{{ value.get_full_name|placeholder }}
-"""
-
-OBJECTCHANGE_OBJECT = """
-{% if value and value.get_absolute_url %}
- {{ record.object_repr }}
-{% else %}
- {{ record.object_repr }}
-{% endif %}
-"""
-
-OBJECTCHANGE_REQUEST_ID = """
-{{ value }}
-"""
diff --git a/netbox/extras/tests/test_event_rules.py b/netbox/extras/tests/test_event_rules.py
index a1dd8b48e7..39b896616d 100644
--- a/netbox/extras/tests/test_event_rules.py
+++ b/netbox/extras/tests/test_event_rules.py
@@ -9,14 +9,15 @@
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
diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py
index b68c02efce..5c737f7cfe 100644
--- a/netbox/extras/tests/test_filtersets.py
+++ b/netbox/extras/tests/test_filtersets.py
@@ -6,15 +6,14 @@
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 @@ def test_object_types(self):
)
-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.
diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py
index fd478acd42..cbede195b4 100644
--- a/netbox/extras/tests/test_views.py
+++ b/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 @@ def setUpTestData(cls):
}
-# 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,
diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py
index 14e74c5ca3..f2e11e71ee 100644
--- a/netbox/extras/urls.py
+++ b/netbox/extras/urls.py
@@ -106,10 +106,6 @@
path('journal-entries/import/', views.JournalEntryBulkImportView.as_view(), name='journalentry_import'),
path('journal-entries//', include(get_model_urls('extras', 'journalentry'))),
- # Change logging
- path('changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'),
- path('changelog//', 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'),
diff --git a/netbox/extras/views.py b/netbox/extras/views.py
index 82f519c006..efbf2c73ac 100644
--- a/netbox/extras/views.py
+++ b/netbox/extras/views.py
@@ -19,7 +19,6 @@
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
#
diff --git a/netbox/extras/context_managers.py b/netbox/netbox/context_managers.py
similarity index 94%
rename from netbox/extras/context_managers.py
rename to netbox/netbox/context_managers.py
index e72cb8cc2d..ca434df822 100644
--- a/netbox/extras/context_managers.py
+++ b/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
diff --git a/netbox/netbox/filtersets.py b/netbox/netbox/filtersets.py
index 7f07cfbfbc..ac43fe57f4 100644
--- a/netbox/netbox/filtersets.py
+++ b/netbox/netbox/filtersets.py
@@ -7,9 +7,11 @@
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
diff --git a/netbox/netbox/graphql/types.py b/netbox/netbox/graphql/types.py
index 64aa3617a7..a4fc990807 100644
--- a/netbox/netbox/graphql/types.py
+++ b/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',
diff --git a/netbox/netbox/middleware.py b/netbox/netbox/middleware.py
index 6e7da9ab05..58c70451c1 100644
--- a/netbox/netbox/middleware.py
+++ b/netbox/netbox/middleware.py
@@ -10,8 +10,8 @@
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
diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py
index 000e717a44..ac6be67d9d 100644
--- a/netbox/netbox/models/features.py
+++ b/netbox/netbox/models/features.py
@@ -9,7 +9,7 @@
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 @@ def to_objectchange(self, action):
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:
diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py
index 002dfd98a9..6db7ac14ca 100644
--- a/netbox/netbox/navigation/menu.py
+++ b/netbox/netbox/navigation/menu.py
@@ -356,7 +356,7 @@
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=[]),
),
),
),
diff --git a/netbox/netbox/views/generic/feature_views.py b/netbox/netbox/views/generic/feature_views.py
index 95b7b57124..9d898be2f2 100644
--- a/netbox/netbox/views/generic/feature_views.py
+++ b/netbox/netbox/views/generic/feature_views.py
@@ -6,10 +6,11 @@
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 @@ def get(self, request, model, **kwargs):
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 @@ def get(self, request, model, **kwargs):
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
diff --git a/netbox/templates/extras/objectchange.html b/netbox/templates/core/objectchange.html
similarity index 90%
rename from netbox/templates/extras/objectchange.html
rename to netbox/templates/core/objectchange.html
index ffd6e77fa1..6613adb226 100644
--- a/netbox/templates/extras/objectchange.html
+++ b/netbox/templates/core/objectchange.html
@@ -6,7 +6,7 @@
{% block title %}{{ object }}{% endblock %}
{% block breadcrumbs %}
- {% trans "Change Log" %}
+ {% trans "Change Log" %}
{% if object.related_object and object.related_object.get_absolute_url %}
{{ object.related_object }}
{% elif object.changed_object and object.changed_object.get_absolute_url %}
@@ -78,10 +78,10 @@