From cb8e0c93f284ea647fc00f9ccf286fdc049e54eb Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 23 May 2016 14:20:42 -0400 Subject: [PATCH] Implemented object add/edit/delete logging --- netbox/circuits/views.py | 7 +- netbox/dcim/views.py | 9 +-- netbox/extras/admin.py | 7 +- netbox/extras/migrations/0004_useraction.py | 34 ++++++++++ netbox/extras/models.py | 73 +++++++++++++++++++++ netbox/ipam/views.py | 16 ++--- netbox/secrets/views.py | 3 +- netbox/utilities/views.py | 29 ++++++-- 8 files changed, 146 insertions(+), 32 deletions(-) create mode 100644 netbox/extras/migrations/0004_useraction.py diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index 2bad5cc75..91be2ad54 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -1,4 +1,3 @@ -from django.contrib import messages from django.contrib.auth.mixins import PermissionRequiredMixin from django.db.models import Count from django.shortcuts import get_object_or_404, render @@ -69,8 +68,7 @@ class ProviderBulkEditView(PermissionRequiredMixin, BulkEditView): if form.cleaned_data[field]: fields_to_update[field] = form.cleaned_data[field] - updated_count = self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update) - messages.success(self.request, "Updated {} providers".format(updated_count)) + return self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update) class ProviderBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): @@ -165,8 +163,7 @@ class CircuitBulkEditView(PermissionRequiredMixin, BulkEditView): if form.cleaned_data[field]: fields_to_update[field] = form.cleaned_data[field] - updated_count = self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update) - messages.success(self.request, "Updated {} circuits".format(updated_count)) + return self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update) class CircuitBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 35950ae31..69d237dde 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -200,8 +200,7 @@ class RackBulkEditView(PermissionRequiredMixin, BulkEditView): if form.cleaned_data[field]: fields_to_update[field] = form.cleaned_data[field] - updated_count = self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update) - messages.success(self.request, "Updated {} racks".format(updated_count)) + return self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update) class RackBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): @@ -305,8 +304,7 @@ class DeviceTypeBulkEditView(PermissionRequiredMixin, BulkEditView): if form.cleaned_data[field]: fields_to_update[field] = form.cleaned_data[field] - updated_count = self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update) - messages.success(self.request, "Updated {} device types".format(updated_count)) + return self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update) class DeviceTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): @@ -590,8 +588,7 @@ class DeviceBulkEditView(PermissionRequiredMixin, BulkEditView): if form.cleaned_data[field]: fields_to_update[field] = form.cleaned_data[field] - updated_count = self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update) - messages.success(self.request, "Updated {} devices".format(updated_count)) + return self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update) class DeviceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index 757891a06..2093db31a 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin -from .models import Graph, ExportTemplate, TopologyMap +from .models import Graph, ExportTemplate, TopologyMap, UserAction @admin.register(Graph) @@ -19,3 +19,8 @@ class TopologyMapAdmin(admin.ModelAdmin): prepopulated_fields = { 'slug': ['name'], } + + +@admin.register(UserAction) +class UserActionAdmin(admin.ModelAdmin): + list_display = ['user', 'action', 'content_type', 'object_id', 'message'] diff --git a/netbox/extras/migrations/0004_useraction.py b/netbox/extras/migrations/0004_useraction.py new file mode 100644 index 000000000..f42caa34f --- /dev/null +++ b/netbox/extras/migrations/0004_useraction.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.5 on 2016-05-23 18:16 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('extras', '0003_auto_20160412_1332'), + ] + + operations = [ + migrations.CreateModel( + name='UserAction', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('time', models.DateTimeField(auto_now_add=True)), + ('object_id', models.PositiveIntegerField(blank=True, null=True)), + ('action', models.PositiveSmallIntegerField(choices=[(1, b'created'), (2, b'imported'), (3, b'modified'), (4, b'bulk edited'), (5, b'deleted'), (6, b'bulk deleted')])), + ('message', models.TextField(blank=True)), + ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-time'], + }, + ), + ] diff --git a/netbox/extras/models.py b/netbox/extras/models.py index 1380f75dd..9dba10bd4 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -1,3 +1,4 @@ +from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.db import models from django.http import HttpResponse @@ -21,6 +22,21 @@ EXPORTTEMPLATE_MODELS = [ 'provider', 'circuit' ] +ACTION_CREATE = 1 +ACTION_IMPORT = 2 +ACTION_EDIT = 3 +ACTION_BULK_EDIT = 4 +ACTION_DELETE = 5 +ACTION_BULK_DELETE = 6 +ACTION_CHOICES = ( + (ACTION_CREATE, 'created'), + (ACTION_IMPORT, 'imported'), + (ACTION_EDIT, 'modified'), + (ACTION_BULK_EDIT, 'bulk edited'), + (ACTION_DELETE, 'deleted'), + (ACTION_BULK_DELETE, 'bulk deleted') +) + class Graph(models.Model): type = models.PositiveSmallIntegerField(choices=GRAPH_TYPE_CHOICES) @@ -93,3 +109,60 @@ class TopologyMap(models.Model): if not self.device_patterns: return None return [line.strip() for line in self.device_patterns.split('\n')] + + +class UserActionManager(models.Manager): + + # Actions affecting a single object + def log_action(self, user, obj, action, message): + self.model.objects.create( + content_type = ContentType.objects.get_for_model(obj), + object_id = obj.pk, + user = user, + action = action, + message = message, + ) + + def log_create(self, user, obj, message=''): + self.log_action(user, obj, ACTION_CREATE, message) + + def log_edit(self, user, obj, message=''): + self.log_action(user, obj, ACTION_EDIT, message) + + def log_delete(self, user, obj, message=''): + self.log_action(user, obj, ACTION_DELETE, message) + + # Actions affecting multiple objects + def log_bulk_action(self, user, content_type, action, message): + self.model.objects.create( + content_type=content_type, + user=user, + action=action, + message=message, + ) + + def log_import(self, user, content_type, message=''): + self.log_bulk_action(user, content_type, ACTION_IMPORT, message) + + def log_bulk_edit(self, user, content_type, message=''): + self.log_bulk_action(user, content_type, ACTION_BULK_EDIT, message) + + def log_bulk_delete(self, user, content_type, message=''): + self.log_bulk_action(user, content_type, ACTION_BULK_DELETE, message) + + +class UserAction(models.Model): + """ + A record of an action (add, edit, or delete) performed on an object by a User. + """ + time = models.DateTimeField(auto_now_add=True, editable=False) + user = models.ForeignKey(User, on_delete=models.CASCADE) + content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) + object_id = models.PositiveIntegerField(blank=True, null=True) + action = models.PositiveSmallIntegerField(choices=ACTION_CHOICES) + message = models.TextField(blank=True) + + objects = UserActionManager() + + class Meta: + ordering = ['-time'] diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index faecc0b27..821c7beec 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -1,7 +1,6 @@ from netaddr import IPSet from django_tables2 import RequestConfig -from django.contrib import messages from django.contrib.auth.mixins import PermissionRequiredMixin from django.db.models import Count from django.shortcuts import get_object_or_404, render @@ -90,8 +89,7 @@ class VRFBulkEditView(PermissionRequiredMixin, BulkEditView): if form.cleaned_data[field]: fields_to_update[field] = form.cleaned_data[field] - updated_count = self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update) - messages.success(self.request, "Updated {} VRFs".format(updated_count)) + return self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update) class VRFBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): @@ -198,8 +196,7 @@ class AggregateBulkEditView(PermissionRequiredMixin, BulkEditView): if form.cleaned_data[field]: fields_to_update[field] = form.cleaned_data[field] - updated_count = self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update) - messages.success(self.request, "Updated {} aggregates".format(updated_count)) + return self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update) class AggregateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): @@ -336,8 +333,7 @@ class PrefixBulkEditView(PermissionRequiredMixin, BulkEditView): if form.cleaned_data[field]: fields_to_update[field] = form.cleaned_data[field] - updated_count = self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update) - messages.success(self.request, "Updated {} prefixes".format(updated_count)) + return self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update) class PrefixBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): @@ -449,8 +445,7 @@ class IPAddressBulkEditView(PermissionRequiredMixin, BulkEditView): if form.cleaned_data[field]: fields_to_update[field] = form.cleaned_data[field] - updated_count = self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update) - messages.success(self.request, "Updated {} IP addresses".format(updated_count)) + return self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update) class IPAddressBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): @@ -519,8 +514,7 @@ class VLANBulkEditView(PermissionRequiredMixin, BulkEditView): if form.cleaned_data[field]: fields_to_update[field] = form.cleaned_data[field] - updated_count = self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update) - messages.success(self.request, "Updated {} VLANs".format(updated_count)) + return self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update) class VLANBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): diff --git a/netbox/secrets/views.py b/netbox/secrets/views.py index a565785a4..d9d712466 100644 --- a/netbox/secrets/views.py +++ b/netbox/secrets/views.py @@ -213,8 +213,7 @@ class SecretBulkEditView(PermissionRequiredMixin, BulkEditView): if form.cleaned_data[field]: fields_to_update[field] = form.cleaned_data[field] - updated_count = self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update) - messages.success(self.request, "Updated {} secrets".format(updated_count)) + return self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update) class SecretBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index a9015ff0d..36396978c 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -13,7 +13,7 @@ from django.utils.decorators import method_decorator from django.utils.http import is_safe_url from django.views.generic import View -from extras.models import ExportTemplate +from extras.models import ExportTemplate, UserAction from .error_handlers import handle_protectederror from .forms import ConfirmationForm @@ -116,6 +116,7 @@ class ObjectEditView(View): obj = form.save(commit=False) obj_created = not obj.pk obj.save() + msg = 'Created ' if obj_created else 'Modified ' msg += self.model._meta.verbose_name if hasattr(obj, 'get_absolute_url'): @@ -123,6 +124,11 @@ class ObjectEditView(View): else: msg += ' {}'.format(obj) messages.success(request, msg) + if obj_created: + UserAction.objects.log_create(request.user, obj, msg) + else: + UserAction.objects.log_edit(request.user, obj, msg) + if '_addanother' in request.POST: return redirect(request.path) elif self.success_url: @@ -169,7 +175,9 @@ class ObjectDeleteView(View): if form.is_valid(): try: obj.delete() - messages.success(request, 'Deleted {} {}'.format(self.model._meta.verbose_name, obj)) + msg = 'Deleted {} {}'.format(self.model._meta.verbose_name, obj) + messages.success(request, msg) + UserAction.objects.log_delete(request.user, obj, msg) return redirect(self.redirect_url) except ProtectedError, e: handle_protectederror(obj, request, e) @@ -208,7 +216,9 @@ class BulkImportView(View): new_objs.append(obj) obj_table = self.table(new_objs) - messages.success(request, "Imported {} objects".format(len(new_objs))) + msg = 'Imported {} objects'.format(len(new_objs)) + messages.success(request, msg) + UserAction.objects.log_import(request.user, ContentType.objects.get_for_model(new_objs[0]), msg) return render(request, "import_success.html", { 'table': obj_table, @@ -247,9 +257,12 @@ class BulkEditView(View): form = self.form(request.POST) if form.is_valid(): pk_list = [obj.pk for obj in form.cleaned_data['pk']] - self.update_objects(pk_list, form) - if not form.errors: - return redirect(redirect_url) + updated_count = self.update_objects(pk_list, form) + msg = 'Updated {} {}'.format(updated_count, self.cls._meta.verbose_name_plural) + messages.success(self.request, msg) + UserAction.objects.log_bulk_edit(request.user, ContentType.objects.get_for_model(self.cls), msg) + + return redirect(redirect_url) else: form = self.form(initial={'pk': request.POST.getlist('pk')}) @@ -306,7 +319,9 @@ class BulkDeleteView(View): handle_protectederror(list(objects_to_delete), request, e) return redirect(redirect_url) - messages.success(request, "Deleted {} {}".format(deleted_count, self.cls._meta.verbose_name_plural)) + msg = 'Deleted {} {}'.format(deleted_count, self.cls._meta.verbose_name_plural) + messages.success(request, msg) + UserAction.objects.log_bulk_delete(request.user, ContentType.objects.get_for_model(self.cls), msg) return redirect(redirect_url) else: