Repository: netbox-community/netbox

Issue:
Object deletions not being logged
### Environment
* Python version:  3.5.2
* NetBox version:  2.4-beta1

### Steps to Reproduce
1. Create and delete an object (an IP address, for example)
2. View the change log

<!-- What did you expect to happen? -->
### Expected Behavior
The creation and deletion of the object should be recorded.

<!-- What happened instead? -->
### Observed Behavior
Only the creation of the object is recorded. The deletion is not recorded because the object's primary key is not accessible at the time of deletion.
Object deletions not being logged
### Environment
* Python version:  3.5.2
* NetBox version:  2.4-beta1

### Steps to Reproduce
1. Create and delete an object (an IP address, for example)
2. View the change log

<!-- What did you expect to happen? -->
### Expected Behavior
The creation and deletion of the object should be recorded.

<!-- What happened instead? -->
### Observed Behavior
Only the creation of the object is recorded. The deletion is not recorded because the object's primary key is not accessible at the time of deletion.


Discussion:

Resolving patch:
diff --git a/netbox/extras/middleware.py b/netbox/extras/middleware.py
--- a/netbox/extras/middleware.py
+++ b/netbox/extras/middleware.py
@@ -8,11 +8,11 @@
 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
 
 from extras.webhooks import enqueue_webhooks
 from .constants import (
     OBJECTCHANGE_ACTION_CREATE, OBJECTCHANGE_ACTION_DELETE, OBJECTCHANGE_ACTION_UPDATE,
-    WEBHOOK_MODELS
 )
 from .models import ObjectChange
 
@@ -20,58 +20,69 @@
 _thread_locals = threading.local()
 
 
-def mark_object_changed(instance, **kwargs):
-    """
-    Mark an object as having been created, saved, or updated. At the end of the request, this change will be recorded
-    and/or associated webhooks fired. 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.
-    """
+def cache_changed_object(instance, **kwargs):
+
+    action = OBJECTCHANGE_ACTION_CREATE if kwargs['created'] else OBJECTCHANGE_ACTION_UPDATE
+
+    # Cache the object for further processing was the response has completed.
+    _thread_locals.changed_objects.append(
+        (instance, action)
+    )
+
+
+def _record_object_deleted(request, instance, **kwargs):
 
-    # 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
+    # Record that the object was deleted.
+    if hasattr(instance, 'log_change'):
+        instance.log_change(request.user, request.id, OBJECTCHANGE_ACTION_DELETE)
 
-    _thread_locals.changed_objects.append((instance, action))
+    enqueue_webhooks(instance, OBJECTCHANGE_ACTION_DELETE)
 
 
 class ObjectChangeMiddleware(object):
     """
-    This middleware intercepts all requests to connects object signals to the Django runtime. The signals collect all
-    changed objects into a local thread by way of the `mark_object_changed()` receiver. At the end of the request,
-    the middleware iterates over the objects to process change events like Change Logging and Webhooks.
-    """
+    This middleware performs two functions in response to an object being created, updated, or deleted:
+
+        1. Create an ObjectChange to reflect the modification to the object in the changelog.
+        2. Enqueue any relevant webhooks.
 
+    The post_save and pre_delete signals are employed to catch object modifications, however changes are recorded a bit
+    differently for each. Objects being saved are cached into thread-local storage for action *after* the response has
+    completed. This ensures that serialization of the object is performed only after any related objects (e.g. tags)
+    have been created. Conversely, deletions are acted upon immediately, so that the serialized representation of the
+    object is recorded before it (and any related objects) are actually deleted from the database.
+    """
     def __init__(self, get_response):
         self.get_response = get_response
 
     def __call__(self, request):
 
-        # Initialize the list of changed objects
+        # Initialize an empty list to cache objects being saved.
         _thread_locals.changed_objects = []
 
         # 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()
 
-        # 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')
+        # Signals don't include the request context, so we're currying it into the pre_delete function ahead of time.
+        record_object_deleted = curry(_record_object_deleted, request)
+
+        # Connect our receivers to the post_save and pre_delete signals.
+        post_save.connect(cache_changed_object, dispatch_uid='record_object_saved')
+        post_delete.connect(record_object_deleted, dispatch_uid='record_object_deleted')
 
         # Process the request
         response = self.get_response(request)
 
-        # Perform change logging and fire Webhook signals
+        # Create records for any cached objects that were created/updated.
         for obj, action in _thread_locals.changed_objects:
-            # Log object changes
-            if obj.pk and hasattr(obj, 'log_change'):
+
+            # Record the change
+            if hasattr(obj, 'log_change'):
                 obj.log_change(request.user, request.id, action)
 
-            # Enqueue Webhooks if they are enabled
-            if settings.WEBHOOKS_ENABLED and obj.__class__.__name__.lower() in WEBHOOK_MODELS:
-                enqueue_webhooks(obj, action)
+            # Enqueue webhooks
+            enqueue_webhooks(obj, action)
 
         # Housekeeping: 1% chance of clearing out expired ObjectChanges
         if _thread_locals.changed_objects and settings.CHANGELOG_RETENTION and random.randint(1, 100) == 1:
diff --git a/netbox/extras/webhooks.py b/netbox/extras/webhooks.py
--- a/netbox/extras/webhooks.py
+++ b/netbox/extras/webhooks.py
@@ -1,11 +1,13 @@
 import datetime
 
+from django.conf import settings
 from django.contrib.contenttypes.models import ContentType
 from django.db.models import Q
 
 from extras.models import Webhook
 from extras.constants import OBJECTCHANGE_ACTION_CREATE, OBJECTCHANGE_ACTION_DELETE, OBJECTCHANGE_ACTION_UPDATE
 from utilities.api import get_serializer_for_model
+from .constants import WEBHOOK_MODELS
 
 
 def enqueue_webhooks(instance, action):
@@ -13,6 +15,9 @@ def enqueue_webhooks(instance, action):
     Find Webhook(s) assigned to this instance + action and enqueue them
     to be processed
     """
+    if not settings.WEBHOOKS_ENABLED or instance._meta.model_name not in WEBHOOK_MODELS:
+        return
+
     type_create = action == OBJECTCHANGE_ACTION_CREATE
     type_update = action == OBJECTCHANGE_ACTION_UPDATE
     type_delete = action == OBJECTCHANGE_ACTION_DELETE


Test patch: