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

* Initial work on #16388

* Misc cleanup
This commit is contained in:
Jeremy Stretch 2024-06-17 08:03:06 -04:00 committed by GitHub
parent c6553c45dd
commit 853d990c03
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
63 changed files with 645 additions and 523 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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'),
)

View File

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

View File

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

View File

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

View File

@ -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')

View File

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

View File

@ -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=[],
),
]

View File

@ -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 *

View File

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

26
netbox/core/querysets.py Normal file
View File

@ -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)

View File

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

View File

@ -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',
)

View File

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

View File

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

View File

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

View File

@ -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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 *

View File

@ -21,7 +21,6 @@ router.register('journal-entries', views.JournalEntryViewSet)
router.register('config-contexts', views.ConfigContextViewSet)
router.register('config-templates', views.ConfigTemplateViewSet)
router.register('scripts', views.ScriptViewSet, basename='script')
router.register('object-changes', views.ObjectChangeViewSet)
router.register('object-types', views.ObjectTypeViewSet)
app_name = 'extras-api'

View File

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

View File

@ -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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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
),
]

View File

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

View File

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

View File

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

View File

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

View File

@ -9,7 +9,8 @@ from django.dispatch import receiver, Signal
from django.utils.translation import gettext_lazy as _
from django_prometheus.models import model_deletes, model_inserts, model_updates
from core.models import ObjectType
from core.choices import ObjectChangeActionChoices
from core.models import ObjectChange, ObjectType
from core.signals import job_end, job_start
from extras.constants import EVENT_JOB_END, EVENT_JOB_START
from extras.events import process_event_rules
@ -19,9 +20,8 @@ from netbox.context import current_request, events_queue
from netbox.models.features import ChangeLoggingMixin
from netbox.signals import post_clean
from utilities.exceptions import AbortRequest
from .choices import ObjectChangeActionChoices
from .events import enqueue_object, get_snapshots, serialize_for_event
from .models import CustomField, ObjectChange, TaggedItem
from .models import CustomField, TaggedItem
from .validators import CustomValidator

View File

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

View File

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

View File

@ -9,14 +9,15 @@ from django.urls import reverse
from requests import Session
from rest_framework import status
from core.choices import ObjectChangeActionChoices
from core.models import ObjectType
from dcim.choices import SiteStatusChoices
from dcim.models import Site
from extras.choices import EventRuleActionChoices, ObjectChangeActionChoices
from extras.context_managers import event_tracking
from extras.choices import EventRuleActionChoices
from extras.events import enqueue_object, flush_events, serialize_for_event
from extras.models import EventRule, Tag, Webhook
from extras.webhooks import generate_signature, send_webhook
from netbox.context_managers import event_tracking
from utilities.testing import APITestCase

View File

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

View File

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

View File

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

View File

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

View File

@ -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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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

View File

@ -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',

View File

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