diff --git a/netbox/extras/middleware.py b/netbox/extras/middleware.py index a7dd3b44e..7c44c2804 100644 --- a/netbox/extras/middleware.py +++ b/netbox/extras/middleware.py @@ -2,20 +2,25 @@ from __future__ import unicode_literals from datetime import timedelta import random +import threading import uuid from django.conf import settings from django.db.models.signals import post_delete, post_save from django.utils import timezone -from django.utils.functional import curry, SimpleLazyObject from .constants import OBJECTCHANGE_ACTION_CREATE, OBJECTCHANGE_ACTION_DELETE, OBJECTCHANGE_ACTION_UPDATE from .models import ObjectChange -def record_object_change(user, request_id, instance, **kwargs): +_thread_locals = threading.local() + + +def mark_object_changed(instance, **kwargs): """ - Create an ObjectChange in response to an object being created or deleted. + Mark an object as having been created, saved, or updated. At the end of the request, this change will be recorded. + We have to wait until the *end* of the request to the serialize the object, because related fields like tags and + custom fields have not yet been updated when the post_save signal is emitted. """ if not hasattr(instance, 'log_change'): return @@ -27,14 +32,7 @@ def record_object_change(user, request_id, instance, **kwargs): else: action = OBJECTCHANGE_ACTION_DELETE - instance.log_change(user, request_id, action) - - # 1% chance of clearing out expired ObjectChanges - if settings.CHANGELOG_RETENTION and random.randint(1, 100) == 1: - cutoff = timezone.now() - timedelta(days=settings.CHANGELOG_RETENTION) - purged_count, _ = ObjectChange.objects.filter( - time__lt=cutoff - ).delete() + _thread_locals.changed_objects.append((instance, action)) class ChangeLoggingMiddleware(object): @@ -44,22 +42,30 @@ class ChangeLoggingMiddleware(object): def __call__(self, request): - def get_user(request): - return request.user + # Initialize the list of changed objects + _thread_locals.changed_objects = [] - # 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)) + # Assign a random unique ID to the request. This will be used to associate multiple object changes made during + # the same request. + request.id = uuid.uuid4() - request_id = uuid.uuid4() + # Connect mark_object_changed to the post_save and post_delete receivers + post_save.connect(mark_object_changed, dispatch_uid='record_object_saved') + post_delete.connect(mark_object_changed, dispatch_uid='record_object_deleted') - # 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, request_id) + # Process the request + response = self.get_response(request) - post_save.connect(_record_object_change, dispatch_uid='record_object_saved') - post_delete.connect(_record_object_change, dispatch_uid='record_object_deleted') + # Record object changes + for obj, action in _thread_locals.changed_objects: + if obj.pk: + obj.log_change(request.user, request.id, action) - return self.get_response(request) + # Housekeeping: 1% chance of clearing out expired ObjectChanges + if _thread_locals.changed_objects and settings.CHANGELOG_RETENTION and random.randint(1, 100) == 1: + cutoff = timezone.now() - timedelta(days=settings.CHANGELOG_RETENTION) + purged_count, _ = ObjectChange.objects.filter( + time__lt=cutoff + ).delete() + + return response