diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index e96ae9ac8..200387f88 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -5,9 +5,9 @@ from django.contrib import admin from django.utils.safestring import mark_safe from utilities.forms import LaxURLField +from .constants import OBJECTCHANGE_ACTION_CREATE, OBJECTCHANGE_ACTION_DELETE, OBJECTCHANGE_ACTION_UPDATE from .models import ( - CustomField, CustomFieldChoice, Graph, ExportTemplate, TopologyMap, UserAction, - Webhook + CustomField, CustomFieldChoice, Graph, ExportTemplate, ObjectChange, TopologyMap, UserAction, Webhook, ) @@ -125,6 +125,49 @@ class TopologyMapAdmin(admin.ModelAdmin): } +# +# Change logging +# + +@admin.register(ObjectChange) +class ObjectChangeAdmin(admin.ModelAdmin): + actions = None + fields = ['time', 'content_type', 'display_object', 'action', 'display_user'] + list_display = ['time', 'content_type', 'display_object', 'display_action', 'display_user'] + list_filter = ['time', 'action', 'user__username'] + list_select_related = ['content_type', 'user'] + readonly_fields = fields + search_fields = ['user_name', 'object_repr'] + + def has_add_permission(self, request): + return False + + def display_user(self, obj): + if obj.user is not None: + return obj.user + else: + return '{} (deleted)'.format(obj.user_name) + display_user.short_description = 'user' + + def display_action(self, obj): + icon = { + OBJECTCHANGE_ACTION_CREATE: 'addlink', + OBJECTCHANGE_ACTION_UPDATE: 'changelink', + OBJECTCHANGE_ACTION_DELETE: 'deletelink', + } + return mark_safe('{}'.format(icon[obj.action], obj.get_action_display())) + display_user.short_description = 'action' + + def display_object(self, obj): + if hasattr(obj.changed_object, 'get_absolute_url'): + return mark_safe('{}'.format(obj.changed_object.get_absolute_url(), obj.changed_object)) + elif obj.changed_object is not None: + return obj.changed_object + else: + return '{} (deleted)'.format(obj.object_repr) + display_object.short_description = 'object' + + # # User actions # diff --git a/netbox/extras/constants.py b/netbox/extras/constants.py index 8a615c076..84c84dfcf 100644 --- a/netbox/extras/constants.py +++ b/netbox/extras/constants.py @@ -66,6 +66,16 @@ TOPOLOGYMAP_TYPE_CHOICES = ( (TOPOLOGYMAP_TYPE_POWER, 'Power'), ) +# Change log actions +OBJECTCHANGE_ACTION_CREATE = 1 +OBJECTCHANGE_ACTION_UPDATE = 2 +OBJECTCHANGE_ACTION_DELETE = 3 +OBJECTCHANGE_ACTION_CHOICES = ( + (OBJECTCHANGE_ACTION_CREATE, 'Created'), + (OBJECTCHANGE_ACTION_UPDATE, 'Updated'), + (OBJECTCHANGE_ACTION_DELETE, 'Deleted'), +) + # User action types ACTION_CREATE = 1 ACTION_IMPORT = 2 diff --git a/netbox/extras/middleware.py b/netbox/extras/middleware.py new file mode 100644 index 000000000..cf0efdfae --- /dev/null +++ b/netbox/extras/middleware.py @@ -0,0 +1,63 @@ +from __future__ import unicode_literals + +import json + +from django.core.serializers import serialize +from django.db.models.signals import post_delete, post_save +from django.utils.functional import curry, SimpleLazyObject + +from utilities.models import ChangeLoggedModel +from .constants import OBJECTCHANGE_ACTION_CREATE, OBJECTCHANGE_ACTION_DELETE, OBJECTCHANGE_ACTION_UPDATE +from .models import ObjectChange + + +def record_object_change(user, instance, **kwargs): + """ + Create an ObjectChange in response to an object being created or deleted. + """ + if not isinstance(instance, ChangeLoggedModel): + return + + # Determine what action is being performed. The post_save signal sends a `created` boolean, whereas post_delete + # does not. + if 'created' in kwargs: + action = OBJECTCHANGE_ACTION_CREATE if kwargs['created'] else OBJECTCHANGE_ACTION_UPDATE + else: + action = OBJECTCHANGE_ACTION_DELETE + + # Serialize the object using Django's built-in JSON serializer, then extract only the `fields` dict. + json_str = serialize('json', [instance]) + object_data = json.loads(json_str)[0]['fields'] + + ObjectChange( + user=user, + changed_object=instance, + action=action, + object_data=object_data + ).save() + + +class ChangeLoggingMiddleware(object): + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + + def get_user(request): + return request.user + + # DRF employs a separate authentication mechanism outside Django's normal request/response cycle, so calling + # request.user in middleware will always return AnonymousUser for API requests. To work around this, we point + # to a lazy object that doesn't resolve the user until after DRF's authentication has been called. For more + # detail, see https://stackoverflow.com/questions/26240832/ + user = SimpleLazyObject(lambda: get_user(request)) + + # Django doesn't provide any request context with the post_save/post_delete signals, so we curry + # record_object_change() to include the user associated with the current request. + _record_object_change = curry(record_object_change, user) + + post_save.connect(_record_object_change) + post_delete.connect(_record_object_change) + + return self.get_response(request) diff --git a/netbox/extras/migrations/0013_objectchange.py b/netbox/extras/migrations/0013_objectchange.py new file mode 100644 index 000000000..fdaf7dfd5 --- /dev/null +++ b/netbox/extras/migrations/0013_objectchange.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.12 on 2018-06-13 20:05 +from __future__ import unicode_literals + +from django.conf import settings +import django.contrib.postgres.fields.jsonb +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', '0012_webhooks'), + ] + + operations = [ + migrations.CreateModel( + name='ObjectChange', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('time', models.DateTimeField(auto_now_add=True)), + ('user_name', models.CharField(editable=False, max_length=150)), + ('action', models.PositiveSmallIntegerField(choices=[(1, 'Created'), (2, 'Updated'), (3, 'Deleted')])), + ('object_id', models.PositiveIntegerField()), + ('object_repr', models.CharField(editable=False, max_length=200)), + ('object_data', django.contrib.postgres.fields.jsonb.JSONField(editable=False)), + ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, 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={ + 'ordering': ['-time'], + }, + ), + ] diff --git a/netbox/extras/models.py b/netbox/extras/models.py index 865ff9fbb..e9fb2d543 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -656,6 +656,71 @@ class ReportResult(models.Model): ordering = ['report'] +# +# Change logging +# + +@python_2_unicode_compatible +class ObjectChange(models.Model): + """ + Record a change to an object and the user account associated with that change. + """ + time = models.DateTimeField( + auto_now_add=True, + editable=False + ) + user = models.ForeignKey( + to=User, + on_delete=models.SET_NULL, + related_name='changes', + blank=True, + null=True + ) + user_name = models.CharField( + max_length=150, + editable=False + ) + action = models.PositiveSmallIntegerField( + choices=OBJECTCHANGE_ACTION_CHOICES + ) + content_type = models.ForeignKey( + to=ContentType, + on_delete=models.CASCADE + ) + object_id = models.PositiveIntegerField() + changed_object = GenericForeignKey( + ct_field='content_type', + fk_field='object_id' + ) + object_repr = models.CharField( + max_length=200, + editable=False + ) + object_data = JSONField( + editable=False + ) + + class Meta: + ordering = ['-time'] + + def __str__(self): + attribution = 'by {}'.format(self.user_name) if self.user_name else '(no attribution)' + return '{} {} {}'.format( + self.object_repr, + self.get_action_display().lower(), + attribution + ) + + def save(self, *args, **kwargs): + + # Record the user's name and the object's representation as static strings + if self.user is not None: + self.user_name = self.user.username + self.object_repr = str(self.changed_object) + + return super(ObjectChange, self).save(*args, **kwargs) + + # # User actions # diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 7acb611f3..83686df94 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -174,6 +174,7 @@ MIDDLEWARE = ( 'utilities.middleware.ExceptionHandlingMiddleware', 'utilities.middleware.LoginRequiredMiddleware', 'utilities.middleware.APIVersionMiddleware', + 'extras.middleware.ChangeLoggingMiddleware', ) ROOT_URLCONF = 'netbox.urls'