├── rtd_requirements.txt ├── docs ├── history.rst ├── api.rst ├── conf.py ├── index.rst └── Makefile ├── tests ├── testproject │ ├── uploads │ │ └── git_won't_commit_an_empty_folder │ ├── __init__.py │ ├── testapp │ │ ├── __init__.py │ │ ├── models.py │ │ └── tests.py │ ├── wsgi.py │ └── settings.py ├── penny_back.png ├── penny_front.png ├── __init__.py ├── manage.py └── test.py ├── MANIFEST.in ├── test_requirements.txt ├── .gitmodules ├── HISTORY.rst ├── .coveragerc ├── save_the_change ├── __init__.py ├── mappings.py ├── util.py ├── mixins.py ├── descriptors.py └── decorators.py ├── .travis.yml ├── LICENSE ├── setup.py └── README.rst /rtd_requirements.txt: -------------------------------------------------------------------------------- 1 | Django==1.10 2 | -------------------------------------------------------------------------------- /docs/history.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../HISTORY.rst 2 | -------------------------------------------------------------------------------- /tests/testproject/uploads/git_won't_commit_an_empty_folder: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst HISTORY.rst LICENSE test_requirements.txt 2 | -------------------------------------------------------------------------------- /test_requirements.txt: -------------------------------------------------------------------------------- 1 | codecov==2.0.5 2 | coverage==4.3.4 3 | pillow==4.0.0 4 | pytz==2016.10 5 | -------------------------------------------------------------------------------- /tests/penny_back.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hellohaptik/django-save-the-change/master/tests/penny_back.png -------------------------------------------------------------------------------- /tests/penny_front.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hellohaptik/django-save-the-change/master/tests/penny_front.png -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "docs/_themes"] 2 | path = docs/_themes 3 | url = https://github.com/snide/sphinx_rtd_theme.git 4 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import division, absolute_import, print_function, unicode_literals 4 | -------------------------------------------------------------------------------- /tests/testproject/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import division, absolute_import, print_function, unicode_literals 4 | -------------------------------------------------------------------------------- /tests/testproject/testapp/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import division, absolute_import, print_function, unicode_literals 4 | -------------------------------------------------------------------------------- /tests/testproject/wsgi.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import division, absolute_import, print_function, unicode_literals 4 | 5 | import os 6 | 7 | 8 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'testapp.settings') 9 | from django.core.wsgi import get_wsgi_application 10 | application = get_wsgi_application() 11 | -------------------------------------------------------------------------------- /HISTORY.rst: -------------------------------------------------------------------------------- 1 | History 2 | ####### 3 | 4 | 1.1.0 (05/16/2014) 5 | ------------------ 6 | 7 | - Add proper support for ForeignKeys (thanks to Brandon Konkle and 8 | Brian Wilson). 9 | 10 | - Add update_together field to model Meta, via UpdateTogetherModel. 11 | 12 | 13 | 1.0.0 (09/08/2013) 14 | ------------------ 15 | 16 | - Initial release. 17 | -------------------------------------------------------------------------------- /tests/manage.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import division, absolute_import, print_function, unicode_literals 4 | 5 | import os 6 | import sys 7 | 8 | 9 | if __name__ == "__main__": 10 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'testproject.settings') 11 | from django.core.management import execute_from_command_line 12 | execute_from_command_line(sys.argv) 13 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | 4 | 5 | [report] 6 | exclude_lines = 7 | # Re-enable pragma 8 | pragma: no cover 9 | 10 | # Debug only 11 | def __repr__ 12 | if self\.debug 13 | if DEBUG 14 | 15 | # Assertions 16 | raise AssertionError 17 | raise NotImplementedError 18 | 19 | # Unrunnable 20 | if False: 21 | if __name__ == .__main__.: 22 | 23 | # Used only in _name_map blocks 24 | except AttributeError 25 | -------------------------------------------------------------------------------- /save_the_change/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import division, absolute_import, print_function, unicode_literals 4 | 5 | 6 | VERSION = (1, 1, 1) 7 | 8 | __title__ = 'Save The Change' 9 | __version__ = '.'.join((str(i) for i in VERSION)) # str for compatibility with setup.py under Python 3. 10 | __author__ = 'Karan Lyons' 11 | __contact__ = 'karan@karanlyons.com' 12 | __homepage__ = 'https://github.com/karanlyons/django-save-the-change' 13 | __license__ = 'Apache 2.0' 14 | __copyright__ = 'Copyright 2013 Karan Lyons' 15 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | python: 4 | - "2.7" 5 | - "3.3" 6 | - "3.4" 7 | - "3.5" 8 | 9 | env: 10 | - DJANGO_VERSION=1.8 11 | - DJANGO_VERSION=1.9 12 | - DJANGO_VERSION=1.10 13 | 14 | matrix: 15 | exclude: 16 | - python: "3.3" 17 | env: DJANGO_VERSION=1.9 18 | - python: "3.3" 19 | env: DJANGO_VERSION=1.10 20 | 21 | install: 22 | - pip install -r test_requirements.txt 23 | - pip install django==$DJANGO_VERSION 24 | - pip install -e . 25 | 26 | script: 27 | - coverage run --source=save_the_change setup.py test 28 | 29 | after_success: 30 | - codecov 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2013 Karan Lyons 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /tests/testproject/settings.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import division, absolute_import, print_function, unicode_literals 4 | 5 | import os 6 | import sys 7 | 8 | 9 | sys.path.insert(0, '..') 10 | 11 | DEBUG = TEMPLATE_DEBUG = True 12 | 13 | DATABASES = { 14 | 'default': { 15 | 'ENGINE': 'django.db.backends.sqlite3', 16 | 'NAME': 'test_database' 17 | }, 18 | } 19 | 20 | TIME_ZONE = 'UTC' 21 | USE_TZ = True 22 | 23 | MEDIA_ROOT = os.path.join(os.path.dirname(__file__), 'uploads') 24 | 25 | SECRET_KEY = 'q+xn9-%#q-u2zu*)utsl)wde%&k6ci88hqpjo1w9=2*@l*3ydl' 26 | 27 | INSTALLED_APPS = ( 28 | 'testproject.testapp', 29 | ) 30 | 31 | TEST_RUNNER = 'django.test.runner.DiscoverRunner' 32 | -------------------------------------------------------------------------------- /tests/test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import division, absolute_import, print_function, unicode_literals 4 | 5 | import os 6 | import sys 7 | 8 | sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) 9 | sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 10 | 11 | os.environ['DJANGO_SETTINGS_MODULE'] = 'testproject.settings' 12 | 13 | import django 14 | from django.test.utils import get_runner 15 | from django.conf import settings 16 | 17 | 18 | def run_tests(): 19 | django.setup() 20 | sys.exit(bool(get_runner(settings)(verbosity=1, interactive=True).run_tests(['testproject.testapp.tests']))) 21 | 22 | 23 | if __name__ == '__main__': 24 | run_tests() 25 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | API 2 | ### 3 | 4 | Developer Interface 5 | =================== 6 | 7 | .. autofunction:: save_the_change.decorators.SaveTheChange 8 | 9 | .. autofunction:: save_the_change.decorators.UpdateTogether 10 | 11 | .. autofunction:: save_the_change.decorators.TrackChanges 12 | 13 | .. autoclass:: save_the_change.mappings.OldValues 14 | 15 | 16 | Internals 17 | ========= 18 | 19 | .. autoclass:: save_the_change.decorators.STCMixin 20 | 21 | .. autofunction:: save_the_change.decorators._inject_stc 22 | 23 | .. autofunction:: save_the_change.decorators._save_the_change_save_hook 24 | 25 | .. autofunction:: save_the_change.decorators._update_together_save_hook 26 | 27 | .. autoclass:: save_the_change.descriptors.ChangeTrackingDescriptor 28 | 29 | .. autofunction:: save_the_change.descriptors._inject_descriptors 30 | 31 | .. autofunction:: save_the_change.util.is_mutable 32 | -------------------------------------------------------------------------------- /save_the_change/mappings.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import division, absolute_import, print_function, unicode_literals 4 | 5 | from collections import Mapping 6 | 7 | 8 | class OldValues(Mapping): 9 | """ 10 | A read-only :class:`~collections.Mapping` of the original values for \ 11 | its model. 12 | 13 | Attributes can be accessed with either dot or bracket notation. 14 | 15 | """ 16 | 17 | def __init__(self, instance): 18 | self.instance = instance 19 | 20 | def __getattr__(self, name): 21 | try: 22 | return self.__getitem__(name) 23 | 24 | except KeyError: 25 | raise AttributeError(name) 26 | 27 | def __getitem__(self, name): 28 | try: 29 | return self.instance._mutable_fields[name] 30 | 31 | except KeyError: 32 | try: 33 | return self.instance._changed_fields[name] 34 | 35 | except KeyError: 36 | try: 37 | return getattr(self.instance, name) 38 | 39 | except AttributeError: 40 | raise KeyError(name) 41 | 42 | def __iter__(self): 43 | for field in self.instance._meta.get_fields(): 44 | yield field.name 45 | 46 | if field.name != field.attname: 47 | yield field.attname 48 | 49 | def __len__(self): 50 | return len(self.instance._meta.get_fields()) 51 | 52 | def __repr__(self): 53 | return '' % repr(self.instance) 54 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import save_the_change 4 | 5 | 6 | try: 7 | from setuptools import setup 8 | 9 | except ImportError: 10 | from distutils.core import setup 11 | 12 | tests_require = [] 13 | for line in open('test_requirements.txt', 'rU').readlines(): 14 | if line and line not in '\n' and not line.startswith(('#', '-')): 15 | tests_require.append(line.replace('\n', '')) 16 | 17 | setup( 18 | name="django-save-the-change", 19 | version=save_the_change.__version__, 20 | description="Automatically save only changed model data.", 21 | long_description="\n\n".join([open('README.rst', 'rU').read(), open('HISTORY.rst', 'rU').read()]), 22 | author=save_the_change.__author__, 23 | author_email=save_the_change.__contact__, 24 | url=save_the_change.__homepage__, 25 | license=open('LICENSE', 'rU').read(), 26 | packages=['save_the_change'], 27 | package_dir={'save_the_change': 'save_the_change'}, 28 | package_data={'': ['README.rst', 'HISTORY.rst', 'LICENSE']}, 29 | include_package_data=True, 30 | tests_require=tests_require, 31 | zip_safe=False, 32 | classifiers=( 33 | 'Development Status :: 5 - Production/Stable', 34 | 'Intended Audience :: Developers', 35 | 'Natural Language :: English', 36 | 'License :: OSI Approved :: Apache Software License', 37 | 'Programming Language :: Python', 38 | 'Programming Language :: Python :: 2.7', 39 | 'Programming Language :: Python :: 3', 40 | 'Programming Language :: Python :: 3.3', 41 | 'Programming Language :: Python :: 3.4', 42 | 'Programming Language :: Python :: 3.5', 43 | ), 44 | test_suite='tests.test.run_tests', 45 | ) 46 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import division, absolute_import, print_function, unicode_literals 4 | 5 | import os 6 | import sys 7 | 8 | sys.path.insert(0, os.path.abspath('..')) 9 | sys.path.append(os.path.abspath('_themes')) 10 | 11 | sys.path.append(os.path.abspath('../tests')) 12 | os.environ['DJANGO_SETTINGS_MODULE'] = 'testproject.settings' 13 | 14 | from save_the_change import __copyright__, __title__, __version__ 15 | 16 | 17 | project = __title__ 18 | copyright = __copyright__ 19 | version = release = __version__ 20 | language = 'English' 21 | 22 | extensions = [ 23 | 'sphinx.ext.intersphinx', 24 | 'sphinx.ext.autodoc', 25 | 'sphinx.ext.viewcode', 26 | ] 27 | 28 | intersphinx_mapping = { 29 | 'python': ('http://docs.python.org/2.7', None), 30 | 'django': ('https://docs.djangoproject.com/en/1.10', 'https://docs.djangoproject.com/en/1.10/_objects'), 31 | } 32 | 33 | templates_path = ['_templates'] 34 | exclude_patterns = ['_build'] 35 | html_theme_path = ['_themes'] 36 | html_static_path = ['_static'] 37 | source_suffix = '.rst' 38 | master_doc = 'index' 39 | 40 | add_function_parentheses = True 41 | add_module_names = True 42 | pygments_style = 'sphinx' 43 | 44 | htmlhelp_basename = 'save_the_change_docs' 45 | html_title = "{title} {version} Documentation".format(title=project, version=version) 46 | html_short_title = project 47 | html_last_updated_fmt = '' 48 | html_show_sphinx = False 49 | html_domain_indices = False 50 | html_use_modindex = False 51 | html_use_index = False 52 | 53 | if os.environ.get('READTHEDOCS', None) == 'True': 54 | html_theme = 'default' 55 | 56 | else: 57 | html_theme = 'sphinx_rtd_theme' 58 | 59 | html_theme_options = { 60 | 'sticky_navigation': True, 61 | } 62 | -------------------------------------------------------------------------------- /save_the_change/util.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import division, absolute_import, print_function, unicode_literals 4 | 5 | from datetime import date, time, datetime, timedelta, tzinfo 6 | from decimal import Decimal 7 | from uuid import UUID 8 | 9 | from django.utils import six 10 | 11 | 12 | #: A :class:`set` listing known immutable types. 13 | IMMUTABLE_TYPES = set(( 14 | type(None), bool, float, complex, Decimal, 15 | six.text_type, six.binary_type, tuple, frozenset, 16 | date, time, datetime, timedelta, tzinfo, 17 | UUID 18 | ) + six.integer_types + six.string_types) 19 | 20 | #: A :class:`set` listing known immutable types that are infinitely 21 | #: recursively iterable. 22 | INFINITELY_ITERABLE_IMMUTABLE_TYPES = set( 23 | (six.text_type, six.binary_type) + six.string_types 24 | ) 25 | 26 | 27 | class DoesNotExist: 28 | """Indicates when an attribute does not exist on an object.""" 29 | pass 30 | 31 | 32 | def is_mutable(obj): 33 | """ 34 | Checks if given object is likely mutable. 35 | 36 | :param obj: object to check. 37 | 38 | We check that the object is itself a known immutable type, and then attempt 39 | to recursively check any objects within it. Strings are special cased to 40 | prevent us getting stuck in an infinite loop. 41 | 42 | :return: 43 | :const:`True` if the object is likely mutable, :const:`False` if it 44 | definitely is not. 45 | :rtype: :obj:`bool` 46 | 47 | """ 48 | 49 | if type(obj) not in IMMUTABLE_TYPES: 50 | return True 51 | 52 | elif type(obj) not in INFINITELY_ITERABLE_IMMUTABLE_TYPES: 53 | try: 54 | for sub_obj in iter(obj): 55 | if is_mutable(sub_obj): 56 | return True 57 | 58 | except TypeError: 59 | pass 60 | 61 | return False 62 | -------------------------------------------------------------------------------- /save_the_change/mixins.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import division, absolute_import, print_function, unicode_literals 4 | 5 | import warnings 6 | 7 | from django.db import models 8 | 9 | 10 | """ 11 | These are just stubs to prevent migrations from failing after upgrading from 12 | STC v1 to v2, and also to warn developers who may blindly upgrade about the 13 | changes to the API. 14 | 15 | """ 16 | 17 | 18 | class UpdateTogetherModel(models.Model): 19 | def __init__(self, *args, **kwargs): 20 | leaf = self.__class__.mro()[0] 21 | 22 | warnings.warn( 23 | "%s.%s: mixins.UpdateTogetherModel is no longer supported, instead use the decorator decorators.UpdateTogether." % ( 24 | leaf.__module__, 25 | leaf.__name__, 26 | ), 27 | RuntimeWarning, 28 | ) 29 | 30 | super(UpdateTogetherModel, self).__init__(*args, **kwargs) 31 | 32 | class Meta: 33 | abstract = True 34 | 35 | 36 | class SaveTheChange(object): 37 | def __init__(self, *args, **kwargs): 38 | leaf = self.__class__.mro()[0] 39 | 40 | warnings.warn( 41 | "%s.%s: mixins.SaveTheChange is no longer supported, instead use the decorator decorators.SaveTheChange." % ( 42 | leaf.__module__, 43 | leaf.__name__, 44 | ), 45 | RuntimeWarning, 46 | ) 47 | 48 | super(SaveTheChange, self).__init__(*args, **kwargs) 49 | 50 | 51 | class TrackChanges(object): 52 | def __init__(self, *args, **kwargs): 53 | leaf = self.__class__.mro()[0] 54 | 55 | warnings.warn( 56 | "%s.%s: mixins.TrackChanges is no longer supported, instead use the decorator decorators.TrackChanges." % ( 57 | leaf.__module__, 58 | leaf.__name__, 59 | ), 60 | RuntimeWarning, 61 | ) 62 | 63 | super(TrackChanges, self).__init__(*args, **kwargs) 64 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ############### 2 | Save The Change 3 | ############### 4 | 5 | .. image:: https://img.shields.io/pypi/v/django-save-the-change.svg 6 | :target: https://pypi.python.org/pypi/django-save-the-change 7 | .. image:: https://travis-ci.org/karanlyons/django-save-the-change.svg?branch=master 8 | :target: https://travis-ci.org/karanlyons/django-save-the-change 9 | .. image:: https://codecov.io/github/karanlyons/django-save-the-change/coverage.svg?branch=master 10 | :target: https://codecov.io/github/karanlyons/django-save-the-change 11 | 12 | Save The Change takes this: 13 | 14 | .. code-block:: pycon 15 | 16 | >>> lancelot = Knight.objects.get(name="Sir Lancelot") 17 | >>> lancelot.favorite_color = "Blue" 18 | >>> lancelot.save() 19 | 20 | 21 | And does this: 22 | 23 | .. code-block:: sql 24 | 25 | UPDATE "roundtable_knight" 26 | SET "favorite_color" = 'Blue' 27 | 28 | 29 | Instead of this: 30 | 31 | .. code-block:: sql 32 | 33 | UPDATE "roundtable_knight" 34 | SET "name" = 'Sir Lancelot', 35 | "from" = 'Camelot', 36 | "quest" = 'To seek the Holy Grail.', 37 | "favorite_color" = 'Blue', 38 | "epithet" = 'The brave', 39 | "actor" = 'John Cleese', 40 | "full_name" = 'John Marwood Cleese', 41 | "height" = '6''11"', 42 | "birth_date" = '1939-10-27', 43 | "birth_union" = 'UK', 44 | "birth_country" = 'England', 45 | "birth_county" = 'Somerset', 46 | "birth_town" = 'Weston-Super-Mare', 47 | "facial_hair" = 'mustache', 48 | "graduated" = true, 49 | "university" = 'Cambridge University', 50 | "degree" = 'LL.B.', 51 | 52 | 53 | Installation 54 | ============ 55 | 56 | Install Save The Change just like everything else: 57 | 58 | .. code-block:: bash 59 | 60 | $ pip install django-save-the-change 61 | 62 | 63 | Documentation 64 | ============= 65 | 66 | Full documentation is available at 67 | `ReadTheDocs `_. 68 | -------------------------------------------------------------------------------- /tests/testproject/testapp/models.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import division, absolute_import, print_function, unicode_literals 4 | 5 | import os 6 | 7 | from django.db import models 8 | 9 | from save_the_change.decorators import SaveTheChange, TrackChanges, UpdateTogether 10 | 11 | 12 | @TrackChanges 13 | @SaveTheChange 14 | class Enlightenment(models.Model): 15 | """ 16 | A model to test ForeignKeys. 17 | 18 | """ 19 | 20 | aspect = models.CharField(max_length=32) 21 | 22 | def __unicode__(self): 23 | return self.aspect 24 | 25 | 26 | @UpdateTogether(('chaos', 'fire')) 27 | @SaveTheChange 28 | @UpdateTogether(('fire', 'brimstone')) 29 | @SaveTheChange 30 | class Disorder(models.Model): 31 | """ 32 | A model to test out of order and duplicate decorators. 33 | 34 | """ 35 | 36 | chaos = models.BooleanField() 37 | fire = models.BooleanField() 38 | brimstone = models.BooleanField() 39 | 40 | 41 | @SaveTheChange 42 | @TrackChanges 43 | @UpdateTogether(('big_integer', 'small_integer')) 44 | class EnlightenedModel(models.Model): 45 | """ 46 | A model to test (almost) everything else. 47 | 48 | """ 49 | 50 | big_integer = models.BigIntegerField() 51 | boolean = models.BooleanField() 52 | char = models.CharField(max_length=32) 53 | comma_seperated_integer = models.CommaSeparatedIntegerField(max_length=32) 54 | date = models.DateField() 55 | date_time = models.DateTimeField() 56 | decimal = models.DecimalField(max_digits=3, decimal_places=2) 57 | email = models.EmailField() 58 | enlightenment = models.ForeignKey(Enlightenment, related_name='enlightened_models') 59 | holism = models.ManyToManyField(Enlightenment) 60 | file = models.FileField(upload_to='./') 61 | file_path = models.FilePathField(path=os.path.join(__file__, '..', 'uploads')) 62 | float = models.FloatField() 63 | image = models.ImageField(upload_to='./') 64 | integer = models.IntegerField() 65 | IP_address = models.IPAddressField() 66 | generic_IP = models.GenericIPAddressField() 67 | null_boolean = models.NullBooleanField() 68 | positive_integer = models.PositiveIntegerField() 69 | positive_small_integer = models.PositiveSmallIntegerField() 70 | slug = models.SlugField() 71 | small_integer = models.SmallIntegerField() 72 | text = models.TextField() 73 | time = models.TimeField() 74 | URL = models.URLField() 75 | 76 | def __init__(self, *args, **kwargs): 77 | self.init_started = True 78 | 79 | super(EnlightenedModel, self).__init__(*args, **kwargs) 80 | 81 | self.init_ended = True 82 | 83 | def refresh_from_db(self, using=None, fields=None): 84 | self.refresh_from_db_started = True 85 | 86 | super(EnlightenedModel, self).refresh_from_db(using, fields) 87 | 88 | self.refresh_from_db_ended = True 89 | 90 | def save(self, *args, **kwargs): 91 | self.save_started = True 92 | 93 | super(EnlightenedModel, self).save(*args, **kwargs) 94 | 95 | self.save_ended = True 96 | -------------------------------------------------------------------------------- /save_the_change/descriptors.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import division, absolute_import, print_function, unicode_literals 4 | 5 | from copy import deepcopy 6 | 7 | from .util import DoesNotExist, is_mutable 8 | 9 | 10 | class ChangeTrackingDescriptor(object): 11 | """ 12 | Descriptor that wraps model attributes to detect changes. 13 | 14 | Not all fields in older versions of Django are represented by descriptors 15 | themselves, so we handle both getting/setting bare attributes on the model 16 | and calling out to descriptors if they exist. 17 | 18 | """ 19 | 20 | def __init__(self, name, django_descriptor=None): 21 | self.name = name 22 | self.django_descriptor = django_descriptor 23 | 24 | def __get__(self, instance=None, owner=None): 25 | if instance is None: 26 | return self.django_descriptor 27 | 28 | if self.django_descriptor: 29 | value = self.django_descriptor.__get__(instance, owner) 30 | 31 | else: 32 | value = instance.__dict__.get(self.name, DoesNotExist) 33 | 34 | # We'll never have to check the value's mutability more than once, and 35 | # then only if it's ever accessed. If it's not mutable the only way 36 | # it'll change (normally) is through a call to our __set__, at which 37 | # point the original value will end up in _changed_fields. 38 | if not ( 39 | self.name in instance.__dict__.get('_mutability_checked', set()) or 40 | self.name in instance.__dict__.get('_changed_fields', {}) or 41 | self.name in instance.__dict__.get('_mutable_fields', {}) 42 | ): 43 | if is_mutable(value): 44 | if '_mutable_fields' not in instance.__dict__: 45 | instance.__dict__['_mutable_fields'] = {} 46 | instance.__dict__['_mutable_fields'][self.name] = deepcopy(value) 47 | if '_mutability_checked' not in instance.__dict__: 48 | instance.__dict__['_mutability_checked'] = set() 49 | 50 | instance.__dict__['_mutability_checked'].add(self.name) 51 | 52 | return value 53 | 54 | def __set__(self, instance, value): 55 | if self.name not in instance.__dict__.get('_mutable_fields', {}): 56 | old_value = instance.__dict__.get(self.name, DoesNotExist) 57 | 58 | if old_value is DoesNotExist and self.django_descriptor and hasattr(self.django_descriptor, 'cache_name'): 59 | old_value = instance.__dict__.get(self.django_descriptor.cache_name, DoesNotExist) 60 | 61 | if old_value is not DoesNotExist: 62 | if '_changed_fields' not in instance.__dict__: 63 | instance.__dict__['_changed_fields'] = {} 64 | if instance.__dict__['_changed_fields'].get(self.name, DoesNotExist) == value: 65 | instance.__dict__['_changed_fields'].pop(self.name, None) 66 | 67 | elif value != old_value: 68 | # Unfortunately we need to make a deep copy here, which is 69 | # a bit more expensive than a shallow copy. This is to 70 | # avoid situations like: 71 | # 72 | # >>> m = Model.objects.get(pk=1) 73 | # ... m.mutable_attr 74 | # [1, 2, 3] 75 | # >>> reference = m.mutable_attr 76 | # >>> m.mutable_attr = None 77 | # >>> reference.append(4) 78 | # >>> reference 79 | # [1, 2, 3, 4] 80 | # >>> m.revert_fields('mutable_attr') 81 | # >>> m.mutable_attr == reference 82 | # True 83 | # 84 | # It's an edge case, to be sure, but one we can't see coming 85 | # without likely worse solutions (such as checking *all* 86 | # attributes for immutability on model 87 | # instantiation/refresh). 88 | instance.__dict__['_changed_fields'].setdefault(self.name, deepcopy(old_value)) 89 | 90 | if self.django_descriptor and hasattr(self.django_descriptor, '__set__'): 91 | self.django_descriptor.__set__(instance, value) 92 | 93 | else: 94 | instance.__dict__[self.name] = value 95 | 96 | 97 | def _inject_descriptors(cls): 98 | """ 99 | Iterates over concrete fields in a model and wraps them in a descriptor to \ 100 | track their changes. 101 | 102 | """ 103 | 104 | for field in cls._meta.concrete_fields: 105 | setattr(cls, field.attname, ChangeTrackingDescriptor(field.attname, cls.__dict__.get(field.attname))) 106 | 107 | if field.attname != field.name: 108 | setattr(cls, field.name, ChangeTrackingDescriptor(field.name, cls.__dict__.get(field.name))) 109 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. toctree:: 2 | :hidden: 3 | :maxdepth: 4 4 | 5 | self 6 | api 7 | history 8 | 9 | 10 | ############### 11 | Save The Change 12 | ############### 13 | 14 | .. image:: https://img.shields.io/pypi/v/django-save-the-change.svg 15 | :target: https://pypi.python.org/pypi/django-save-the-change 16 | .. image:: https://travis-ci.org/karanlyons/django-save-the-change.svg?branch=master 17 | :target: https://travis-ci.org/karanlyons/django-save-the-change 18 | .. image:: https://codecov.io/github/karanlyons/django-save-the-change/coverage.svg?branch=master 19 | :target: https://codecov.io/github/karanlyons/django-save-the-change 20 | 21 | Save The Change takes this: 22 | 23 | .. code-block:: pycon 24 | 25 | >>> lancelot = Knight.objects.get(name="Sir Lancelot") 26 | >>> lancelot.favorite_color = "Blue" 27 | >>> lancelot.save() 28 | 29 | 30 | And does this: 31 | 32 | .. code-block:: sql 33 | 34 | UPDATE "roundtable_knight" 35 | SET "favorite_color" = 'Blue' 36 | 37 | 38 | Instead of this: 39 | 40 | .. code-block:: sql 41 | 42 | UPDATE "roundtable_knight" 43 | SET "name" = 'Sir Lancelot', 44 | "from" = 'Camelot', 45 | "quest" = 'To seek the Holy Grail.', 46 | "favorite_color" = 'Blue', 47 | "epithet" = 'The brave', 48 | "actor" = 'John Cleese', 49 | "full_name" = 'John Marwood Cleese', 50 | "height" = '6''11"', 51 | "birth_date" = '1939-10-27', 52 | "birth_union" = 'UK', 53 | "birth_country" = 'England', 54 | "birth_county" = 'Somerset', 55 | "birth_town" = 'Weston-Super-Mare', 56 | "facial_hair" = 'mustache', 57 | "graduated" = true, 58 | "university" = 'Cambridge University', 59 | "degree" = 'LL.B.', 60 | 61 | 62 | Installation 63 | ============ 64 | 65 | Install Save The Change just like everything else: 66 | 67 | .. code-block:: bash 68 | 69 | $ pip install django-save-the-change 70 | 71 | 72 | Usage 73 | ===== 74 | 75 | Just add the :class:`~save_the_change.decorators.SaveTheChange` decorator to 76 | your model: 77 | 78 | .. code-block:: python 79 | 80 | from django.db import models 81 | from save_the_change.decorators import SaveTheChange 82 | 83 | @SaveTheChange 84 | class Knight(models.model): 85 | ... 86 | 87 | 88 | And that's it! Keep using Django like you always have, Save The Change will take 89 | care of you. 90 | 91 | 92 | How It Works 93 | ============ 94 | 95 | Save The Change encapsulates the fields of your model with its own descriptors 96 | that track their values for any changes. When you call 97 | :meth:`~django.db.models.Model.save`, Save The Change passes the names of 98 | your changed fields through Django's ``update_fields`` argument, and Django does 99 | the rest, sending only those fields back to the database. 100 | 101 | 102 | Caveats 103 | ======= 104 | 105 | Save The Change can't help you with 106 | :class:`~django.db.models.ManyToManyField`\s nor reverse relations, as 107 | those aren't handled through :meth:`~django.db.models.Model.save`. But 108 | everything else should work. 109 | 110 | 111 | Goodies 112 | ======= 113 | 114 | Save The Change also comes with two additional decorators, 115 | :class:`~save_the_change.decorators.TrackChanges` and 116 | :class:`~save_the_change.decorators.UpdateTogether`. 117 | 118 | :class:`~save_the_change.decorators.TrackChanges` provides some additional 119 | properties and methods to keep interact with changes made to your model, 120 | including comparing the old and new values and reverting any changes to your 121 | model before you save it. It can be used independently 122 | of :class:`~save_the_change.decorators.SaveTheChange`. 123 | 124 | :class:`~save_the_change.decorators.UpdateTogether` is an additional decorator 125 | which allows you to specify groups of fields that are dependent on each other in 126 | your model, ensuring that if any of them change they'll all be saved together. 127 | For example: 128 | 129 | .. code-block:: python 130 | 131 | from django.db import models 132 | from save_the_change.decorators import SaveTheChange, UpdateTogether 133 | 134 | @SaveTheChange 135 | @UpdateTogether(('height_feet', 'height_inches')) 136 | class Knight(models.model): 137 | ... 138 | 139 | Now if you ever make a change to either part of our Knight's height, *both* 140 | the feet and the inches will be sent to the database together, so that they 141 | can't accidentally fall out of sync. 142 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | # the i18n builder cannot share the environment and doctrees with the others 15 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 16 | 17 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 18 | 19 | help: 20 | @echo "Please use 'make ' where is one of" 21 | @echo " html to make standalone HTML files" 22 | @echo " dirhtml to make HTML files named index.html in directories" 23 | @echo " singlehtml to make a single large HTML file" 24 | @echo " pickle to make pickle files" 25 | @echo " json to make JSON files" 26 | @echo " htmlhelp to make HTML files and a HTML help project" 27 | @echo " qthelp to make HTML files and a qthelp project" 28 | @echo " devhelp to make HTML files and a Devhelp project" 29 | @echo " epub to make an epub" 30 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 31 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 32 | @echo " text to make text files" 33 | @echo " man to make manual pages" 34 | @echo " texinfo to make Texinfo files" 35 | @echo " info to make Texinfo files and run them through makeinfo" 36 | @echo " gettext to make PO message catalogs" 37 | @echo " changes to make an overview of all changed/added/deprecated items" 38 | @echo " linkcheck to check all external links for integrity" 39 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 40 | 41 | autoreload: 42 | fswatch ../ 'make html' 43 | 44 | clean: 45 | -rm -rf $(BUILDDIR)/* 46 | 47 | html: 48 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 49 | @echo 50 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 51 | 52 | dirhtml: 53 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 56 | 57 | singlehtml: 58 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 59 | @echo 60 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 61 | 62 | pickle: 63 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 64 | @echo 65 | @echo "Build finished; now you can process the pickle files." 66 | 67 | json: 68 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 69 | @echo 70 | @echo "Build finished; now you can process the JSON files." 71 | 72 | htmlhelp: 73 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 74 | @echo 75 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 76 | ".hhp project file in $(BUILDDIR)/htmlhelp." 77 | 78 | qthelp: 79 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 80 | @echo 81 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 82 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 83 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/save_the_change.qhcp" 84 | @echo "To view the help file:" 85 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/save_the_change.qhc" 86 | 87 | devhelp: 88 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 89 | @echo 90 | @echo "Build finished." 91 | @echo "To view the help file:" 92 | @echo "# mkdir -p $$HOME/.local/share/devhelp/save_the_change" 93 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/save_the_change" 94 | @echo "# devhelp" 95 | 96 | epub: 97 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 98 | @echo 99 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 100 | 101 | latex: 102 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 103 | @echo 104 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 105 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 106 | "(use \`make latexpdf' here to do that automatically)." 107 | 108 | latexpdf: 109 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 110 | @echo "Running LaTeX files through pdflatex..." 111 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 112 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 113 | 114 | text: 115 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 116 | @echo 117 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 118 | 119 | man: 120 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 121 | @echo 122 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 123 | 124 | texinfo: 125 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 126 | @echo 127 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 128 | @echo "Run \`make' in that directory to run these through makeinfo" \ 129 | "(use \`make info' here to do that automatically)." 130 | 131 | info: 132 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 133 | @echo "Running Texinfo files through makeinfo..." 134 | make -C $(BUILDDIR)/texinfo info 135 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 136 | 137 | gettext: 138 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 139 | @echo 140 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 141 | 142 | changes: 143 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 144 | @echo 145 | @echo "The overview file is in $(BUILDDIR)/changes." 146 | 147 | linkcheck: 148 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 149 | @echo 150 | @echo "Link check complete; look for any errors in the above output " \ 151 | "or in $(BUILDDIR)/linkcheck/output.txt." 152 | 153 | doctest: 154 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 155 | @echo "Testing of doctests in the sources finished, look at the " \ 156 | "results in $(BUILDDIR)/doctest/output.txt." 157 | -------------------------------------------------------------------------------- /save_the_change/decorators.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import division, absolute_import, print_function, unicode_literals 4 | 5 | from collections import defaultdict 6 | 7 | from django.utils import six 8 | 9 | from .util import DoesNotExist 10 | from .mappings import OldValues 11 | 12 | from .descriptors import _inject_descriptors 13 | 14 | 15 | __all__ = ('SaveTheChange', 'UpdateTogether', 'TrackChanges') 16 | 17 | 18 | class STCMixin(object): 19 | """ 20 | Hooks into :meth:`~django.db.models.Model.__init__`, 21 | :meth:`~django.db.models.Model.save`, and 22 | :meth:`~django.db.models.Model.refresh_from_db`, and adds some new, private 23 | attributes to the model: 24 | 25 | :attr:`_mutable_fields` 26 | A :class:`dict` storing a copy of potentially mutable values on 27 | first access. 28 | :attr:`_changed_fields` 29 | A :class:`dict` storing a copy of immutable fields' original values when 30 | they're changed. 31 | 32 | """ 33 | 34 | def __init__(self, *args, **kwargs): 35 | self._changed_fields = {} 36 | self._mutable_fields = {} 37 | self._mutability_checked = set() 38 | 39 | super(STCMixin, self).__init__(*args, **kwargs) 40 | 41 | def save(self, *args, **kwargs): 42 | for save_hook in self._meta._stc_save_hooks: 43 | continue_saving, args, kwargs = save_hook(self, *args, **kwargs) 44 | 45 | if not continue_saving: 46 | break 47 | 48 | else: 49 | super(STCMixin, self).save(*args, **kwargs) 50 | 51 | self._changed_fields = {} 52 | self._mutable_fields = {} 53 | self._mutability_checked = set() 54 | 55 | def refresh_from_db(self, using=None, fields=None): 56 | super(STCMixin, self).refresh_from_db(using, fields) 57 | 58 | if fields: 59 | for field in fields: 60 | self._changed_fields.pop(field, None) 61 | self._mutable_fields.pop(field, None) 62 | self._mutability_checked.discard(field) 63 | 64 | else: 65 | self._changed_fields = {} 66 | self._mutable_fields = {} 67 | self._mutability_checked = set() 68 | 69 | 70 | def _inject_stc(cls): 71 | """ 72 | Wraps model attributes in descriptors to track their changes. 73 | 74 | Injects a mixin into the model's __bases__ as well to handle the 75 | {create,load}/change/save lifecycle, and adds some attributes to the 76 | model's :attr:`_meta`: 77 | 78 | :attr:`_stc_injected` 79 | :const:`True` if we've already wrapped fields on this model. 80 | :attr:`_stc_save_hooks` 81 | A :class:`list` of hooks to run 82 | during :meth:`~django.db.models.Model.save`. 83 | 84 | """ 85 | 86 | if not hasattr(cls._meta, '_stc_injected'): 87 | _inject_descriptors(cls) 88 | 89 | cls.__bases__ = (STCMixin,) + cls.__bases__ 90 | 91 | cls._meta._stc_injected = True 92 | cls._meta._stc_save_hooks = [] 93 | 94 | 95 | def _save_the_change_save_hook(instance, *args, **kwargs): 96 | """ 97 | Sets ``update_fields`` on :meth:`~django.db.models.Model.save` to only \ 98 | what's changed. 99 | 100 | ``update_fields`` is only set if it doesn't already exist and when doing so 101 | is safe. This means its not set if the instance is new and yet to be saved 102 | to the database, if the instance is being saved with a new primary key, or 103 | if :meth:`~django.db.models.Model.save` has been called 104 | with ``force_insert``. 105 | 106 | :return: (continue_saving, args, kwargs) 107 | :rtype: :class:`tuple` 108 | 109 | """ 110 | 111 | if ( 112 | not instance._state.adding and 113 | hasattr(instance, '_changed_fields') and 114 | hasattr(instance, '_mutable_fields') and 115 | 'update_fields' not in kwargs and 116 | not kwargs.get('force_insert', False) and 117 | instance._meta.pk.attname not in instance._changed_fields 118 | ): 119 | kwargs['update_fields'] = ( 120 | [name for name, value in six.iteritems(instance._changed_fields)] + 121 | [name for name, value in six.iteritems(instance._mutable_fields) if hasattr(instance, name) and getattr(instance, name) != value] 122 | ) 123 | 124 | return (bool(kwargs['update_fields']), args, kwargs) 125 | 126 | return (True, args, kwargs) 127 | 128 | 129 | def SaveTheChange(cls): 130 | """ 131 | Decorator that wraps models with a save hook to save only what's changed. 132 | 133 | """ 134 | 135 | if not hasattr(cls._meta, '_stc_injected') or _save_the_change_save_hook not in cls._meta._stc_save_hooks: 136 | _inject_stc(cls) 137 | 138 | # We need to ensure that if SaveTheChange and UpdateTogether are both 139 | # used, that UpdateTogether's save hook will always run second. 140 | if _update_together_save_hook in cls._meta._stc_save_hooks: 141 | cls._meta._stc_save_hooks = [_save_the_change_save_hook, _update_together_save_hook] 142 | 143 | else: 144 | cls._meta._stc_save_hooks.append(_save_the_change_save_hook) 145 | 146 | return cls 147 | 148 | 149 | def TrackChanges(cls): 150 | """ 151 | Decorator that adds some methods and properties to models for working with \ 152 | changed fields. 153 | 154 | :attr:`~django.db.models.Model.has_changed` 155 | :const:`True` if any fields on the model have changed from its last 156 | known database representation. 157 | :attr:`~django.db.models.Model.changed_fields` 158 | A :class:`set` of the names of all changed fields on the model. 159 | :attr:`~django.db.models.Model.old_values` 160 | The model's fields in their last known database representation as a 161 | read-only mapping (:class:`~save_the_change.mappings.OldValues`). 162 | :meth:`~django.db.models.Model._meta.revert_fields` 163 | Reverts the given fields back to their last known 164 | database representation. 165 | 166 | """ 167 | 168 | _inject_stc(cls) 169 | 170 | def has_changed(self): 171 | return ( 172 | bool(self._changed_fields) or 173 | any(getattr(self, name) != value for name, value in six.iteritems(self._mutable_fields)) 174 | ) 175 | 176 | cls.has_changed = property(has_changed) 177 | 178 | def changed_fields(self): 179 | return ( 180 | set(name for name in six.iterkeys(self._changed_fields)) | 181 | set(name for name, value in six.iteritems(self._mutable_fields) if getattr(self, name, DoesNotExist) != value) 182 | ) 183 | 184 | cls.changed_fields = property(changed_fields) 185 | 186 | def old_values(self): 187 | return OldValues(self) 188 | 189 | cls.old_values = property(old_values) 190 | 191 | def revert_fields(self, names=None): 192 | """ 193 | Sets ``update_fields`` on :meth:`~django.db.models.Model.save` to only \ 194 | what's changed. 195 | 196 | :param names: The name of the field to revert or an iterable of 197 | multiple names. 198 | 199 | """ 200 | 201 | if names is None: 202 | names = list(self._mutable_fields) + list(self._changed_fields) 203 | 204 | if isinstance(names, (six.text_type, six.binary_type) + six.string_types): 205 | names = [names] 206 | 207 | for name in names: 208 | if name in self.changed_fields: 209 | setattr(self, name, self.old_values[name]) 210 | 211 | cls.revert_fields = revert_fields 212 | 213 | return cls 214 | 215 | 216 | def _update_together_save_hook(instance, *args, **kwargs): 217 | """ 218 | Sets ``update_fields`` on :meth:`~django.db.models.Model.save` to include \ 219 | any fields that have been marked as needing to be updated together with \ 220 | fields already in ``update_fields``. 221 | 222 | :return: (continue_saving, args, kwargs) 223 | :rtype: :class:`tuple` 224 | 225 | """ 226 | 227 | if 'update_fields' in kwargs: 228 | new_update_fields = set(kwargs['update_fields']) 229 | 230 | for field in kwargs['update_fields']: 231 | new_update_fields.update(instance._meta.update_together.get(field, [])) 232 | 233 | kwargs['update_fields'] = list(new_update_fields) 234 | 235 | return(True, args, kwargs) 236 | 237 | 238 | def UpdateTogether(*groups): 239 | """ 240 | Decorator for specifying groups of fields to be updated together. 241 | 242 | Usage: 243 | >>> from django.db import models 244 | >>> from save_the_change.decorators import SaveTheChange, UpdateTogether 245 | >>> 246 | >>> @SaveTheChange 247 | >>> @UpdateTogether( 248 | ... ('height_feet', 'height_inches'), 249 | ... ('weight_pounds', 'weight_kilos') 250 | ... ) 251 | >>> class Knight(models.model): 252 | >>> ... 253 | 254 | """ 255 | 256 | def UpdateTogether(cls, groups=groups): 257 | _inject_stc(cls) 258 | 259 | cls._meta.update_together_groups = getattr(cls._meta, 'update_together_groups', []) + list(groups) 260 | cls._meta.update_together = defaultdict(set) 261 | field_names = {field.name for field in cls._meta.fields if field.concrete} 262 | 263 | # Fields may be referenced in multiple groups, so we'll walk the 264 | # graph when the model's class is built. If this decorator is used 265 | # multiple times things will still work, but we'll end up doing this 266 | # walk multiple times as well. It's only at startup, though, so not 267 | # a big deal. 268 | neighbors = defaultdict(set) 269 | seen_nodes = set() 270 | 271 | for group in ( 272 | {field for field in group if field in field_names} 273 | for group in cls._meta.update_together_groups 274 | ): 275 | for node in group: 276 | neighbors[node].update(group) 277 | 278 | for node in neighbors: 279 | sqaushed_group = set() 280 | 281 | if node not in seen_nodes: 282 | nodes = set([node]) 283 | 284 | while nodes: 285 | node = nodes.pop() 286 | seen_nodes.add(node) 287 | nodes |= neighbors[node] - seen_nodes 288 | sqaushed_group.add(node) 289 | 290 | for grouped_node in sqaushed_group: 291 | cls._meta.update_together[grouped_node] = sqaushed_group 292 | 293 | if not hasattr(cls._meta, '_stc_injected') or _update_together_save_hook not in cls._meta._stc_save_hooks: 294 | cls._meta._stc_save_hooks.append(_update_together_save_hook) 295 | 296 | return cls 297 | 298 | return UpdateTogether 299 | -------------------------------------------------------------------------------- /tests/testproject/testapp/tests.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import division, absolute_import, print_function, unicode_literals 4 | 5 | import datetime 6 | import os 7 | import pytz 8 | import warnings 9 | from decimal import Decimal 10 | 11 | import django 12 | from django.core.files import File 13 | from django.core.files.images import ImageFile 14 | from django.db import models 15 | from django.test import TestCase 16 | 17 | from testproject.testapp.models import Enlightenment, EnlightenedModel, Disorder 18 | 19 | from save_the_change.decorators import _save_the_change_save_hook, _update_together_save_hook 20 | from save_the_change.mixins import SaveTheChange, TrackChanges, UpdateTogetherModel 21 | 22 | 23 | ATTR_MISSING = object() 24 | 25 | 26 | class EnlightenedModelTestCase(TestCase): 27 | def setUp(self): 28 | super(EnlightenedModelTestCase, self).setUp() 29 | 30 | self.maxDiff = None 31 | 32 | self.penny_front = os.path.abspath(os.path.join(__file__, '..', '..', '..', 'penny_front.png')) 33 | self.penny_back = os.path.abspath(os.path.join(__file__, '..', '..', '..', 'penny_back.png')) 34 | 35 | self.uploads = os.path.abspath(os.path.join(self.penny_front, '..', 'testproject', 'uploads')) 36 | 37 | self.knowledge = Enlightenment.objects.create(id=100, aspect='knowledge') 38 | self.wisdom = Enlightenment.objects.create(id=200, aspect='wisdom') 39 | 40 | self.old_values = { 41 | 'big_integer': 3735928559, 42 | 'boolean': True, 43 | 'char': '2 cents', 44 | 'comma_seperated_integer': '4,8,15', 45 | 'date': datetime.date(1999, 12, 31), 46 | 'date_time': pytz.utc.localize(datetime.datetime(1999, 12, 31, 23, 59, 59)), 47 | 'decimal': Decimal('0.02'), 48 | 'email': 'gautama@kapilavastu.org', 49 | 'enlightenment': self.knowledge, 50 | 'enlightenment_id': self.knowledge.id, 51 | 'file': File(open(self.penny_front, 'rbU'), 'penny_front_file.png'), 52 | 'file_path': 'uploads/penny_front_file.png', 53 | 'float': 1.61803, 54 | 'image': ImageFile(open(self.penny_front, 'rbU'), 'penny_front_image.png'), 55 | 'integer': 42, 56 | 'IP_address': '127.0.0.1', 57 | 'generic_IP': '::1', 58 | 'null_boolean': None, 59 | 'positive_integer': 1, 60 | 'positive_small_integer': 2, 61 | 'slug': 'onchidiacea', 62 | 'small_integer': 4, 63 | 'text': 'old', 64 | 'time': datetime.time(23, 59, 59), 65 | 'URL': 'http://djangosnippets.org/snippets/2985/', 66 | } 67 | 68 | self.new_values = { 69 | 'big_integer': 3735928495, 70 | 'boolean': False, 71 | 'char': 'Three fiddy', 72 | 'comma_seperated_integer': '16,23,42', 73 | 'date': datetime.date(2000, 1, 1), 74 | 'date_time': pytz.utc.localize(datetime.datetime(2000, 1, 1, 0, 0, 0)), 75 | 'decimal': Decimal('3.50'), 76 | 'email': 'maitreya@unknown.org', 77 | 'enlightenment': self.wisdom, 78 | 'enlightenment_id': self.wisdom.id, 79 | 'file': File(open(self.penny_back, 'rbU'), 'penny_back_file.png'), 80 | 'file_path': 'uploads/penny_back_file.png', 81 | 'float': 3.14159, 82 | 'image': ImageFile(open(self.penny_back, 'rbU'), 'penny_back_image.png'), 83 | 'integer': 108, 84 | 'IP_address': '255.255.255.255', 85 | 'generic_IP': 'ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff', 86 | 'null_boolean': True, 87 | 'positive_integer': 5, 88 | 'positive_small_integer': 6, 89 | 'slug': 'soleolifera', 90 | 'small_integer': 9, 91 | 'text': 'new', 92 | 'time': datetime.time(0, 0, 0), 93 | 'URL': 'https://github.com/karanlyons/django-save-the-change', 94 | } 95 | 96 | self.old_public_values = {k: v for k, v in self.old_values.items() if not k.endswith('_id')} 97 | self.new_public_values = {k: v for k, v in self.new_values.items() if not k.endswith('_id')} 98 | 99 | # When assigning a file initially to {File,Image}Field, Django replaces 100 | # it with a FieldFile instance. We need to grab that instance for 101 | # testing, which trips our mutability checks. This isn't at all a bug, 102 | # but requires some extra boilerplate for equality tests. 103 | self.always_in__mutable_fields = {'file': self.old_values['file'], 'image': self.old_values['image']} 104 | 105 | def create_initial(self): 106 | self.tearDown() 107 | 108 | m = EnlightenedModel(**self.old_public_values) 109 | m.save() 110 | 111 | self.old_values['id'] = self.old_public_values['id'] = m.id 112 | self.new_values['id'] = self.new_public_values['id'] = m.id 113 | 114 | self.old_values['holism'] = self.old_public_values['holism'] = m.holism 115 | self.new_values['holism'] = self.new_public_values['holism'] = m.holism 116 | 117 | # The aforementioned _mutable_fields side effects happen here. 118 | self.old_values['file'] = self.old_public_values['file'] = m.file 119 | self.old_values['image'] = self.old_public_values['image'] = m.image 120 | self.always_in__mutable_fields = {'file': self.old_values['file'], 'image': self.old_values['image']} 121 | 122 | return m 123 | 124 | def create_changed(self): 125 | m = self.create_initial() 126 | 127 | for field_name, value in self.new_values.items(): 128 | if not hasattr(value, '__call__'): 129 | setattr(m, field_name, value) 130 | 131 | self.new_values['file'] = self.new_public_values['file'] = m.file 132 | self.new_values['image'] = self.new_public_values['image'] = m.image 133 | 134 | return m 135 | 136 | def create_reverted(self): 137 | m = self.create_changed() 138 | 139 | for field_name, value in self.old_values.items(): 140 | if not hasattr(value, '__call__'): 141 | setattr(m, field_name, value) 142 | 143 | return m 144 | 145 | def create_saved(self): 146 | m = self.create_changed() 147 | 148 | m.save() 149 | 150 | return m 151 | 152 | def get_model_attrs(self, model): 153 | return {attr: getattr(model, attr, ATTR_MISSING) for attr in self.new_values} 154 | 155 | def test_initial__changed_fields(self): 156 | m = self.create_initial() 157 | 158 | self.assertEquals(m._changed_fields, {}) 159 | 160 | def test_initial_changed_fields(self): 161 | m = self.create_initial() 162 | 163 | self.assertEquals(m.changed_fields, set()) 164 | 165 | def test_initial_has_changed(self): 166 | m = self.create_initial() 167 | 168 | self.assertEquals(m.has_changed, False) 169 | 170 | def test_initial_new_values(self): 171 | m = self.create_initial() 172 | 173 | self.assertEquals(self.get_model_attrs(m), self.old_values) 174 | 175 | def test_initial_old_values(self): 176 | m = self.create_initial() 177 | 178 | self.assertEquals(dict(m.old_values), self.old_values) 179 | 180 | def test_old_values_with_bad_key(self): 181 | m = self.create_initial() 182 | 183 | self.assertRaises(KeyError, lambda: m.old_values['bad_key']) 184 | 185 | def test_old_values_with_bad_attr(self): 186 | m = self.create_initial() 187 | 188 | self.assertRaises(AttributeError, lambda: m.old_values.bad_key) 189 | 190 | def test_initial_saved_without_changes(self): 191 | m = self.create_initial() 192 | 193 | self.assertNumQueries(0, lambda: m.save()) 194 | 195 | def test_initial_saved_with_changed_id(self): 196 | m = self.create_initial() 197 | m.pk += 100 198 | 199 | self.assertEqual(m.changed_fields, {'id'}) 200 | self.assertEqual(m.old_values.id, self.old_values['id']) 201 | 202 | m.save() 203 | 204 | self.assertEqual(m, EnlightenedModel.objects.get(pk=m.pk)) 205 | 206 | def test_changed__changed_fields(self): 207 | m = self.create_changed() 208 | old_values = self.old_values 209 | del(old_values['id']) 210 | del(old_values['holism']) 211 | del(old_values['file']) 212 | del(old_values['image']) 213 | 214 | self.assertEquals(m._changed_fields, old_values) 215 | 216 | def test_touched_mutable_field__mutable_fields(self): 217 | m = self.create_initial() 218 | m.enlightenment 219 | m.holism.all() 220 | mutable_fields = {'enlightenment': self.old_values['enlightenment']} 221 | mutable_fields.update(self.always_in__mutable_fields) 222 | 223 | self.assertEquals(m._mutable_fields, mutable_fields) 224 | 225 | def test_changed_inside_mutable_field__mutable_fields(self): 226 | m = self.create_initial() 227 | m.enlightenment.aspect = 'Holistic' 228 | old_values = {'enlightenment': self.old_values['enlightenment']} 229 | old_values.update(self.always_in__mutable_fields) 230 | 231 | self.assertEquals(m._mutable_fields, old_values) 232 | 233 | def test_touched_then_changed_inside_mutable_field__mutable_fields(self): 234 | m = self.create_initial() 235 | m.enlightenment 236 | m.enlightenment = self.new_values['enlightenment'] 237 | mutable_fields = {'enlightenment': self.old_values['enlightenment']} 238 | mutable_fields.update(self.always_in__mutable_fields) 239 | 240 | self.assertEquals(m._mutable_fields, mutable_fields) 241 | 242 | def test_touched_immutable_field_with_mutable_element__mutable_fields(self): 243 | m = self.create_initial() 244 | m.comma_seperated_integer = (1, [0], 1) 245 | m._changed_fields = {} 246 | m.comma_seperated_integer 247 | mutable_fields = {'comma_seperated_integer': (1, [0], 1)} 248 | mutable_fields.update(self.always_in__mutable_fields) 249 | 250 | self.assertEquals(m._mutable_fields, mutable_fields) 251 | 252 | def test_touched_immutable_field_with_immutable_elements__changed_fields(self): 253 | m = self.create_initial() 254 | m.comma_seperated_integer = (1, 1, 1) 255 | m._changed_fields = {} 256 | m.comma_seperated_integer 257 | 258 | self.assertEquals(m._changed_fields, {}) 259 | 260 | def test_changed_changed_fields(self): 261 | m = self.create_changed() 262 | new_values = self.new_values 263 | del(new_values['id']) 264 | del(new_values['holism']) 265 | 266 | self.assertEquals(sorted(m.changed_fields), sorted(new_values.keys())) 267 | 268 | def test_changed_has_changed(self): 269 | m = self.create_changed() 270 | 271 | self.assertEquals(m.has_changed, True) 272 | 273 | def test_changed_new_values(self): 274 | m = self.create_changed() 275 | 276 | self.assertEquals(self.get_model_attrs(m), self.new_values) 277 | 278 | def test_changed_old_values(self): 279 | m = self.create_changed() 280 | 281 | self.assertEquals(dict(m.old_values), self.old_values) 282 | 283 | def test_changed_reverts(self): 284 | m = self.create_changed() 285 | m.revert_fields(self.new_values.keys()) 286 | 287 | self.assertEquals(self.get_model_attrs(m), self.old_values) 288 | 289 | def test_changed_revert_nonexistent_field(self): 290 | m = self.create_changed() 291 | m.revert_fields('not_a_field') 292 | 293 | self.assertEquals(self.get_model_attrs(m), self.new_values) 294 | 295 | def test_changed_reverts_all(self): 296 | m = self.create_changed() 297 | m.revert_fields('enlightenment') 298 | m.enlightenment.aspect = 'Holistic' 299 | m.revert_fields() 300 | 301 | self.assertEquals(self.get_model_attrs(m), self.old_values) 302 | 303 | def test_reverted__changed_fields(self): 304 | m = self.create_reverted() 305 | 306 | self.assertEquals(m._changed_fields, {}) 307 | 308 | def test_reverted_changed_fields(self): 309 | m = self.create_reverted() 310 | 311 | self.assertEquals(m.changed_fields, set()) 312 | 313 | def test_reverted_has_changed(self): 314 | m = self.create_reverted() 315 | 316 | self.assertEquals(m.has_changed, False) 317 | 318 | def test_reverted_new_values(self): 319 | m = self.create_reverted() 320 | 321 | self.assertEquals(self.get_model_attrs(m), self.old_values) 322 | 323 | def test_reverted_old_values(self): 324 | m = self.create_reverted() 325 | 326 | self.assertEquals(dict(m.old_values), self.old_values) 327 | 328 | def test_saved__changed_fields(self): 329 | m = self.create_saved() 330 | 331 | self.assertEquals(m._changed_fields, {}) 332 | 333 | def test_saved_changed_fields(self): 334 | m = self.create_saved() 335 | 336 | self.assertEquals(m.changed_fields, set()) 337 | 338 | def test_saved_has_changed(self): 339 | m = self.create_saved() 340 | 341 | self.assertEquals(m.has_changed, False) 342 | 343 | def test_saved_new_values(self): 344 | m = self.create_saved() 345 | 346 | self.assertEquals(self.get_model_attrs(m), self.new_values) 347 | 348 | def test_saved_old_values(self): 349 | m = self.create_saved() 350 | 351 | self.assertEquals(dict(m.old_values), self.new_values) 352 | 353 | def test_changed_twice_new_values(self): 354 | m = self.create_changed() 355 | new_values = self.new_values 356 | m.text = 'newer' 357 | new_values['text'] = 'newer' 358 | 359 | self.assertEquals(self.get_model_attrs(m), new_values) 360 | 361 | def test_updated_together_values(self): 362 | m = self.create_saved() 363 | EnlightenedModel.objects.filter(pk=m.pk).update(big_integer=0) 364 | 365 | new_values = self.new_values 366 | new_values['small_integer'] = 0 367 | 368 | m.small_integer = new_values['small_integer'] 369 | m.save() 370 | m.refresh_from_db() 371 | 372 | self.assertEquals(self.get_model_attrs(m), new_values) 373 | 374 | def test_updated_together_with_deferred_fields(self): 375 | m = self.create_saved() 376 | 377 | m = EnlightenedModel.objects.only('big_integer').get(pk=m.pk) 378 | 379 | self.assertEquals(self.get_model_attrs(m), self.new_values) 380 | 381 | def test_save_hook_order(self): 382 | self.assertEquals(EnlightenedModel._meta._stc_save_hooks, [_save_the_change_save_hook, _update_together_save_hook]) 383 | 384 | def test_save_hook_order_with_out_of_order_decorators(self): 385 | self.assertEquals(Disorder._meta._stc_save_hooks, [_save_the_change_save_hook, _update_together_save_hook]) 386 | 387 | def update_together_with_multiple_decorators(self): 388 | together = {'chaos', 'fire', 'brimstone'} 389 | self.assertEquals(Disorder._meta.update_together, {field: together for field in together}) 390 | 391 | def test_altered_file_field(self): 392 | m = self.create_initial() 393 | 394 | m.file.delete(save=False) 395 | m.file.save('penny_back_file.png', File(open(self.penny_back, 'rbU')), save=False) 396 | 397 | self.assertEquals(m.changed_fields, {'file'}) 398 | 399 | m.file.save('penny_front_file.png', File(open(self.penny_front, 'rbU')), save=False) 400 | 401 | self.assertEquals(m.changed_fields, set()) 402 | 403 | def test_refresh_from_db(self): 404 | m = self.create_changed() 405 | m.refresh_from_db() 406 | 407 | self.assertEquals(m._changed_fields, {}) 408 | self.assertEquals(m._mutable_fields, {}) 409 | self.assertEquals(m._mutability_checked, set()) 410 | 411 | def test_refresh_single_field_from_db(self): 412 | m = self.create_initial() 413 | m.small_integer = self.new_values['small_integer'] 414 | m.big_integer = self.new_values['big_integer'] 415 | m.refresh_from_db(fields=('small_integer',)) 416 | 417 | self.assertEquals(m._changed_fields, {'big_integer': self.old_values['big_integer']}) 418 | self.assertEquals(m._mutable_fields, self.always_in__mutable_fields) 419 | 420 | # A side effect of create_initial is that 'id' will end up in _mutability_checked. 421 | self.assertEquals(m._mutability_checked, {'id'} | set(self.always_in__mutable_fields.keys())) 422 | 423 | def test_mixins_warnings(self): 424 | with warnings.catch_warnings(record=True) as w: 425 | warnings.simplefilter('always') 426 | 427 | SaveTheChange() 428 | TrackChanges() 429 | UpdateTogetherModel() 430 | 431 | self.assertEquals(len(w), 3) 432 | self.assertEquals(w[0].category, RuntimeWarning) 433 | self.assertEquals( 434 | str(w[0].message), 435 | "save_the_change.mixins.SaveTheChange: mixins.SaveTheChange is no longer supported, instead use the decorator decorators.SaveTheChange." 436 | ) 437 | self.assertEquals(w[1].category, RuntimeWarning) 438 | self.assertEquals( 439 | str(w[1].message), 440 | "save_the_change.mixins.TrackChanges: mixins.TrackChanges is no longer supported, instead use the decorator decorators.TrackChanges." 441 | ) 442 | self.assertEquals(w[2].category, RuntimeWarning) 443 | self.assertEquals( 444 | str(w[2].message), 445 | "save_the_change.mixins.UpdateTogetherModel: mixins.UpdateTogetherModel is no longer supported, instead use the decorator decorators.UpdateTogether." 446 | ) 447 | 448 | def test_descriptor__get__in_class(self): 449 | if django.VERSION < (1, 10): 450 | self.assertEquals(EnlightenedModel.big_integer.__class__, type(None)) 451 | 452 | else: 453 | self.assertEquals(EnlightenedModel.big_integer.__class__, models.query_utils.DeferredAttribute) 454 | 455 | if django.VERSION < (1, 9): 456 | self.assertEquals(EnlightenedModel.holism.__class__, models.fields.related.ReverseManyRelatedObjectsDescriptor) 457 | self.assertEquals(EnlightenedModel.enlightenment.__class__, django.db.models.fields.related.ReverseSingleRelatedObjectDescriptor) 458 | 459 | else: 460 | self.assertEquals(EnlightenedModel.holism.__class__, models.fields.related_descriptors.ManyToManyDescriptor) 461 | self.assertEquals(EnlightenedModel.enlightenment.__class__, models.fields.related_descriptors.ForwardManyToOneDescriptor) 462 | 463 | def test_inheritance_is_honored(self): 464 | m = self.create_initial() 465 | m = EnlightenedModel.objects.get(pk=m.pk) 466 | 467 | self.assertEquals(getattr(m, 'init_started', None), True) 468 | self.assertEquals(getattr(m, 'init_ended', None), True) 469 | self.assertEquals(getattr(m, 'save_started', None), None) 470 | self.assertEquals(getattr(m, 'save_ended', None), None) 471 | self.assertEquals(getattr(m, 'refresh_from_db_started', None), None) 472 | self.assertEquals(getattr(m, 'refresh_from_db_ended', None), None) 473 | 474 | m.save() 475 | 476 | self.assertEquals(getattr(m, 'init_started', None), True) 477 | self.assertEquals(getattr(m, 'init_ended', None), True) 478 | self.assertEquals(getattr(m, 'save_started', None), True) 479 | self.assertEquals(getattr(m, 'save_ended', None), True) 480 | self.assertEquals(getattr(m, 'refresh_from_db_started', None), None) 481 | self.assertEquals(getattr(m, 'refresh_from_db_ended', None), None) 482 | 483 | m.refresh_from_db() 484 | 485 | self.assertEquals(getattr(m, 'init_started', None), True) 486 | self.assertEquals(getattr(m, 'init_ended', None), True) 487 | self.assertEquals(getattr(m, 'save_started', None), True) 488 | self.assertEquals(getattr(m, 'save_ended', None), True) 489 | self.assertEquals(getattr(m, 'refresh_from_db_started', None), True) 490 | self.assertEquals(getattr(m, 'refresh_from_db_ended', None), True) 491 | 492 | def tearDown(self): 493 | for file_name in os.listdir(self.uploads): 494 | if file_name.endswith('.png'): 495 | os.remove(os.path.join(os.path.join(self.uploads, file_name))) 496 | 497 | self.old_values.pop('id', None) 498 | self.new_values.pop('id', None) 499 | --------------------------------------------------------------------------------