Repository: netbox-community/netbox

Issue:
Missing change log entries for updated many-to-many fields as part of deletes
### NetBox version

v3.6.4

### Python version

3.8

### Steps to Reproduce

1. Create a couple of device roles and a config context that is assigned to those two roles:

```
{
    "id": 1,
    "url": "https://demo.netbox.dev/api/extras/config-contexts/1/",
    "display": "Test Context",
    "name": "Test Context",
    "weight": 1000,
    "description": "",
    "is_active": true,
    "regions": [],
    "site_groups": [],
    "sites": [],
    "locations": [],
    "device_types": [],
    "roles": [
        {
            "id": 13,
            "url": "https://demo.netbox.dev/api/dcim/device-roles/13/",
            "display": "Test Role",
            "name": "Test Role",
            "slug": "test-role"
        },
        {
            "id": 14,
            "url": "https://demo.netbox.dev/api/dcim/device-roles/14/",
            "display": "Test Role 2",
            "name": "Test Role 2",
            "slug": "test-role-2"
        }
    ],
    "platforms": [],
    "cluster_types": [],
    "cluster_groups": [],
    "clusters": [],
    "tenant_groups": [],
    "tenants": [],
    "tags": [],
    "data_source": null,
    "data_path": "",
    "data_file": null,
    "data_synced": null,
    "data": {
        "foo": 123
    },
    "created": "2023-10-18T22:07:46.872083Z",
    "last_updated": "2023-10-18T22:07:46.872120Z"
}
```
2. Delete one of the roles

### Expected Behavior

This should result in two change log entries: one for the device role deletion and one for the update to the config context.

### Observed Behavior

Only a single change log entry is created:

`GET /api/extras/object-changes/?request_id=8f69edb2-918f-40da-ac13-2fd1b8d5a3e6`
```
{
    "count": 1,
    "next": null,
    "previous": null,
    "results": [
        {
            "id": 5179,
            "url": "https://demo.netbox.dev/api/extras/object-changes/5179/",
            "display": "dcim | device role Test Role deleted by admin",
            "time": "2023-10-18T22:08:46.787241Z",
            "user": {
                "id": 54,
                "url": "https://demo.netbox.dev/api/users/users/54/",
                "display": "admin",
                "username": "admin"
            },
            "user_name": "admin",
            "request_id": "8f69edb2-918f-40da-ac13-2fd1b8d5a3e6",
            "action": {
                "value": "delete",
                "label": "Deleted"
            },
            "changed_object_type": "dcim.devicerole",
            "changed_object_id": 13,
            "changed_object": null,
            "prechange_data": {
                "name": "Test Role",
                "slug": "test-role",
                "tags": [],
                "color": "9e9e9e",
                "created": "2023-10-18T22:07:13.945Z",
                "vm_role": true,
                "description": "",
                "last_updated": "2023-10-18T22:07:13.945Z",
                "custom_fields": {},
                "config_template": null
            },
            "postchange_data": null
        }
    ]
}
```

But the config context is updated:
```
{
    "id": 1,
    "url": "https://demo.netbox.dev/api/extras/config-contexts/1/",
    "display": "Test Context",
    "name": "Test Context",
    "weight": 1000,
    "description": "",
    "is_active": true,
    "regions": [],
    "site_groups": [],
    "sites": [],
    "locations": [],
    "device_types": [],
    "roles": [
        {
            "id": 14,
            "url": "https://demo.netbox.dev/api/dcim/device-roles/14/",
            "display": "Test Role 2",
            "name": "Test Role 2",
            "slug": "test-role-2"
        }
    ],
    "platforms": [],
    "cluster_types": [],
    "cluster_groups": [],
    "clusters": [],
    "tenant_groups": [],
    "tenants": [],
    "tags": [],
    "data_source": null,
    "data_path": "",
    "data_file": null,
    "data_synced": null,
    "data": {
        "foo": 123
    },
    "created": "2023-10-18T22:07:46.872083Z",
    "last_updated": "2023-10-18T22:07:46.872120Z"
}
```

Note: I believe this affects all models that use `ManyToManyField` relations.


Discussion:
I believe this would be addressed by FR #14059.
> I believe this would be addressed by FR #14059.

Ah yes! I tried to search for open issues, but couldn't find anything. Great timing how you just opened it 2 days ago :)
Note: I also found this 12 year old Django bug that seems related: https://code.djangoproject.com/ticket/17688
So the easiest way for NetBox to fix this, is probably #14059.
It may be some time before we can tackle #14059 (assuming it's even feasible), so let's see if we can come with a fix in the interim.

Resolving patch:
diff --git a/netbox/extras/signals.py b/netbox/extras/signals.py
--- a/netbox/extras/signals.py
+++ b/netbox/extras/signals.py
@@ -3,6 +3,7 @@
 
 from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ValidationError
+from django.db.models.fields.reverse_related import ManyToManyRel
 from django.db.models.signals import m2m_changed, post_save, pre_delete
 from django.dispatch import receiver, Signal
 from django.utils.translation import gettext_lazy as _
@@ -15,6 +16,7 @@
 from extras.validators import CustomValidator
 from netbox.config import get_config
 from netbox.context import current_request, events_queue
+from netbox.models.features import ChangeLoggingMixin
 from netbox.signals import post_clean
 from utilities.exceptions import AbortRequest
 from .choices import ObjectChangeActionChoices
@@ -68,7 +70,7 @@ def handle_changed_object(sender, instance, **kwargs):
     else:
         return
 
-    # Create/update an ObejctChange record for this change
+    # Create/update an ObjectChange record for this change
     objectchange = instance.to_objectchange(action)
     # If this is a many-to-many field change, check for a previous ObjectChange instance recorded
     # for this object by this request and update it
@@ -122,6 +124,25 @@ def handle_deleted_object(sender, instance, **kwargs):
         objectchange.request_id = request.id
         objectchange.save()
 
+    # Django does not automatically send an m2m_changed signal for the reverse direction of a
+    # many-to-many relationship (see https://code.djangoproject.com/ticket/17688), so we need to
+    # trigger one manually. We do this by checking for any reverse M2M relationships on the
+    # instance being deleted, and explicitly call .remove() on the remote M2M field to delete
+    # the association. This triggers an m2m_changed signal with the `post_remove` action type
+    # for the forward direction of the relationship, ensuring that the change is recorded.
+    for relation in instance._meta.related_objects:
+        if type(relation) is not ManyToManyRel:
+            continue
+        related_model = relation.related_model
+        related_field_name = relation.remote_field.name
+        if not issubclass(related_model, ChangeLoggingMixin):
+            # We only care about triggering the m2m_changed signal for models which support
+            # change logging
+            continue
+        for obj in related_model.objects.filter(**{related_field_name: instance.pk}):
+            obj.snapshot()  # Ensure the change record includes the "before" state
+            getattr(obj, related_field_name).remove(instance)
+
     # Enqueue webhooks
     queue = events_queue.get()
     enqueue_object(queue, instance, request.user, request.id, ObjectChangeActionChoices.ACTION_DELETE)


Test patch: