From 8c6f3431112f3a2a7c9868cfa70603b5c29877f0 Mon Sep 17 00:00:00 2001 From: Mikhail Silonov Date: Thu, 8 Aug 2013 18:02:12 +0400 Subject: [PATCH] Added JSON Fields support --- AUTHORS.rst | 1 + CHANGES.rst | 2 + model_utils/tests/fields.py | 30 +++++++ model_utils/tests/models.py | 10 +++ model_utils/tests/tests.py | 171 +++++++++++++++++++++++++++++++++++- model_utils/tracker.py | 12 ++- 6 files changed, 222 insertions(+), 4 deletions(-) create mode 100644 model_utils/tests/fields.py diff --git a/AUTHORS.rst b/AUTHORS.rst index 3a33840..5ea4eba 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -19,3 +19,4 @@ Simon Meers sayane Trey Hunner zyegfryed +Mikhail Silonov diff --git a/CHANGES.rst b/CHANGES.rst index 9b3bb75..6f9defa 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -7,6 +7,8 @@ master (unreleased) * `Choices` now `__contains__` its Python identifier values. Thanks Keryn Knight. (Merge of GH-69). +* Added JSON Fields support. + 1.4.0 (2013.06.03) ------------------ diff --git a/model_utils/tests/fields.py b/model_utils/tests/fields.py new file mode 100644 index 0000000..b0cdcad --- /dev/null +++ b/model_utils/tests/fields.py @@ -0,0 +1,30 @@ +import json + +from django.db import models +from django.core.serializers.json import DjangoJSONEncoder + + +class SimpleJSONField(models.TextField): + + __metaclass__ = models.SubfieldBase + + def to_python(self, value): + if value == "": + return None + + try: + if isinstance(value, basestring): + return json.loads(value) + except ValueError: + pass + + return value + + def get_db_prep_save(self, value, connection): + if value == "": + return None + + if isinstance(value, dict): + value = json.dumps(value, cls=DjangoJSONEncoder) + + return super(SimpleJSONField, self).get_db_prep_save(value, connection) diff --git a/model_utils/tests/models.py b/model_utils/tests/models.py index 99d33ad..af8ff95 100644 --- a/model_utils/tests/models.py +++ b/model_utils/tests/models.py @@ -5,6 +5,7 @@ from model_utils.models import TimeStampedModel, StatusModel, TimeFramedModel from model_utils.tracker import FieldTracker, ModelTracker from model_utils.managers import QueryManager, InheritanceManager, PassThroughManager from model_utils.fields import SplitField, MonitorField, StatusField +from model_utils.tests.fields import SimpleJSONField from model_utils import Choices @@ -271,6 +272,15 @@ class TrackedMultiple(models.Model): number_tracker = FieldTracker(fields=['number']) +class TrackedWithJsonField(models.Model): + name = models.CharField(max_length=20) + number = models.IntegerField() + + props = SimpleJSONField() + + tracker = FieldTracker() + + class ModelTracked(models.Model): name = models.CharField(max_length=20) number = models.IntegerField() diff --git a/model_utils/tests/tests.py b/model_utils/tests/tests.py index ab60a62..779209c 100644 --- a/model_utils/tests/tests.py +++ b/model_utils/tests/tests.py @@ -26,9 +26,9 @@ from model_utils.tests.models import ( StatusPlainTuple, TimeFrame, Monitored, StatusManagerAdded, TimeFrameManagerAdded, Dude, SplitFieldAbstractParent, Car, Spot, ModelTracked, ModelTrackedFK, ModelTrackedNotDefault, ModelTrackedMultiple, - Tracked, TrackedFK, TrackedNotDefault, TrackedNonFieldAttr, - TrackedMultiple, StatusFieldDefaultFilled, StatusFieldDefaultNotFilled) - + Tracked, TrackedFK, TrackedNotDefault, TrackedWithJsonField, + TrackedNonFieldAttr, TrackedMultiple, StatusFieldDefaultFilled, + StatusFieldDefaultNotFilled) class GetExcerptTests(TestCase): @@ -924,6 +924,171 @@ class FieldTrackedModelCustomTests(FieldTrackerTestCase, self.assertCurrent(name='new age') +class JSONFieldTrackedModelTests(FieldTrackerTestCase): + + tracked_class = TrackedWithJsonField + + def setUp(self): + self.instance = self.tracked_class() + self.tracker = self.instance.tracker + + def test_pre_save_changed(self): + self.assertChanged(name=None) + self.instance.name = 'new age' + self.assertChanged(name=None) + self.instance.number = 8 + self.assertChanged(name=None, number=None) + self.instance.name = '' + self.assertChanged(name=None, number=None) + self.instance.props = {'attr': 1} + self.assertChanged(name=None, number=None, props=None) + + def test_first_save(self): + self.assertHasChanged(name=True) + self.assertPrevious(name=None, number=None, props=None) + self.assertCurrent(name='', number=None, props=None, id=None) + self.assertChanged(name=None) + self.instance.name = 'retro' + self.instance.number = 4 + self.instance.props = {'vodka': True} + self.assertHasChanged(name=True, number=True, props=True) + self.assertPrevious(name=None, number=None, props=None) + self.assertCurrent(name='retro', number=4, + props={'vodka': True}, id=None) + self.assertChanged(name=None, number=None, props=None) + + def test_pre_save_has_changed(self): + self.assertHasChanged(name=True) + self.instance.name = 'new age' + self.assertHasChanged(name=True) + self.instance.number = 7 + self.assertHasChanged(name=True, number=True) + self.instance.props = {'bears_on_red_square': False} + self.assertChanged(name=None, number=None, props=None) + + def test_post_save_has_changed(self): + self.update_instance( + name='retro', number=4, + props={ + 'goodies': { + 'balalaika': True, + 'Topol-M': True + } + } + ) + self.assertHasChanged(name=False, number=False, props=False) + self.instance.name = 'new age' + self.assertHasChanged(name=True, number=False, props=False) + self.instance.number = 8 + self.assertHasChanged(name=True, number=True) + self.instance.name = 'retro' + self.assertHasChanged(name=False, number=True) + self.instance.props = { + 'goodies': { + 'balalaika': False, + 'Topol-M': True + } + } + self.assertHasChanged(name=False, number=True, props=True) + + def test_post_save_previous(self): + self.update_instance( + name='retro', number=4, + props={ + 'goodies': { + 'balalaika': True, + 'Topol-M': True + } + } + ) + self.instance.name = 'new age' + self.instance.props = { + 'goodies': { + 'balalaika': False, + 'Topol-M': True + } + } + self.assertPrevious( + name='retro', + number=4, + props={ + 'goodies': { + 'balalaika': True, + 'Topol-M': True + } + } + ) + + def test_post_save_changed(self): + self.update_instance( + name='retro', number=4, + props={ + 'goodies': { + 'balalaika': True, + 'Topol-M': True + } + } + ) + self.assertChanged() + self.instance.name = 'new age' + self.assertChanged(name='retro') + self.instance.number = 8 + self.assertChanged(name='retro', number=4) + self.instance.name = 'retro' + self.assertChanged(number=4) + self.instance.props = { + 'goodies': { + 'balalaika': False, + 'Topol-M': True + } + } + self.assertChanged( + number=4, + props={ + 'goodies': { + 'balalaika': True, + 'Topol-M': True + } + } + ) + + def test_current(self): + self.assertCurrent(name='', number=None, props=None, id=None) + self.instance.name = 'new age' + self.assertCurrent(name='new age', number=None, props=None, id=None) + self.instance.number = 8 + self.assertCurrent(name='new age', number=8, props=None, id=None) + self.instance.props = { + 'goodies': { + 'balalaika': False, + 'Topol-M': True + } + } + self.assertCurrent( + name='new age', + number=8, + props={ + 'goodies': { + 'balalaika': False, + 'Topol-M': True + } + }, + id=None + ) + self.instance.save() + self.assertCurrent( + name='new age', + number=8, + props={ + 'goodies': { + 'balalaika': False, + 'Topol-M': True + } + }, + id=self.instance.id + ) + + class FieldTrackedModelAttributeTests(FieldTrackerTestCase): tracked_class = TrackedNonFieldAttr diff --git a/model_utils/tracker.py b/model_utils/tracker.py index 31fd0f2..a302c99 100644 --- a/model_utils/tracker.py +++ b/model_utils/tracker.py @@ -1,4 +1,7 @@ from __future__ import unicode_literals + +from copy import deepcopy + from django.db import models from django.core.exceptions import FieldError @@ -19,6 +22,7 @@ class FieldInstanceTracker(object): self.saved_data = self.current() else: self.saved_data.update(**self.current(fields=fields)) + return self.saved_data def current(self, fields=None): """Return dict of current values for all tracked fields""" @@ -76,7 +80,8 @@ class FieldTracker(object): def initialize_tracker(self, sender, instance, **kwargs): tracker = self.tracker_class(instance, self.fields, self.field_map) setattr(instance, self.attname, tracker) - tracker.set_saved_fields() + saved_data = tracker.set_saved_fields() + self.prevent_side_effects(saved_data) self.patch_save(instance) def patch_save(self, instance): @@ -88,6 +93,11 @@ class FieldTracker(object): return ret instance.save = save + def prevent_side_effects(self, saved_data): + for field, field_value in saved_data.items(): + if isinstance(field_value, dict): + saved_data[field] = deepcopy(field_value) + def __get__(self, instance, owner): if instance is None: return self