From c8cd5fd6cd05f474a5a64ca8a9612aff402a36ae Mon Sep 17 00:00:00 2001 From: Arthur Date: Mon, 16 Mar 2026 17:14:26 -0700 Subject: [PATCH 1/5] #14329 Improve diffs for custom_fields --- netbox/core/models/change_logging.py | 10 +++-- netbox/core/views.py | 7 +--- netbox/utilities/data.py | 30 ++++++++++++++ netbox/utilities/tests/test_data.py | 61 ++++++++++++++++++++++++++++ 4 files changed, 99 insertions(+), 9 deletions(-) diff --git a/netbox/core/models/change_logging.py b/netbox/core/models/change_logging.py index c5aab1838..b6bf13f7a 100644 --- a/netbox/core/models/change_logging.py +++ b/netbox/core/models/change_logging.py @@ -11,7 +11,7 @@ from mptt.models import MPTTModel from core.choices import ObjectChangeActionChoices from core.querysets import ObjectChangeQuerySet from netbox.models.features import ChangeLoggingMixin, has_feature -from utilities.data import shallow_compare_dict +from utilities.data import deep_compare_dict, shallow_compare_dict __all__ = ( 'ObjectChange', @@ -202,9 +202,11 @@ class ObjectChange(models.Model): elif self.action == ObjectChangeActionChoices.ACTION_DELETE: changed_attrs = sorted(prechange_data.keys()) else: - # TODO: Support deep (recursive) comparison - changed_data = shallow_compare_dict(prechange_data, postchange_data) - changed_attrs = sorted(changed_data.keys()) + diff_added, diff_removed = deep_compare_dict(prechange_data, postchange_data) + return { + 'pre': dict(sorted(diff_removed.items())), + 'post': dict(sorted(diff_added.items())), + } return { 'pre': { diff --git a/netbox/core/views.py b/netbox/core/views.py index 21e68d1b6..a8a45e5cc 100644 --- a/netbox/core/views.py +++ b/netbox/core/views.py @@ -30,7 +30,7 @@ from netbox.views import generic from netbox.views.generic.base import BaseObjectView from netbox.views.generic.mixins import TableMixin from utilities.apps import get_installed_apps -from utilities.data import shallow_compare_dict +from utilities.data import deep_compare_dict, shallow_compare_dict from utilities.forms import ConfirmationForm from utilities.htmx import htmx_partial from utilities.json import ConfigJSONEncoder @@ -273,14 +273,11 @@ class ObjectChangeView(generic.ObjectView): prechange_data = instance.prechange_data_clean if prechange_data and instance.postchange_data: - diff_added = shallow_compare_dict( + diff_added, diff_removed = deep_compare_dict( prechange_data or dict(), instance.postchange_data_clean or dict(), exclude=['last_updated'], ) - diff_removed = { - x: prechange_data.get(x) for x in diff_added - } if prechange_data else {} else: diff_added = None diff_removed = None diff --git a/netbox/utilities/data.py b/netbox/utilities/data.py index 549bf96ef..8ab8e110b 100644 --- a/netbox/utilities/data.py +++ b/netbox/utilities/data.py @@ -7,6 +7,7 @@ __all__ = ( 'array_to_ranges', 'array_to_string', 'check_ranges_overlap', + 'deep_compare_dict', 'deepmerge', 'drange', 'flatten_dict', @@ -83,6 +84,35 @@ def shallow_compare_dict(source_dict, destination_dict, exclude=tuple()): return difference +def deep_compare_dict(source_dict, destination_dict, exclude=tuple()): + """ + Return a two-tuple of dictionaries (added, removed) representing the differences between source_dict and + destination_dict. For values which are themselves dicts, the comparison is performed recursively such that only + the changed keys within the nested dict are included. `exclude` is a list or tuple of keys to be ignored. + """ + added = {} + removed = {} + + all_keys = set(source_dict) | set(destination_dict) + for key in all_keys: + if key in exclude: + continue + src_val = source_dict.get(key) + dst_val = destination_dict.get(key) + if src_val == dst_val: + continue + if isinstance(src_val, dict) and isinstance(dst_val, dict): + sub_added, sub_removed = deep_compare_dict(src_val, dst_val) + if sub_added or sub_removed: + added[key] = sub_added + removed[key] = sub_removed + else: + added[key] = dst_val + removed[key] = src_val + + return added, removed + + # # Array utilities # diff --git a/netbox/utilities/tests/test_data.py b/netbox/utilities/tests/test_data.py index 0d4bd95df..ab48ce496 100644 --- a/netbox/utilities/tests/test_data.py +++ b/netbox/utilities/tests/test_data.py @@ -3,6 +3,7 @@ from django.test import TestCase from utilities.data import ( check_ranges_overlap, + deep_compare_dict, get_config_value_ci, ranges_to_string, ranges_to_string_list, @@ -100,6 +101,66 @@ class RangeFunctionsTestCase(TestCase): ) +class DeepCompareDictTestCase(TestCase): + + def test_no_changes(self): + source = {'a': 1, 'b': 'foo', 'c': {'x': 1, 'y': 2}} + dest = {'a': 1, 'b': 'foo', 'c': {'x': 1, 'y': 2}} + added, removed = deep_compare_dict(source, dest) + self.assertEqual(added, {}) + self.assertEqual(removed, {}) + + def test_scalar_change(self): + source = {'a': 1, 'b': 'foo'} + dest = {'a': 2, 'b': 'foo'} + added, removed = deep_compare_dict(source, dest) + self.assertEqual(added, {'a': 2}) + self.assertEqual(removed, {'a': 1}) + + def test_key_added(self): + source = {'a': 1} + dest = {'a': 1, 'b': 'new'} + added, removed = deep_compare_dict(source, dest) + self.assertEqual(added, {'b': 'new'}) + self.assertEqual(removed, {'b': None}) + + def test_key_removed(self): + source = {'a': 1, 'b': 'old'} + dest = {'a': 1} + added, removed = deep_compare_dict(source, dest) + self.assertEqual(added, {'b': None}) + self.assertEqual(removed, {'b': 'old'}) + + def test_nested_dict_partial_change(self): + """Only changed sub-keys of a nested dict are included.""" + source = {'custom_fields': {'cf1': 'old', 'cf2': 'unchanged'}} + dest = {'custom_fields': {'cf1': 'new', 'cf2': 'unchanged'}} + added, removed = deep_compare_dict(source, dest) + self.assertEqual(added, {'custom_fields': {'cf1': 'new'}}) + self.assertEqual(removed, {'custom_fields': {'cf1': 'old'}}) + + def test_nested_dict_no_change(self): + source = {'name': 'test', 'custom_fields': {'cf1': 'same'}} + dest = {'name': 'test', 'custom_fields': {'cf1': 'same'}} + added, removed = deep_compare_dict(source, dest) + self.assertEqual(added, {}) + self.assertEqual(removed, {}) + + def test_exclude(self): + source = {'a': 1, 'last_updated': '2024-01-01'} + dest = {'a': 2, 'last_updated': '2024-06-01'} + added, removed = deep_compare_dict(source, dest, exclude=['last_updated']) + self.assertEqual(added, {'a': 2}) + self.assertEqual(removed, {'a': 1}) + + def test_deeply_nested(self): + source = {'level1': {'level2': {'val': 'old', 'other': 'same'}}} + dest = {'level1': {'level2': {'val': 'new', 'other': 'same'}}} + added, removed = deep_compare_dict(source, dest) + self.assertEqual(added, {'level1': {'level2': {'val': 'new'}}}) + self.assertEqual(removed, {'level1': {'level2': {'val': 'old'}}}) + + class GetConfigValueCITestCase(TestCase): def test_exact_match(self): From 992630d670fd5fbe654040b85694df95e704f6f1 Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 17 Mar 2026 08:44:18 -0700 Subject: [PATCH 2/5] #14329 Improve diffs for custom_fields --- netbox/core/models/change_logging.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/core/models/change_logging.py b/netbox/core/models/change_logging.py index b6bf13f7a..09da4fe4d 100644 --- a/netbox/core/models/change_logging.py +++ b/netbox/core/models/change_logging.py @@ -11,7 +11,7 @@ from mptt.models import MPTTModel from core.choices import ObjectChangeActionChoices from core.querysets import ObjectChangeQuerySet from netbox.models.features import ChangeLoggingMixin, has_feature -from utilities.data import deep_compare_dict, shallow_compare_dict +from utilities.data import deep_compare_dict __all__ = ( 'ObjectChange', From 45b53ee036bac86b7508a3e998a798b66456d0da Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 17 Mar 2026 09:03:57 -0700 Subject: [PATCH 3/5] #14329 Improve diffs for custom_fields --- netbox/templates/core/objectchange.html | 24 +++++++++- netbox/utilities/tests/test_data.py | 60 ------------------------- 2 files changed, 22 insertions(+), 62 deletions(-) diff --git a/netbox/templates/core/objectchange.html b/netbox/templates/core/objectchange.html index e4c7d4900..d08af4db7 100644 --- a/netbox/templates/core/objectchange.html +++ b/netbox/templates/core/objectchange.html @@ -120,7 +120,17 @@ {% spaceless %}
                   {% for k, v in object.prechange_data_clean.items %}
