mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-14 01:41:22 -06:00
* Initial work on #16388 * Misc cleanup
This commit is contained in:
parent
c6553c45dd
commit
853d990c03
@ -19,8 +19,10 @@ from django.views.generic import View
|
|||||||
from social_core.backends.utils import load_backends
|
from social_core.backends.utils import load_backends
|
||||||
|
|
||||||
from account.models import UserToken
|
from account.models import UserToken
|
||||||
from extras.models import Bookmark, ObjectChange
|
from core.models import ObjectChange
|
||||||
from extras.tables import BookmarkTable, ObjectChangeTable
|
from core.tables import ObjectChangeTable
|
||||||
|
from extras.models import Bookmark
|
||||||
|
from extras.tables import BookmarkTable
|
||||||
from netbox.authentication import get_auth_backend_display, get_saml_idps
|
from netbox.authentication import get_auth_backend_display, get_saml_idps
|
||||||
from netbox.config import get_config
|
from netbox.config import get_config
|
||||||
from netbox.views import generic
|
from netbox.views import generic
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
from .serializers_.change_logging import *
|
||||||
from .serializers_.data import *
|
from .serializers_.data import *
|
||||||
from .serializers_.jobs import *
|
from .serializers_.jobs import *
|
||||||
from .nested_serializers import *
|
from .nested_serializers import *
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
from drf_spectacular.utils import extend_schema_field
|
from drf_spectacular.utils import extend_schema_field
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
from extras.choices import *
|
from core.choices import *
|
||||||
from extras.models import ObjectChange
|
from core.models import ObjectChange
|
||||||
from netbox.api.exceptions import SerializerNotFound
|
from netbox.api.exceptions import SerializerNotFound
|
||||||
from netbox.api.fields import ChoiceField, ContentTypeField
|
from netbox.api.fields import ChoiceField, ContentTypeField
|
||||||
from netbox.api.serializers import BaseModelSerializer
|
from netbox.api.serializers import BaseModelSerializer
|
||||||
@ -15,7 +15,7 @@ __all__ = (
|
|||||||
|
|
||||||
|
|
||||||
class ObjectChangeSerializer(BaseModelSerializer):
|
class ObjectChangeSerializer(BaseModelSerializer):
|
||||||
url = serializers.HyperlinkedIdentityField(view_name='extras-api:objectchange-detail')
|
url = serializers.HyperlinkedIdentityField(view_name='core-api:objectchange-detail')
|
||||||
user = UserSerializer(
|
user = UserSerializer(
|
||||||
nested=True,
|
nested=True,
|
||||||
read_only=True
|
read_only=True
|
@ -5,12 +5,10 @@ from . import views
|
|||||||
router = NetBoxRouter()
|
router = NetBoxRouter()
|
||||||
router.APIRootView = views.CoreRootView
|
router.APIRootView = views.CoreRootView
|
||||||
|
|
||||||
# Data sources
|
|
||||||
router.register('data-sources', views.DataSourceViewSet)
|
router.register('data-sources', views.DataSourceViewSet)
|
||||||
router.register('data-files', views.DataFileViewSet)
|
router.register('data-files', views.DataFileViewSet)
|
||||||
|
|
||||||
# Jobs
|
|
||||||
router.register('jobs', views.JobViewSet)
|
router.register('jobs', views.JobViewSet)
|
||||||
|
router.register('object-changes', views.ObjectChangeViewSet)
|
||||||
|
|
||||||
app_name = 'core-api'
|
app_name = 'core-api'
|
||||||
urlpatterns = router.urls
|
urlpatterns = router.urls
|
||||||
|
@ -8,6 +8,7 @@ from rest_framework.viewsets import ReadOnlyModelViewSet
|
|||||||
|
|
||||||
from core import filtersets
|
from core import filtersets
|
||||||
from core.models import *
|
from core.models import *
|
||||||
|
from netbox.api.metadata import ContentTypeMetadata
|
||||||
from netbox.api.viewsets import NetBoxModelViewSet, NetBoxReadOnlyModelViewSet
|
from netbox.api.viewsets import NetBoxModelViewSet, NetBoxReadOnlyModelViewSet
|
||||||
from . import serializers
|
from . import serializers
|
||||||
|
|
||||||
@ -54,3 +55,13 @@ class JobViewSet(ReadOnlyModelViewSet):
|
|||||||
queryset = Job.objects.all()
|
queryset = Job.objects.all()
|
||||||
serializer_class = serializers.JobSerializer
|
serializer_class = serializers.JobSerializer
|
||||||
filterset_class = filtersets.JobFilterSet
|
filterset_class = filtersets.JobFilterSet
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectChangeViewSet(ReadOnlyModelViewSet):
|
||||||
|
"""
|
||||||
|
Retrieve a list of recent changes.
|
||||||
|
"""
|
||||||
|
metadata_class = ContentTypeMetadata
|
||||||
|
queryset = ObjectChange.objects.valid_models()
|
||||||
|
serializer_class = serializers.ObjectChangeSerializer
|
||||||
|
filterset_class = filtersets.ObjectChangeFilterSet
|
||||||
|
@ -64,3 +64,20 @@ class JobStatusChoices(ChoiceSet):
|
|||||||
STATUS_ERRORED,
|
STATUS_ERRORED,
|
||||||
STATUS_FAILED,
|
STATUS_FAILED,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# ObjectChanges
|
||||||
|
#
|
||||||
|
|
||||||
|
class ObjectChangeActionChoices(ChoiceSet):
|
||||||
|
|
||||||
|
ACTION_CREATE = 'create'
|
||||||
|
ACTION_UPDATE = 'update'
|
||||||
|
ACTION_DELETE = 'delete'
|
||||||
|
|
||||||
|
CHOICES = (
|
||||||
|
(ACTION_CREATE, _('Created'), 'green'),
|
||||||
|
(ACTION_UPDATE, _('Updated'), 'blue'),
|
||||||
|
(ACTION_DELETE, _('Deleted'), 'red'),
|
||||||
|
)
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
@ -5,6 +7,7 @@ import django_filters
|
|||||||
|
|
||||||
from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet
|
from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet
|
||||||
from netbox.utils import get_data_backend_choices
|
from netbox.utils import get_data_backend_choices
|
||||||
|
from utilities.filters import ContentTypeFilter
|
||||||
from .choices import *
|
from .choices import *
|
||||||
from .models import *
|
from .models import *
|
||||||
|
|
||||||
@ -13,6 +16,7 @@ __all__ = (
|
|||||||
'DataFileFilterSet',
|
'DataFileFilterSet',
|
||||||
'DataSourceFilterSet',
|
'DataSourceFilterSet',
|
||||||
'JobFilterSet',
|
'JobFilterSet',
|
||||||
|
'ObjectChangeFilterSet',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -126,6 +130,43 @@ class JobFilterSet(BaseFilterSet):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectChangeFilterSet(BaseFilterSet):
|
||||||
|
q = django_filters.CharFilter(
|
||||||
|
method='search',
|
||||||
|
label=_('Search'),
|
||||||
|
)
|
||||||
|
time = django_filters.DateTimeFromToRangeFilter()
|
||||||
|
changed_object_type = ContentTypeFilter()
|
||||||
|
changed_object_type_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
queryset=ContentType.objects.all()
|
||||||
|
)
|
||||||
|
user_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
queryset=get_user_model().objects.all(),
|
||||||
|
label=_('User (ID)'),
|
||||||
|
)
|
||||||
|
user = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
field_name='user__username',
|
||||||
|
queryset=get_user_model().objects.all(),
|
||||||
|
to_field_name='username',
|
||||||
|
label=_('User name'),
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = ObjectChange
|
||||||
|
fields = (
|
||||||
|
'id', 'user', 'user_name', 'request_id', 'action', 'changed_object_type_id', 'changed_object_id',
|
||||||
|
'related_object_type', 'related_object_id', 'object_repr',
|
||||||
|
)
|
||||||
|
|
||||||
|
def search(self, queryset, name, value):
|
||||||
|
if not value.strip():
|
||||||
|
return queryset
|
||||||
|
return queryset.filter(
|
||||||
|
Q(user_name__icontains=value) |
|
||||||
|
Q(object_repr__icontains=value)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ConfigRevisionFilterSet(BaseFilterSet):
|
class ConfigRevisionFilterSet(BaseFilterSet):
|
||||||
q = django_filters.CharFilter(
|
q = django_filters.CharFilter(
|
||||||
method='search',
|
method='search',
|
||||||
|
@ -7,8 +7,10 @@ from core.models import *
|
|||||||
from netbox.forms import NetBoxModelFilterSetForm
|
from netbox.forms import NetBoxModelFilterSetForm
|
||||||
from netbox.forms.mixins import SavedFiltersMixin
|
from netbox.forms.mixins import SavedFiltersMixin
|
||||||
from netbox.utils import get_data_backend_choices
|
from netbox.utils import get_data_backend_choices
|
||||||
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm
|
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice
|
||||||
from utilities.forms.fields import ContentTypeChoiceField, DynamicModelMultipleChoiceField
|
from utilities.forms.fields import (
|
||||||
|
ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField,
|
||||||
|
)
|
||||||
from utilities.forms.rendering import FieldSet
|
from utilities.forms.rendering import FieldSet
|
||||||
from utilities.forms.widgets import DateTimePicker
|
from utilities.forms.widgets import DateTimePicker
|
||||||
|
|
||||||
@ -17,6 +19,7 @@ __all__ = (
|
|||||||
'DataFileFilterForm',
|
'DataFileFilterForm',
|
||||||
'DataSourceFilterForm',
|
'DataSourceFilterForm',
|
||||||
'JobFilterForm',
|
'JobFilterForm',
|
||||||
|
'ObjectChangeFilterForm',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -124,6 +127,40 @@ class JobFilterForm(SavedFiltersMixin, FilterForm):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectChangeFilterForm(SavedFiltersMixin, FilterForm):
|
||||||
|
model = ObjectChange
|
||||||
|
fieldsets = (
|
||||||
|
FieldSet('q', 'filter_id'),
|
||||||
|
FieldSet('time_before', 'time_after', name=_('Time')),
|
||||||
|
FieldSet('action', 'user_id', 'changed_object_type_id', name=_('Attributes')),
|
||||||
|
)
|
||||||
|
time_after = forms.DateTimeField(
|
||||||
|
required=False,
|
||||||
|
label=_('After'),
|
||||||
|
widget=DateTimePicker()
|
||||||
|
)
|
||||||
|
time_before = forms.DateTimeField(
|
||||||
|
required=False,
|
||||||
|
label=_('Before'),
|
||||||
|
widget=DateTimePicker()
|
||||||
|
)
|
||||||
|
action = forms.ChoiceField(
|
||||||
|
label=_('Action'),
|
||||||
|
choices=add_blank_choice(ObjectChangeActionChoices),
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
user_id = DynamicModelMultipleChoiceField(
|
||||||
|
queryset=get_user_model().objects.all(),
|
||||||
|
required=False,
|
||||||
|
label=_('User')
|
||||||
|
)
|
||||||
|
changed_object_type_id = ContentTypeMultipleChoiceField(
|
||||||
|
queryset=ObjectType.objects.with_feature('change_logging'),
|
||||||
|
required=False,
|
||||||
|
label=_('Object Type'),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ConfigRevisionFilterForm(SavedFiltersMixin, FilterForm):
|
class ConfigRevisionFilterForm(SavedFiltersMixin, FilterForm):
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
FieldSet('q', 'filter_id'),
|
FieldSet('q', 'filter_id'),
|
||||||
|
@ -6,6 +6,7 @@ from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin
|
|||||||
__all__ = (
|
__all__ = (
|
||||||
'DataFileFilter',
|
'DataFileFilter',
|
||||||
'DataSourceFilter',
|
'DataSourceFilter',
|
||||||
|
'ObjectChangeFilter',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -19,3 +20,9 @@ class DataFileFilter(BaseFilterMixin):
|
|||||||
@autotype_decorator(filtersets.DataSourceFilterSet)
|
@autotype_decorator(filtersets.DataSourceFilterSet)
|
||||||
class DataSourceFilter(BaseFilterMixin):
|
class DataSourceFilter(BaseFilterMixin):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@strawberry_django.filter(models.ObjectChange, lookups=True)
|
||||||
|
@autotype_decorator(filtersets.ObjectChangeFilterSet)
|
||||||
|
class ObjectChangeFilter(BaseFilterMixin):
|
||||||
|
pass
|
||||||
|
24
netbox/core/graphql/mixins.py
Normal file
24
netbox/core/graphql/mixins.py
Normal 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')
|
@ -10,6 +10,7 @@ from .filters import *
|
|||||||
__all__ = (
|
__all__ = (
|
||||||
'DataFileType',
|
'DataFileType',
|
||||||
'DataSourceType',
|
'DataSourceType',
|
||||||
|
'ObjectChangeType',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -30,3 +31,12 @@ class DataFileType(BaseObjectType):
|
|||||||
class DataSourceType(NetBoxObjectType):
|
class DataSourceType(NetBoxObjectType):
|
||||||
|
|
||||||
datafiles: List[Annotated["DataFileType", strawberry.lazy('core.graphql.types')]]
|
datafiles: List[Annotated["DataFileType", strawberry.lazy('core.graphql.types')]]
|
||||||
|
|
||||||
|
|
||||||
|
@strawberry_django.type(
|
||||||
|
models.ObjectChange,
|
||||||
|
fields='__all__',
|
||||||
|
filters=ObjectChangeFilter
|
||||||
|
)
|
||||||
|
class ObjectChangeType(BaseObjectType):
|
||||||
|
pass
|
||||||
|
45
netbox/core/migrations/0011_move_objectchange.py
Normal file
45
netbox/core/migrations/0011_move_objectchange.py
Normal 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=[],
|
||||||
|
),
|
||||||
|
]
|
@ -1,5 +1,6 @@
|
|||||||
from .config import *
|
|
||||||
from .contenttypes import *
|
from .contenttypes import *
|
||||||
|
from .change_logging import *
|
||||||
|
from .config import *
|
||||||
from .data import *
|
from .data import *
|
||||||
from .files import *
|
from .files import *
|
||||||
from .jobs import *
|
from .jobs import *
|
||||||
|
@ -8,11 +8,11 @@ from django.urls import reverse
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from mptt.models import MPTTModel
|
from mptt.models import MPTTModel
|
||||||
|
|
||||||
from core.models import ObjectType
|
from core.choices import ObjectChangeActionChoices
|
||||||
from extras.choices import *
|
from core.querysets import ObjectChangeQuerySet
|
||||||
from netbox.models.features import ChangeLoggingMixin
|
from netbox.models.features import ChangeLoggingMixin
|
||||||
from utilities.data import shallow_compare_dict
|
from utilities.data import shallow_compare_dict
|
||||||
from ..querysets import ObjectChangeQuerySet
|
from .contenttypes import ObjectType
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'ObjectChange',
|
'ObjectChange',
|
||||||
@ -136,7 +136,7 @@ class ObjectChange(models.Model):
|
|||||||
return super().save(*args, **kwargs)
|
return super().save(*args, **kwargs)
|
||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse('extras:objectchange', args=[self.pk])
|
return reverse('core:objectchange', args=[self.pk])
|
||||||
|
|
||||||
def get_action_color(self):
|
def get_action_color(self):
|
||||||
return ObjectChangeActionChoices.colors.get(self.action)
|
return ObjectChangeActionChoices.colors.get(self.action)
|
26
netbox/core/querysets.py
Normal file
26
netbox/core/querysets.py
Normal 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)
|
@ -1,3 +1,4 @@
|
|||||||
|
from .change_logging import *
|
||||||
from .config import *
|
from .config import *
|
||||||
from .data import *
|
from .data import *
|
||||||
from .jobs import *
|
from .jobs import *
|
||||||
|
53
netbox/core/tables/change_logging.py
Normal file
53
netbox/core/tables/change_logging.py
Normal 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',
|
||||||
|
)
|
16
netbox/core/tables/template_code.py
Normal file
16
netbox/core/tables/template_code.py
Normal 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>
|
||||||
|
"""
|
@ -3,11 +3,12 @@ from django.test import override_settings
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
|
|
||||||
from core.models import ObjectType
|
from core.choices import ObjectChangeActionChoices
|
||||||
|
from core.models import ObjectChange, ObjectType
|
||||||
from dcim.choices import SiteStatusChoices
|
from dcim.choices import SiteStatusChoices
|
||||||
from dcim.models import Site
|
from dcim.models import Site
|
||||||
from extras.choices import *
|
from extras.choices import *
|
||||||
from extras.models import CustomField, CustomFieldChoiceSet, ObjectChange, Tag
|
from extras.models import CustomField, CustomFieldChoiceSet, Tag
|
||||||
from utilities.testing import APITestCase
|
from utilities.testing import APITestCase
|
||||||
from utilities.testing.utils import create_tags, post_data
|
from utilities.testing.utils import create_tags, post_data
|
||||||
from utilities.testing.views import ModelViewTestCase
|
from utilities.testing.views import ModelViewTestCase
|
@ -1,7 +1,13 @@
|
|||||||
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from utilities.testing import ChangeLoggedFilterSetTests
|
|
||||||
|
from dcim.models import Site
|
||||||
|
from ipam.models import IPAddress
|
||||||
|
from users.models import User
|
||||||
|
from utilities.testing import BaseFilterSetTests, ChangeLoggedFilterSetTests
|
||||||
from ..choices import *
|
from ..choices import *
|
||||||
from ..filtersets import *
|
from ..filtersets import *
|
||||||
from ..models import *
|
from ..models import *
|
||||||
@ -132,3 +138,99 @@ class DataFileTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
'a78168c7c97115bafd96450ed03ea43acec495094c5caa28f0d02e20e3a76cc2',
|
'a78168c7c97115bafd96450ed03ea43acec495094c5caa28f0d02e20e3a76cc2',
|
||||||
]}
|
]}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectChangeTestCase(TestCase, BaseFilterSetTests):
|
||||||
|
queryset = ObjectChange.objects.all()
|
||||||
|
filterset = ObjectChangeFilterSet
|
||||||
|
ignore_fields = ('prechange_data', 'postchange_data')
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
users = (
|
||||||
|
User(username='user1'),
|
||||||
|
User(username='user2'),
|
||||||
|
User(username='user3'),
|
||||||
|
)
|
||||||
|
User.objects.bulk_create(users)
|
||||||
|
|
||||||
|
site = Site.objects.create(name='Test Site 1', slug='test-site-1')
|
||||||
|
ipaddress = IPAddress.objects.create(address='192.0.2.1/24')
|
||||||
|
|
||||||
|
object_changes = (
|
||||||
|
ObjectChange(
|
||||||
|
user=users[0],
|
||||||
|
user_name=users[0].username,
|
||||||
|
request_id=uuid.uuid4(),
|
||||||
|
action=ObjectChangeActionChoices.ACTION_CREATE,
|
||||||
|
changed_object=site,
|
||||||
|
object_repr=str(site),
|
||||||
|
postchange_data={'name': site.name, 'slug': site.slug}
|
||||||
|
),
|
||||||
|
ObjectChange(
|
||||||
|
user=users[0],
|
||||||
|
user_name=users[0].username,
|
||||||
|
request_id=uuid.uuid4(),
|
||||||
|
action=ObjectChangeActionChoices.ACTION_UPDATE,
|
||||||
|
changed_object=site,
|
||||||
|
object_repr=str(site),
|
||||||
|
postchange_data={'name': site.name, 'slug': site.slug}
|
||||||
|
),
|
||||||
|
ObjectChange(
|
||||||
|
user=users[1],
|
||||||
|
user_name=users[1].username,
|
||||||
|
request_id=uuid.uuid4(),
|
||||||
|
action=ObjectChangeActionChoices.ACTION_DELETE,
|
||||||
|
changed_object=site,
|
||||||
|
object_repr=str(site),
|
||||||
|
postchange_data={'name': site.name, 'slug': site.slug}
|
||||||
|
),
|
||||||
|
ObjectChange(
|
||||||
|
user=users[1],
|
||||||
|
user_name=users[1].username,
|
||||||
|
request_id=uuid.uuid4(),
|
||||||
|
action=ObjectChangeActionChoices.ACTION_CREATE,
|
||||||
|
changed_object=ipaddress,
|
||||||
|
object_repr=str(ipaddress),
|
||||||
|
postchange_data={'address': ipaddress.address, 'status': ipaddress.status}
|
||||||
|
),
|
||||||
|
ObjectChange(
|
||||||
|
user=users[2],
|
||||||
|
user_name=users[2].username,
|
||||||
|
request_id=uuid.uuid4(),
|
||||||
|
action=ObjectChangeActionChoices.ACTION_UPDATE,
|
||||||
|
changed_object=ipaddress,
|
||||||
|
object_repr=str(ipaddress),
|
||||||
|
postchange_data={'address': ipaddress.address, 'status': ipaddress.status}
|
||||||
|
),
|
||||||
|
ObjectChange(
|
||||||
|
user=users[2],
|
||||||
|
user_name=users[2].username,
|
||||||
|
request_id=uuid.uuid4(),
|
||||||
|
action=ObjectChangeActionChoices.ACTION_DELETE,
|
||||||
|
changed_object=ipaddress,
|
||||||
|
object_repr=str(ipaddress),
|
||||||
|
postchange_data={'address': ipaddress.address, 'status': ipaddress.status}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
ObjectChange.objects.bulk_create(object_changes)
|
||||||
|
|
||||||
|
def test_q(self):
|
||||||
|
params = {'q': 'Site 1'}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||||
|
|
||||||
|
def test_user(self):
|
||||||
|
params = {'user_id': User.objects.filter(username__in=['user1', 'user2']).values_list('pk', flat=True)}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||||
|
params = {'user': ['user1', 'user2']}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||||
|
|
||||||
|
def test_user_name(self):
|
||||||
|
params = {'user_name': ['user1', 'user2']}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||||
|
|
||||||
|
def test_changed_object_type(self):
|
||||||
|
params = {'changed_object_type': 'dcim.site'}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||||
|
params = {'changed_object_type_id': [ContentType.objects.get(app_label='dcim', model='site').pk]}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
from core.models import DataSource
|
from core.models import DataSource
|
||||||
from extras.choices import ObjectChangeActionChoices
|
from core.choices import ObjectChangeActionChoices
|
||||||
from netbox.constants import CENSOR_TOKEN, CENSOR_TOKEN_CHANGED
|
from netbox.constants import CENSOR_TOKEN, CENSOR_TOKEN_CHANGED
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import logging
|
import urllib.parse
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
@ -10,8 +10,11 @@ from django_rq.workers import get_worker
|
|||||||
from rq.job import Job as RQ_Job, JobStatus
|
from rq.job import Job as RQ_Job, JobStatus
|
||||||
from rq.registry import DeferredJobRegistry, FailedJobRegistry, FinishedJobRegistry, StartedJobRegistry
|
from rq.registry import DeferredJobRegistry, FailedJobRegistry, FinishedJobRegistry, StartedJobRegistry
|
||||||
|
|
||||||
|
from core.choices import ObjectChangeActionChoices
|
||||||
|
from core.models import *
|
||||||
|
from dcim.models import Site
|
||||||
|
from users.models import User
|
||||||
from utilities.testing import TestCase, ViewTestCases, create_tags
|
from utilities.testing import TestCase, ViewTestCases, create_tags
|
||||||
from ..models import *
|
|
||||||
|
|
||||||
|
|
||||||
class DataSourceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
class DataSourceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||||
@ -99,6 +102,43 @@ class DataFileTestCase(
|
|||||||
DataFile.objects.bulk_create(data_files)
|
DataFile.objects.bulk_create(data_files)
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: Convert to StandardTestCases.Views
|
||||||
|
class ObjectChangeTestCase(TestCase):
|
||||||
|
user_permissions = (
|
||||||
|
'core.view_objectchange',
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
|
||||||
|
site = Site(name='Site 1', slug='site-1')
|
||||||
|
site.save()
|
||||||
|
|
||||||
|
# Create three ObjectChanges
|
||||||
|
user = User.objects.create_user(username='testuser2')
|
||||||
|
for i in range(1, 4):
|
||||||
|
oc = site.to_objectchange(action=ObjectChangeActionChoices.ACTION_UPDATE)
|
||||||
|
oc.user = user
|
||||||
|
oc.request_id = uuid.uuid4()
|
||||||
|
oc.save()
|
||||||
|
|
||||||
|
def test_objectchange_list(self):
|
||||||
|
|
||||||
|
url = reverse('core:objectchange_list')
|
||||||
|
params = {
|
||||||
|
"user": User.objects.first().pk,
|
||||||
|
}
|
||||||
|
|
||||||
|
response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
|
||||||
|
self.assertHttpStatus(response, 200)
|
||||||
|
|
||||||
|
def test_objectchange(self):
|
||||||
|
|
||||||
|
objectchange = ObjectChange.objects.first()
|
||||||
|
response = self.client.get(objectchange.get_absolute_url())
|
||||||
|
self.assertHttpStatus(response, 200)
|
||||||
|
|
||||||
|
|
||||||
class BackgroundTaskTestCase(TestCase):
|
class BackgroundTaskTestCase(TestCase):
|
||||||
user_permissions = ()
|
user_permissions = ()
|
||||||
|
|
||||||
|
@ -25,6 +25,10 @@ urlpatterns = (
|
|||||||
path('jobs/<int:pk>/', views.JobView.as_view(), name='job'),
|
path('jobs/<int:pk>/', views.JobView.as_view(), name='job'),
|
||||||
path('jobs/<int:pk>/delete/', views.JobDeleteView.as_view(), name='job_delete'),
|
path('jobs/<int:pk>/delete/', views.JobDeleteView.as_view(), name='job_delete'),
|
||||||
|
|
||||||
|
# Change logging
|
||||||
|
path('changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'),
|
||||||
|
path('changelog/<int:pk>/', include(get_model_urls('core', 'objectchange'))),
|
||||||
|
|
||||||
# Background Tasks
|
# Background Tasks
|
||||||
path('background-queues/', views.BackgroundQueueListView.as_view(), name='background_queue_list'),
|
path('background-queues/', views.BackgroundQueueListView.as_view(), name='background_queue_list'),
|
||||||
path('background-queues/<int:queue_index>/<str:status>/', views.BackgroundTaskListView.as_view(), name='background_task_list'),
|
path('background-queues/<int:queue_index>/<str:status>/', views.BackgroundTaskListView.as_view(), name='background_task_list'),
|
||||||
|
@ -29,6 +29,7 @@ from netbox.config import get_config, PARAMS
|
|||||||
from netbox.views import generic
|
from netbox.views import generic
|
||||||
from netbox.views.generic.base import BaseObjectView
|
from netbox.views.generic.base import BaseObjectView
|
||||||
from netbox.views.generic.mixins import TableMixin
|
from netbox.views.generic.mixins import TableMixin
|
||||||
|
from utilities.data import shallow_compare_dict
|
||||||
from utilities.forms import ConfirmationForm
|
from utilities.forms import ConfirmationForm
|
||||||
from utilities.htmx import htmx_partial
|
from utilities.htmx import htmx_partial
|
||||||
from utilities.query import count_related
|
from utilities.query import count_related
|
||||||
@ -176,6 +177,75 @@ class JobBulkDeleteView(generic.BulkDeleteView):
|
|||||||
table = tables.JobTable
|
table = tables.JobTable
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# Change logging
|
||||||
|
#
|
||||||
|
|
||||||
|
class ObjectChangeListView(generic.ObjectListView):
|
||||||
|
queryset = ObjectChange.objects.valid_models()
|
||||||
|
filterset = filtersets.ObjectChangeFilterSet
|
||||||
|
filterset_form = forms.ObjectChangeFilterForm
|
||||||
|
table = tables.ObjectChangeTable
|
||||||
|
template_name = 'core/objectchange_list.html'
|
||||||
|
actions = {
|
||||||
|
'export': {'view'},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@register_model_view(ObjectChange)
|
||||||
|
class ObjectChangeView(generic.ObjectView):
|
||||||
|
queryset = ObjectChange.objects.valid_models()
|
||||||
|
|
||||||
|
def get_extra_context(self, request, instance):
|
||||||
|
related_changes = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter(
|
||||||
|
request_id=instance.request_id
|
||||||
|
).exclude(
|
||||||
|
pk=instance.pk
|
||||||
|
)
|
||||||
|
related_changes_table = tables.ObjectChangeTable(
|
||||||
|
data=related_changes[:50],
|
||||||
|
orderable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
objectchanges = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter(
|
||||||
|
changed_object_type=instance.changed_object_type,
|
||||||
|
changed_object_id=instance.changed_object_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
next_change = objectchanges.filter(time__gt=instance.time).order_by('time').first()
|
||||||
|
prev_change = objectchanges.filter(time__lt=instance.time).order_by('-time').first()
|
||||||
|
|
||||||
|
if not instance.prechange_data and instance.action in ['update', 'delete'] and prev_change:
|
||||||
|
non_atomic_change = True
|
||||||
|
prechange_data = prev_change.postchange_data_clean
|
||||||
|
else:
|
||||||
|
non_atomic_change = False
|
||||||
|
prechange_data = instance.prechange_data_clean
|
||||||
|
|
||||||
|
if prechange_data and instance.postchange_data:
|
||||||
|
diff_added = shallow_compare_dict(
|
||||||
|
prechange_data or dict(),
|
||||||
|
instance.postchange_data_clean or dict(),
|
||||||
|
exclude=['last_updated'],
|
||||||
|
)
|
||||||
|
diff_removed = {
|
||||||
|
x: prechange_data.get(x) for x in diff_added
|
||||||
|
} if prechange_data else {}
|
||||||
|
else:
|
||||||
|
diff_added = None
|
||||||
|
diff_removed = None
|
||||||
|
|
||||||
|
return {
|
||||||
|
'diff_added': diff_added,
|
||||||
|
'diff_removed': diff_removed,
|
||||||
|
'next_change': next_change,
|
||||||
|
'prev_change': prev_change,
|
||||||
|
'related_changes_table': related_changes_table,
|
||||||
|
'related_changes_count': related_changes.count(),
|
||||||
|
'non_atomic_change': non_atomic_change
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Config Revisions
|
# Config Revisions
|
||||||
#
|
#
|
||||||
|
@ -3,14 +3,10 @@ from typing import Annotated, List, Union
|
|||||||
import strawberry
|
import strawberry
|
||||||
import strawberry_django
|
import strawberry_django
|
||||||
|
|
||||||
|
from core.graphql.mixins import ChangelogMixin
|
||||||
from dcim import models
|
from dcim import models
|
||||||
from extras.graphql.mixins import (
|
from extras.graphql.mixins import (
|
||||||
ChangelogMixin,
|
ConfigContextMixin, ContactsMixin, CustomFieldsMixin, ImageAttachmentsMixin, TagsMixin,
|
||||||
ConfigContextMixin,
|
|
||||||
ContactsMixin,
|
|
||||||
CustomFieldsMixin,
|
|
||||||
ImageAttachmentsMixin,
|
|
||||||
TagsMixin,
|
|
||||||
)
|
)
|
||||||
from ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin
|
from ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin
|
||||||
from netbox.graphql.scalars import BigInt
|
from netbox.graphql.scalars import BigInt
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
from .serializers_.objecttypes import *
|
from .serializers_.objecttypes import *
|
||||||
from .serializers_.attachments import *
|
from .serializers_.attachments import *
|
||||||
from .serializers_.bookmarks import *
|
from .serializers_.bookmarks import *
|
||||||
from .serializers_.change_logging import *
|
|
||||||
from .serializers_.customfields import *
|
from .serializers_.customfields import *
|
||||||
from .serializers_.customlinks import *
|
from .serializers_.customlinks import *
|
||||||
from .serializers_.dashboard import *
|
from .serializers_.dashboard import *
|
||||||
|
@ -21,7 +21,6 @@ router.register('journal-entries', views.JournalEntryViewSet)
|
|||||||
router.register('config-contexts', views.ConfigContextViewSet)
|
router.register('config-contexts', views.ConfigContextViewSet)
|
||||||
router.register('config-templates', views.ConfigTemplateViewSet)
|
router.register('config-templates', views.ConfigTemplateViewSet)
|
||||||
router.register('scripts', views.ScriptViewSet, basename='script')
|
router.register('scripts', views.ScriptViewSet, basename='script')
|
||||||
router.register('object-changes', views.ObjectChangeViewSet)
|
|
||||||
router.register('object-types', views.ObjectTypeViewSet)
|
router.register('object-types', views.ObjectTypeViewSet)
|
||||||
|
|
||||||
app_name = 'extras-api'
|
app_name = 'extras-api'
|
||||||
|
@ -271,20 +271,6 @@ class ScriptViewSet(ModelViewSet):
|
|||||||
return Response(input_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
return Response(input_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
|
||||||
#
|
|
||||||
# Change logging
|
|
||||||
#
|
|
||||||
|
|
||||||
class ObjectChangeViewSet(ReadOnlyModelViewSet):
|
|
||||||
"""
|
|
||||||
Retrieve a list of recent changes.
|
|
||||||
"""
|
|
||||||
metadata_class = ContentTypeMetadata
|
|
||||||
queryset = ObjectChange.objects.valid_models()
|
|
||||||
serializer_class = serializers.ObjectChangeSerializer
|
|
||||||
filterset_class = filtersets.ObjectChangeFilterSet
|
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Object types
|
# Object types
|
||||||
#
|
#
|
||||||
|
@ -123,23 +123,6 @@ class BookmarkOrderingChoices(ChoiceSet):
|
|||||||
(ORDERING_OLDEST, _('Oldest')),
|
(ORDERING_OLDEST, _('Oldest')),
|
||||||
)
|
)
|
||||||
|
|
||||||
#
|
|
||||||
# ObjectChanges
|
|
||||||
#
|
|
||||||
|
|
||||||
|
|
||||||
class ObjectChangeActionChoices(ChoiceSet):
|
|
||||||
|
|
||||||
ACTION_CREATE = 'create'
|
|
||||||
ACTION_UPDATE = 'update'
|
|
||||||
ACTION_DELETE = 'delete'
|
|
||||||
|
|
||||||
CHOICES = (
|
|
||||||
(ACTION_CREATE, _('Created'), 'green'),
|
|
||||||
(ACTION_UPDATE, _('Updated'), 'blue'),
|
|
||||||
(ACTION_DELETE, _('Deleted'), 'red'),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Journal entries
|
# Journal entries
|
||||||
|
@ -128,7 +128,7 @@ DEFAULT_DASHBOARD = [
|
|||||||
'title': 'Change Log',
|
'title': 'Change Log',
|
||||||
'color': 'blue',
|
'color': 'blue',
|
||||||
'config': {
|
'config': {
|
||||||
'model': 'extras.objectchange',
|
'model': 'core.objectchange',
|
||||||
'page_size': 25,
|
'page_size': 25,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
@ -6,6 +8,7 @@ from django.utils.module_loading import import_string
|
|||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
from django_rq import get_queue
|
from django_rq import get_queue
|
||||||
|
|
||||||
|
from core.choices import ObjectChangeActionChoices
|
||||||
from core.models import Job
|
from core.models import Job
|
||||||
from netbox.config import get_config
|
from netbox.config import get_config
|
||||||
from netbox.constants import RQ_QUEUE_DEFAULT
|
from netbox.constants import RQ_QUEUE_DEFAULT
|
||||||
@ -13,7 +16,7 @@ from netbox.registry import registry
|
|||||||
from utilities.api import get_serializer_for_model
|
from utilities.api import get_serializer_for_model
|
||||||
from utilities.rqworker import get_rq_retry
|
from utilities.rqworker import get_rq_retry
|
||||||
from utilities.serialization import serialize_object
|
from utilities.serialization import serialize_object
|
||||||
from .choices import *
|
from .choices import EventRuleActionChoices
|
||||||
from .models import EventRule
|
from .models import EventRule
|
||||||
|
|
||||||
logger = logging.getLogger('netbox.events_processor')
|
logger = logging.getLogger('netbox.events_processor')
|
||||||
|
@ -26,7 +26,6 @@ __all__ = (
|
|||||||
'ImageAttachmentFilterSet',
|
'ImageAttachmentFilterSet',
|
||||||
'JournalEntryFilterSet',
|
'JournalEntryFilterSet',
|
||||||
'LocalConfigContextFilterSet',
|
'LocalConfigContextFilterSet',
|
||||||
'ObjectChangeFilterSet',
|
|
||||||
'ObjectTypeFilterSet',
|
'ObjectTypeFilterSet',
|
||||||
'SavedFilterFilterSet',
|
'SavedFilterFilterSet',
|
||||||
'ScriptFilterSet',
|
'ScriptFilterSet',
|
||||||
@ -645,43 +644,6 @@ class LocalConfigContextFilterSet(django_filters.FilterSet):
|
|||||||
return queryset.exclude(local_context_data__isnull=value)
|
return queryset.exclude(local_context_data__isnull=value)
|
||||||
|
|
||||||
|
|
||||||
class ObjectChangeFilterSet(BaseFilterSet):
|
|
||||||
q = django_filters.CharFilter(
|
|
||||||
method='search',
|
|
||||||
label=_('Search'),
|
|
||||||
)
|
|
||||||
time = django_filters.DateTimeFromToRangeFilter()
|
|
||||||
changed_object_type = ContentTypeFilter()
|
|
||||||
changed_object_type_id = django_filters.ModelMultipleChoiceFilter(
|
|
||||||
queryset=ContentType.objects.all()
|
|
||||||
)
|
|
||||||
user_id = django_filters.ModelMultipleChoiceFilter(
|
|
||||||
queryset=get_user_model().objects.all(),
|
|
||||||
label=_('User (ID)'),
|
|
||||||
)
|
|
||||||
user = django_filters.ModelMultipleChoiceFilter(
|
|
||||||
field_name='user__username',
|
|
||||||
queryset=get_user_model().objects.all(),
|
|
||||||
to_field_name='username',
|
|
||||||
label=_('User name'),
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = ObjectChange
|
|
||||||
fields = (
|
|
||||||
'id', 'user', 'user_name', 'request_id', 'action', 'changed_object_type_id', 'changed_object_id',
|
|
||||||
'related_object_type', 'related_object_id', 'object_repr',
|
|
||||||
)
|
|
||||||
|
|
||||||
def search(self, queryset, name, value):
|
|
||||||
if not value.strip():
|
|
||||||
return queryset
|
|
||||||
return queryset.filter(
|
|
||||||
Q(user_name__icontains=value) |
|
|
||||||
Q(object_repr__icontains=value)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# ContentTypes
|
# ContentTypes
|
||||||
#
|
#
|
||||||
|
@ -14,7 +14,7 @@ from utilities.forms.fields import (
|
|||||||
ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, TagFilterField,
|
ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, TagFilterField,
|
||||||
)
|
)
|
||||||
from utilities.forms.rendering import FieldSet
|
from utilities.forms.rendering import FieldSet
|
||||||
from utilities.forms.widgets import APISelectMultiple, DateTimePicker
|
from utilities.forms.widgets import DateTimePicker
|
||||||
from virtualization.models import Cluster, ClusterGroup, ClusterType
|
from virtualization.models import Cluster, ClusterGroup, ClusterType
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
@ -28,7 +28,6 @@ __all__ = (
|
|||||||
'ImageAttachmentFilterForm',
|
'ImageAttachmentFilterForm',
|
||||||
'JournalEntryFilterForm',
|
'JournalEntryFilterForm',
|
||||||
'LocalConfigContextFilterForm',
|
'LocalConfigContextFilterForm',
|
||||||
'ObjectChangeFilterForm',
|
|
||||||
'SavedFilterFilterForm',
|
'SavedFilterFilterForm',
|
||||||
'TagFilterForm',
|
'TagFilterForm',
|
||||||
'WebhookFilterForm',
|
'WebhookFilterForm',
|
||||||
@ -475,37 +474,3 @@ class JournalEntryFilterForm(NetBoxModelFilterSetForm):
|
|||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
tag = TagFilterField(model)
|
tag = TagFilterField(model)
|
||||||
|
|
||||||
|
|
||||||
class ObjectChangeFilterForm(SavedFiltersMixin, FilterForm):
|
|
||||||
model = ObjectChange
|
|
||||||
fieldsets = (
|
|
||||||
FieldSet('q', 'filter_id'),
|
|
||||||
FieldSet('time_before', 'time_after', name=_('Time')),
|
|
||||||
FieldSet('action', 'user_id', 'changed_object_type_id', name=_('Attributes')),
|
|
||||||
)
|
|
||||||
time_after = forms.DateTimeField(
|
|
||||||
required=False,
|
|
||||||
label=_('After'),
|
|
||||||
widget=DateTimePicker()
|
|
||||||
)
|
|
||||||
time_before = forms.DateTimeField(
|
|
||||||
required=False,
|
|
||||||
label=_('Before'),
|
|
||||||
widget=DateTimePicker()
|
|
||||||
)
|
|
||||||
action = forms.ChoiceField(
|
|
||||||
label=_('Action'),
|
|
||||||
choices=add_blank_choice(ObjectChangeActionChoices),
|
|
||||||
required=False
|
|
||||||
)
|
|
||||||
user_id = DynamicModelMultipleChoiceField(
|
|
||||||
queryset=get_user_model().objects.all(),
|
|
||||||
required=False,
|
|
||||||
label=_('User')
|
|
||||||
)
|
|
||||||
changed_object_type_id = ContentTypeMultipleChoiceField(
|
|
||||||
queryset=ObjectType.objects.with_feature('change_logging'),
|
|
||||||
required=False,
|
|
||||||
label=_('Object Type'),
|
|
||||||
)
|
|
||||||
|
@ -13,7 +13,6 @@ __all__ = (
|
|||||||
'ExportTemplateFilter',
|
'ExportTemplateFilter',
|
||||||
'ImageAttachmentFilter',
|
'ImageAttachmentFilter',
|
||||||
'JournalEntryFilter',
|
'JournalEntryFilter',
|
||||||
'ObjectChangeFilter',
|
|
||||||
'SavedFilterFilter',
|
'SavedFilterFilter',
|
||||||
'TagFilter',
|
'TagFilter',
|
||||||
'WebhookFilter',
|
'WebhookFilter',
|
||||||
@ -68,12 +67,6 @@ class JournalEntryFilter(BaseFilterMixin):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.ObjectChange, lookups=True)
|
|
||||||
@autotype_decorator(filtersets.ObjectChangeFilterSet)
|
|
||||||
class ObjectChangeFilter(BaseFilterMixin):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.SavedFilter, lookups=True)
|
@strawberry_django.filter(models.SavedFilter, lookups=True)
|
||||||
@autotype_decorator(filtersets.SavedFilterFilterSet)
|
@autotype_decorator(filtersets.SavedFilterFilterSet)
|
||||||
class SavedFilterFilter(BaseFilterMixin):
|
class SavedFilterFilter(BaseFilterMixin):
|
||||||
|
@ -2,12 +2,8 @@ from typing import TYPE_CHECKING, Annotated, List
|
|||||||
|
|
||||||
import strawberry
|
import strawberry
|
||||||
import strawberry_django
|
import strawberry_django
|
||||||
from django.contrib.contenttypes.models import ContentType
|
|
||||||
|
|
||||||
from extras.models import ObjectChange
|
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'ChangelogMixin',
|
|
||||||
'ConfigContextMixin',
|
'ConfigContextMixin',
|
||||||
'ContactsMixin',
|
'ContactsMixin',
|
||||||
'CustomFieldsMixin',
|
'CustomFieldsMixin',
|
||||||
@ -17,23 +13,10 @@ __all__ = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .types import ImageAttachmentType, JournalEntryType, ObjectChangeType, TagType
|
from .types import ImageAttachmentType, JournalEntryType, TagType
|
||||||
from tenancy.graphql.types import ContactAssignmentType
|
from tenancy.graphql.types import ContactAssignmentType
|
||||||
|
|
||||||
|
|
||||||
@strawberry.type
|
|
||||||
class ChangelogMixin:
|
|
||||||
|
|
||||||
@strawberry_django.field
|
|
||||||
def changelog(self, info) -> List[Annotated["ObjectChangeType", strawberry.lazy('.types')]]:
|
|
||||||
content_type = ContentType.objects.get_for_model(self)
|
|
||||||
object_changes = ObjectChange.objects.filter(
|
|
||||||
changed_object_type=content_type,
|
|
||||||
changed_object_id=self.pk
|
|
||||||
)
|
|
||||||
return object_changes.restrict(info.context.request.user, 'view')
|
|
||||||
|
|
||||||
|
|
||||||
@strawberry.type
|
@strawberry.type
|
||||||
class ConfigContextMixin:
|
class ConfigContextMixin:
|
||||||
|
|
||||||
|
@ -18,7 +18,6 @@ __all__ = (
|
|||||||
'ExportTemplateType',
|
'ExportTemplateType',
|
||||||
'ImageAttachmentType',
|
'ImageAttachmentType',
|
||||||
'JournalEntryType',
|
'JournalEntryType',
|
||||||
'ObjectChangeType',
|
|
||||||
'SavedFilterType',
|
'SavedFilterType',
|
||||||
'TagType',
|
'TagType',
|
||||||
'WebhookType',
|
'WebhookType',
|
||||||
@ -123,15 +122,6 @@ class JournalEntryType(CustomFieldsMixin, TagsMixin, ObjectType):
|
|||||||
created_by: Annotated["UserType", strawberry.lazy('users.graphql.types')] | None
|
created_by: Annotated["UserType", strawberry.lazy('users.graphql.types')] | None
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.type(
|
|
||||||
models.ObjectChange,
|
|
||||||
fields='__all__',
|
|
||||||
filters=ObjectChangeFilter
|
|
||||||
)
|
|
||||||
class ObjectChangeType(BaseObjectType):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.type(
|
@strawberry_django.type(
|
||||||
models.SavedFilter,
|
models.SavedFilter,
|
||||||
exclude=['content_types',],
|
exclude=['content_types',],
|
||||||
|
@ -9,8 +9,7 @@ from django.db import DEFAULT_DB_ALIAS
|
|||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from packaging import version
|
from packaging import version
|
||||||
|
|
||||||
from core.models import Job
|
from core.models import Job, ObjectChange
|
||||||
from extras.models import ObjectChange
|
|
||||||
from netbox.config import Config
|
from netbox.config import Config
|
||||||
|
|
||||||
|
|
||||||
|
@ -10,9 +10,9 @@ from django.db import transaction
|
|||||||
|
|
||||||
from core.choices import JobStatusChoices
|
from core.choices import JobStatusChoices
|
||||||
from core.models import Job
|
from core.models import Job
|
||||||
from extras.context_managers import event_tracking
|
|
||||||
from extras.scripts import get_module_and_script
|
from extras.scripts import get_module_and_script
|
||||||
from extras.signals import clear_events
|
from extras.signals import clear_events
|
||||||
|
from netbox.context_managers import event_tracking
|
||||||
from utilities.exceptions import AbortTransaction
|
from utilities.exceptions import AbortTransaction
|
||||||
from utilities.request import NetBoxFakeRequest
|
from utilities.request import NetBoxFakeRequest
|
||||||
|
|
||||||
|
57
netbox/extras/migrations/0116_move_objectchange.py
Normal file
57
netbox/extras/migrations/0116_move_objectchange.py
Normal 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
|
||||||
|
),
|
||||||
|
]
|
@ -1,4 +1,3 @@
|
|||||||
from .change_logging import *
|
|
||||||
from .configs import *
|
from .configs import *
|
||||||
from .customfields import *
|
from .customfields import *
|
||||||
from .dashboard import *
|
from .dashboard import *
|
||||||
|
@ -8,7 +8,6 @@ from django.db import models
|
|||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.formats import date_format
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from rest_framework.utils.encoders import JSONEncoder
|
from rest_framework.utils.encoders import JSONEncoder
|
||||||
|
|
||||||
@ -23,9 +22,9 @@ from netbox.models.features import (
|
|||||||
CloningMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin,
|
CloningMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin,
|
||||||
)
|
)
|
||||||
from utilities.html import clean_html
|
from utilities.html import clean_html
|
||||||
|
from utilities.jinja2 import render_jinja2
|
||||||
from utilities.querydict import dict_to_querydict
|
from utilities.querydict import dict_to_querydict
|
||||||
from utilities.querysets import RestrictedQuerySet
|
from utilities.querysets import RestrictedQuerySet
|
||||||
from utilities.jinja2 import render_jinja2
|
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'Bookmark',
|
'Bookmark',
|
||||||
|
@ -1,8 +1,5 @@
|
|||||||
from django.apps import apps
|
|
||||||
from django.contrib.contenttypes.models import ContentType
|
|
||||||
from django.contrib.postgres.aggregates import JSONBAgg
|
from django.contrib.postgres.aggregates import JSONBAgg
|
||||||
from django.db.models import OuterRef, Subquery, Q
|
from django.db.models import OuterRef, Subquery, Q
|
||||||
from django.db.utils import ProgrammingError
|
|
||||||
|
|
||||||
from extras.models.tags import TaggedItem
|
from extras.models.tags import TaggedItem
|
||||||
from utilities.query_functions import EmptyGroupByJSONBAgg
|
from utilities.query_functions import EmptyGroupByJSONBAgg
|
||||||
@ -148,20 +145,3 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
|
|||||||
)
|
)
|
||||||
|
|
||||||
return base_query
|
return base_query
|
||||||
|
|
||||||
|
|
||||||
class ObjectChangeQuerySet(RestrictedQuerySet):
|
|
||||||
|
|
||||||
def valid_models(self):
|
|
||||||
# Exclude any change records which refer to an instance of a model that's no longer installed. This
|
|
||||||
# can happen when a plugin is removed but its data remains in the database, for example.
|
|
||||||
try:
|
|
||||||
content_types = ContentType.objects.get_for_models(*apps.get_models()).values()
|
|
||||||
except ProgrammingError:
|
|
||||||
# Handle the case where the database schema has not yet been initialized
|
|
||||||
content_types = ContentType.objects.none()
|
|
||||||
|
|
||||||
content_type_ids = set(
|
|
||||||
ct.pk for ct in content_types
|
|
||||||
)
|
|
||||||
return self.filter(changed_object_type_id__in=content_type_ids)
|
|
||||||
|
@ -21,11 +21,11 @@ from extras.models import ScriptModule, Script as ScriptModel
|
|||||||
from extras.signals import clear_events
|
from extras.signals import clear_events
|
||||||
from ipam.formfields import IPAddressFormField, IPNetworkFormField
|
from ipam.formfields import IPAddressFormField, IPNetworkFormField
|
||||||
from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
|
from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
|
||||||
|
from netbox.context_managers import event_tracking
|
||||||
from utilities.exceptions import AbortScript, AbortTransaction
|
from utilities.exceptions import AbortScript, AbortTransaction
|
||||||
from utilities.forms import add_blank_choice
|
from utilities.forms import add_blank_choice
|
||||||
from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField
|
from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField
|
||||||
from utilities.forms.widgets import DatePicker, DateTimePicker
|
from utilities.forms.widgets import DatePicker, DateTimePicker
|
||||||
from .context_managers import event_tracking
|
|
||||||
from .forms import ScriptForm
|
from .forms import ScriptForm
|
||||||
from .utils import is_report
|
from .utils import is_report
|
||||||
|
|
||||||
|
@ -9,7 +9,8 @@ from django.dispatch import receiver, Signal
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django_prometheus.models import model_deletes, model_inserts, model_updates
|
from django_prometheus.models import model_deletes, model_inserts, model_updates
|
||||||
|
|
||||||
from core.models import ObjectType
|
from core.choices import ObjectChangeActionChoices
|
||||||
|
from core.models import ObjectChange, ObjectType
|
||||||
from core.signals import job_end, job_start
|
from core.signals import job_end, job_start
|
||||||
from extras.constants import EVENT_JOB_END, EVENT_JOB_START
|
from extras.constants import EVENT_JOB_END, EVENT_JOB_START
|
||||||
from extras.events import process_event_rules
|
from extras.events import process_event_rules
|
||||||
@ -19,9 +20,8 @@ from netbox.context import current_request, events_queue
|
|||||||
from netbox.models.features import ChangeLoggingMixin
|
from netbox.models.features import ChangeLoggingMixin
|
||||||
from netbox.signals import post_clean
|
from netbox.signals import post_clean
|
||||||
from utilities.exceptions import AbortRequest
|
from utilities.exceptions import AbortRequest
|
||||||
from .choices import ObjectChangeActionChoices
|
|
||||||
from .events import enqueue_object, get_snapshots, serialize_for_event
|
from .events import enqueue_object, get_snapshots, serialize_for_event
|
||||||
from .models import CustomField, ObjectChange, TaggedItem
|
from .models import CustomField, TaggedItem
|
||||||
from .validators import CustomValidator
|
from .validators import CustomValidator
|
||||||
|
|
||||||
|
|
||||||
|
@ -19,7 +19,6 @@ __all__ = (
|
|||||||
'ExportTemplateTable',
|
'ExportTemplateTable',
|
||||||
'ImageAttachmentTable',
|
'ImageAttachmentTable',
|
||||||
'JournalEntryTable',
|
'JournalEntryTable',
|
||||||
'ObjectChangeTable',
|
|
||||||
'SavedFilterTable',
|
'SavedFilterTable',
|
||||||
'ReportResultsTable',
|
'ReportResultsTable',
|
||||||
'ScriptResultsTable',
|
'ScriptResultsTable',
|
||||||
@ -451,49 +450,6 @@ class ConfigTemplateTable(NetBoxTable):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ObjectChangeTable(NetBoxTable):
|
|
||||||
time = columns.DateTimeColumn(
|
|
||||||
verbose_name=_('Time'),
|
|
||||||
timespec='minutes',
|
|
||||||
linkify=True
|
|
||||||
)
|
|
||||||
user_name = tables.Column(
|
|
||||||
verbose_name=_('Username')
|
|
||||||
)
|
|
||||||
full_name = tables.TemplateColumn(
|
|
||||||
accessor=tables.A('user'),
|
|
||||||
template_code=OBJECTCHANGE_FULL_NAME,
|
|
||||||
verbose_name=_('Full Name'),
|
|
||||||
orderable=False
|
|
||||||
)
|
|
||||||
action = columns.ChoiceFieldColumn(
|
|
||||||
verbose_name=_('Action'),
|
|
||||||
)
|
|
||||||
changed_object_type = columns.ContentTypeColumn(
|
|
||||||
verbose_name=_('Type')
|
|
||||||
)
|
|
||||||
object_repr = tables.TemplateColumn(
|
|
||||||
accessor=tables.A('changed_object'),
|
|
||||||
template_code=OBJECTCHANGE_OBJECT,
|
|
||||||
verbose_name=_('Object'),
|
|
||||||
orderable=False
|
|
||||||
)
|
|
||||||
request_id = tables.TemplateColumn(
|
|
||||||
template_code=OBJECTCHANGE_REQUEST_ID,
|
|
||||||
verbose_name=_('Request ID')
|
|
||||||
)
|
|
||||||
actions = columns.ActionsColumn(
|
|
||||||
actions=()
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta(NetBoxTable.Meta):
|
|
||||||
model = ObjectChange
|
|
||||||
fields = (
|
|
||||||
'pk', 'id', 'time', 'user_name', 'full_name', 'action', 'changed_object_type', 'object_repr', 'request_id',
|
|
||||||
'actions',
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class JournalEntryTable(NetBoxTable):
|
class JournalEntryTable(NetBoxTable):
|
||||||
created = columns.DateTimeColumn(
|
created = columns.DateTimeColumn(
|
||||||
verbose_name=_('Created'),
|
verbose_name=_('Created'),
|
||||||
|
@ -6,20 +6,3 @@ CONFIGCONTEXT_ACTIONS = """
|
|||||||
<a href="{% url 'extras:configcontext_delete' pk=record.pk %}" class="btn btn-sm btn-danger"><i class="mdi mdi-trash-can-outline" aria-hidden="true"></i></a>
|
<a href="{% url 'extras:configcontext_delete' pk=record.pk %}" class="btn btn-sm btn-danger"><i class="mdi mdi-trash-can-outline" aria-hidden="true"></i></a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
OBJECTCHANGE_FULL_NAME = """
|
|
||||||
{% load helpers %}
|
|
||||||
{{ value.get_full_name|placeholder }}
|
|
||||||
"""
|
|
||||||
|
|
||||||
OBJECTCHANGE_OBJECT = """
|
|
||||||
{% if value and value.get_absolute_url %}
|
|
||||||
<a href="{{ value.get_absolute_url }}">{{ record.object_repr }}</a>
|
|
||||||
{% else %}
|
|
||||||
{{ record.object_repr }}
|
|
||||||
{% endif %}
|
|
||||||
"""
|
|
||||||
|
|
||||||
OBJECTCHANGE_REQUEST_ID = """
|
|
||||||
<a href="{% url 'extras:objectchange_list' %}?request_id={{ value }}">{{ value }}</a>
|
|
||||||
"""
|
|
||||||
|
@ -9,14 +9,15 @@ from django.urls import reverse
|
|||||||
from requests import Session
|
from requests import Session
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
|
|
||||||
|
from core.choices import ObjectChangeActionChoices
|
||||||
from core.models import ObjectType
|
from core.models import ObjectType
|
||||||
from dcim.choices import SiteStatusChoices
|
from dcim.choices import SiteStatusChoices
|
||||||
from dcim.models import Site
|
from dcim.models import Site
|
||||||
from extras.choices import EventRuleActionChoices, ObjectChangeActionChoices
|
from extras.choices import EventRuleActionChoices
|
||||||
from extras.context_managers import event_tracking
|
|
||||||
from extras.events import enqueue_object, flush_events, serialize_for_event
|
from extras.events import enqueue_object, flush_events, serialize_for_event
|
||||||
from extras.models import EventRule, Tag, Webhook
|
from extras.models import EventRule, Tag, Webhook
|
||||||
from extras.webhooks import generate_signature, send_webhook
|
from extras.webhooks import generate_signature, send_webhook
|
||||||
|
from netbox.context_managers import event_tracking
|
||||||
from utilities.testing import APITestCase
|
from utilities.testing import APITestCase
|
||||||
|
|
||||||
|
|
||||||
|
@ -6,15 +6,14 @@ from django.contrib.contenttypes.models import ContentType
|
|||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
from circuits.models import Provider
|
from circuits.models import Provider
|
||||||
from core.choices import ManagedFileRootPathChoices
|
from core.choices import ManagedFileRootPathChoices, ObjectChangeActionChoices
|
||||||
from core.models import ObjectType
|
from core.models import ObjectChange, ObjectType
|
||||||
from dcim.filtersets import SiteFilterSet
|
from dcim.filtersets import SiteFilterSet
|
||||||
from dcim.models import DeviceRole, DeviceType, Manufacturer, Platform, Rack, Region, Site, SiteGroup
|
from dcim.models import DeviceRole, DeviceType, Manufacturer, Platform, Rack, Region, Site, SiteGroup
|
||||||
from dcim.models import Location
|
from dcim.models import Location
|
||||||
from extras.choices import *
|
from extras.choices import *
|
||||||
from extras.filtersets import *
|
from extras.filtersets import *
|
||||||
from extras.models import *
|
from extras.models import *
|
||||||
from ipam.models import IPAddress
|
|
||||||
from tenancy.models import Tenant, TenantGroup
|
from tenancy.models import Tenant, TenantGroup
|
||||||
from utilities.testing import BaseFilterSetTests, ChangeLoggedFilterSetTests, create_tags
|
from utilities.testing import BaseFilterSetTests, ChangeLoggedFilterSetTests, create_tags
|
||||||
from virtualization.models import Cluster, ClusterGroup, ClusterType
|
from virtualization.models import Cluster, ClusterGroup, ClusterType
|
||||||
@ -1280,102 +1279,6 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ObjectChangeTestCase(TestCase, BaseFilterSetTests):
|
|
||||||
queryset = ObjectChange.objects.all()
|
|
||||||
filterset = ObjectChangeFilterSet
|
|
||||||
ignore_fields = ('prechange_data', 'postchange_data')
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def setUpTestData(cls):
|
|
||||||
users = (
|
|
||||||
User(username='user1'),
|
|
||||||
User(username='user2'),
|
|
||||||
User(username='user3'),
|
|
||||||
)
|
|
||||||
User.objects.bulk_create(users)
|
|
||||||
|
|
||||||
site = Site.objects.create(name='Test Site 1', slug='test-site-1')
|
|
||||||
ipaddress = IPAddress.objects.create(address='192.0.2.1/24')
|
|
||||||
|
|
||||||
object_changes = (
|
|
||||||
ObjectChange(
|
|
||||||
user=users[0],
|
|
||||||
user_name=users[0].username,
|
|
||||||
request_id=uuid.uuid4(),
|
|
||||||
action=ObjectChangeActionChoices.ACTION_CREATE,
|
|
||||||
changed_object=site,
|
|
||||||
object_repr=str(site),
|
|
||||||
postchange_data={'name': site.name, 'slug': site.slug}
|
|
||||||
),
|
|
||||||
ObjectChange(
|
|
||||||
user=users[0],
|
|
||||||
user_name=users[0].username,
|
|
||||||
request_id=uuid.uuid4(),
|
|
||||||
action=ObjectChangeActionChoices.ACTION_UPDATE,
|
|
||||||
changed_object=site,
|
|
||||||
object_repr=str(site),
|
|
||||||
postchange_data={'name': site.name, 'slug': site.slug}
|
|
||||||
),
|
|
||||||
ObjectChange(
|
|
||||||
user=users[1],
|
|
||||||
user_name=users[1].username,
|
|
||||||
request_id=uuid.uuid4(),
|
|
||||||
action=ObjectChangeActionChoices.ACTION_DELETE,
|
|
||||||
changed_object=site,
|
|
||||||
object_repr=str(site),
|
|
||||||
postchange_data={'name': site.name, 'slug': site.slug}
|
|
||||||
),
|
|
||||||
ObjectChange(
|
|
||||||
user=users[1],
|
|
||||||
user_name=users[1].username,
|
|
||||||
request_id=uuid.uuid4(),
|
|
||||||
action=ObjectChangeActionChoices.ACTION_CREATE,
|
|
||||||
changed_object=ipaddress,
|
|
||||||
object_repr=str(ipaddress),
|
|
||||||
postchange_data={'address': ipaddress.address, 'status': ipaddress.status}
|
|
||||||
),
|
|
||||||
ObjectChange(
|
|
||||||
user=users[2],
|
|
||||||
user_name=users[2].username,
|
|
||||||
request_id=uuid.uuid4(),
|
|
||||||
action=ObjectChangeActionChoices.ACTION_UPDATE,
|
|
||||||
changed_object=ipaddress,
|
|
||||||
object_repr=str(ipaddress),
|
|
||||||
postchange_data={'address': ipaddress.address, 'status': ipaddress.status}
|
|
||||||
),
|
|
||||||
ObjectChange(
|
|
||||||
user=users[2],
|
|
||||||
user_name=users[2].username,
|
|
||||||
request_id=uuid.uuid4(),
|
|
||||||
action=ObjectChangeActionChoices.ACTION_DELETE,
|
|
||||||
changed_object=ipaddress,
|
|
||||||
object_repr=str(ipaddress),
|
|
||||||
postchange_data={'address': ipaddress.address, 'status': ipaddress.status}
|
|
||||||
),
|
|
||||||
)
|
|
||||||
ObjectChange.objects.bulk_create(object_changes)
|
|
||||||
|
|
||||||
def test_q(self):
|
|
||||||
params = {'q': 'Site 1'}
|
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
|
||||||
|
|
||||||
def test_user(self):
|
|
||||||
params = {'user_id': User.objects.filter(username__in=['user1', 'user2']).values_list('pk', flat=True)}
|
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
|
||||||
params = {'user': ['user1', 'user2']}
|
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
|
||||||
|
|
||||||
def test_user_name(self):
|
|
||||||
params = {'user_name': ['user1', 'user2']}
|
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
|
||||||
|
|
||||||
def test_changed_object_type(self):
|
|
||||||
params = {'changed_object_type': 'dcim.site'}
|
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
|
||||||
params = {'changed_object_type_id': [ContentType.objects.get(app_label='dcim', model='site').pk]}
|
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
|
||||||
|
|
||||||
|
|
||||||
class ChangeLoggedFilterSetTestCase(TestCase):
|
class ChangeLoggedFilterSetTestCase(TestCase):
|
||||||
"""
|
"""
|
||||||
Evaluate base ChangeLoggedFilterSet filters using the Site model.
|
Evaluate base ChangeLoggedFilterSet filters using the Site model.
|
||||||
|
@ -1,6 +1,3 @@
|
|||||||
import urllib.parse
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
@ -567,43 +564,6 @@ class ConfigTemplateTestCase(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# TODO: Convert to StandardTestCases.Views
|
|
||||||
class ObjectChangeTestCase(TestCase):
|
|
||||||
user_permissions = (
|
|
||||||
'extras.view_objectchange',
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def setUpTestData(cls):
|
|
||||||
|
|
||||||
site = Site(name='Site 1', slug='site-1')
|
|
||||||
site.save()
|
|
||||||
|
|
||||||
# Create three ObjectChanges
|
|
||||||
user = User.objects.create_user(username='testuser2')
|
|
||||||
for i in range(1, 4):
|
|
||||||
oc = site.to_objectchange(action=ObjectChangeActionChoices.ACTION_UPDATE)
|
|
||||||
oc.user = user
|
|
||||||
oc.request_id = uuid.uuid4()
|
|
||||||
oc.save()
|
|
||||||
|
|
||||||
def test_objectchange_list(self):
|
|
||||||
|
|
||||||
url = reverse('extras:objectchange_list')
|
|
||||||
params = {
|
|
||||||
"user": User.objects.first().pk,
|
|
||||||
}
|
|
||||||
|
|
||||||
response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
|
|
||||||
self.assertHttpStatus(response, 200)
|
|
||||||
|
|
||||||
def test_objectchange(self):
|
|
||||||
|
|
||||||
objectchange = ObjectChange.objects.first()
|
|
||||||
response = self.client.get(objectchange.get_absolute_url())
|
|
||||||
self.assertHttpStatus(response, 200)
|
|
||||||
|
|
||||||
|
|
||||||
class JournalEntryTestCase(
|
class JournalEntryTestCase(
|
||||||
# ViewTestCases.GetObjectViewTestCase,
|
# ViewTestCases.GetObjectViewTestCase,
|
||||||
ViewTestCases.CreateObjectViewTestCase,
|
ViewTestCases.CreateObjectViewTestCase,
|
||||||
|
@ -106,10 +106,6 @@ urlpatterns = [
|
|||||||
path('journal-entries/import/', views.JournalEntryBulkImportView.as_view(), name='journalentry_import'),
|
path('journal-entries/import/', views.JournalEntryBulkImportView.as_view(), name='journalentry_import'),
|
||||||
path('journal-entries/<int:pk>/', include(get_model_urls('extras', 'journalentry'))),
|
path('journal-entries/<int:pk>/', include(get_model_urls('extras', 'journalentry'))),
|
||||||
|
|
||||||
# Change logging
|
|
||||||
path('changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'),
|
|
||||||
path('changelog/<int:pk>/', include(get_model_urls('extras', 'objectchange'))),
|
|
||||||
|
|
||||||
# User dashboard
|
# User dashboard
|
||||||
path('dashboard/reset/', views.DashboardResetView.as_view(), name='dashboard_reset'),
|
path('dashboard/reset/', views.DashboardResetView.as_view(), name='dashboard_reset'),
|
||||||
path('dashboard/widgets/add/', views.DashboardWidgetAddView.as_view(), name='dashboardwidget_add'),
|
path('dashboard/widgets/add/', views.DashboardWidgetAddView.as_view(), name='dashboardwidget_add'),
|
||||||
|
@ -19,7 +19,6 @@ from extras.dashboard.utils import get_widget_class
|
|||||||
from netbox.constants import DEFAULT_ACTION_PERMISSIONS
|
from netbox.constants import DEFAULT_ACTION_PERMISSIONS
|
||||||
from netbox.views import generic
|
from netbox.views import generic
|
||||||
from netbox.views.generic.mixins import TableMixin
|
from netbox.views.generic.mixins import TableMixin
|
||||||
from utilities.data import shallow_compare_dict
|
|
||||||
from utilities.forms import ConfirmationForm, get_field_value
|
from utilities.forms import ConfirmationForm, get_field_value
|
||||||
from utilities.htmx import htmx_partial
|
from utilities.htmx import htmx_partial
|
||||||
from utilities.paginator import EnhancedPaginator, get_paginate_count
|
from utilities.paginator import EnhancedPaginator, get_paginate_count
|
||||||
@ -683,75 +682,6 @@ class ConfigTemplateBulkSyncDataView(generic.BulkSyncDataView):
|
|||||||
queryset = ConfigTemplate.objects.all()
|
queryset = ConfigTemplate.objects.all()
|
||||||
|
|
||||||
|
|
||||||
#
|
|
||||||
# Change logging
|
|
||||||
#
|
|
||||||
|
|
||||||
class ObjectChangeListView(generic.ObjectListView):
|
|
||||||
queryset = ObjectChange.objects.valid_models()
|
|
||||||
filterset = filtersets.ObjectChangeFilterSet
|
|
||||||
filterset_form = forms.ObjectChangeFilterForm
|
|
||||||
table = tables.ObjectChangeTable
|
|
||||||
template_name = 'extras/objectchange_list.html'
|
|
||||||
actions = {
|
|
||||||
'export': {'view'},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@register_model_view(ObjectChange)
|
|
||||||
class ObjectChangeView(generic.ObjectView):
|
|
||||||
queryset = ObjectChange.objects.valid_models()
|
|
||||||
|
|
||||||
def get_extra_context(self, request, instance):
|
|
||||||
related_changes = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter(
|
|
||||||
request_id=instance.request_id
|
|
||||||
).exclude(
|
|
||||||
pk=instance.pk
|
|
||||||
)
|
|
||||||
related_changes_table = tables.ObjectChangeTable(
|
|
||||||
data=related_changes[:50],
|
|
||||||
orderable=False
|
|
||||||
)
|
|
||||||
|
|
||||||
objectchanges = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter(
|
|
||||||
changed_object_type=instance.changed_object_type,
|
|
||||||
changed_object_id=instance.changed_object_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
next_change = objectchanges.filter(time__gt=instance.time).order_by('time').first()
|
|
||||||
prev_change = objectchanges.filter(time__lt=instance.time).order_by('-time').first()
|
|
||||||
|
|
||||||
if not instance.prechange_data and instance.action in ['update', 'delete'] and prev_change:
|
|
||||||
non_atomic_change = True
|
|
||||||
prechange_data = prev_change.postchange_data_clean
|
|
||||||
else:
|
|
||||||
non_atomic_change = False
|
|
||||||
prechange_data = instance.prechange_data_clean
|
|
||||||
|
|
||||||
if prechange_data and instance.postchange_data:
|
|
||||||
diff_added = shallow_compare_dict(
|
|
||||||
prechange_data or dict(),
|
|
||||||
instance.postchange_data_clean or dict(),
|
|
||||||
exclude=['last_updated'],
|
|
||||||
)
|
|
||||||
diff_removed = {
|
|
||||||
x: prechange_data.get(x) for x in diff_added
|
|
||||||
} if prechange_data else {}
|
|
||||||
else:
|
|
||||||
diff_added = None
|
|
||||||
diff_removed = None
|
|
||||||
|
|
||||||
return {
|
|
||||||
'diff_added': diff_added,
|
|
||||||
'diff_removed': diff_removed,
|
|
||||||
'next_change': next_change,
|
|
||||||
'prev_change': prev_change,
|
|
||||||
'related_changes_table': related_changes_table,
|
|
||||||
'related_changes_count': related_changes.count(),
|
|
||||||
'non_atomic_change': non_atomic_change
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Image attachments
|
# Image attachments
|
||||||
#
|
#
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
|
|
||||||
from netbox.context import current_request, events_queue
|
from netbox.context import current_request, events_queue
|
||||||
from .events import flush_events
|
from extras.events import flush_events
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
@ -7,9 +7,11 @@ from django_filters.exceptions import FieldLookupError
|
|||||||
from django_filters.utils import get_model_field, resolve_field
|
from django_filters.utils import get_model_field, resolve_field
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
from extras.choices import CustomFieldFilterLogicChoices, ObjectChangeActionChoices
|
from core.choices import ObjectChangeActionChoices
|
||||||
|
from core.models import ObjectChange
|
||||||
|
from extras.choices import CustomFieldFilterLogicChoices
|
||||||
from extras.filters import TagFilter
|
from extras.filters import TagFilter
|
||||||
from extras.models import CustomField, ObjectChange, SavedFilter
|
from extras.models import CustomField, SavedFilter
|
||||||
from utilities.constants import (
|
from utilities.constants import (
|
||||||
FILTER_CHAR_BASED_LOOKUP_MAP, FILTER_NEGATION_LOOKUP_MAP, FILTER_TREENODE_NEGATION_LOOKUP_MAP,
|
FILTER_CHAR_BASED_LOOKUP_MAP, FILTER_NEGATION_LOOKUP_MAP, FILTER_TREENODE_NEGATION_LOOKUP_MAP,
|
||||||
FILTER_NUMERIC_BASED_LOOKUP_MAP
|
FILTER_NUMERIC_BASED_LOOKUP_MAP
|
||||||
|
@ -1,17 +1,10 @@
|
|||||||
from typing import Annotated, List
|
|
||||||
|
|
||||||
import strawberry
|
import strawberry
|
||||||
from strawberry import auto
|
|
||||||
import strawberry_django
|
import strawberry_django
|
||||||
|
|
||||||
from core.models import ObjectType as ObjectType_
|
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from extras.graphql.mixins import (
|
|
||||||
ChangelogMixin,
|
from core.graphql.mixins import ChangelogMixin
|
||||||
CustomFieldsMixin,
|
from core.models import ObjectType as ObjectType_
|
||||||
JournalEntriesMixin,
|
from extras.graphql.mixins import CustomFieldsMixin, JournalEntriesMixin, TagsMixin
|
||||||
TagsMixin,
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'BaseObjectType',
|
'BaseObjectType',
|
||||||
|
@ -10,8 +10,8 @@ from django.db import connection, ProgrammingError
|
|||||||
from django.db.utils import InternalError
|
from django.db.utils import InternalError
|
||||||
from django.http import Http404, HttpResponseRedirect
|
from django.http import Http404, HttpResponseRedirect
|
||||||
|
|
||||||
from extras.context_managers import event_tracking
|
|
||||||
from netbox.config import clear_config, get_config
|
from netbox.config import clear_config, get_config
|
||||||
|
from netbox.context_managers import event_tracking
|
||||||
from netbox.views import handler_500
|
from netbox.views import handler_500
|
||||||
from utilities.api import is_api_request
|
from utilities.api import is_api_request
|
||||||
from utilities.error_handlers import handle_rest_api_exception
|
from utilities.error_handlers import handle_rest_api_exception
|
||||||
|
@ -9,7 +9,7 @@ from django.utils import timezone
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from taggit.managers import TaggableManager
|
from taggit.managers import TaggableManager
|
||||||
|
|
||||||
from core.choices import JobStatusChoices
|
from core.choices import JobStatusChoices, ObjectChangeActionChoices
|
||||||
from core.models import ObjectType
|
from core.models import ObjectType
|
||||||
from extras.choices import *
|
from extras.choices import *
|
||||||
from extras.utils import is_taggable
|
from extras.utils import is_taggable
|
||||||
@ -90,7 +90,8 @@ class ChangeLoggingMixin(models.Model):
|
|||||||
Return a new ObjectChange representing a change made to this object. This will typically be called automatically
|
Return a new ObjectChange representing a change made to this object. This will typically be called automatically
|
||||||
by ChangeLoggingMiddleware.
|
by ChangeLoggingMiddleware.
|
||||||
"""
|
"""
|
||||||
from extras.models import ObjectChange
|
# TODO: Fix circular import
|
||||||
|
from core.models import ObjectChange
|
||||||
|
|
||||||
exclude = []
|
exclude = []
|
||||||
if get_config().CHANGELOG_SKIP_EMPTY_CHANGES:
|
if get_config().CHANGELOG_SKIP_EMPTY_CHANGES:
|
||||||
|
@ -356,7 +356,7 @@ OPERATIONS_MENU = Menu(
|
|||||||
label=_('Logging'),
|
label=_('Logging'),
|
||||||
items=(
|
items=(
|
||||||
get_model_item('extras', 'journalentry', _('Journal Entries'), actions=['import']),
|
get_model_item('extras', 'journalentry', _('Journal Entries'), actions=['import']),
|
||||||
get_model_item('extras', 'objectchange', _('Change Log'), actions=[]),
|
get_model_item('core', 'objectchange', _('Change Log'), actions=[]),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -6,10 +6,11 @@ from django.shortcuts import get_object_or_404, redirect, render
|
|||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
from django.views.generic import View
|
from django.views.generic import View
|
||||||
|
|
||||||
from core.models import Job
|
from core.models import Job, ObjectChange
|
||||||
from core.tables import JobTable
|
from core.tables import JobTable, ObjectChangeTable
|
||||||
from extras import forms, tables
|
from extras.forms import JournalEntryForm
|
||||||
from extras.models import *
|
from extras.models import JournalEntry
|
||||||
|
from extras.tables import JournalEntryTable
|
||||||
from utilities.permissions import get_permission_for_model
|
from utilities.permissions import get_permission_for_model
|
||||||
from utilities.views import GetReturnURLMixin, ViewTab
|
from utilities.views import GetReturnURLMixin, ViewTab
|
||||||
from .base import BaseMultiObjectView
|
from .base import BaseMultiObjectView
|
||||||
@ -56,7 +57,7 @@ class ObjectChangeLogView(View):
|
|||||||
Q(changed_object_type=content_type, changed_object_id=obj.pk) |
|
Q(changed_object_type=content_type, changed_object_id=obj.pk) |
|
||||||
Q(related_object_type=content_type, related_object_id=obj.pk)
|
Q(related_object_type=content_type, related_object_id=obj.pk)
|
||||||
)
|
)
|
||||||
objectchanges_table = tables.ObjectChangeTable(
|
objectchanges_table = ObjectChangeTable(
|
||||||
data=objectchanges,
|
data=objectchanges,
|
||||||
orderable=False,
|
orderable=False,
|
||||||
user=request.user
|
user=request.user
|
||||||
@ -108,13 +109,13 @@ class ObjectJournalView(View):
|
|||||||
assigned_object_type=content_type,
|
assigned_object_type=content_type,
|
||||||
assigned_object_id=obj.pk
|
assigned_object_id=obj.pk
|
||||||
)
|
)
|
||||||
journalentry_table = tables.JournalEntryTable(journalentries, user=request.user)
|
journalentry_table = JournalEntryTable(journalentries, user=request.user)
|
||||||
journalentry_table.configure(request)
|
journalentry_table.configure(request)
|
||||||
journalentry_table.columns.hide('assigned_object_type')
|
journalentry_table.columns.hide('assigned_object_type')
|
||||||
journalentry_table.columns.hide('assigned_object')
|
journalentry_table.columns.hide('assigned_object')
|
||||||
|
|
||||||
if request.user.has_perm('extras.add_journalentry'):
|
if request.user.has_perm('extras.add_journalentry'):
|
||||||
form = forms.JournalEntryForm(
|
form = JournalEntryForm(
|
||||||
initial={
|
initial={
|
||||||
'assigned_object_type': ContentType.objects.get_for_model(obj),
|
'assigned_object_type': ContentType.objects.get_for_model(obj),
|
||||||
'assigned_object_id': obj.pk
|
'assigned_object_id': obj.pk
|
||||||
|
@ -6,7 +6,7 @@
|
|||||||
{% block title %}{{ object }}{% endblock %}
|
{% block title %}{{ object }}{% endblock %}
|
||||||
|
|
||||||
{% block breadcrumbs %}
|
{% block breadcrumbs %}
|
||||||
<li class="breadcrumb-item"><a href="{% url 'extras:objectchange_list' %}">{% trans "Change Log" %}</a></li>
|
<li class="breadcrumb-item"><a href="{% url 'core:objectchange_list' %}">{% trans "Change Log" %}</a></li>
|
||||||
{% if object.related_object and object.related_object.get_absolute_url %}
|
{% if object.related_object and object.related_object.get_absolute_url %}
|
||||||
<li class="breadcrumb-item"><a href="{{ object.related_object.get_absolute_url }}changelog/">{{ object.related_object }}</a></li>
|
<li class="breadcrumb-item"><a href="{{ object.related_object.get_absolute_url }}changelog/">{{ object.related_object }}</a></li>
|
||||||
{% elif object.changed_object and object.changed_object.get_absolute_url %}
|
{% elif object.changed_object and object.changed_object.get_absolute_url %}
|
||||||
@ -78,10 +78,10 @@
|
|||||||
<h5 class="card-header d-flex justify-content-between">
|
<h5 class="card-header d-flex justify-content-between">
|
||||||
{% trans "Difference" %}
|
{% trans "Difference" %}
|
||||||
<div class="btn-group btn-group-sm d-print-none">
|
<div class="btn-group btn-group-sm d-print-none">
|
||||||
<a {% if prev_change %}href="{% url 'extras:objectchange' pk=prev_change.pk %}"{% else %}disabled{% endif %} class="btn btn-outline-secondary">
|
<a {% if prev_change %}href="{% url 'core:objectchange' pk=prev_change.pk %}"{% else %}disabled{% endif %} class="btn btn-outline-secondary">
|
||||||
<i class="mdi mdi-chevron-left" aria-hidden="true"></i> {% trans "Previous" %}
|
<i class="mdi mdi-chevron-left" aria-hidden="true"></i> {% trans "Previous" %}
|
||||||
</a>
|
</a>
|
||||||
<a {% if next_change %}href="{% url 'extras:objectchange' pk=next_change.pk %}"{% else %}disabled{% endif %} class="btn btn-outline-secondary">
|
<a {% if next_change %}href="{% url 'core:objectchange' pk=next_change.pk %}"{% else %}disabled{% endif %} class="btn btn-outline-secondary">
|
||||||
{% trans "Next" %} <i class="mdi mdi-chevron-right" aria-hidden="true"></i>
|
{% trans "Next" %} <i class="mdi mdi-chevron-right" aria-hidden="true"></i>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@ -119,7 +119,7 @@
|
|||||||
</pre>
|
</pre>
|
||||||
{% endspaceless %}
|
{% endspaceless %}
|
||||||
{% elif non_atomic_change %}
|
{% elif non_atomic_change %}
|
||||||
{% trans "Warning: Comparing non-atomic change to previous change record" %} (<a href="{% url 'extras:objectchange' pk=prev_change.pk %}">{{ prev_change.pk }}</a>)
|
{% trans "Warning: Comparing non-atomic change to previous change record" %} (<a href="{% url 'core:objectchange' pk=prev_change.pk %}">{{ prev_change.pk }}</a>)
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-muted">{% trans "None" %}</span>
|
<span class="text-muted">{% trans "None" %}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -158,7 +158,7 @@
|
|||||||
{% include 'inc/panel_table.html' with table=related_changes_table heading='Related Changes' panel_class='default' %}
|
{% include 'inc/panel_table.html' with table=related_changes_table heading='Related Changes' panel_class='default' %}
|
||||||
{% if related_changes_count > related_changes_table.rows|length %}
|
{% if related_changes_count > related_changes_table.rows|length %}
|
||||||
<div class="float-end">
|
<div class="float-end">
|
||||||
<a href="{% url 'extras:objectchange_list' %}?request_id={{ object.request_id }}" class="btn btn-primary">
|
<a href="{% url 'core:objectchange_list' %}?request_id={{ object.request_id }}" class="btn btn-primary">
|
||||||
{% blocktrans trimmed with count=related_changes_count|add:"1" %}
|
{% blocktrans trimmed with count=related_changes_count|add:"1" %}
|
||||||
See All {{ count }} Changes
|
See All {{ count }} Changes
|
||||||
{% endblocktrans %}
|
{% endblocktrans %}
|
@ -1,7 +1,7 @@
|
|||||||
from django.db.models import Count
|
from django.db.models import Count
|
||||||
|
|
||||||
from extras.models import ObjectChange
|
from core.models import ObjectChange
|
||||||
from extras.tables import ObjectChangeTable
|
from core.tables import ObjectChangeTable
|
||||||
from netbox.views import generic
|
from netbox.views import generic
|
||||||
from utilities.views import register_model_view
|
from utilities.views import register_model_view
|
||||||
from . import filtersets, forms, tables
|
from . import filtersets, forms, tables
|
||||||
|
@ -1,29 +1,26 @@
|
|||||||
import inspect
|
import inspect
|
||||||
import json
|
import json
|
||||||
import strawberry_django
|
|
||||||
|
|
||||||
|
import strawberry_django
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.urls import reverse
|
|
||||||
from django.test import override_settings
|
from django.test import override_settings
|
||||||
|
from django.urls import reverse
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
from strawberry.lazy_type import LazyType
|
||||||
|
from strawberry.type import StrawberryList, StrawberryOptional
|
||||||
|
from strawberry.union import StrawberryUnion
|
||||||
|
|
||||||
from core.models import ObjectType
|
from core.choices import ObjectChangeActionChoices
|
||||||
from extras.choices import ObjectChangeActionChoices
|
from core.models import ObjectChange, ObjectType
|
||||||
from extras.models import ObjectChange
|
from ipam.graphql.types import IPAddressFamilyType
|
||||||
from users.models import ObjectPermission, Token
|
from users.models import ObjectPermission, Token
|
||||||
from utilities.api import get_graphql_type_for_model
|
from utilities.api import get_graphql_type_for_model
|
||||||
from .base import ModelTestCase
|
from .base import ModelTestCase
|
||||||
from .utils import disable_warnings
|
from .utils import disable_warnings
|
||||||
|
|
||||||
from ipam.graphql.types import IPAddressFamilyType
|
|
||||||
from strawberry.field import StrawberryField
|
|
||||||
from strawberry.lazy_type import LazyType
|
|
||||||
from strawberry.type import StrawberryList, StrawberryOptional
|
|
||||||
from strawberry.union import StrawberryUnion
|
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'APITestCase',
|
'APITestCase',
|
||||||
'APIViewTestCases',
|
'APIViewTestCases',
|
||||||
|
@ -8,9 +8,8 @@ from django.test import override_settings
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
from core.models import ObjectType
|
from core.choices import ObjectChangeActionChoices
|
||||||
from extras.choices import ObjectChangeActionChoices
|
from core.models import ObjectChange, ObjectType
|
||||||
from extras.models import ObjectChange
|
|
||||||
from netbox.choices import CSVDelimiterChoices, ImportFormatChoices
|
from netbox.choices import CSVDelimiterChoices, ImportFormatChoices
|
||||||
from netbox.models.features import ChangeLoggingMixin, CustomFieldsMixin
|
from netbox.models.features import ChangeLoggingMixin, CustomFieldsMixin
|
||||||
from users.models import ObjectPermission
|
from users.models import ObjectPermission
|
||||||
|
Loading…
Reference in New Issue
Block a user