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 %} - + {% if object.related_object and object.related_object.get_absolute_url %} {% elif object.changed_object and object.changed_object.get_absolute_url %} @@ -78,10 +78,10 @@
{% trans "Change" %}
{% trans "Difference" %} @@ -119,7 +119,7 @@
{% trans "Pre-Change Data" %}
{% endspaceless %} {% elif non_atomic_change %} - {% trans "Warning: Comparing non-atomic change to previous change record" %} ({{ prev_change.pk }}) + {% trans "Warning: Comparing non-atomic change to previous change record" %} ({{ prev_change.pk }}) {% else %} {% trans "None" %} {% endif %} @@ -158,7 +158,7 @@
{% trans "Post-Change Data" %}
{% 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 %}
- + {% blocktrans trimmed with count=related_changes_count|add:"1" %} See All {{ count }} Changes {% endblocktrans %} diff --git a/netbox/templates/extras/objectchange_list.html b/netbox/templates/core/objectchange_list.html similarity index 100% rename from netbox/templates/extras/objectchange_list.html rename to netbox/templates/core/objectchange_list.html diff --git a/netbox/users/views.py b/netbox/users/views.py index 40d69e98ce..b2f9a8d04d 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -1,7 +1,7 @@ from django.db.models import Count -from extras.models import ObjectChange -from extras.tables import ObjectChangeTable +from core.models import ObjectChange +from core.tables import ObjectChangeTable from netbox.views import generic from utilities.views import register_model_view from . import filtersets, forms, tables diff --git a/netbox/utilities/testing/api.py b/netbox/utilities/testing/api.py index 62ac817e2d..a0d4be8487 100644 --- a/netbox/utilities/testing/api.py +++ b/netbox/utilities/testing/api.py @@ -1,29 +1,26 @@ import inspect import json -import strawberry_django +import strawberry_django from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType -from django.urls import reverse from django.test import override_settings +from django.urls import reverse from rest_framework import status from rest_framework.test import APIClient +from strawberry.lazy_type import LazyType +from strawberry.type import StrawberryList, StrawberryOptional +from strawberry.union import StrawberryUnion -from core.models import ObjectType -from extras.choices import ObjectChangeActionChoices -from extras.models import ObjectChange +from core.choices import ObjectChangeActionChoices +from core.models import ObjectChange, ObjectType +from ipam.graphql.types import IPAddressFamilyType from users.models import ObjectPermission, Token from utilities.api import get_graphql_type_for_model from .base import ModelTestCase from .utils import disable_warnings -from ipam.graphql.types import IPAddressFamilyType -from strawberry.field import StrawberryField -from strawberry.lazy_type import LazyType -from strawberry.type import StrawberryList, StrawberryOptional -from strawberry.union import StrawberryUnion - __all__ = ( 'APITestCase', 'APIViewTestCases', diff --git a/netbox/utilities/testing/views.py b/netbox/utilities/testing/views.py index 6d4ca00df5..18c767bd08 100644 --- a/netbox/utilities/testing/views.py +++ b/netbox/utilities/testing/views.py @@ -8,9 +8,8 @@ from django.urls import reverse from django.utils.translation import gettext as _ -from core.models import ObjectType -from extras.choices import ObjectChangeActionChoices -from extras.models import ObjectChange +from core.choices import ObjectChangeActionChoices +from core.models import ObjectChange, ObjectType from netbox.choices import CSVDelimiterChoices, ImportFormatChoices from netbox.models.features import ChangeLoggingMixin, CustomFieldsMixin from users.models import ObjectPermission