Repository: netbox-community/netbox
Pull request: 14947 fix for missing changelog if only update m2m
### Fixes: #14947 

Fix for missing changelog if only update m2m (tag).  Issue is if you only update tags (no other edits) then the changelog entry for the post_save (not m2m_changed) is not created because there are no changes at that point.  When the m2m_changed comes in the update does not work because the query is empty.

Fix is if there is an m2m change and there isn't an existing objectchange then create one, otherwise do the update.

Problem statement:
Changelog entry missing for tag change
### Deployment Type

Self-hosted

### NetBox Version

v3.7.1

### Python Version

3.11

### Steps to Reproduce

1. Edit an Interface / Device / Site
2. Add / remove a tag

Reproducible within demo.netbox.dev.

### Expected Behavior

After adding / removing the tag I would expect to see a changelog entry. 

### Observed Behavior

No changelog entry is created.

Hints from issue discussion:
Seems like a regression introduced in v3.7.0 by #14147.

Setting `CHANGELOG_SKIP_EMPTY_CHANGES = False` works around the bug.

Commit message: 14947 fix for missing changelog if only update m2m

Patch to review:
diff --git a/netbox/extras/signals.py b/netbox/extras/signals.py
index d1b20961ad2..a2d4f91ef6c 100644
--- a/netbox/extras/signals.py
+++ b/netbox/extras/signals.py
@@ -70,13 +70,21 @@ def handle_changed_object(sender, instance, **kwargs):
 
     # Record an ObjectChange if applicable
     if m2m_changed:
-        ObjectChange.objects.filter(
+        qs = ObjectChange.objects.filter(
             changed_object_type=ContentType.objects.get_for_model(instance),
             changed_object_id=instance.pk,
             request_id=request.id
-        ).update(
-            postchange_data=instance.to_objectchange(action).postchange_data
         )
+        if not qs:
+            objectchange = instance.to_objectchange(action)
+            if objectchange and objectchange.has_changes:
+                objectchange.user = request.user
+                objectchange.request_id = request.id
+                objectchange.save()
+        else:
+            qs.update(
+                postchange_data=instance.to_objectchange(action).postchange_data
+            )
     else:
         objectchange = instance.to_objectchange(action)
         if objectchange and objectchange.has_changes:


Code review comments:
diff_hunk: @@ -70,13 +70,21 @@ def handle_changed_object(sender, instance, **kwargs):
 
     # Record an ObjectChange if applicable
     if m2m_changed:
-        ObjectChange.objects.filter(
+        qs = ObjectChange.objects.filter(
             changed_object_type=ContentType.objects.get_for_model(instance),
             changed_object_id=instance.pk,
             request_id=request.id
-        ).update(
-            postchange_data=instance.to_objectchange(action).postchange_data
         )
+        if not qs:
+            objectchange = instance.to_objectchange(action)
+            if objectchange and objectchange.has_changes:
+                objectchange.user = request.user
+                objectchange.request_id = request.id
+                objectchange.save()
+        else:
+            qs.update(
+                postchange_data=instance.to_objectchange(action).postchange_data
+            )
path: netbox/extras/signals.py
text: @user1:
We can clean this up a bit using the [`update_or_create()`](https://docs.djangoproject.com/en/5.0/ref/models/querysets/#update-or-create) queryset method to automatically create a new change record where needed. We can also pull `objectchange = instance.to_objectchange(action)` up to the top, as it appears to be needed under every condition.

@author:
I moved the objectchange = line.

For update_or_create I don't think that would be any cleaner as on create you are doing objectchange.save and not specifying all the fields.  For update_or_create you would need to specify all the fields for defaults and create_defaults which is sort of duplicating instance.to_objectchange.