Skip to content

Commit

Permalink
Initial work on #16388
Browse files Browse the repository at this point in the history
  • Loading branch information
jeremystretch committed Jun 13, 2024
1 parent c6553c4 commit e7d1b7d
Show file tree
Hide file tree
Showing 63 changed files with 641 additions and 521 deletions.
6 changes: 4 additions & 2 deletions netbox/account/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@
from social_core.backends.utils import load_backends

from account.models import UserToken
from extras.models import Bookmark, ObjectChange
from extras.tables import BookmarkTable, ObjectChangeTable
from core.models import ObjectChange
from core.tables import ObjectChangeTable
from extras.models import Bookmark
from extras.tables import BookmarkTable
from netbox.authentication import get_auth_backend_display, get_saml_idps
from netbox.config import get_config
from netbox.views import generic
Expand Down
1 change: 1 addition & 0 deletions netbox/core/api/serializers.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from .serializers_.change_logging import *
from .serializers_.data import *
from .serializers_.jobs import *
from .nested_serializers import *
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers

from extras.choices import *
from extras.models import ObjectChange
from core.choices import *
from core.models import ObjectChange
from netbox.api.exceptions import SerializerNotFound
from netbox.api.fields import ChoiceField, ContentTypeField
from netbox.api.serializers import BaseModelSerializer
Expand All @@ -15,7 +15,7 @@


class ObjectChangeSerializer(BaseModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:objectchange-detail')
url = serializers.HyperlinkedIdentityField(view_name='core-api:objectchange-detail')
user = UserSerializer(
nested=True,
read_only=True
Expand Down
4 changes: 1 addition & 3 deletions netbox/core/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,10 @@
router = NetBoxRouter()
router.APIRootView = views.CoreRootView

# Data sources
router.register('data-sources', views.DataSourceViewSet)
router.register('data-files', views.DataFileViewSet)

# Jobs
router.register('jobs', views.JobViewSet)
router.register('object-changes', views.ObjectChangeViewSet)

app_name = 'core-api'
urlpatterns = router.urls
11 changes: 11 additions & 0 deletions netbox/core/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from core import filtersets
from core.models import *
from netbox.api.metadata import ContentTypeMetadata
from netbox.api.viewsets import NetBoxModelViewSet, NetBoxReadOnlyModelViewSet
from . import serializers

Expand Down Expand Up @@ -54,3 +55,13 @@ class JobViewSet(ReadOnlyModelViewSet):
queryset = Job.objects.all()
serializer_class = serializers.JobSerializer
filterset_class = filtersets.JobFilterSet


class ObjectChangeViewSet(ReadOnlyModelViewSet):
"""
Retrieve a list of recent changes.
"""
metadata_class = ContentTypeMetadata
queryset = ObjectChange.objects.valid_models()
serializer_class = serializers.ObjectChangeSerializer
filterset_class = filtersets.ObjectChangeFilterSet
17 changes: 17 additions & 0 deletions netbox/core/choices.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,20 @@ class JobStatusChoices(ChoiceSet):
STATUS_ERRORED,
STATUS_FAILED,
)


#
# ObjectChanges
#

class ObjectChangeActionChoices(ChoiceSet):

ACTION_CREATE = 'create'
ACTION_UPDATE = 'update'
ACTION_DELETE = 'delete'

CHOICES = (
(ACTION_CREATE, _('Created'), 'green'),
(ACTION_UPDATE, _('Updated'), 'blue'),
(ACTION_DELETE, _('Deleted'), 'red'),
)
41 changes: 41 additions & 0 deletions netbox/core/filtersets.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.db.models import Q
from django.utils.translation import gettext as _

import django_filters

from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet
from netbox.utils import get_data_backend_choices
from utilities.filters import ContentTypeFilter
from .choices import *
from .models import *

Expand All @@ -13,6 +16,7 @@
'DataFileFilterSet',
'DataSourceFilterSet',
'JobFilterSet',
'ObjectChangeFilterSet',
)


Expand Down Expand Up @@ -126,6 +130,43 @@ def search(self, queryset, name, value):
)


class ObjectChangeFilterSet(BaseFilterSet):
q = django_filters.CharFilter(
method='search',
label=_('Search'),
)
time = django_filters.DateTimeFromToRangeFilter()
changed_object_type = ContentTypeFilter()
changed_object_type_id = django_filters.ModelMultipleChoiceFilter(
queryset=ContentType.objects.all()
)
user_id = django_filters.ModelMultipleChoiceFilter(
queryset=get_user_model().objects.all(),
label=_('User (ID)'),
)
user = django_filters.ModelMultipleChoiceFilter(
field_name='user__username',
queryset=get_user_model().objects.all(),
to_field_name='username',
label=_('User name'),
)

class Meta:
model = ObjectChange
fields = (
'id', 'user', 'user_name', 'request_id', 'action', 'changed_object_type_id', 'changed_object_id',
'related_object_type', 'related_object_id', 'object_repr',
)

