Просмотр исходного кода

Implemented new object change logging to replace UserActions

Jeremy Stretch 7 лет назад
Родитель
Сommit
33cf227bc8

+ 45 - 2
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('<span class="{}">{}</span>'.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('<a href="{}">{}</a>'.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
 #

+ 10 - 0
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

+ 63 - 0
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)

+ 37 - 0
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'],
+            },
+        ),
+    ]

+ 65 - 0
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
 #

+ 1 - 0
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'