1
0
mirror of https://github.com/netbox-community/netbox.git synced 2024-05-10 07:54:54 +00:00

Implemented new object change logging to replace UserActions

This commit is contained in:
Jeremy Stretch
2018-06-13 17:06:33 -04:00
parent b556d2d626
commit 33cf227bc8
6 changed files with 221 additions and 2 deletions

View File

@ -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
#

View File

@ -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

View File

@ -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)

View File

@ -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'],
},
),
]

View File

@ -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
#

View File

@ -174,6 +174,7 @@ MIDDLEWARE = (
'utilities.middleware.ExceptionHandlingMiddleware',
'utilities.middleware.LoginRequiredMiddleware',
'utilities.middleware.APIVersionMiddleware',
'extras.middleware.ChangeLoggingMiddleware',
)
ROOT_URLCONF = 'netbox.urls'