-                    {{ k }}: {{ v|json }}
+                    {% with subdiff=diff_removed|get_key:k %}
+                      {% if subdiff.items %}
+                        {{ k }}: {
+                        {% for sub_k, sub_v in v.items %}
+                          {{ sub_k }}: {{ sub_v|json }}
+                        {% endfor %}
+                        }
+                      {% else %}
+                        {{ k }}: {{ v|json }}
+                      {% endif %}
+                    {% endwith %}
                   {% endfor %}
                 
{% endspaceless %} @@ -140,7 +150,17 @@ {% spaceless %}
                       {% for k, v in object.postchange_data_clean.items %}
-                        {{ k }}: {{ v|json }}
+                        {% with subdiff=diff_added|get_key:k %}
+                          {% if subdiff.items %}
+                            {{ k }}: {
+                            {% for sub_k, sub_v in v.items %}
+                              {{ sub_k }}: {{ sub_v|json }}
+                            {% endfor %}
+                            }
+                          {% else %}
+                            {{ k }}: {{ v|json }}
+                          {% endif %}
+                        {% endwith %}
                       {% endfor %}
                     
{% endspaceless %} diff --git a/netbox/utilities/tests/test_data.py b/netbox/utilities/tests/test_data.py index ab48ce496..fe63ddea2 100644 --- a/netbox/utilities/tests/test_data.py +++ b/netbox/utilities/tests/test_data.py @@ -3,7 +3,6 @@ from django.test import TestCase from utilities.data import ( check_ranges_overlap, - deep_compare_dict, get_config_value_ci, ranges_to_string, ranges_to_string_list, @@ -101,65 +100,6 @@ class RangeFunctionsTestCase(TestCase): ) -class DeepCompareDictTestCase(TestCase): - - def test_no_changes(self): - source = {'a': 1, 'b': 'foo', 'c': {'x': 1, 'y': 2}} - dest = {'a': 1, 'b': 'foo', 'c': {'x': 1, 'y': 2}} - added, removed = deep_compare_dict(source, dest) - self.assertEqual(added, {}) - self.assertEqual(removed, {}) - - def test_scalar_change(self): - source = {'a': 1, 'b': 'foo'} - dest = {'a': 2, 'b': 'foo'} - added, removed = deep_compare_dict(source, dest) - self.assertEqual(added, {'a': 2}) - self.assertEqual(removed, {'a': 1}) - - def test_key_added(self): - source = {'a': 1} - dest = {'a': 1, 'b': 'new'} - added, removed = deep_compare_dict(source, dest) - self.assertEqual(added, {'b': 'new'}) - self.assertEqual(removed, {'b': None}) - - def test_key_removed(self): - source = {'a': 1, 'b': 'old'} - dest = {'a': 1} - added, removed = deep_compare_dict(source, dest) - self.assertEqual(added, {'b': None}) - self.assertEqual(removed, {'b': 'old'}) - - def test_nested_dict_partial_change(self): - """Only changed sub-keys of a nested dict are included.""" - source = {'custom_fields': {'cf1': 'old', 'cf2': 'unchanged'}} - dest = {'custom_fields': {'cf1': 'new', 'cf2': 'unchanged'}} - added, removed = deep_compare_dict(source, dest) - self.assertEqual(added, {'custom_fields': {'cf1': 'new'}}) - self.assertEqual(removed, {'custom_fields': {'cf1': 'old'}}) - - def test_nested_dict_no_change(self): - source = {'name': 'test', 'custom_fields': {'cf1': 'same'}} - dest = {'name': 'test', 'custom_fields': {'cf1': 'same'}} - added, removed = deep_compare_dict(source, dest) - self.assertEqual(added, {}) - self.assertEqual(removed, {}) - - def test_exclude(self): - source = {'a': 1, 'last_updated': '2024-01-01'} - dest = {'a': 2, 'last_updated': '2024-06-01'} - added, removed = deep_compare_dict(source, dest, exclude=['last_updated']) - self.assertEqual(added, {'a': 2}) - self.assertEqual(removed, {'a': 1}) - - def test_deeply_nested(self): - source = {'level1': {'level2': {'val': 'old', 'other': 'same'}}} - dest = {'level1': {'level2': {'val': 'new', 'other': 'same'}}} - added, removed = deep_compare_dict(source, dest) - self.assertEqual(added, {'level1': {'level2': {'val': 'new'}}}) - self.assertEqual(removed, {'level1': {'level2': {'val': 'old'}}}) - class GetConfigValueCITestCase(TestCase): From ca021e808bb2f600db4ee29404f3b416a22bc7cb Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 17 Mar 2026 09:14:41 -0700 Subject: [PATCH 4/5] #14329 Improve diffs for custom_fields --- netbox/core/views.py | 2 +- netbox/utilities/tests/test_data.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/netbox/core/views.py b/netbox/core/views.py index a8a45e5cc..bf767d18b 100644 --- a/netbox/core/views.py +++ b/netbox/core/views.py @@ -30,7 +30,7 @@ from netbox.views import generic from netbox.views.generic.base import BaseObjectView from netbox.views.generic.mixins import TableMixin from utilities.apps import get_installed_apps -from utilities.data import deep_compare_dict, shallow_compare_dict +from utilities.data import deep_compare_dict from utilities.forms import ConfirmationForm from utilities.htmx import htmx_partial from utilities.json import ConfigJSONEncoder diff --git a/netbox/utilities/tests/test_data.py b/netbox/utilities/tests/test_data.py index fe63ddea2..0d4bd95df 100644 --- a/netbox/utilities/tests/test_data.py +++ b/netbox/utilities/tests/test_data.py @@ -100,7 +100,6 @@ class RangeFunctionsTestCase(TestCase): ) - class GetConfigValueCITestCase(TestCase): def test_exact_match(self): From 1fb6507cc1cb4c0980be9c2324076a2c59b6f642 Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 17 Mar 2026 09:44:01 -0700 Subject: [PATCH 5/5] #14329 Improve diffs for custom_fields --- netbox/core/models/change_logging.py | 24 +++++----- netbox/core/views.py | 4 +- netbox/templates/core/objectchange.html | 4 +- netbox/utilities/tests/test_data.py | 59 +++++++++++++++++++++++++ 4 files changed, 74 insertions(+), 17 deletions(-) diff --git a/netbox/core/models/change_logging.py b/netbox/core/models/change_logging.py index 09da4fe4d..4409994da 100644 --- a/netbox/core/models/change_logging.py +++ b/netbox/core/models/change_logging.py @@ -199,20 +199,18 @@ class ObjectChange(models.Model): # Determine which attributes have changed if self.action == ObjectChangeActionChoices.ACTION_CREATE: changed_attrs = sorted(postchange_data.keys()) - elif self.action == ObjectChangeActionChoices.ACTION_DELETE: - changed_attrs = sorted(prechange_data.keys()) - else: - diff_added, diff_removed = deep_compare_dict(prechange_data, postchange_data) return { - 'pre': dict(sorted(diff_removed.items())), - 'post': dict(sorted(diff_added.items())), + 'pre': {k: prechange_data.get(k) for k in changed_attrs}, + 'post': {k: postchange_data.get(k) for k in changed_attrs}, } - + if self.action == ObjectChangeActionChoices.ACTION_DELETE: + changed_attrs = sorted(prechange_data.keys()) + return { + 'pre': {k: prechange_data.get(k) for k in changed_attrs}, + 'post': {k: postchange_data.get(k) for k in changed_attrs}, + } + diff_added, diff_removed = deep_compare_dict(prechange_data, postchange_data) return { - 'pre': { - k: prechange_data.get(k) for k in changed_attrs - }, - 'post': { - k: postchange_data.get(k) for k in changed_attrs - }, + 'pre': dict(sorted(diff_removed.items())), + 'post': dict(sorted(diff_added.items())), } diff --git a/netbox/core/views.py b/netbox/core/views.py index bf767d18b..2b8eb4c52 100644 --- a/netbox/core/views.py +++ b/netbox/core/views.py @@ -274,8 +274,8 @@ class ObjectChangeView(generic.ObjectView): if prechange_data and instance.postchange_data: diff_added, diff_removed = deep_compare_dict( - prechange_data or dict(), - instance.postchange_data_clean or dict(), + prechange_data, + instance.postchange_data_clean, exclude=['last_updated'], ) else: diff --git a/netbox/templates/core/objectchange.html b/netbox/templates/core/objectchange.html index d08af4db7..1864e2332 100644 --- a/netbox/templates/core/objectchange.html +++ b/netbox/templates/core/objectchange.html @@ -124,7 +124,7 @@ {% if subdiff.items %} {{ k }}: { {% for sub_k, sub_v in v.items %} - {{ sub_k }}: {{ sub_v|json }} + {{ sub_k }}: {{ sub_v|json }} {% endfor %} } {% else %} @@ -154,7 +154,7 @@ {% if subdiff.items %} {{ k }}: { {% for sub_k, sub_v in v.items %} - {{ sub_k }}: {{ sub_v|json }} + {{ sub_k }}: {{ sub_v|json }} {% endfor %} } {% else %} diff --git a/netbox/utilities/tests/test_data.py b/netbox/utilities/tests/test_data.py index 0d4bd95df..957f09ed7 100644 --- a/netbox/utilities/tests/test_data.py +++ b/netbox/utilities/tests/test_data.py @@ -3,6 +3,7 @@ from django.test import TestCase from utilities.data import ( check_ranges_overlap, + deep_compare_dict, get_config_value_ci, ranges_to_string, ranges_to_string_list, @@ -100,6 +101,64 @@ class RangeFunctionsTestCase(TestCase): ) +class DeepCompareDictTestCase(TestCase): + + def test_no_changes(self): + source = {'a': 1, 'b': 'foo', 'c': {'x': 1, 'y': 2}} + added, removed = deep_compare_dict(source, source) + self.assertEqual(added, {}) + self.assertEqual(removed, {}) + + def test_scalar_change(self): + source = {'a': 1, 'b': 'foo'} + dest = {'a': 2, 'b': 'foo'} + added, removed = deep_compare_dict(source, dest) + self.assertEqual(added, {'a': 2}) + self.assertEqual(removed, {'a': 1}) + + def test_key_added(self): + source = {'a': 1} + dest = {'a': 1, 'b': 'new'} + added, removed = deep_compare_dict(source, dest) + self.assertEqual(added, {'b': 'new'}) + self.assertEqual(removed, {'b': None}) + + def test_key_removed(self): + source = {'a': 1, 'b': 'old'} + dest = {'a': 1} + added, removed = deep_compare_dict(source, dest) + self.assertEqual(added, {'b': None}) + self.assertEqual(removed, {'b': 'old'}) + + def test_nested_dict_partial_change(self): + """Only changed sub-keys of a nested dict are included.""" + source = {'custom_fields': {'cf1': 'old', 'cf2': 'unchanged'}} + dest = {'custom_fields': {'cf1': 'new', 'cf2': 'unchanged'}} + added, removed = deep_compare_dict(source, dest) + self.assertEqual(added, {'custom_fields': {'cf1': 'new'}}) + self.assertEqual(removed, {'custom_fields': {'cf1': 'old'}}) + + def test_nested_dict_no_change(self): + source = {'name': 'test', 'custom_fields': {'cf1': 'same'}} + added, removed = deep_compare_dict(source, source) + self.assertEqual(added, {}) + self.assertEqual(removed, {}) + + def test_mixed_flat_and_nested(self): + source = {'name': 'old', 'custom_fields': {'cf1': 'old', 'cf2': 'same'}} + dest = {'name': 'new', 'custom_fields': {'cf1': 'new', 'cf2': 'same'}} + added, removed = deep_compare_dict(source, dest) + self.assertEqual(added, {'name': 'new', 'custom_fields': {'cf1': 'new'}}) + self.assertEqual(removed, {'name': 'old', 'custom_fields': {'cf1': 'old'}}) + + def test_exclude(self): + source = {'a': 1, 'last_updated': '2024-01-01'} + dest = {'a': 2, 'last_updated': '2024-06-01'} + added, removed = deep_compare_dict(source, dest, exclude=['last_updated']) + self.assertEqual(added, {'a': 2}) + self.assertEqual(removed, {'a': 1}) + + class GetConfigValueCITestCase(TestCase): def test_exact_match(self):