From 41f69b8f3199f64d185971830f52f62197e5645a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 5 Sep 2025 15:14:03 -0400 Subject: [PATCH] Closes #20277: Add support for attribute assignment to deserialize_object() --- netbox/utilities/serialization.py | 27 ++++++++--- netbox/utilities/tests/test_serialization.py | 49 ++++++++++++++++++++ 2 files changed, 70 insertions(+), 6 deletions(-) create mode 100644 netbox/utilities/tests/test_serialization.py diff --git a/netbox/utilities/serialization.py b/netbox/utilities/serialization.py index 037977742..5509867ae 100644 --- a/netbox/utilities/serialization.py +++ b/netbox/utilities/serialization.py @@ -51,30 +51,45 @@ def serialize_object(obj, resolve_tags=True, extra=None, exclude=None): return data -def deserialize_object(model, fields, pk=None): +def deserialize_object(model, data, pk=None): """ Instantiate an object from the given model and field data. Functions as the complement to serialize_object(). """ content_type = ContentType.objects.get_for_model(model) + data = data.copy() m2m_data = {} # Account for custom field data - if 'custom_fields' in fields: - fields['custom_field_data'] = fields.pop('custom_fields') + if 'custom_fields' in data: + data['custom_field_data'] = data.pop('custom_fields') # Pop any assigned tags to handle the M2M relationships manually - if is_taggable(model) and fields.get('tags'): + if is_taggable(model) and data.get('tags'): Tag = apps.get_model('extras', 'Tag') - m2m_data['tags'] = Tag.objects.filter(name__in=fields.pop('tags')) + m2m_data['tags'] = Tag.objects.filter(name__in=data.pop('tags')) + # Separate any non-field attributes for assignment after deserialization of the object + model_fields = [ + field.name for field in model._meta.get_fields() + ] + attrs = { + name: data.pop(name) for name in list(data.keys()) + if name not in model_fields + } + + # Employ Django's native Python deserializer to produce the instance data = { 'model': '.'.join(content_type.natural_key()), 'pk': pk, - 'fields': fields, + 'fields': data, } instance = list(serializers.deserialize('python', [data]))[0] + # Assign non-field attributes + for name, value in attrs.items(): + setattr(instance.object, name, value) + # Apply any additional M2M assignments instance.m2m_data.update(**m2m_data) diff --git a/netbox/utilities/tests/test_serialization.py b/netbox/utilities/tests/test_serialization.py new file mode 100644 index 000000000..044b52cc1 --- /dev/null +++ b/netbox/utilities/tests/test_serialization.py @@ -0,0 +1,49 @@ +from django.test import TestCase + +from dcim.choices import SiteStatusChoices +from dcim.models import Site +from extras.models import Tag +from utilities.serialization import deserialize_object, serialize_object + + +class SerializationTestCase(TestCase): + + @classmethod + def setUpTestData(cls): + tags = ( + Tag(name='Tag 1', slug='tag-1'), + Tag(name='Tag 2', slug='tag-2'), + Tag(name='Tag 3', slug='tag-3'), + ) + Tag.objects.bulk_create(tags) + + def test_serialize_object(self): + site = Site.objects.create( + name='Site 1', + slug='site=1', + description='Ignore me', + ) + site.tags.set(Tag.objects.all()) + + data = serialize_object(site, extra={'foo': 123}, exclude=['description']) + self.assertEqual(data['name'], site.name) + self.assertEqual(data['slug'], site.slug) + self.assertEqual(data['tags'], [tag.name for tag in Tag.objects.all()]) + self.assertEqual(data['foo'], 123) + self.assertNotIn('description', data) + + def test_deserialize_object(self): + data = { + 'name': 'Site 1', + 'slug': 'site-1', + 'tags': ['Tag 1', 'Tag 2', 'Tag 3'], + 'foo': 123, + } + + instance = deserialize_object(Site, data, pk=123) + self.assertEqual(instance.object.pk, 123) + self.assertEqual(instance.object.name, data['name']) + self.assertEqual(instance.object.slug, data['slug']) + self.assertEqual(instance.object.status, SiteStatusChoices.STATUS_ACTIVE) # Default field value + self.assertEqual(instance.object.foo, data['foo']) # Non-field attribute + self.assertEqual(list(instance.m2m_data['tags']), list(Tag.objects.all()))