Skip to content

Commit 35db7c4

Browse files
authored
fix: response seralize() should counts new properties added by user (#725)
1 parent 5764df8 commit 35db7c4

File tree

2 files changed

+135
-3
lines changed

2 files changed

+135
-3
lines changed

pynetbox/core/response.py

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,18 @@ class Record:
295295

296296
url = None
297297

298+
# Internal Record metadata that should not be serialized for API updates.
299+
# These are object bookkeeping attributes, not NetBox API fields.
300+
_INTERNAL_ATTRS = frozenset(
301+
[
302+
"api", # API client instance
303+
"endpoint", # Endpoint object reference
304+
"url", # Object URL (read-only field provided by API)
305+
"has_details", # Flag for lazy-loading full details
306+
"default_ret", # Default Record class for nested objects
307+
]
308+
)
309+
298310
def __init__(self, values, api, endpoint):
299311
self.has_details = False
300312
self._full_cache = []
@@ -538,6 +550,14 @@ def serialize(self, nested=False, init=False):
538550
If an attribute's value is a ``Record`` type it's replaced with
539551
the ``id`` field of that object.
540552
553+
When ``init=False`` (default), includes both original fields from the
554+
API response and any fields that have been set on the object after
555+
initialization. This allows proper change detection for fields set
556+
to None or other values.
557+
558+
When ``init=True``, returns only the original fields from the initial
559+
API response, used for comparing against the current state to detect
560+
changes.
541561
542562
.. note::
543563
@@ -550,13 +570,31 @@ def serialize(self, nested=False, init=False):
550570
if nested:
551571
return get_return(self)
552572

573+
# Determine which fields to serialize
553574
if init:
554-
init_vals = dict(self._init_cache)
575+
# For initial state, use only _init_cache
576+
init_cache_dict = dict(self._init_cache)
577+
fields_to_serialize = init_cache_dict.keys()
578+
init_vals = init_cache_dict
579+
else:
580+
# For current state, include all fields (original + modified)
581+
init_cache_keys = {k for k, _ in self._init_cache}
582+
583+
# Get all non-internal field names from object's __dict__
584+
obj_keys = {
585+
k
586+
for k in self.__dict__.keys()
587+
if not k.startswith("_") and k not in self._INTERNAL_ATTRS
588+
}
589+
590+
# Combine both sets
591+
fields_to_serialize = init_cache_keys | obj_keys
592+
init_vals = {} # Not used when init=False
555593

556594
ret = {}
557595

558-
for i in dict(self):
559-
current_val = getattr(self, i) if not init else init_vals.get(i)
596+
for i in fields_to_serialize:
597+
current_val = getattr(self, i, None) if not init else init_vals.get(i)
560598
if i == "custom_fields":
561599
ret[i] = flatten_custom(current_val)
562600
else:

tests/unit/test_response.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,100 @@ def test_save_updates_cache(self):
416416
# This should now contain the bridge update
417417
self.assertEqual(updates, {"bridge": 1})
418418

419+
def test_serialize_includes_new_field_set_to_none(self):
420+
"""Regression test for issue #708: serialize() should include fields set after init."""
421+
test_obj = Record({"id": 123, "name": "test"}, None, None)
422+
test_obj.new_field = None
423+
424+
current = test_obj.serialize()
425+
self.assertIn("new_field", current)
426+
self.assertIsNone(current["new_field"])
427+
428+
init = test_obj.serialize(init=True)
429+
self.assertNotIn("new_field", init)
430+
431+
def test_serialize_includes_new_field_set_to_value(self):
432+
"""Regression test for issue #708: serialize() should include all new fields."""
433+
test_obj = Record({"id": 123, "name": "test"}, None, None)
434+
test_obj.new_string = "value"
435+
test_obj.new_int = 42
436+
test_obj.new_none = None
437+
438+
current = test_obj.serialize()
439+
self.assertEqual(current["new_string"], "value")
440+
self.assertEqual(current["new_int"], 42)
441+
self.assertIsNone(current["new_none"])
442+
443+
init = test_obj.serialize(init=True)
444+
self.assertNotIn("new_string", init)
445+
self.assertNotIn("new_int", init)
446+
self.assertNotIn("new_none", init)
447+
448+
def test_diff_detects_new_field_set_to_none(self):
449+
"""Regression test for issue #708: _diff() should detect new fields."""
450+
test_obj = Record({"id": 123, "name": "test"}, None, None)
451+
test_obj.primary_mac_address = None
452+
453+
diff = test_obj._diff()
454+
self.assertIn("primary_mac_address", diff)
455+
456+
def test_updates_includes_new_field_set_to_none(self):
457+
"""Regression test for issue #708: updates() should include new fields set to None."""
458+
test_obj = Record({"id": 123, "name": "test"}, None, None)
459+
test_obj.primary_mac_address = None
460+
461+
updates = test_obj.updates()
462+
self.assertIn("primary_mac_address", updates)
463+
self.assertIsNone(updates["primary_mac_address"])
464+
465+
def test_updates_includes_new_field_set_to_value(self):
466+
"""Regression test for issue #708: updates() should include all new fields."""
467+
test_obj = Record({"id": 123, "name": "test"}, None, None)
468+
test_obj.new_field = "new_value"
469+
test_obj.another_field = 42
470+
471+
updates = test_obj.updates()
472+
self.assertEqual(updates["new_field"], "new_value")
473+
self.assertEqual(updates["another_field"], 42)
474+
475+
def test_nested_object_field_update_issue_708(self):
476+
"""Regression test for issue #708: nested objects with limited fields."""
477+
api = Mock()
478+
api.base_url = "http://localhost:8000/api"
479+
interface = Record(
480+
{
481+
"id": 1,
482+
"name": "eth0",
483+
"url": "http://localhost:8000/api/dcim/interfaces/1/",
484+
},
485+
api,
486+
None,
487+
)
488+
interface.primary_mac_address = None
489+
490+
updates = interface.updates()
491+
self.assertIn("primary_mac_address", updates)
492+
self.assertIsNone(updates["primary_mac_address"])
493+
494+
diff = interface._diff()
495+
self.assertIn("primary_mac_address", diff)
496+
497+
def test_serialize_excludes_internal_attributes(self):
498+
"""Ensure serialize() filters out internal Record metadata."""
499+
test_obj = Record({"id": 123, "name": "test"}, None, None)
500+
501+
serialized = test_obj.serialize()
502+
for attr in [
503+
"api",
504+
"endpoint",
505+
"url",
506+
"has_details",
507+
"default_ret",
508+
"_init_cache",
509+
"_full_cache",
510+
]:
511+
self.assertNotIn(attr, serialized)
512+
419513

420514
class RecordSetTestCase(unittest.TestCase):
421515
ids = [1, 3, 5]

0 commit comments

Comments
 (0)