diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 6a3c6256f..8f9822a90 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -6,11 +6,12 @@ from taggit.models import Tag from dcim.api.serializers import NestedDeviceSerializer, NestedRackSerializer, NestedSiteSerializer from dcim.models import Device, Rack, Site -from extras.constants import ACTION_CHOICES, GRAPH_TYPE_CHOICES -from extras.models import ExportTemplate, Graph, ImageAttachment, ReportResult, TopologyMap, UserAction -from users.api.serializers import NestedUserSerializer -from utilities.api import ChoiceFieldSerializer, ContentTypeFieldSerializer, ValidatedModelSerializer +from extras.models import ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, TopologyMap, UserAction from extras.constants import * +from users.api.serializers import NestedUserSerializer +from utilities.api import ( + ChoiceFieldSerializer, ContentTypeFieldSerializer, get_serializer_for_model, ValidatedModelSerializer, +) # @@ -155,6 +156,31 @@ class ReportDetailSerializer(ReportSerializer): result = ReportResultSerializer() +# +# Change logging +# + +class ObjectChangeSerializer(serializers.ModelSerializer): + user = NestedUserSerializer(read_only=True) + content_type = ContentTypeFieldSerializer(read_only=True) + changed_object = serializers.SerializerMethodField(read_only=True) + + class Meta: + model = ObjectChange + fields = ['id', 'time', 'user', 'user_name', 'action', 'content_type', 'changed_object', 'object_data'] + + def get_changed_object(self, obj): + """ + Serialize a nested representation of the changed object. + """ + serializer = get_serializer_for_model(obj.changed_object, prefix='Nested') + if serializer is None: + return obj.object_repr + context = {'request': self.context['request']} + data = serializer(obj.changed_object, context=context).data + return data + + # # User actions # diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py index 4e1f9d2ef..3b4e59ef2 100644 --- a/netbox/extras/api/urls.py +++ b/netbox/extras/api/urls.py @@ -37,6 +37,9 @@ router.register(r'image-attachments', views.ImageAttachmentViewSet) # Reports router.register(r'reports', views.ReportViewSet, base_name='report') +# Change logging +router.register(r'object-changes', views.ObjectChangeViewSet) + # Recent activity router.register(r'recent-activity', views.RecentActivityViewSet) diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 37d07060b..d65a099ad 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -11,7 +11,9 @@ from rest_framework.viewsets import ReadOnlyModelViewSet, ViewSet from taggit.models import Tag from extras import filters -from extras.models import CustomField, ExportTemplate, Graph, ImageAttachment, ReportResult, TopologyMap, UserAction +from extras.models import ( + CustomField, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, TopologyMap, UserAction, +) from extras.reports import get_report, get_reports from utilities.api import FieldChoicesViewSet, IsAuthenticatedOrLoginNotRequired, ModelViewSet from . import serializers @@ -206,6 +208,19 @@ class ReportViewSet(ViewSet): return Response(serializer.data) +# +# Change logging +# + +class ObjectChangeViewSet(ReadOnlyModelViewSet): + """ + Retrieve a list of recent changes. + """ + queryset = ObjectChange.objects.select_related('user') + serializer_class = serializers.ObjectChangeSerializer + filter_class = filters.ObjectChangeFilter + + # # User activity # diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py index bb1202e28..679a251f2 100644 --- a/netbox/extras/filters.py +++ b/netbox/extras/filters.py @@ -8,7 +8,7 @@ from taggit.models import Tag from dcim.models import Site from .constants import CF_FILTER_DISABLED, CF_FILTER_EXACT, CF_TYPE_BOOLEAN, CF_TYPE_SELECT -from .models import CustomField, Graph, ExportTemplate, TopologyMap, UserAction +from .models import CustomField, Graph, ExportTemplate, ObjectChange, TopologyMap, UserAction class CustomFieldFilter(django_filters.Filter): @@ -124,6 +124,25 @@ class TopologyMapFilter(django_filters.FilterSet): fields = ['name', 'slug'] +class ObjectChangeFilter(django_filters.FilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) + + class Meta: + model = ObjectChange + fields = ['user_name', 'action', 'content_type', '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 UserActionFilter(django_filters.FilterSet): username = django_filters.ModelMultipleChoiceFilter( name='user__username', diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index 61be3bc63..876d46173 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -16,6 +16,8 @@ from rest_framework.response import Response from rest_framework.serializers import Field, ModelSerializer, RelatedField, ValidationError from rest_framework.viewsets import GenericViewSet, ViewSet +from .utils import dynamic_import + WRITE_OPERATIONS = ['create', 'update', 'partial_update', 'delete'] @@ -24,6 +26,20 @@ class ServiceUnavailable(APIException): default_detail = "Service temporarily unavailable, please try again later." +def get_serializer_for_model(model, prefix=''): + """ + Dynamically resolve and return the appropriate serializer for a model. + """ + app_name, model_name = model._meta.label.split('.') + serializer_name = '{}.api.serializers.{}{}Serializer'.format( + app_name, prefix, model_name + ) + try: + return dynamic_import(serializer_name) + except ImportError: + return None + + # # Authentication #