diff --git a/netbox/extras/context_managers.py b/netbox/extras/context_managers.py index 66b5ff94d..9f73fe9c3 100644 --- a/netbox/extras/context_managers.py +++ b/netbox/extras/context_managers.py @@ -2,8 +2,9 @@ from contextlib import contextmanager from django.db.models.signals import m2m_changed, pre_delete, post_save -from extras.signals import clear_webhooks, _clear_webhook_queue, _handle_changed_object, _handle_deleted_object -from utilities.utils import curry +from extras.signals import clear_webhooks, clear_webhook_queue, handle_changed_object, handle_deleted_object +from netbox import thread_locals +from netbox.request_context import set_request from .webhooks import flush_webhooks @@ -15,12 +16,8 @@ def change_logging(request): :param request: WSGIRequest object with a unique `id` set """ - webhook_queue = [] - - # Curry signals receivers to pass the current request - handle_changed_object = curry(_handle_changed_object, request, webhook_queue) - handle_deleted_object = curry(_handle_deleted_object, request, webhook_queue) - clear_webhook_queue = curry(_clear_webhook_queue, webhook_queue) + set_request(request) + thread_locals.webhook_queue = [] # Connect our receivers to the post_save and post_delete signals. post_save.connect(handle_changed_object, dispatch_uid='handle_changed_object') @@ -38,5 +35,8 @@ def change_logging(request): clear_webhooks.disconnect(clear_webhook_queue, dispatch_uid='clear_webhook_queue') # Flush queued webhooks to RQ - flush_webhooks(webhook_queue) - del webhook_queue + flush_webhooks(thread_locals.webhook_queue) + del thread_locals.webhook_queue + + # Clear the request from thread-local storage + set_request(None) diff --git a/netbox/extras/signals.py b/netbox/extras/signals.py index 4f09706be..ec3653e15 100644 --- a/netbox/extras/signals.py +++ b/netbox/extras/signals.py @@ -6,6 +6,8 @@ from django.db.models.signals import m2m_changed, post_save, pre_delete from django.dispatch import receiver, Signal from django_prometheus.models import model_deletes, model_inserts, model_updates +from netbox import thread_locals +from netbox.request_context import get_request from netbox.signals import post_clean from .choices import ObjectChangeActionChoices from .models import CustomField, ObjectChange @@ -20,10 +22,16 @@ from .webhooks import enqueue_object, get_snapshots, serialize_for_webhook clear_webhooks = Signal() -def _handle_changed_object(request, webhook_queue, sender, instance, **kwargs): +def handle_changed_object(sender, instance, **kwargs): """ Fires when an object is created or updated. """ + if not hasattr(instance, 'to_objectchange'): + return + + request = get_request() + m2m_changed = False + def is_same_object(instance, webhook_data): return ( ContentType.objects.get_for_model(instance) == webhook_data['content_type'] and @@ -31,11 +39,6 @@ def _handle_changed_object(request, webhook_queue, sender, instance, **kwargs): request.id == webhook_data['request_id'] ) - if not hasattr(instance, 'to_objectchange'): - return - - m2m_changed = False - # Determine the type of change being made if kwargs.get('created'): action = ObjectChangeActionChoices.ACTION_CREATE @@ -65,6 +68,7 @@ def _handle_changed_object(request, webhook_queue, sender, instance, **kwargs): objectchange.save() # If this is an M2M change, update the previously queued webhook (from post_save) + webhook_queue = thread_locals.webhook_queue if m2m_changed and webhook_queue and is_same_object(instance, webhook_queue[-1]): instance.refresh_from_db() # Ensure that we're working with fresh M2M assignments webhook_queue[-1]['data'] = serialize_for_webhook(instance) @@ -79,13 +83,15 @@ def _handle_changed_object(request, webhook_queue, sender, instance, **kwargs): model_updates.labels(instance._meta.model_name).inc() -def _handle_deleted_object(request, webhook_queue, sender, instance, **kwargs): +def handle_deleted_object(sender, instance, **kwargs): """ Fires when an object is deleted. """ if not hasattr(instance, 'to_objectchange'): return + request = get_request() + # Record an ObjectChange if applicable if hasattr(instance, 'to_objectchange'): objectchange = instance.to_objectchange(ObjectChangeActionChoices.ACTION_DELETE) @@ -94,19 +100,21 @@ def _handle_deleted_object(request, webhook_queue, sender, instance, **kwargs): objectchange.save() # Enqueue webhooks + webhook_queue = thread_locals.webhook_queue enqueue_object(webhook_queue, instance, request.user, request.id, ObjectChangeActionChoices.ACTION_DELETE) # Increment metric counters model_deletes.labels(instance._meta.model_name).inc() -def _clear_webhook_queue(webhook_queue, sender, **kwargs): +def clear_webhook_queue(sender, **kwargs): """ Delete any queued webhooks (e.g. because of an aborted bulk transaction) """ logger = logging.getLogger('webhooks') - logger.info(f"Clearing {len(webhook_queue)} queued webhooks ({sender})") + webhook_queue = thread_locals.webhook_queue + logger.info(f"Clearing {len(webhook_queue)} queued webhooks ({sender})") webhook_queue.clear() diff --git a/netbox/netbox/__init__.py b/netbox/netbox/__init__.py index e69de29bb..5cf431025 100644 --- a/netbox/netbox/__init__.py +++ b/netbox/netbox/__init__.py @@ -0,0 +1,3 @@ +import threading + +thread_locals = threading.local() diff --git a/netbox/netbox/middleware.py b/netbox/netbox/middleware.py index a8f989a2a..ed308ea54 100644 --- a/netbox/netbox/middleware.py +++ b/netbox/netbox/middleware.py @@ -1,10 +1,10 @@ +import logging import uuid from urllib import parse -import logging from django.conf import settings -from django.contrib.auth.middleware import RemoteUserMiddleware as RemoteUserMiddleware_ from django.contrib import auth +from django.contrib.auth.middleware import RemoteUserMiddleware as RemoteUserMiddleware_ from django.core.exceptions import ImproperlyConfigured from django.db import ProgrammingError from django.http import Http404, HttpResponseRedirect @@ -114,7 +114,7 @@ class RemoteUserMiddleware(RemoteUserMiddleware_): return groups -class ObjectChangeMiddleware(object): +class ObjectChangeMiddleware: """ This middleware performs three functions in response to an object being created, updated, or deleted: diff --git a/netbox/netbox/request_context.py b/netbox/netbox/request_context.py new file mode 100644 index 000000000..41e8283e8 --- /dev/null +++ b/netbox/netbox/request_context.py @@ -0,0 +1,9 @@ +from netbox import thread_locals + + +def set_request(request): + thread_locals.request = request + + +def get_request(): + return getattr(thread_locals, 'request', None) diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index 9ddb072f3..8fab500dc 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -327,13 +327,6 @@ def decode_dict(encoded_dict: Dict, *, decode_keys: bool = True) -> Dict: return {urllib.parse.unquote(k): decode_value(v, decode_keys) for k, v in encoded_dict.items()} -# Taken from django.utils.functional (<3.0) -def curry(_curried_func, *args, **kwargs): - def _curried(*moreargs, **morekwargs): - return _curried_func(*args, *moreargs, **{**kwargs, **morekwargs}) - return _curried - - def array_to_string(array): """ Generate an efficient, human-friendly string from a set of integers. Intended for use with ArrayField.