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'