def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(user_name__icontains=value) |
Q(object_repr__icontains=value)
)


class ConfigRevisionFilterSet(BaseFilterSet):
q = django_filters.CharFilter(
method='search',
Expand Down
41 changes: 39 additions & 2 deletions netbox/core/forms/filtersets.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@
from netbox.forms import NetBoxModelFilterSetForm
from netbox.forms.mixins import SavedFiltersMixin
from netbox.utils import get_data_backend_choices
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm
from utilities.forms.fields import ContentTypeChoiceField, DynamicModelMultipleChoiceField
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice
from utilities.forms.fields import (
ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField,
)
from utilities.forms.rendering import FieldSet
from utilities.forms.widgets import DateTimePicker

Expand All @@ -17,6 +19,7 @@
'DataFileFilterForm',
'DataSourceFilterForm',
'JobFilterForm',
'ObjectChangeFilterForm',
)


Expand Down Expand Up @@ -124,6 +127,40 @@ class JobFilterForm(SavedFiltersMixin, FilterForm):
)


class ObjectChangeFilterForm(SavedFiltersMixin, FilterForm):
model = ObjectChange
fieldsets = (
FieldSet('q', 'filter_id'),
FieldSet('time_before', 'time_after', name=_('Time')),
FieldSet('action', 'user_id', 'changed_object_type_id', name=_('Attributes')),
)
time_after = forms.DateTimeField(
required=False,
label=_('After'),
widget=DateTimePicker()
)
time_before = forms.DateTimeField(
required=False,
label=_('Before'),
widget=DateTimePicker()
)
action = forms.ChoiceField(
label=_('Action'),
choices=add_blank_choice(ObjectChangeActionChoices),
required=False
)
user_id = DynamicModelMultipleChoiceField(
queryset=get_user_model().objects.all(),
required=False,
label=_('User')
)
changed_object_type_id = ContentTypeMultipleChoiceField(
queryset=ObjectType.objects.with_feature('change_logging'),
required=False,
label=_('Object Type'),
)


class ConfigRevisionFilterForm(SavedFiltersMixin, FilterForm):
fieldsets = (
FieldSet('q', 'filter_id'),
Expand Down
7 changes: 7 additions & 0 deletions netbox/core/graphql/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
__all__ = (
'DataFileFilter',
'DataSourceFilter',
'ObjectChangeFilter',
)


Expand All @@ -19,3 +20,9 @@ class DataFileFilter(BaseFilterMixin):
@autotype_decorator(filtersets.DataSourceFilterSet)
class DataSourceFilter(BaseFilterMixin):
pass


@strawberry_django.filter(models.ObjectChange, lookups=True)
@autotype_decorator(filtersets.ObjectChangeFilterSet)
class ObjectChangeFilter(BaseFilterMixin):
pass
24 changes: 24 additions & 0 deletions netbox/core/graphql/mixins.py
Original file line number Diff line number Diff line change
@@ -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 changes: 10 additions & 0 deletions netbox/core/graphql/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
__all__ = (
'DataFileType',
'DataSourceType',
'ObjectChangeType',
)


Expand All @@ -30,3 +31,12 @@ class DataFileType(BaseObjectType):
class DataSourceType(NetBoxObjectType):

datafiles: List[Annotated["DataFileType", strawberry.lazy('core.graphql.types')]]


@strawberry_django.type(
models.ObjectChange,
fields='__all__',
filters=ObjectChangeFilter
)
class ObjectChangeType(BaseObjectType):
pass
45 changes: 45 additions & 0 deletions netbox/core/migrations/0011_move_objectchange.py
Original file line number Diff line number Diff line change
@@ -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=[],
),
]
3 changes: 2 additions & 1 deletion netbox/core/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from .config import *
from .contenttypes import *
from .change_logging import *
from .config import *
from .data import *
from .files import *
from .jobs import *
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@
from django.utils.translation import gettext_lazy as _
from mptt.models import MPTTModel

from core.models import ObjectType
from extras.choices import *
from core.choices import ObjectChangeActionChoices
from core.querysets import ObjectChangeQuerySet
from netbox.models.features import ChangeLoggingMixin
from utilities.data import shallow_compare_dict
from ..querysets import ObjectChangeQuerySet
from .contenttypes import ObjectType

__all__ = (
'ObjectChange',
Expand Down Expand Up @@ -136,7 +136,7 @@ def save(self, *args, **kwargs):
return super().save(*args, **kwargs)

def get_absolute_url(self):
return reverse('extras:objectchange', args=[self.pk])
return reverse('core:objectchange', args=[self.pk])

def get_action_color(self):
return ObjectChangeActionChoices.colors.get(self.action)
Expand Down
26 changes: 26 additions & 0 deletions netbox/core/querysets.py
Original file line number Diff line number Diff line change
@@ -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 change: 1 addition & 0 deletions netbox/core/tables/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from .change_logging import *
from .config import *
from .data import *
from .jobs import *
Expand Down
Loading

0 comments on commit e7d1b7d

Please sign in to comment.