From 69d6c6040af67ab948b757ac683fbebdbcb0e399 Mon Sep 17 00:00:00 2001 From: Arseny Sysolyatin Date: Mon, 22 May 2017 20:23:24 +0300 Subject: [PATCH 01/52] Russian locale added --- model_utils/locale/ru/LC_MESSAGES/django.mo | Bin 0 -> 840 bytes model_utils/locale/ru/LC_MESSAGES/django.po | 43 ++++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 model_utils/locale/ru/LC_MESSAGES/django.mo create mode 100644 model_utils/locale/ru/LC_MESSAGES/django.po diff --git a/model_utils/locale/ru/LC_MESSAGES/django.mo b/model_utils/locale/ru/LC_MESSAGES/django.mo new file mode 100644 index 0000000000000000000000000000000000000000..8edb2d893946d0bf7f530c0ea34767f99bfed20a GIT binary patch literal 840 zcmZXQ&1(}u7{<3+KUhViR|Us|#ZV{iZc}YH+0@vk7%(QpSkT*=ovvFpvtege4D^x; zdhzQ2(SybMk%9-WvJ2h>uY!MoCl5ZmNg9d+zkTPK_kG@Xc7D#yzE!Z!fXko_&Ve@| zT?Zh|eE=uH7jPDQ1*gC_a2k9E7r+njcSccukM*w;ilSow2k|1}44RxroEyh0h?k&m zAi|dQMCpGE>Fge}i0M3~6Ea&eM`7Gix`E?uc@RR{p-jIr65~KR&us1@*9?Pg){(U4 z&`lN!FW@F|wvh`c{LH73^n9VVST6`Atqa$4=-tQ_v>lj4Z8q9;EgcCTUB_gT7@B^Y zYD?5Gh`wSLZY^kwnubE!V$Z#_|G4gPwbG*X23@a^dkwQ*TX@oVP*v~SLeh3=44urC6?k2#!_?CdBnj*jlQUEA~XouFGr1MWt)%V?X~UEFY@O%g_Gy;iSI ze^bwE>RP}h;~3fR;qH>XkT-k2?eSvzK$s(~N9_%|JgrNww#7nPH|h!5lU@w<30 w9>#}C{1I{(?IBpd5ywPC`<;)F63_VG8r5b, 2017. +msgid "" +msgstr "" +"Project-Id-Version: django-model-utils\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2017-05-22 19:46+0300\n" +"PO-Revision-Date: 2017-05-22 19:46+0300\n" +"Last-Translator: Arseny Sysolyatin \n" +"Language-Team: \n" +"Language: ru\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=4; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n" +"%10<=4 && (n%100<12 || n%100>14) ? 1 : n%10==0 || (n%10>=5 && n%10<=9) || (n" +"%100>=11 && n%100<=14)? 2 : 3);\n" + +#: models.py:24 +msgid "created" +msgstr "создано" + +#: models.py:25 +msgid "modified" +msgstr "изменено" + +#: models.py:37 +msgid "start" +msgstr "начало" + +#: models.py:38 +msgid "end" +msgstr "конец" + +#: models.py:53 +msgid "status" +msgstr "статус" + +#: models.py:54 +msgid "status changed" +msgstr "статус изменен" From 0a809df4da3c714f0ba4b327028e96c9dc0e17a5 Mon Sep 17 00:00:00 2001 From: Hanley Date: Fri, 23 Jun 2017 09:51:32 -0400 Subject: [PATCH 02/52] make InheritanceIterable inherit from ModelIterable instead of BaseIterable --- AUTHORS.rst | 1 + model_utils/managers.py | 4 ++-- tests/test_inheritance_iterable.py | 22 ++++++++++++++++++++++ 3 files changed, 25 insertions(+), 2 deletions(-) create mode 100644 tests/test_inheritance_iterable.py diff --git a/AUTHORS.rst b/AUTHORS.rst index 4dd3044..fd350c0 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -15,6 +15,7 @@ Facundo Gaich Felipe Prenholato Filipe Ximenes Gregor Müllegger +Hanley Hansen ivirabyan James Oakley Jannis Leidel diff --git a/model_utils/managers.py b/model_utils/managers.py index 9dc68a2..8e022b4 100644 --- a/model_utils/managers.py +++ b/model_utils/managers.py @@ -7,14 +7,14 @@ try: from django.db.models.query import BaseIterable, ModelIterable except ImportError: # Django 1.8 does not have iterable classes - BaseIterable = object + BaseIterable, ModelIterable = object, object from django.core.exceptions import ObjectDoesNotExist from django.db.models.constants import LOOKUP_SEP from django.utils.six import string_types -class InheritanceIterable(BaseIterable): +class InheritanceIterable(ModelIterable): def __iter__(self): queryset = self.queryset iter = ModelIterable(queryset) diff --git a/tests/test_inheritance_iterable.py b/tests/test_inheritance_iterable.py new file mode 100644 index 0000000..884b763 --- /dev/null +++ b/tests/test_inheritance_iterable.py @@ -0,0 +1,22 @@ +from __future__ import unicode_literals + +from unittest import skipIf + +import django +from django.test import TestCase +from django.db.models import Prefetch + +from tests.models import InheritanceManagerTestParent, InheritanceManagerTestChild1 + + +class InheritanceIterableTest(TestCase): + @skipIf(django.VERSION[:2] == (1, 10), "Django 1.10 expects ModelIterable not a subclass of it") + def test_prefetch(self): + qs = InheritanceManagerTestChild1.objects.all().prefetch_related( + Prefetch( + 'normal_field', + queryset=InheritanceManagerTestParent.objects.all(), + to_attr='normal_field_prefetched' + ) + ) + self.assertEquals(qs.count(), 0) From 8c935d3a83b5689975b800e69c80baed0ac229d3 Mon Sep 17 00:00:00 2001 From: Hanley Date: Fri, 23 Jun 2017 09:56:51 -0400 Subject: [PATCH 03/52] update changes.rst --- CHANGES.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index a578c65..b85dcaf 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -3,6 +3,8 @@ CHANGES master (unreleased) ------------------- +* Update InheritanceIterable to inherit from + ModelIterable instead of BaseIterable, fixes GH-277. 3.0.0 (2017.04.13) ------------------ From fb67a52d0ae2d0ea45e73db971b97948860eda07 Mon Sep 17 00:00:00 2001 From: Adam Dobrawy Date: Sun, 25 Jun 2017 11:26:00 +0200 Subject: [PATCH 04/52] Add missing classifiers in setup.py --- setup.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/setup.py b/setup.py index a5e5904..82e85da 100644 --- a/setup.py +++ b/setup.py @@ -46,6 +46,10 @@ setup( 'Programming Language :: Python :: 3.4', 'Programming Language :: Python :: 3.5', 'Framework :: Django', + 'Framework :: Django :: 1.8', + 'Framework :: Django :: 1.9', + 'Framework :: Django :: 1.10', + 'Framework :: Django :: 1.11', ], zip_safe=False, tests_require=['Django>=1.8'], From d34043fd2510b2c59a6a62a9a71b5cc54266d509 Mon Sep 17 00:00:00 2001 From: Jack Cushman Date: Fri, 9 Feb 2018 14:39:14 -0500 Subject: [PATCH 05/52] Avoid fetching deferred fields in has_changed --- AUTHORS.rst | 1 + CHANGES.rst | 3 ++ docs/utilities.rst | 6 ++++ model_utils/tracker.py | 19 +++++++++++ tests/test_fields/test_field_tracker.py | 44 ++++++++++++++++++++++--- 5 files changed, 68 insertions(+), 5 deletions(-) diff --git a/AUTHORS.rst b/AUTHORS.rst index 4dd3044..a5841d5 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -42,3 +42,4 @@ Trey Hunner Karl Wan Nan Wo zyegfryed Radosław Jan Ganczarek +Jack Cushman diff --git a/CHANGES.rst b/CHANGES.rst index 922821d..a5a29e0 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -4,6 +4,9 @@ CHANGES master (unreleased) ------------------- +- Fix `FieldTracker.has_changed()` and `FieldTracker.previous()` to return + correct responses for deferred fields. + 3.1.1 (2017.12.17) ------------------ diff --git a/docs/utilities.rst b/docs/utilities.rst index 44824f5..b763ba0 100644 --- a/docs/utilities.rst +++ b/docs/utilities.rst @@ -150,6 +150,10 @@ Returns the value of the given field during the last save: Returns ``None`` when the model instance isn't saved yet. +If a field is `deferred`_, calling ``previous()`` will load the previous value from the database. + +.. _deferred: https://docs.djangoproject.com/en/2.0/ref/models/querysets/#defer + has_changed ~~~~~~~~~~~ @@ -167,6 +171,8 @@ Returns ``True`` if the given field has changed since the last save. The ``has_c The ``has_changed`` method relies on ``previous`` to determine whether a field's values has changed. +If a field is `deferred`_ and has been assigned locally, calling ``has_changed()`` +will load the previous value from the database to perform the comparison. changed ~~~~~~~ diff --git a/model_utils/tracker.py b/model_utils/tracker.py index 93e4a5d..3583a68 100644 --- a/model_utils/tracker.py +++ b/model_utils/tracker.py @@ -67,12 +67,31 @@ class FieldInstanceTracker(object): def has_changed(self, field): """Returns ``True`` if field has changed from currently saved value""" if field in self.fields: + # deferred fields haven't changed + if field in self.instance._deferred_fields and field not in self.instance.__dict__: + return False return self.previous(field) != self.get_field_value(field) else: raise FieldError('field "%s" not tracked' % field) def previous(self, field): """Returns currently saved value of given field""" + + # handle deferred fields that have not yet been loaded from the database + if self.instance.pk and field in self.instance._deferred_fields and field not in self.saved_data: + + # if the field has not been assigned locally, simply fetch and un-defer the value + if field not in self.instance.__dict__: + self.get_field_value(field) + + # if the field has been assigned locally, store the local value, fetch the database value, + # store database value to saved_data, and restore the local value + else: + current_value = self.get_field_value(field) + self.instance.refresh_from_db(fields=[field]) + self.saved_data[field] = deepcopy(self.get_field_value(field)) + setattr(self.instance, self.field_map[field], current_value) + return self.saved_data.get(field) def changed(self): diff --git a/tests/test_fields/test_field_tracker.py b/tests/test_fields/test_field_tracker.py index 00c28b3..5580773 100644 --- a/tests/test_fields/test_field_tracker.py +++ b/tests/test_fields/test_field_tracker.py @@ -180,20 +180,54 @@ class FieldTrackerTests(FieldTrackerTestCase, FieldTrackerCommonTests): self.instance.name = 'new age' self.instance.number = 1 self.instance.save() - item = list(self.tracked_class.objects.only('name').all())[0] + item = self.tracked_class.objects.only('name').first() self.assertTrue(item._deferred_fields) - self.assertEqual(item.tracker.previous('number'), None) - self.assertTrue('number' in item._deferred_fields) + # has_changed() returns False for deferred fields, without un-deferring them. + # Use an if because ModelTracked doesn't support has_changed() in this case. + if self.tracked_class == Tracked: + self.assertFalse(item.tracker.has_changed('number')) + self.assertTrue('number' in item._deferred_fields) + # previous() un-defers field and returns value + self.assertEqual(item.tracker.previous('number'), 1) + self.assertTrue('number' not in item._deferred_fields) + + # examining a deferred field un-defers it + item = self.tracked_class.objects.only('name').first() self.assertEqual(item.number, 1) self.assertTrue('number' not in item._deferred_fields) - self.assertEqual(item.tracker.previous('number'), 1) - self.assertFalse(item.tracker.has_changed('number')) + # has_changed() returns correct values after deferred field is examined + self.assertFalse(item.tracker.has_changed('number')) item.number = 2 self.assertTrue(item.tracker.has_changed('number')) + # previous() returns correct value after deferred field is examined + self.assertEqual(item.tracker.previous('number'), 1) + + # assigning to a deferred field un-defers it + # Use an if because ModelTracked doesn't handle this case. + if self.tracked_class == Tracked: + + item = self.tracked_class.objects.only('name').first() + item.number = 2 + + # _deferred_fields is not updated by assignment + self.assertTrue('number' in item._deferred_fields) + + # previous() fetches correct value from database after deferred field is assigned + self.assertEqual(item.tracker.previous('number'), 1) + + # database fetch of previous() value doesn't affect current value + self.assertEqual(item.number, 2) + + # has_changed() returns correct values after deferred field is assigned + self.assertTrue(item.tracker.has_changed('number')) + item.number = 1 + self.assertFalse(item.tracker.has_changed('number')) + + class FieldTrackerMultipleInstancesTests(TestCase): From b15f44c2602119802c6f1b41127148e21a8272b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20Dohnal?= Date: Fri, 4 May 2018 13:45:53 +0200 Subject: [PATCH 06/52] add czech (cs) translations --- model_utils/locale/cs/LC_MESSAGES/django.mo | Bin 0 -> 647 bytes model_utils/locale/cs/LC_MESSAGES/django.po | 43 ++++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 model_utils/locale/cs/LC_MESSAGES/django.mo create mode 100644 model_utils/locale/cs/LC_MESSAGES/django.po diff --git a/model_utils/locale/cs/LC_MESSAGES/django.mo b/model_utils/locale/cs/LC_MESSAGES/django.mo new file mode 100644 index 0000000000000000000000000000000000000000..bb59a77bba2cb289b3db2e44b274ed4b1ed6b66a GIT binary patch literal 647 zcmZvZ&u-K(5XKFZe*tme%)zIs2zha`i&$j|NR$>;>WZZbhyxclnN7p)+L7%)6$%J&i)?(IV_Lub%6XbZgxT|#l~q$a|DR_Gn( z2zNq1R#;M=N=CY53sct98j17D`tbCv`y;$KP`bj|#F|%Bc(GH8=VV=NbjqVWYK-+_ z?5esH58KL#++ch1Bo~{Z0bJq{`lLLO@leP+LJoM`PY3drOr(TS>`_;TYXA53SH4}$ z(5eb`3Kxxc##^T|#61~p8tqAYksmg&czW$`H*;0%QJCI3^4YU|TMSQqR%tgSE4Flj zch^Ko$GtqAZ*6i zOh(bJau_AFm@98AzQ^D3MNVpg{DpW-n(XRyVyLV=PkORJN cRb5Dz>ges)kDg}X&vb0LIQpO!4{^(W1Ml6TlmGw# literal 0 HcmV?d00001 diff --git a/model_utils/locale/cs/LC_MESSAGES/django.po b/model_utils/locale/cs/LC_MESSAGES/django.po new file mode 100644 index 0000000..4efd414 --- /dev/null +++ b/model_utils/locale/cs/LC_MESSAGES/django.po @@ -0,0 +1,43 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: django-model-utils\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2018-05-04 13:40+0200\n" +"PO-Revision-Date: 2018-05-04 13:43+0200\n" +"Language: cs\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=3; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2;\n" +"Last-Translator: Václav Dohnal \n" +"Language-Team: \n" +"X-Generator: Poedit 2.0.7\n" + +#: .\models.py:24 +msgid "created" +msgstr "vytvořeno" + +#: .\models.py:25 +msgid "modified" +msgstr "upraveno" + +#: .\models.py:37 +msgid "start" +msgstr "začátek" + +#: .\models.py:38 +msgid "end" +msgstr "konec" + +#: .\models.py:53 +msgid "status" +msgstr "stav" + +#: .\models.py:54 +msgid "status changed" +msgstr "změna stavu" From 248db7bde4a45b0495d8a3bfacd4968755c410c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20Dohnal?= Date: Fri, 4 May 2018 13:46:16 +0200 Subject: [PATCH 07/52] update czech (cs) translations --- model_utils/locale/cs/LC_MESSAGES/django.mo | Bin 647 -> 705 bytes model_utils/locale/cs/LC_MESSAGES/django.po | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/model_utils/locale/cs/LC_MESSAGES/django.mo b/model_utils/locale/cs/LC_MESSAGES/django.mo index bb59a77bba2cb289b3db2e44b274ed4b1ed6b66a..758c32d5a986f8fa4f74c53148bc5d6d32849c34 100644 GIT binary patch delta 136 zcmZo?J;*vC#B>281H&r@1_mx5?q*_OUty?18E*0y=7u& zsZdH*VqSW_Zf<@`YL0GcNoG#*#D7VQW|I>ck7$G)UYMMdSf=2TpOKfCqhM2(2;%Cc VfVmFoxrv!Mddc~@c9T_^k^l}?B@+Mu delta 77 zcmX@e+Ri#5#59eOf#DSc0|OTj2Qo1*umb5AAk6`!lYlfQkj@6uEI_&xNb>;cs)?PY TlLZ))7>y?vGai{7&y)lJq>~Iv diff --git a/model_utils/locale/cs/LC_MESSAGES/django.po b/model_utils/locale/cs/LC_MESSAGES/django.po index 4efd414..2f65819 100644 --- a/model_utils/locale/cs/LC_MESSAGES/django.po +++ b/model_utils/locale/cs/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: django-model-utils\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2018-05-04 13:40+0200\n" -"PO-Revision-Date: 2018-05-04 13:43+0200\n" +"PO-Revision-Date: 2018-05-04 13:46+0200\n" "Language: cs\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" From d7e8144d4c7fe6d4b99b577fd9b5d0454c252652 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20Dohnal?= Date: Fri, 4 May 2018 13:50:58 +0200 Subject: [PATCH 08/52] add missing file header --- model_utils/locale/cs/LC_MESSAGES/django.po | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/model_utils/locale/cs/LC_MESSAGES/django.po b/model_utils/locale/cs/LC_MESSAGES/django.po index 2f65819..d05da38 100644 --- a/model_utils/locale/cs/LC_MESSAGES/django.po +++ b/model_utils/locale/cs/LC_MESSAGES/django.po @@ -1,7 +1,10 @@ -# SOME DESCRIPTIVE TITLE. -# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER -# This file is distributed under the same license as the PACKAGE package. -# FIRST AUTHOR , YEAR. +# Czech translations of django-model-utils +# +# This file is distributed under the same license as the django-model-utils package. +# +# Translators: +# ------------ +# Václav Dohnal , 2018. # msgid "" msgstr "" From db35475ece517918b417cbe7bb568febee4ad678 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20Dohnal?= Date: Fri, 4 May 2018 13:51:15 +0200 Subject: [PATCH 09/52] add link to the issue tracker on Github --- model_utils/locale/cs/LC_MESSAGES/django.po | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/model_utils/locale/cs/LC_MESSAGES/django.po b/model_utils/locale/cs/LC_MESSAGES/django.po index d05da38..67b98d7 100644 --- a/model_utils/locale/cs/LC_MESSAGES/django.po +++ b/model_utils/locale/cs/LC_MESSAGES/django.po @@ -9,7 +9,7 @@ msgid "" msgstr "" "Project-Id-Version: django-model-utils\n" -"Report-Msgid-Bugs-To: \n" +"Report-Msgid-Bugs-To: https://github.com/jazzband/django-model-utils/issues\n" "POT-Creation-Date: 2018-05-04 13:40+0200\n" "PO-Revision-Date: 2018-05-04 13:46+0200\n" "Language: cs\n" From a6ce9fcc3b590a38d70af52395e1098325ab27ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20Dohnal?= Date: Fri, 4 May 2018 13:51:46 +0200 Subject: [PATCH 10/52] add N/A as Language-Team --- model_utils/locale/cs/LC_MESSAGES/django.po | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/model_utils/locale/cs/LC_MESSAGES/django.po b/model_utils/locale/cs/LC_MESSAGES/django.po index 67b98d7..eae5e9a 100644 --- a/model_utils/locale/cs/LC_MESSAGES/django.po +++ b/model_utils/locale/cs/LC_MESSAGES/django.po @@ -18,7 +18,7 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=3; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2;\n" "Last-Translator: Václav Dohnal \n" -"Language-Team: \n" +"Language-Team: N/A\n" "X-Generator: Poedit 2.0.7\n" #: .\models.py:24 From 6d2ba6338725d55b3c39f6b8124f5a6a2f831772 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Amaro?= Date: Wed, 9 May 2018 14:45:37 -0300 Subject: [PATCH 11/52] update changelog and versions for 3.1.2 --- CHANGES.rst | 4 ++++ model_utils/__init__.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 495d972..84ea8d7 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -3,9 +3,13 @@ CHANGES master (unreleased) ------------------- + +3.1.2 (2018.05.09) +------------------ * Update InheritanceIterable to inherit from ModelIterable instead of BaseIterable, fixes GH-277. + 3.1.1 (2017.12.17) ------------------ diff --git a/model_utils/__init__.py b/model_utils/__init__.py index f23bd9c..f297da6 100644 --- a/model_utils/__init__.py +++ b/model_utils/__init__.py @@ -1,4 +1,4 @@ from .choices import Choices from .tracker import FieldTracker, ModelTracker -__version__ = '3.1.1' +__version__ = '3.1.2' From 0fc0b44c95de3e39b71a1e01a3ef36a6bc887d12 Mon Sep 17 00:00:00 2001 From: Lucas Wiman Date: Mon, 2 Apr 2018 17:00:03 -0700 Subject: [PATCH 12/52] Remove version checks for django<1.8. Support for older versions of django was dropped in 3.0.0. --- model_utils/managers.py | 30 +++----------- model_utils/tracker.py | 40 ++++++------------- .../test_managers/test_inheritance_manager.py | 3 -- 3 files changed, 18 insertions(+), 55 deletions(-) diff --git a/model_utils/managers.py b/model_utils/managers.py index 2f2d1d2..98a2a54 100644 --- a/model_utils/managers.py +++ b/model_utils/managers.py @@ -48,11 +48,10 @@ class InheritanceIterable(ModelIterable): class InheritanceQuerySetMixin(object): def __init__(self, *args, **kwargs): super(InheritanceQuerySetMixin, self).__init__(*args, **kwargs) - if django.VERSION > (1, 8): - self._iterable_class = InheritanceIterable + self._iterable_class = InheritanceIterable def select_subclasses(self, *subclasses): - levels = self._get_maximum_depth() + levels = None calculated_subclasses = self._get_subclasses_recurse( self.model, levels=levels) # if none were passed in, we can just short circuit and select all @@ -151,12 +150,9 @@ class InheritanceQuerySetMixin(object): recursively, returning a `list` of strings representing the relations for select_related """ - if django.VERSION < (1, 8): - related_objects = model._meta.get_all_related_objects() - else: - related_objects = [ - f for f in model._meta.get_fields() - if isinstance(f, OneToOneRel)] + related_objects = [ + f for f in model._meta.get_fields() + if isinstance(f, OneToOneRel)] rels = [ rel for rel in related_objects @@ -199,10 +195,7 @@ class InheritanceQuerySetMixin(object): related = parent_link.remote_field ancestry.insert(0, related.get_accessor_name()) if levels or levels is None: - if django.VERSION < (1, 8): - parent_model = related.parent_model - else: - parent_model = related.model + parent_model = related.model parent_link = parent_model._meta.get_ancestor_link( self.model) else: @@ -230,17 +223,6 @@ class InheritanceQuerySetMixin(object): def get_subclass(self, *args, **kwargs): return self.select_subclasses().get(*args, **kwargs) - def _get_maximum_depth(self): - """ - Under Django versions < 1.6, to avoid triggering - https://code.djangoproject.com/ticket/16572 we can only look - as far as children. - """ - levels = None - if django.VERSION < (1, 6, 0): - levels = 1 - return levels - class InheritanceQuerySet(InheritanceQuerySetMixin, QuerySet): pass diff --git a/model_utils/tracker.py b/model_utils/tracker.py index 93e4a5d..0fec85d 100644 --- a/model_utils/tracker.py +++ b/model_utils/tracker.py @@ -97,35 +97,19 @@ class FieldInstanceTracker(object): def _get_field_name(self): return self.field.name - if django.VERSION >= (1, 8): - self.instance._deferred_fields = self.instance.get_deferred_fields() - for field in self.instance._deferred_fields: - if django.VERSION >= (1, 10): - field_obj = getattr(self.instance.__class__, field) - else: - field_obj = self.instance.__class__.__dict__.get(field) - if isinstance(field_obj, FileDescriptor): - field_tracker = FileDescriptorTracker(field_obj.field) - setattr(self.instance.__class__, field, field_tracker) - else: - field_tracker = DeferredAttributeTracker( - field_obj.field_name, None) - setattr(self.instance.__class__, field, field_tracker) - else: - for field in self.fields: + self.instance._deferred_fields = self.instance.get_deferred_fields() + for field in self.instance._deferred_fields: + if django.VERSION >= (1, 10): + field_obj = getattr(self.instance.__class__, field) + else: field_obj = self.instance.__class__.__dict__.get(field) - if isinstance(field_obj, DeferredAttribute): - self.instance._deferred_fields.add(field) - - # Django 1.4 - if django.VERSION >= (1, 5): - model = None - else: - model = field_obj.model_ref() - - field_tracker = DeferredAttributeTracker( - field_obj.field_name, model) - setattr(self.instance.__class__, field, field_tracker) + if isinstance(field_obj, FileDescriptor): + field_tracker = FileDescriptorTracker(field_obj.field) + setattr(self.instance.__class__, field, field_tracker) + else: + field_tracker = DeferredAttributeTracker( + field_obj.field_name, None) + setattr(self.instance.__class__, field, field_tracker) class FieldTracker(object): diff --git a/tests/test_managers/test_inheritance_manager.py b/tests/test_managers/test_inheritance_manager.py index 4509175..39c694e 100644 --- a/tests/test_managers/test_inheritance_manager.py +++ b/tests/test_managers/test_inheritance_manager.py @@ -115,9 +115,6 @@ class InheritanceManagerTests(TestCase): "inheritancemanagertestchild2").get(pk=self.child1.pk) obj.inheritancemanagertestchild1 - def test_version_determining_any_depth(self): - self.assertIsNone(self.get_manager().all()._get_maximum_depth()) - def test_manually_specifying_parent_fk_including_grandchildren(self): """ given a Model which inherits from another Model, but also declares From be52bc929009b58535de75dfdb40e6aa58bb4836 Mon Sep 17 00:00:00 2001 From: Lucas Wiman Date: Tue, 3 Apr 2018 13:18:03 -0700 Subject: [PATCH 13/52] Add failing test for deferred attributes. --- model_utils/tracker.py | 3 +- tests/models.py | 35 +++++++++++++++ tests/test_models/test_deferred_fields.py | 53 +++++++++++++++++++++++ 3 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 tests/test_models/test_deferred_fields.py diff --git a/model_utils/tracker.py b/model_utils/tracker.py index 0fec85d..5da9dd4 100644 --- a/model_utils/tracker.py +++ b/model_utils/tracker.py @@ -107,8 +107,7 @@ class FieldInstanceTracker(object): field_tracker = FileDescriptorTracker(field_obj.field) setattr(self.instance.__class__, field, field_tracker) else: - field_tracker = DeferredAttributeTracker( - field_obj.field_name, None) + field_tracker = DeferredAttributeTracker(field, type(self.instance)) setattr(self.instance.__class__, field, field_tracker) diff --git a/tests/models.py b/tests/models.py index a65d499..841f612 100644 --- a/tests/models.py +++ b/tests/models.py @@ -1,6 +1,7 @@ from __future__ import unicode_literals, absolute_import from django.db import models +from django.db.models.query_utils import DeferredAttribute from django.db.models import Manager from django.utils.encoding import python_2_unicode_compatible from django.utils.translation import ugettext_lazy as _ @@ -331,3 +332,37 @@ class CustomSoftDelete(SoftDeletableModel): is_read = models.BooleanField(default=False) objects = CustomSoftDeleteManager() + + +class StringyDescriptor(object): + """ + Descriptor that returns a string version of the underlying integer value. + """ + def __init__(self, name): + self.name = name + + def __get__(self, obj, cls=None): + if obj is None: + return self + if self.name in obj.get_deferred_fields(): + # This queries the database, and sets the value on the instance. + DeferredAttribute(field_name=self.name, model=cls).__get__(obj, cls) + return str(obj.__dict__[self.name]) + + def __set__(self, obj, value): + obj.__dict__[self.name] = int(value) + + +class CustomDescriptorField(models.IntegerField): + def contribute_to_class(self, cls, name, **kwargs): + super(CustomDescriptorField, self).contribute_to_class(cls, name, **kwargs) + setattr(cls, name, StringyDescriptor(name)) + + +class ModelWithCustomDescriptor(models.Model): + custom_field = CustomDescriptorField() + tracked_custom_field = CustomDescriptorField() + regular_field = models.IntegerField() + tracked_regular_field = models.IntegerField() + + tracker = FieldTracker(fields=['tracked_custom_field', 'tracked_regular_field']) diff --git a/tests/test_models/test_deferred_fields.py b/tests/test_models/test_deferred_fields.py new file mode 100644 index 0000000..6a159be --- /dev/null +++ b/tests/test_models/test_deferred_fields.py @@ -0,0 +1,53 @@ +from __future__ import unicode_literals + +from django.test import TestCase + +from tests.models import ModelWithCustomDescriptor + + +class CustomDescriptorTests(TestCase): + def setUp(self): + self.instance = ModelWithCustomDescriptor.objects.create( + custom_field='1', + tracked_custom_field='1', + regular_field=1, + tracked_regular_field=1, + ) + + def test_custom_descriptor_works(self): + instance = self.instance + self.assertEqual(instance.custom_field, '1') + self.assertEqual(instance.__dict__['custom_field'], 1) + self.assertEqual(instance.regular_field, 1) + instance.custom_field = 2 + self.assertEqual(instance.custom_field, '2') + self.assertEqual(instance.__dict__['custom_field'], 2) + instance.save() + intance = ModelWithCustomDescriptor.objects.get(pk=instance.pk) + self.assertEqual(instance.custom_field, '2') + self.assertEqual(instance.__dict__['custom_field'], 2) + + def test_deferred(self): + instance = ModelWithCustomDescriptor.objects.only('id').get( + pk=self.instance.pk) + self.assertIn('custom_field', instance.get_deferred_fields()) + self.assertEqual(instance.custom_field, '1') + self.assertNotIn('custom_field', instance.get_deferred_fields()) + self.assertEqual(instance.regular_field, 1) + self.assertEqual(instance.tracked_custom_field, '1') + self.assertEqual(instance.tracked_regular_field, 1) + + self.assertFalse(instance.tracker.has_changed('tracked_custom_field')) + self.assertFalse(instance.tracker.has_changed('tracked_regular_field')) + + instance.tracked_custom_field = 2 + instance.tracked_regular_field = 2 + self.assertTrue(instance.tracker.has_changed('tracked_custom_field')) + self.assertTrue(instance.tracker.has_changed('tracked_regular_field')) + instance.save() + + instance = ModelWithCustomDescriptor.objects.get(pk=instance.pk) + self.assertEqual(instance.custom_field, '1') + self.assertEqual(instance.regular_field, 1) + self.assertEqual(instance.tracked_custom_field, '2') + self.assertEqual(instance.tracked_regular_field, 2) From 80b099f12918d28c297005fbbb15ed1ebaf10a12 Mon Sep 17 00:00:00 2001 From: Lucas Wiman Date: Tue, 3 Apr 2018 15:43:29 -0700 Subject: [PATCH 14/52] Do not override custom descriptors when present. This commit adds a collection of wrapper classes for tracking fields while still using custom descriptors that may be present. This fixes a bug where deferring a model field with a custom descriptor meant that the descriptor was overridden in all subsequent queries. --- model_utils/tracker.py | 77 ++++++++++++++++++++++- tests/test_fields/test_field_tracker.py | 15 ++++- tests/test_models/test_deferred_fields.py | 11 +++- 3 files changed, 95 insertions(+), 8 deletions(-) diff --git a/model_utils/tracker.py b/model_utils/tracker.py index 5da9dd4..095d87e 100644 --- a/model_utils/tracker.py +++ b/model_utils/tracker.py @@ -29,12 +29,73 @@ class DescriptorMixin(object): return self.field_name +class DescriptorWrapper(object): + + def __init__(self, field_name, descriptor, tracker_attname): + self.field_name = field_name + self.descriptor = descriptor + self.tracker_attname = tracker_attname + + def __get__(self, instance, owner): + if instance is None: + return self + was_deferred = self.field_name in instance.get_deferred_fields() + if self.descriptor: + value = self.descriptor.__get__(instance, owner) + else: + value = instance.__dict__[self.field_name] + if was_deferred: + tracker_instance = getattr(instance, self.tracker_attname) + tracker_instance.saved_data[self.field_name] = deepcopy(value) + return value + + @staticmethod + def cls_for_descriptor(descriptor): + has_set = hasattr(descriptor, '__set__') + has_del = hasattr(descriptor, '__delete__') + if has_set and has_del: + return FullDescriptorWrapper + elif has_set: + return SettableDescriptorWrapper + elif has_del: + return DeleteableDescriptorWrapper + else: + return DescriptorWrapper + + +class SettableDescriptorWrapper(DescriptorWrapper): + """ + Descriptor wrapper for descriptors with a __delete__ method. + + This should not be used for descriptors + """ + def __set__(self, instance, value): + return self.descriptor.__set__(instance, value) + + +class DeleteableDescriptorWrapper(DescriptorWrapper): + """ + Descriptor wrapper for descriptors with a __delete__ method. + + This should not be used for descriptors + """ + def __delete__(self, instance): + self.descriptor.__delete__(instance) + + +class FullDescriptorWrapper(SettableDescriptorWrapper, DeleteableDescriptorWrapper): + """ + Wrapper for descriptors with all three descriptor methods. + """ + + class FieldInstanceTracker(object): def __init__(self, instance, fields, field_map): self.instance = instance self.fields = fields self.field_map = field_map - self.init_deferred_fields() + if django.VERSION < (1, 10): + self.init_deferred_fields() def get_field_value(self, field): return getattr(self.instance, self.field_map[field]) @@ -54,10 +115,11 @@ class FieldInstanceTracker(object): def current(self, fields=None): """Returns dict of current values for all tracked fields""" if fields is None: - if self.instance._deferred_fields: + deferred_fields = self.instance._deferred_fields if django.VERSION < (1, 10) else self.instance.get_deferred_fields() + if deferred_fields: fields = [ field for field in self.fields - if field not in self.instance._deferred_fields + if field not in deferred_fields ] else: fields = self.fields @@ -135,6 +197,15 @@ class FieldTracker(object): if self.fields is None: self.fields = (field.attname for field in sender._meta.fields) self.fields = set(self.fields) + if django.VERSION >= (1, 10): + for field_name in self.fields: + if django.VERSION >= (1, 10): + descriptor = getattr(sender, field_name) + else: + descriptor = sender.__dict__.get(field_name) + wrapper_cls = DescriptorWrapper.cls_for_descriptor(descriptor) + wrapped_descriptor = wrapper_cls(field_name, descriptor, self.attname) + setattr(sender, field_name, wrapped_descriptor) self.field_map = self.get_field_map(sender) models.signals.post_init.connect(self.initialize_tracker) self.model_class = sender diff --git a/tests/test_fields/test_field_tracker.py b/tests/test_fields/test_field_tracker.py index 00c28b3..b389861 100644 --- a/tests/test_fields/test_field_tracker.py +++ b/tests/test_fields/test_field_tracker.py @@ -181,13 +181,22 @@ class FieldTrackerTests(FieldTrackerTestCase, FieldTrackerCommonTests): self.instance.number = 1 self.instance.save() item = list(self.tracked_class.objects.only('name').all())[0] - self.assertTrue(item._deferred_fields) + if django.VERSION >= (1, 10): + self.assertTrue(item.get_deferred_fields()) + else: + self.assertTrue(item._deferred_fields) self.assertEqual(item.tracker.previous('number'), None) - self.assertTrue('number' in item._deferred_fields) + if django.VERSION >= (1, 10): + self.assertTrue('number' in item.get_deferred_fields()) + else: + self.assertTrue('number' in item._deferred_fields) self.assertEqual(item.number, 1) - self.assertTrue('number' not in item._deferred_fields) + if django.VERSION >= (1, 10): + self.assertTrue('number' not in item.get_deferred_fields()) + else: + self.assertTrue('number' not in item._deferred_fields) self.assertEqual(item.tracker.previous('number'), 1) self.assertFalse(item.tracker.has_changed('number')) diff --git a/tests/test_models/test_deferred_fields.py b/tests/test_models/test_deferred_fields.py index 6a159be..b235843 100644 --- a/tests/test_models/test_deferred_fields.py +++ b/tests/test_models/test_deferred_fields.py @@ -1,5 +1,6 @@ from __future__ import unicode_literals +import django from django.test import TestCase from tests.models import ModelWithCustomDescriptor @@ -30,9 +31,15 @@ class CustomDescriptorTests(TestCase): def test_deferred(self): instance = ModelWithCustomDescriptor.objects.only('id').get( pk=self.instance.pk) - self.assertIn('custom_field', instance.get_deferred_fields()) + if django.VERSION >= (1, 10): + self.assertIn('custom_field', instance.get_deferred_fields()) + else: + self.assertIn('custom_field', instance._deferred_fields) self.assertEqual(instance.custom_field, '1') - self.assertNotIn('custom_field', instance.get_deferred_fields()) + if django.VERSION >= (1, 10): + self.assertNotIn('custom_field', instance.get_deferred_fields()) + else: + self.assertNotIn('custom_field', instance._deferred_fields) self.assertEqual(instance.regular_field, 1) self.assertEqual(instance.tracked_custom_field, '1') self.assertEqual(instance.tracked_regular_field, 1) From be1a7d92811f9e57fd43b14b735218612f30f6ff Mon Sep 17 00:00:00 2001 From: Lucas Wiman Date: Tue, 3 Apr 2018 16:10:30 -0700 Subject: [PATCH 15/52] Update AUTHORS and CHANGES. As far as I can tell, no changes to documentation is required. --- AUTHORS.rst | 1 + CHANGES.rst | 1 + 2 files changed, 2 insertions(+) diff --git a/AUTHORS.rst b/AUTHORS.rst index fd350c0..33224c5 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -43,3 +43,4 @@ Trey Hunner Karl Wan Nan Wo zyegfryed Radosław Jan Ganczarek +Lucas Wiman diff --git a/CHANGES.rst b/CHANGES.rst index 84ea8d7..9bd6129 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -3,6 +3,7 @@ CHANGES master (unreleased) ------------------- +- Fix handling of deferred attributes on Django 1.10+, fixes GH-278 3.1.2 (2018.05.09) ------------------ From 90ed7fc9052f46ea1007af26ff9953f3f1e198b7 Mon Sep 17 00:00:00 2001 From: Lucas Wiman Date: Wed, 4 Apr 2018 10:02:46 -0700 Subject: [PATCH 16/52] Improve coverage. --- model_utils/tracker.py | 33 +++++------------------ tests/models.py | 3 +++ tests/test_models/test_deferred_fields.py | 11 ++++++++ 3 files changed, 21 insertions(+), 26 deletions(-) diff --git a/model_utils/tracker.py b/model_utils/tracker.py index 095d87e..0ec3044 100644 --- a/model_utils/tracker.py +++ b/model_utils/tracker.py @@ -40,10 +40,7 @@ class DescriptorWrapper(object): if instance is None: return self was_deferred = self.field_name in instance.get_deferred_fields() - if self.descriptor: - value = self.descriptor.__get__(instance, owner) - else: - value = instance.__dict__[self.field_name] + value = self.descriptor.__get__(instance, owner) if was_deferred: tracker_instance = getattr(instance, self.tracker_attname) tracker_instance.saved_data[self.field_name] = deepcopy(value) @@ -53,12 +50,10 @@ class DescriptorWrapper(object): def cls_for_descriptor(descriptor): has_set = hasattr(descriptor, '__set__') has_del = hasattr(descriptor, '__delete__') - if has_set and has_del: + if has_del: return FullDescriptorWrapper elif has_set: return SettableDescriptorWrapper - elif has_del: - return DeleteableDescriptorWrapper else: return DescriptorWrapper @@ -73,20 +68,12 @@ class SettableDescriptorWrapper(DescriptorWrapper): return self.descriptor.__set__(instance, value) -class DeleteableDescriptorWrapper(DescriptorWrapper): - """ - Descriptor wrapper for descriptors with a __delete__ method. - - This should not be used for descriptors - """ - def __delete__(self, instance): - self.descriptor.__delete__(instance) - - -class FullDescriptorWrapper(SettableDescriptorWrapper, DeleteableDescriptorWrapper): +class FullDescriptorWrapper(SettableDescriptorWrapper): """ Wrapper for descriptors with all three descriptor methods. """ + def __delete__(self, obj): + self.descriptor.__delete__(obj) class FieldInstanceTracker(object): @@ -161,10 +148,7 @@ class FieldInstanceTracker(object): self.instance._deferred_fields = self.instance.get_deferred_fields() for field in self.instance._deferred_fields: - if django.VERSION >= (1, 10): - field_obj = getattr(self.instance.__class__, field) - else: - field_obj = self.instance.__class__.__dict__.get(field) + field_obj = self.instance.__class__.__dict__.get(field) if isinstance(field_obj, FileDescriptor): field_tracker = FileDescriptorTracker(field_obj.field) setattr(self.instance.__class__, field, field_tracker) @@ -199,10 +183,7 @@ class FieldTracker(object): self.fields = set(self.fields) if django.VERSION >= (1, 10): for field_name in self.fields: - if django.VERSION >= (1, 10): - descriptor = getattr(sender, field_name) - else: - descriptor = sender.__dict__.get(field_name) + descriptor = getattr(sender, field_name) wrapper_cls = DescriptorWrapper.cls_for_descriptor(descriptor) wrapped_descriptor = wrapper_cls(field_name, descriptor, self.attname) setattr(sender, field_name, wrapped_descriptor) diff --git a/tests/models.py b/tests/models.py index 841f612..91df0b6 100644 --- a/tests/models.py +++ b/tests/models.py @@ -352,6 +352,9 @@ class StringyDescriptor(object): def __set__(self, obj, value): obj.__dict__[self.name] = int(value) + def __delete__(self, obj): + del obj.__dict__[self.name] + class CustomDescriptorField(models.IntegerField): def contribute_to_class(self, cls, name, **kwargs): diff --git a/tests/test_models/test_deferred_fields.py b/tests/test_models/test_deferred_fields.py index b235843..05ba336 100644 --- a/tests/test_models/test_deferred_fields.py +++ b/tests/test_models/test_deferred_fields.py @@ -58,3 +58,14 @@ class CustomDescriptorTests(TestCase): self.assertEqual(instance.regular_field, 1) self.assertEqual(instance.tracked_custom_field, '2') self.assertEqual(instance.tracked_regular_field, 2) + + instance = ModelWithCustomDescriptor.objects.only('id').get(pk=instance.pk) + if django.VERSION >= (1, 10): + # This fails on 1.8 and 1.9, which is a bug in the deferred field + # implementation on those versions. + instance.tracked_custom_field = 3 + self.assertEqual(instance.tracked_custom_field, '3') + self.assertTrue(instance.tracker.has_changed('tracked_custom_field')) + del instance.tracked_custom_field + self.assertEqual(instance.tracked_custom_field, '2') + self.assertFalse(instance.tracker.has_changed('tracked_custom_field')) From 5d410e9ccceb092947ae9c0777e4b42b93b8376a Mon Sep 17 00:00:00 2001 From: Lucas Wiman Date: Thu, 21 Jun 2018 13:07:13 -0700 Subject: [PATCH 17/52] Fix test failures from merge. --- model_utils/tracker.py | 10 +++++++--- tests/test_fields/test_field_tracker.py | 9 ++++----- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/model_utils/tracker.py b/model_utils/tracker.py index d1101e2..e69d177 100644 --- a/model_utils/tracker.py +++ b/model_utils/tracker.py @@ -84,6 +84,10 @@ class FieldInstanceTracker(object): if django.VERSION < (1, 10): self.init_deferred_fields() + @property + def deferred_fields(self): + return self.instance._deferred_fields if django.VERSION < (1, 10) else self.instance.get_deferred_fields() + def get_field_value(self, field): return getattr(self.instance, self.field_map[field]) @@ -102,7 +106,7 @@ class FieldInstanceTracker(object): def current(self, fields=None): """Returns dict of current values for all tracked fields""" if fields is None: - deferred_fields = self.instance._deferred_fields if django.VERSION < (1, 10) else self.instance.get_deferred_fields() + deferred_fields = self.deferred_fields if deferred_fields: fields = [ field for field in self.fields @@ -117,7 +121,7 @@ class FieldInstanceTracker(object): """Returns ``True`` if field has changed from currently saved value""" if field in self.fields: # deferred fields haven't changed - if field in self.instance._deferred_fields and field not in self.instance.__dict__: + if field in self.deferred_fields and field not in self.instance.__dict__: return False return self.previous(field) != self.get_field_value(field) else: @@ -127,7 +131,7 @@ class FieldInstanceTracker(object): """Returns currently saved value of given field""" # handle deferred fields that have not yet been loaded from the database - if self.instance.pk and field in self.instance._deferred_fields and field not in self.saved_data: + if self.instance.pk and field in self.deferred_fields and field not in self.saved_data: # if the field has not been assigned locally, simply fetch and un-defer the value if field not in self.instance.__dict__: diff --git a/tests/test_fields/test_field_tracker.py b/tests/test_fields/test_field_tracker.py index bc0da4c..dc29e70 100644 --- a/tests/test_fields/test_field_tracker.py +++ b/tests/test_fields/test_field_tracker.py @@ -189,7 +189,6 @@ class FieldTrackerTests(FieldTrackerTestCase, FieldTrackerCommonTests): # has_changed() returns False for deferred fields, without un-deferring them. # Use an if because ModelTracked doesn't support has_changed() in this case. if self.tracked_class == Tracked: - self.assertEqual(item.tracker.previous('number'), None) if django.VERSION >= (1, 10): self.assertTrue('number' in item.get_deferred_fields()) else: @@ -197,7 +196,10 @@ class FieldTrackerTests(FieldTrackerTestCase, FieldTrackerCommonTests): # previous() un-defers field and returns value self.assertEqual(item.tracker.previous('number'), 1) - self.assertTrue('number' not in item._deferred_fields) + if django.VERSION >= (1, 10): + self.assertNotIn('number', item.get_deferred_fields()) + else: + self.assertNotIn('number', item._deferred_fields) # examining a deferred field un-defers it item = self.tracked_class.objects.only('name').first() @@ -224,9 +226,6 @@ class FieldTrackerTests(FieldTrackerTestCase, FieldTrackerCommonTests): item = self.tracked_class.objects.only('name').first() item.number = 2 - # _deferred_fields is not updated by assignment - self.assertTrue('number' in item._deferred_fields) - # previous() fetches correct value from database after deferred field is assigned self.assertEqual(item.tracker.previous('number'), 1) From a84c3afdddfe75520ac2caf92d99bda580c793bc Mon Sep 17 00:00:00 2001 From: Lucas Wiman Date: Thu, 28 Jun 2018 13:09:42 -0700 Subject: [PATCH 18/52] Fix behavior of .previous() in Django 1.10+. The complications are that when the attribute is set in Django 1.10, it no longer counts as a deferred attribute, and it is not retrieved from the database. Naively updating __set__ to retrieve the value if it is deferred leads to infinite recursion because accessing the attribute involves loading data from the database and trying to set the attribute based on that value. This commit introduces a somewhat hacky flag that records whether we're already trying to set the attribute further up in the call stack. --- model_utils/tracker.py | 40 ++++++++++++++++++++++++++-------------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/model_utils/tracker.py b/model_utils/tracker.py index e69d177..a145b1a 100644 --- a/model_utils/tracker.py +++ b/model_utils/tracker.py @@ -46,29 +46,40 @@ class DescriptorWrapper(object): tracker_instance.saved_data[self.field_name] = deepcopy(value) return value + def __set__(self, instance, value): + initialized = hasattr(instance, '_instance_intialized') + was_deferred = self.field_name in instance.get_deferred_fields() + + # Sentinel attribute to detect whether we are already trying to + # set the attribute higher up the stack. This prevents infinite + # recursion when retrieving deferred values from the database. + recursion_sentinel_attname = '_setting_' + self.field_name + already_setting = hasattr(instance, recursion_sentinel_attname) + + if initialized and was_deferred and not already_setting: + setattr(instance, recursion_sentinel_attname, True) + try: + # Retrieve the value to set the saved_data value. + # This will undefer the field + getattr(instance, self.field_name) + finally: + if already_setting: + instance.__dict__.pop(recursion_sentinel_attname, None) + if hasattr(self.descriptor, '__set__'): + self.descriptor.__set__(instance, value) + else: + instance.__dict__[self.field_name] = value + @staticmethod def cls_for_descriptor(descriptor): - has_set = hasattr(descriptor, '__set__') has_del = hasattr(descriptor, '__delete__') if has_del: return FullDescriptorWrapper - elif has_set: - return SettableDescriptorWrapper else: return DescriptorWrapper -class SettableDescriptorWrapper(DescriptorWrapper): - """ - Descriptor wrapper for descriptors with a __delete__ method. - - This should not be used for descriptors - """ - def __set__(self, instance, value): - return self.descriptor.__set__(instance, value) - - -class FullDescriptorWrapper(SettableDescriptorWrapper): +class FullDescriptorWrapper(DescriptorWrapper): """ Wrapper for descriptors with all three descriptor methods. """ @@ -222,6 +233,7 @@ class FieldTracker(object): setattr(instance, self.attname, tracker) tracker.set_saved_fields() self.patch_save(instance) + instance._instance_intialized = True def patch_save(self, instance): original_save = instance.save From 15f9393bb21e2ea349113868d38eeb180dee97cb Mon Sep 17 00:00:00 2001 From: Lucas Wiman Date: Thu, 28 Jun 2018 13:16:33 -0700 Subject: [PATCH 19/52] Handle API change in DeferredAttribute descriptor in django-trunk. This should maintain compatibility with the next version of django. --- model_utils/tracker.py | 5 ++++- tests/models.py | 6 +++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/model_utils/tracker.py b/model_utils/tracker.py index a145b1a..a0bc0d0 100644 --- a/model_utils/tracker.py +++ b/model_utils/tracker.py @@ -187,7 +187,10 @@ class FieldInstanceTracker(object): field_tracker = FileDescriptorTracker(field_obj.field) setattr(self.instance.__class__, field, field_tracker) else: - field_tracker = DeferredAttributeTracker(field, type(self.instance)) + if django.VERSION < (2, 1): + field_tracker = DeferredAttributeTracker(field, type(self.instance)) + else: + field_tracker = DeferredAttributeTracker(field) setattr(self.instance.__class__, field, field_tracker) diff --git a/tests/models.py b/tests/models.py index 91df0b6..7c9bb56 100644 --- a/tests/models.py +++ b/tests/models.py @@ -1,5 +1,6 @@ from __future__ import unicode_literals, absolute_import +import django from django.db import models from django.db.models.query_utils import DeferredAttribute from django.db.models import Manager @@ -346,7 +347,10 @@ class StringyDescriptor(object): return self if self.name in obj.get_deferred_fields(): # This queries the database, and sets the value on the instance. - DeferredAttribute(field_name=self.name, model=cls).__get__(obj, cls) + if django.VERSION < (2, 1): + DeferredAttribute(field_name=self.name, model=cls).__get__(obj, cls) + else: + DeferredAttribute(field_name=self.name).__get__(obj, cls) return str(obj.__dict__[self.name]) def __set__(self, obj, value): From 4740ab43ec161cbe5b80da5bc6ee59c644161b16 Mon Sep 17 00:00:00 2001 From: Lucas Wiman Date: Thu, 28 Jun 2018 13:41:09 -0700 Subject: [PATCH 20/52] Update passed environment variables to match codecov documentation. Hopefully this will combine the coverage reports. --- tox.ini | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tox.ini b/tox.ini index fe42bb1..861074c 100644 --- a/tox.ini +++ b/tox.ini @@ -18,6 +18,10 @@ deps = pytest-cov ignore_outcome = djangotrunk: True +passenv = + CI + TRAVIS + TRAVIS_* commands = pip install -e . From c16a275bd7c04d42ac35ede7fb7be005a479d7dd Mon Sep 17 00:00:00 2001 From: Lucas Wiman Date: Thu, 28 Jun 2018 13:46:39 -0700 Subject: [PATCH 21/52] Use --cov-append option in travis build to include coverage data from all tox environments run on the travis environment. Note that it is run as one per python version, but multiple versions of django are tested in each. --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 83a070b..295d123 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,7 +8,7 @@ python: - 3.6 install: pip install tox-travis codecov # positional args ({posargs}) to pass into tox.ini -script: tox -- --cov +script: tox -- --cov --cov-append after_success: codecov deploy: provider: pypi From 59347ef36fc032717ffa462bb56edd17b2a09d45 Mon Sep 17 00:00:00 2001 From: Lucas Wiman Date: Thu, 28 Jun 2018 13:52:52 -0700 Subject: [PATCH 22/52] Correctly clean up recursion sentinel value. --- model_utils/tracker.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/model_utils/tracker.py b/model_utils/tracker.py index a0bc0d0..e1e61fe 100644 --- a/model_utils/tracker.py +++ b/model_utils/tracker.py @@ -63,8 +63,7 @@ class DescriptorWrapper(object): # This will undefer the field getattr(instance, self.field_name) finally: - if already_setting: - instance.__dict__.pop(recursion_sentinel_attname, None) + instance.__dict__.pop(recursion_sentinel_attname, None) if hasattr(self.descriptor, '__set__'): self.descriptor.__set__(instance, value) else: From cde1d706afb16ef3a48eeef414310da9ae75aa36 Mon Sep 17 00:00:00 2001 From: Lucas Wiman Date: Thu, 28 Jun 2018 14:08:03 -0700 Subject: [PATCH 23/52] Cover a branch in `has_changed`. --- tests/test_fields/test_field_tracker.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_fields/test_field_tracker.py b/tests/test_fields/test_field_tracker.py index dc29e70..604ec60 100644 --- a/tests/test_fields/test_field_tracker.py +++ b/tests/test_fields/test_field_tracker.py @@ -189,6 +189,7 @@ class FieldTrackerTests(FieldTrackerTestCase, FieldTrackerCommonTests): # has_changed() returns False for deferred fields, without un-deferring them. # Use an if because ModelTracked doesn't support has_changed() in this case. if self.tracked_class == Tracked: + self.assertFalse(item.tracker.has_changed('number')) if django.VERSION >= (1, 10): self.assertTrue('number' in item.get_deferred_fields()) else: From ca2fbb4ccd27e85dbe95744bd3d1fe5412a4d72d Mon Sep 17 00:00:00 2001 From: Lucas Wiman Date: Thu, 28 Jun 2018 16:59:30 -0700 Subject: [PATCH 24/52] Fix coverage for a codepath only executed in <1.10 environments. --- model_utils/tracker.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/model_utils/tracker.py b/model_utils/tracker.py index e1e61fe..9c6de5a 100644 --- a/model_utils/tracker.py +++ b/model_utils/tracker.py @@ -186,10 +186,7 @@ class FieldInstanceTracker(object): field_tracker = FileDescriptorTracker(field_obj.field) setattr(self.instance.__class__, field, field_tracker) else: - if django.VERSION < (2, 1): - field_tracker = DeferredAttributeTracker(field, type(self.instance)) - else: - field_tracker = DeferredAttributeTracker(field) + field_tracker = DeferredAttributeTracker(field, type(self.instance)) setattr(self.instance.__class__, field, field_tracker) From 7d6b45f0c1943c1af50c60305ccfdd703f4290ea Mon Sep 17 00:00:00 2001 From: Lucas Wiman Date: Thu, 28 Jun 2018 17:04:57 -0700 Subject: [PATCH 25/52] Increase coverage: verify that accessing the descriptor from the class yields the descriptor object. --- model_utils/tracker.py | 3 +-- tests/test_fields/test_field_tracker.py | 2 ++ 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/model_utils/tracker.py b/model_utils/tracker.py index 9c6de5a..582ae06 100644 --- a/model_utils/tracker.py +++ b/model_utils/tracker.py @@ -71,8 +71,7 @@ class DescriptorWrapper(object): @staticmethod def cls_for_descriptor(descriptor): - has_del = hasattr(descriptor, '__delete__') - if has_del: + if hasattr(descriptor, '__delete__'): return FullDescriptorWrapper else: return DescriptorWrapper diff --git a/tests/test_fields/test_field_tracker.py b/tests/test_fields/test_field_tracker.py index 604ec60..93b9efa 100644 --- a/tests/test_fields/test_field_tracker.py +++ b/tests/test_fields/test_field_tracker.py @@ -7,6 +7,7 @@ from django.core.exceptions import FieldError from django.test import TestCase from model_utils import FieldTracker +from model_utils.tracker import DescriptorWrapper from tests.models import ( Tracked, TrackedFK, InheritedTrackedFK, TrackedNotDefault, TrackedNonFieldAttr, TrackedMultiple, InheritedTracked, TrackedFileField, @@ -191,6 +192,7 @@ class FieldTrackerTests(FieldTrackerTestCase, FieldTrackerCommonTests): if self.tracked_class == Tracked: self.assertFalse(item.tracker.has_changed('number')) if django.VERSION >= (1, 10): + self.assertIsInstance(item.__class__.number, DescriptorWrapper) self.assertTrue('number' in item.get_deferred_fields()) else: self.assertTrue('number' in item._deferred_fields) From 45502c0ec2044f3a35183756d3b46ce456486853 Mon Sep 17 00:00:00 2001 From: Lucas Wiman Date: Sat, 30 Jun 2018 15:52:00 -0700 Subject: [PATCH 26/52] Put changelog in the right place. --- CHANGES.rst | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index b367ada..85cf4cb 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -4,16 +4,14 @@ CHANGES master (unreleased) ------------------- - Fix handling of deferred attributes on Django 1.10+, fixes GH-278 +- Fix `FieldTracker.has_changed()` and `FieldTracker.previous()` to return + correct responses for deferred fields. 3.1.2 (2018.05.09) ------------------ * Update InheritanceIterable to inherit from ModelIterable instead of BaseIterable, fixes GH-277. - -- Fix `FieldTracker.has_changed()` and `FieldTracker.previous()` to return - correct responses for deferred fields. - 3.1.1 (2017.12.17) ------------------ From 11978397653addd1457867acbab958df436fc12a Mon Sep 17 00:00:00 2001 From: Harry Moreno Date: Tue, 26 Jun 2018 18:32:15 -0400 Subject: [PATCH 27/52] Update docs to support django 2.0 --- docs/setup.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/setup.rst b/docs/setup.rst index db2a34e..1335bc5 100644 --- a/docs/setup.rst +++ b/docs/setup.rst @@ -17,7 +17,7 @@ modify your ``INSTALLED_APPS`` setting. Dependencies ============ -``django-model-utils`` supports `Django`_ 1.8 through 1.10 (latest bugfix +``django-model-utils`` supports `Django`_ 1.8 through 2.0 (latest bugfix release in each series only) on Python 2.7, 3.3 (Django 1.8 only), 3.4 and 3.5. .. _Django: http://www.djangoproject.com/ From e750fc7408b9b9dc542b41d820cbf5417389eb43 Mon Sep 17 00:00:00 2001 From: Martey Dodoo Date: Thu, 26 Apr 2018 16:42:43 -0400 Subject: [PATCH 28/52] Fix AUTHORS.rst formatting. Add pipe character at the beginning of each line so that all authors are not concatenated together when ReStructuredText is parsed. Add Martey to AUTHORS as well. --- AUTHORS.rst | 91 +++++++++++++++++++++++++++-------------------------- 1 file changed, 46 insertions(+), 45 deletions(-) diff --git a/AUTHORS.rst b/AUTHORS.rst index fd350c0..99c50b4 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -1,45 +1,46 @@ -ad-m -Alejandro Varas -Alex Orange -Alexey Evseev -Andy Freeland -Artis Avotins -Bram Boogaard -Carl Meyer -Curtis Maloney -Den Lesnov -Dmytro Kyrychuk -Donald Stufft -Douglas Meehan -Facundo Gaich -Felipe Prenholato -Filipe Ximenes -Gregor Müllegger -Hanley Hansen -ivirabyan -James Oakley -Jannis Leidel -Jarek Glowacki -Javier García Sogo -Jeff Elmore -Keryn Knight -Matthew Schinckel -Michael van Tellingen -Mike Bryant -Mikhail Silonov -Patryk Zawadzki -Paul McLanahan -Philipp Steinhardt -Rinat Shigapov -Rodney Folz -Romain Garrigues -rsenkbeil -Ryan Kaskel -Simon Meers -sayane -Tony Aldridge -Travis Swicegood -Trey Hunner -Karl Wan Nan Wo -zyegfryed -Radosław Jan Ganczarek +| ad-m +| Alejandro Varas +| Alex Orange +| Alexey Evseev +| Andy Freeland +| Artis Avotins +| Bram Boogaard +| Carl Meyer +| Curtis Maloney +| Den Lesnov +| Dmytro Kyrychuk +| Donald Stufft +| Douglas Meehan +| Facundo Gaich +| Felipe Prenholato +| Filipe Ximenes +| Gregor Müllegger +| Hanley Hansen +| ivirabyan +| James Oakley +| Jannis Leidel +| Jarek Glowacki +| Javier García Sogo +| Jeff Elmore +| Keryn Knight +| Martey Dodoo +| Matthew Schinckel +| Michael van Tellingen +| Mike Bryant +| Mikhail Silonov +| Patryk Zawadzki +| Paul McLanahan +| Philipp Steinhardt +| Rinat Shigapov +| Rodney Folz +| Romain Garrigues +| rsenkbeil +| Ryan Kaskel +| Simon Meers +| sayane +| Tony Aldridge +| Travis Swicegood +| Trey Hunner +| Karl Wan Nan Wo +| zyegfryed +| Radosław Jan Ganczarek From 54543f1e8d3813652a22b745b87848ca6dad7c2f Mon Sep 17 00:00:00 2001 From: Lucas Wiman Date: Mon, 2 Jul 2018 11:43:38 -0700 Subject: [PATCH 29/52] Add a flake8 environment to ensure pep8 compatibility. --- tox.ini | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/tox.ini b/tox.ini index 861074c..aaa2ca7 100644 --- a/tox.ini +++ b/tox.ini @@ -4,6 +4,7 @@ envlist = py34-django{18,19,110,111,200} py35-django{18,19,110,111,200,trunk} py36-django{111,200,trunk} + flake8 [testenv] deps = @@ -19,10 +20,18 @@ deps = ignore_outcome = djangotrunk: True passenv = - CI - TRAVIS - TRAVIS_* + CI + TRAVIS + TRAVIS_* commands = pip install -e . py.test {posargs} + +[testenv:flake8] +basepython = + python3.6 +deps = + flake8 +commands = + flake8 model_utils tests From 9a6634b87a3e13b6b7cddf4a0f38bbca5acd1259 Mon Sep 17 00:00:00 2001 From: Lucas Wiman Date: Mon, 2 Jul 2018 11:45:51 -0700 Subject: [PATCH 30/52] Ignore assigning to lambda warning. --- tox.ini | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tox.ini b/tox.ini index aaa2ca7..6c6da4e 100644 --- a/tox.ini +++ b/tox.ini @@ -35,3 +35,7 @@ deps = flake8 commands = flake8 model_utils tests + +[flake8] +ignore = + E731 From 654e13235e3f1dd5bcafc2b76729c9b79486b439 Mon Sep 17 00:00:00 2001 From: Lucas Wiman Date: Mon, 2 Jul 2018 11:47:19 -0700 Subject: [PATCH 31/52] Fix E303 too many blank lines. --- model_utils/choices.py | 12 ------------ tests/test_fields/test_field_tracker.py | 1 - 2 files changed, 13 deletions(-) diff --git a/model_utils/choices.py b/model_utils/choices.py index d48ba90..c036cf4 100644 --- a/model_utils/choices.py +++ b/model_utils/choices.py @@ -57,7 +57,6 @@ class Choices(object): self._process(choices) - def _store(self, triple, triple_collector, double_collector): self._identifier_map[triple[1]] = triple[0] self._display_map[triple[0]] = triple[2] @@ -65,7 +64,6 @@ class Choices(object): triple_collector.append(triple) double_collector.append((triple[0], triple[2])) - def _process(self, choices, triple_collector=None, double_collector=None): if triple_collector is None: triple_collector = self._triples @@ -98,26 +96,21 @@ class Choices(object): else: store((choice, choice, choice)) - def __len__(self): return len(self._doubles) - def __iter__(self): return iter(self._doubles) - def __getattr__(self, attname): try: return self._identifier_map[attname] except KeyError: raise AttributeError(attname) - def __getitem__(self, key): return self._display_map[key] - def __add__(self, other): if isinstance(other, self.__class__): other = other._triples @@ -125,29 +118,24 @@ class Choices(object): other = list(other) return Choices(*(self._triples + other)) - def __radd__(self, other): # radd is never called for matching types, so we don't check here other = list(other) return Choices(*(other + self._triples)) - def __eq__(self, other): if isinstance(other, self.__class__): return self._triples == other._triples return False - def __repr__(self): return '%s(%s)' % ( self.__class__.__name__, ', '.join(("%s" % repr(i) for i in self._triples)) ) - def __contains__(self, item): return item in self._db_values - def __deepcopy__(self, memo): return self.__class__(*copy.deepcopy(self._triples, memo)) diff --git a/tests/test_fields/test_field_tracker.py b/tests/test_fields/test_field_tracker.py index 93b9efa..cff2e35 100644 --- a/tests/test_fields/test_field_tracker.py +++ b/tests/test_fields/test_field_tracker.py @@ -241,7 +241,6 @@ class FieldTrackerTests(FieldTrackerTestCase, FieldTrackerCommonTests): self.assertFalse(item.tracker.has_changed('number')) - class FieldTrackerMultipleInstancesTests(TestCase): def test_with_deferred_fields_access_multiple(self): From f845dcb24c924531503f9695f63ea61cf4c84d01 Mon Sep 17 00:00:00 2001 From: Lucas Wiman Date: Mon, 2 Jul 2018 11:49:16 -0700 Subject: [PATCH 32/52] Fix E231: missing whitespace after ",". --- tests/test_fields/test_field_tracker.py | 52 ++++++++++++------------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/tests/test_fields/test_field_tracker.py b/tests/test_fields/test_field_tracker.py index cff2e35..b0f2dba 100644 --- a/tests/test_fields/test_field_tracker.py +++ b/tests/test_fields/test_field_tracker.py @@ -75,7 +75,7 @@ class FieldTrackerTests(FieldTrackerTestCase, FieldTrackerCommonTests): self.assertChanged(name=None, number=None) self.instance.name = '' self.assertChanged(name=None, number=None) - self.instance.mutable = [1,2,3] + self.instance.mutable = [1, 2, 3] self.assertChanged(name=None, number=None, mutable=None) def test_pre_save_has_changed(self): @@ -84,7 +84,7 @@ class FieldTrackerTests(FieldTrackerTestCase, FieldTrackerCommonTests): self.assertHasChanged(name=True, number=False, mutable=False) self.instance.number = 7 self.assertHasChanged(name=True, number=True) - self.instance.mutable = [1,2,3] + self.instance.mutable = [1, 2, 3] self.assertHasChanged(name=True, number=True, mutable=True) def test_first_save(self): @@ -94,22 +94,22 @@ class FieldTrackerTests(FieldTrackerTestCase, FieldTrackerCommonTests): self.assertChanged(name=None) self.instance.name = 'retro' self.instance.number = 4 - self.instance.mutable = [1,2,3] + self.instance.mutable = [1, 2, 3] self.assertHasChanged(name=True, number=True, mutable=True) self.assertPrevious(name=None, number=None, mutable=None) - self.assertCurrent(name='retro', number=4, id=None, mutable=[1,2,3]) + self.assertCurrent(name='retro', number=4, id=None, mutable=[1, 2, 3]) self.assertChanged(name=None, number=None, mutable=None) self.instance.save(update_fields=[]) self.assertHasChanged(name=True, number=True, mutable=True) self.assertPrevious(name=None, number=None, mutable=None) - self.assertCurrent(name='retro', number=4, id=None, mutable=[1,2,3]) + self.assertCurrent(name='retro', number=4, id=None, mutable=[1, 2, 3]) self.assertChanged(name=None, number=None, mutable=None) with self.assertRaises(ValueError): self.instance.save(update_fields=['number']) def test_post_save_has_changed(self): - self.update_instance(name='retro', number=4, mutable=[1,2,3]) + self.update_instance(name='retro', number=4, mutable=[1, 2, 3]) self.assertHasChanged(name=False, number=False, mutable=False) self.instance.name = 'new age' self.assertHasChanged(name=True, number=False) @@ -121,14 +121,14 @@ class FieldTrackerTests(FieldTrackerTestCase, FieldTrackerCommonTests): self.assertHasChanged(name=False, number=True, mutable=True) def test_post_save_previous(self): - self.update_instance(name='retro', number=4, mutable=[1,2,3]) + self.update_instance(name='retro', number=4, mutable=[1, 2, 3]) self.instance.name = 'new age' - self.assertPrevious(name='retro', number=4, mutable=[1,2,3]) + self.assertPrevious(name='retro', number=4, mutable=[1, 2, 3]) self.instance.mutable[1] = 4 - self.assertPrevious(name='retro', number=4, mutable=[1,2,3]) + self.assertPrevious(name='retro', number=4, mutable=[1, 2, 3]) def test_post_save_changed(self): - self.update_instance(name='retro', number=4, mutable=[1,2,3]) + self.update_instance(name='retro', number=4, mutable=[1, 2, 3]) self.assertChanged() self.instance.name = 'new age' self.assertChanged(name='retro') @@ -137,8 +137,8 @@ class FieldTrackerTests(FieldTrackerTestCase, FieldTrackerCommonTests): self.instance.name = 'retro' self.assertChanged(number=4) self.instance.mutable[1] = 4 - self.assertChanged(number=4, mutable=[1,2,3]) - self.instance.mutable = [1,2,3] + self.assertChanged(number=4, mutable=[1, 2, 3]) + self.instance.mutable = [1, 2, 3] self.assertChanged(number=4) def test_current(self): @@ -147,29 +147,29 @@ class FieldTrackerTests(FieldTrackerTestCase, FieldTrackerCommonTests): self.assertCurrent(id=None, name='new age', number=None, mutable=None) self.instance.number = 8 self.assertCurrent(id=None, name='new age', number=8, mutable=None) - self.instance.mutable = [1,2,3] - self.assertCurrent(id=None, name='new age', number=8, mutable=[1,2,3]) + self.instance.mutable = [1, 2, 3] + self.assertCurrent(id=None, name='new age', number=8, mutable=[1, 2, 3]) self.instance.mutable[1] = 4 - self.assertCurrent(id=None, name='new age', number=8, mutable=[1,4,3]) + self.assertCurrent(id=None, name='new age', number=8, mutable=[1, 4, 3]) self.instance.save() - self.assertCurrent(id=self.instance.id, name='new age', number=8, mutable=[1,4,3]) + self.assertCurrent(id=self.instance.id, name='new age', number=8, mutable=[1, 4, 3]) def test_update_fields(self): - self.update_instance(name='retro', number=4, mutable=[1,2,3]) + self.update_instance(name='retro', number=4, mutable=[1, 2, 3]) self.assertChanged() self.instance.name = 'new age' self.instance.number = 8 - self.instance.mutable = [4,5,6] - self.assertChanged(name='retro', number=4, mutable=[1,2,3]) + self.instance.mutable = [4, 5, 6] + self.assertChanged(name='retro', number=4, mutable=[1, 2, 3]) self.instance.save(update_fields=[]) - self.assertChanged(name='retro', number=4, mutable=[1,2,3]) + self.assertChanged(name='retro', number=4, mutable=[1, 2, 3]) self.instance.save(update_fields=['name']) in_db = self.tracked_class.objects.get(id=self.instance.id) self.assertEqual(in_db.name, self.instance.name) self.assertNotEqual(in_db.number, self.instance.number) - self.assertChanged(number=4, mutable=[1,2,3]) + self.assertChanged(number=4, mutable=[1, 2, 3]) self.instance.save(update_fields=['number']) - self.assertChanged(mutable=[1,2,3]) + self.assertChanged(mutable=[1, 2, 3]) self.instance.save(update_fields=['mutable']) self.assertChanged() in_db = self.tracked_class.objects.get(id=self.instance.id) @@ -649,7 +649,7 @@ class ModelTrackerTests(FieldTrackerTests): self.assertChanged() self.instance.name = '' self.assertChanged() - self.instance.mutable = [1,2,3] + self.instance.mutable = [1, 2, 3] self.assertChanged() def test_first_save(self): @@ -659,16 +659,16 @@ class ModelTrackerTests(FieldTrackerTests): self.assertChanged() self.instance.name = 'retro' self.instance.number = 4 - self.instance.mutable = [1,2,3] + self.instance.mutable = [1, 2, 3] self.assertHasChanged(name=True, number=True, mutable=True) self.assertPrevious(name=None, number=None, mutable=None) - self.assertCurrent(name='retro', number=4, id=None, mutable=[1,2,3]) + self.assertCurrent(name='retro', number=4, id=None, mutable=[1, 2, 3]) self.assertChanged() self.instance.save(update_fields=[]) self.assertHasChanged(name=True, number=True, mutable=True) self.assertPrevious(name=None, number=None, mutable=None) - self.assertCurrent(name='retro', number=4, id=None, mutable=[1,2,3]) + self.assertCurrent(name='retro', number=4, id=None, mutable=[1, 2, 3]) self.assertChanged() with self.assertRaises(ValueError): self.instance.save(update_fields=['number']) From 954624cb2221ece1fc199e55d21c3aaf159c8343 Mon Sep 17 00:00:00 2001 From: Lucas Wiman Date: Mon, 2 Jul 2018 11:50:56 -0700 Subject: [PATCH 33/52] Fix F841: local variable is assigned but never used. --- tests/test_models/test_deferred_fields.py | 2 +- tests/test_models/test_status_model.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_models/test_deferred_fields.py b/tests/test_models/test_deferred_fields.py index 05ba336..ea8e3bd 100644 --- a/tests/test_models/test_deferred_fields.py +++ b/tests/test_models/test_deferred_fields.py @@ -24,7 +24,7 @@ class CustomDescriptorTests(TestCase): self.assertEqual(instance.custom_field, '2') self.assertEqual(instance.__dict__['custom_field'], 2) instance.save() - intance = ModelWithCustomDescriptor.objects.get(pk=instance.pk) + instance = ModelWithCustomDescriptor.objects.get(pk=instance.pk) self.assertEqual(instance.custom_field, '2') self.assertEqual(instance.__dict__['custom_field'], 2) diff --git a/tests/test_models/test_status_model.py b/tests/test_models/test_status_model.py index f660936..6950dbf 100644 --- a/tests/test_models/test_status_model.py +++ b/tests/test_models/test_status_model.py @@ -18,7 +18,7 @@ class StatusModelTests(TestCase): c1 = self.model.objects.create() self.assertTrue(c1.status_changed, datetime(2016, 1, 1)) - c2 = self.model.objects.create() + self.model.objects.create() self.assertEqual(self.model.active.count(), 2) self.assertEqual(self.model.deleted.count(), 0) From 679aed41a298d6e97cf8abfb1ee3248d65b542fd Mon Sep 17 00:00:00 2001 From: Lucas Wiman Date: Mon, 2 Jul 2018 11:52:05 -0700 Subject: [PATCH 34/52] Remove unused __unicode__ method (dead code). --- tests/models.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/models.py b/tests/models.py index 7c9bb56..80f3a3f 100644 --- a/tests/models.py +++ b/tests/models.py @@ -38,9 +38,6 @@ class InheritanceManagerTestParent(models.Model): on_delete=models.CASCADE) objects = InheritanceManager() - def __unicode__(self): - return unicode(self.pk) - def __str__(self): return "%s(%s)" % ( self.__class__.__name__[len('InheritanceManagerTest'):], From 600ddc8dc546f046b461a8c7cae9af9f26dc7ac8 Mon Sep 17 00:00:00 2001 From: Lucas Wiman Date: Mon, 2 Jul 2018 12:16:41 -0700 Subject: [PATCH 35/52] Fix F401: imported but unused. --- model_utils/__init__.py | 4 ++-- tests/test_fields/test_field_tracker.py | 2 -- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/model_utils/__init__.py b/model_utils/__init__.py index f297da6..54f1b02 100644 --- a/model_utils/__init__.py +++ b/model_utils/__init__.py @@ -1,4 +1,4 @@ -from .choices import Choices -from .tracker import FieldTracker, ModelTracker +from .choices import Choices # noqa:F401 +from .tracker import FieldTracker, ModelTracker # noqa:F401 __version__ = '3.1.2' diff --git a/tests/test_fields/test_field_tracker.py b/tests/test_fields/test_field_tracker.py index b0f2dba..43f12f5 100644 --- a/tests/test_fields/test_field_tracker.py +++ b/tests/test_fields/test_field_tracker.py @@ -1,7 +1,5 @@ from __future__ import unicode_literals -from unittest import skipUnless - import django from django.core.exceptions import FieldError from django.test import TestCase From da65d0be3245df66a4ba3f687d35d2cac92f50ee Mon Sep 17 00:00:00 2001 From: Lucas Wiman Date: Mon, 2 Jul 2018 12:20:38 -0700 Subject: [PATCH 36/52] Fix E30X too-few line spacing errors. --- model_utils/fields.py | 12 +++++++++--- model_utils/tracker.py | 2 ++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/model_utils/fields.py b/model_utils/fields.py index f308706..d9d9b85 100644 --- a/model_utils/fields.py +++ b/model_utils/fields.py @@ -141,6 +141,7 @@ SPLIT_DEFAULT_PARAGRAPHS = getattr(settings, 'SPLIT_DEFAULT_PARAGRAPHS', 2) _excerpt_field_name = lambda name: '_%s_excerpt' % name + def get_excerpt(content): excerpt = [] default_excerpt = [] @@ -156,6 +157,7 @@ def get_excerpt(content): return '\n'.join(default_excerpt) + @python_2_unicode_compatible class SplitText(object): def __init__(self, instance, field_name, excerpt_field_name): @@ -166,11 +168,13 @@ class SplitText(object): self.excerpt_field_name = excerpt_field_name # content is read/write - def _get_content(self): + @property + def content(self): return self.instance.__dict__[self.field_name] - def _set_content(self, val): + + @content.setter + def content(self, val): setattr(self.instance, self.field_name, val) - content = property(_get_content, _set_content) # excerpt is a read only property def _get_excerpt(self): @@ -185,6 +189,7 @@ class SplitText(object): def __str__(self): return self.content + class SplitDescriptor(object): def __init__(self, field): self.field = field @@ -205,6 +210,7 @@ class SplitDescriptor(object): else: obj.__dict__[self.field.name] = value + class SplitField(models.TextField): def __init__(self, *args, **kwargs): # for South FakeORM compatibility: the frozen version of a diff --git a/model_utils/tracker.py b/model_utils/tracker.py index 582ae06..8ce10dc 100644 --- a/model_utils/tracker.py +++ b/model_utils/tracker.py @@ -235,6 +235,7 @@ class FieldTracker(object): def patch_save(self, instance): original_save = instance.save + def save(**kwargs): ret = original_save(**kwargs) update_fields = kwargs.get('update_fields') @@ -251,6 +252,7 @@ class FieldTracker(object): fields=fields ) return ret + instance.save = save def __get__(self, instance, owner): From e23e86a2beb13a66884d1670fd11a8988a605cc9 Mon Sep 17 00:00:00 2001 From: Lucas Wiman Date: Mon, 2 Jul 2018 12:52:47 -0700 Subject: [PATCH 37/52] Ignore W503 line break before binary operator It doesn't seem like following this rule will lead to clearer code in the violations in this codebase. --- tox.ini | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 6c6da4e..c46b78a 100644 --- a/tox.ini +++ b/tox.ini @@ -38,4 +38,5 @@ commands = [flake8] ignore = - E731 + E731, ; do not assign a lambda expression, use a def + W503 ; line break before binary operator From 0859508a6422bc57b54060ee504f3166e8536d9e Mon Sep 17 00:00:00 2001 From: Lucas Wiman Date: Mon, 2 Jul 2018 12:57:14 -0700 Subject: [PATCH 38/52] Fix E123 closing bracket does not match indentation of opening bracket's line --- model_utils/choices.py | 4 ++-- model_utils/managers.py | 4 ++-- tests/test_fields/test_status_field.py | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/model_utils/choices.py b/model_utils/choices.py index c036cf4..681e44b 100644 --- a/model_utils/choices.py +++ b/model_utils/choices.py @@ -92,7 +92,7 @@ class Choices(object): raise ValueError( "Choices can't take a list of length %s, only 2 or 3" % len(choice) - ) + ) else: store((choice, choice, choice)) @@ -132,7 +132,7 @@ class Choices(object): return '%s(%s)' % ( self.__class__.__name__, ', '.join(("%s" % repr(i) for i in self._triples)) - ) + ) def __contains__(self, item): return item in self._db_values diff --git a/model_utils/managers.py b/model_utils/managers.py index 98a2a54..b760ffd 100644 --- a/model_utils/managers.py +++ b/model_utils/managers.py @@ -76,7 +76,7 @@ class InheritanceQuerySetMixin(object): raise ValueError( '%r is not in the discovered subclasses, tried: %s' % ( subclass, ', '.join(calculated_subclasses)) - ) + ) subclasses = verified_subclasses # workaround https://code.djangoproject.com/ticket/16855 @@ -159,7 +159,7 @@ class InheritanceQuerySetMixin(object): if isinstance(rel.field, OneToOneField) and issubclass(rel.field.model, model) and model is not rel.field.model - ] + ] subclasses = [] if levels: diff --git a/tests/test_fields/test_status_field.py b/tests/test_fields/test_status_field.py index 5f077da..dc0f223 100644 --- a/tests/test_fields/test_status_field.py +++ b/tests/test_fields/test_status_field.py @@ -6,7 +6,7 @@ from model_utils.fields import StatusField from tests.models import ( Article, StatusFieldDefaultFilled, StatusFieldDefaultNotFilled, StatusFieldChoicesName, - ) +) class StatusFieldTests(TestCase): From 9189d6099682f8d81ce153b3e939421f70f6ec91 Mon Sep 17 00:00:00 2001 From: Lucas Wiman Date: Mon, 2 Jul 2018 13:01:49 -0700 Subject: [PATCH 39/52] Fix remaining non-E501 line length changes. The E402 module level import appears to be an error of some kind in flake8? --- tests/test_choices.py | 2 +- .../test_managers/test_inheritance_manager.py | 32 +++++++++---------- tox.ini | 1 + 3 files changed, 18 insertions(+), 17 deletions(-) diff --git a/tests/test_choices.py b/tests/test_choices.py index a503405..29dbb36 100644 --- a/tests/test_choices.py +++ b/tests/test_choices.py @@ -79,7 +79,7 @@ class LabelChoicesTests(ChoicesTests): ('DRAFT', 'is draft'), ('PUBLISHED', 'is published'), ('DELETED', 'DELETED')) - ) + ) def test_indexing(self): self.assertEqual(self.STATUS['PUBLISHED'], 'is published') diff --git a/tests/test_managers/test_inheritance_manager.py b/tests/test_managers/test_inheritance_manager.py index 39c694e..7cde8d3 100644 --- a/tests/test_managers/test_inheritance_manager.py +++ b/tests/test_managers/test_inheritance_manager.py @@ -6,11 +6,12 @@ import django from django.db import models from django.test import TestCase -from tests.models import (InheritanceManagerTestRelated, InheritanceManagerTestGrandChild1, - InheritanceManagerTestGrandChild1_2, InheritanceManagerTestParent, - InheritanceManagerTestChild1, - InheritanceManagerTestChild2, TimeFrame, InheritanceManagerTestChild3 - ) +from tests.models import ( + InheritanceManagerTestRelated, InheritanceManagerTestGrandChild1, + InheritanceManagerTestGrandChild1_2, InheritanceManagerTestParent, + InheritanceManagerTestChild1, + InheritanceManagerTestChild2, TimeFrame, InheritanceManagerTestChild3 +) class InheritanceManagerTests(TestCase): @@ -177,27 +178,26 @@ class InheritanceManagerTests(TestCase): # No argument to select_subclasses objs_1 = list( - self.get_manager(). - select_subclasses(). - values_list('id') + self.get_manager() + .select_subclasses() + .values_list('id') ) # String argument to select_subclasses objs_2 = list( - self.get_manager(). - select_subclasses( + self.get_manager() + .select_subclasses( "inheritancemanagertestchild2" - ). - values_list('id') + ) + .values_list('id') ) # String argument to select_subclasses objs_3 = list( - self.get_manager(). - select_subclasses( + self.get_manager() + .select_subclasses( InheritanceManagerTestChild2 - ). - values_list('id') + ).values_list('id') ) assert all(( diff --git a/tox.ini b/tox.ini index c46b78a..339c671 100644 --- a/tox.ini +++ b/tox.ini @@ -40,3 +40,4 @@ commands = ignore = E731, ; do not assign a lambda expression, use a def W503 ; line break before binary operator + E402 ; module level import not at top of file From c53b19e50d35ec5e14b552074af817cb7ad69b97 Mon Sep 17 00:00:00 2001 From: Lucas Wiman Date: Mon, 2 Jul 2018 14:33:47 -0700 Subject: [PATCH 40/52] Ignore 80 character line length restriction of pep8. It seems like this hasn't been consistently followed in this codebase, and the number of changes was fairly large. --- tox.ini | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 339c671..71caa88 100644 --- a/tox.ini +++ b/tox.ini @@ -38,6 +38,7 @@ commands = [flake8] ignore = - E731, ; do not assign a lambda expression, use a def + E731 ; do not assign a lambda expression, use a def W503 ; line break before binary operator E402 ; module level import not at top of file + E501 ; line too long From df8ceed2650c58a00273de4a9eb5a1539ffa436c Mon Sep 17 00:00:00 2001 From: Bo Marchman Date: Tue, 7 Aug 2018 11:02:41 -0400 Subject: [PATCH 41/52] Add Django 2.1 to tox --- tox.ini | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tox.ini b/tox.ini index 71caa88..7fd9cca 100644 --- a/tox.ini +++ b/tox.ini @@ -2,8 +2,8 @@ envlist = py27-django{18,19,110,111} py34-django{18,19,110,111,200} - py35-django{18,19,110,111,200,trunk} - py36-django{111,200,trunk} + py35-django{18,19,110,111,200,201,trunk} + py36-django{111,200,201,trunk} flake8 [testenv] @@ -13,6 +13,7 @@ deps = django110: Django>=1.10,<1.11 django111: Django>=1.11,<1.12 django200: Django>=2.0,<2.1 + django201: Django>=2.1,<2.2 djangotrunk: https://github.com/django/django/archive/master.tar.gz freezegun == 0.3.8 -rrequirements-test.txt From 4562da4e18b2cf00379e22950bdb3cfd2b8f1e4c Mon Sep 17 00:00:00 2001 From: Asif Saif Uddin Date: Mon, 24 Sep 2018 19:39:33 +0600 Subject: [PATCH 42/52] updated doc about supported python and django versions. --- docs/setup.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/setup.rst b/docs/setup.rst index 1335bc5..1fca10c 100644 --- a/docs/setup.rst +++ b/docs/setup.rst @@ -17,7 +17,7 @@ modify your ``INSTALLED_APPS`` setting. Dependencies ============ -``django-model-utils`` supports `Django`_ 1.8 through 2.0 (latest bugfix -release in each series only) on Python 2.7, 3.3 (Django 1.8 only), 3.4 and 3.5. +``django-model-utils`` supports `Django`_ 1.8 through 2.1 (latest bugfix +release in each series only) on Python 2.7, 3.4, 3.5 and 3.6. .. _Django: http://www.djangoproject.com/ From 3efac688ed857c433dcd9225dbe832d8a7470a2b Mon Sep 17 00:00:00 2001 From: Ant Somers Date: Fri, 5 Oct 2018 02:43:52 +0300 Subject: [PATCH 43/52] Change supported version to 2.1 at README.rst --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 3014eb0..b814c47 100644 --- a/README.rst +++ b/README.rst @@ -14,7 +14,7 @@ django-model-utils Django model mixins and utilities. -``django-model-utils`` supports `Django`_ 1.8 to 2.0. +``django-model-utils`` supports `Django`_ 1.8 to 2.1. .. _Django: http://www.djangoproject.com/ From 05671695bb347fea57fcf33c2f0897d918184819 Mon Sep 17 00:00:00 2001 From: Asif Saif Uddin Date: Sat, 3 Nov 2018 11:12:50 +0600 Subject: [PATCH 44/52] update tag --- model_utils/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/model_utils/__init__.py b/model_utils/__init__.py index 54f1b02..2fa87c0 100644 --- a/model_utils/__init__.py +++ b/model_utils/__init__.py @@ -1,4 +1,4 @@ from .choices import Choices # noqa:F401 from .tracker import FieldTracker, ModelTracker # noqa:F401 -__version__ = '3.1.2' +__version__ = '3.2.0' From ba83be0b43f9e137ae56762f509ea9a35592ba90 Mon Sep 17 00:00:00 2001 From: tumb1er Date: Tue, 20 Nov 2018 17:49:52 +0300 Subject: [PATCH 45/52] fix #330 patch MyModel.save instead of MyModel().save --- CHANGES.rst | 1 + model_utils/tracker.py | 12 ++++++------ tests/settings.py | 6 ++++++ tests/test_fields/test_field_tracker.py | 12 +++++++++++- 4 files changed, 24 insertions(+), 7 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 85cf4cb..7d71665 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -6,6 +6,7 @@ master (unreleased) - Fix handling of deferred attributes on Django 1.10+, fixes GH-278 - Fix `FieldTracker.has_changed()` and `FieldTracker.previous()` to return correct responses for deferred fields. +- Fix Model instance non picklable GH-330 3.1.2 (2018.05.09) ------------------ diff --git a/model_utils/tracker.py b/model_utils/tracker.py index 8ce10dc..0059224 100644 --- a/model_utils/tracker.py +++ b/model_utils/tracker.py @@ -207,6 +207,7 @@ class FieldTracker(object): def contribute_to_class(self, cls, name): self.name = name self.attname = '_%s' % name + self.patch_save(cls) models.signals.class_prepared.connect(self.finalize_class, sender=cls) def finalize_class(self, sender, **kwargs): @@ -230,14 +231,13 @@ class FieldTracker(object): tracker = self.tracker_class(instance, self.fields, self.field_map) setattr(instance, self.attname, tracker) tracker.set_saved_fields() - self.patch_save(instance) instance._instance_intialized = True - def patch_save(self, instance): - original_save = instance.save + def patch_save(self, model): + original_save = model.save - def save(**kwargs): - ret = original_save(**kwargs) + def save(instance, **kwargs): + ret = original_save(instance, **kwargs) update_fields = kwargs.get('update_fields') if not update_fields and update_fields is not None: # () or [] fields = update_fields @@ -253,7 +253,7 @@ class FieldTracker(object): ) return ret - instance.save = save + model.save = save def __get__(self, instance, owner): if instance is None: diff --git a/tests/settings.py b/tests/settings.py index 8817e83..b3be03a 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -8,3 +8,9 @@ DATABASES = { } } SECRET_KEY = 'dummy' + +CACHES = { + 'default': { + 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', + } +} diff --git a/tests/test_fields/test_field_tracker.py b/tests/test_fields/test_field_tracker.py index 43f12f5..65a2bfe 100644 --- a/tests/test_fields/test_field_tracker.py +++ b/tests/test_fields/test_field_tracker.py @@ -3,7 +3,7 @@ from __future__ import unicode_literals import django from django.core.exceptions import FieldError from django.test import TestCase - +from django.core.cache import cache from model_utils import FieldTracker from model_utils.tracker import DescriptorWrapper from tests.models import ( @@ -639,6 +639,16 @@ class ModelTrackerTests(FieldTrackerTests): tracked_class = ModelTracked + def test_cache_compatible(self): + cache.set('key', self.instance) + instance = cache.get('key') + instance.number = 1 + instance.name = 'cached' + instance.save() + self.assertChanged() + instance.number = 2 + self.assertHasChanged(number=True) + def test_pre_save_changed(self): self.assertChanged() self.instance.name = 'new age' From 2cb773372dec28a6ef777663ae642a8198702cc9 Mon Sep 17 00:00:00 2001 From: Jonathan Sundqvist Date: Sun, 25 Nov 2018 15:18:23 +0100 Subject: [PATCH 46/52] Add a JoinManager that helps with performance (#351) * Add the join manager + tests * Documentation for join manager * Use order_by for consistent tests * Use postgres instead sqlite for tests for better reliability * Fix coverage * Drop django 1.8 --- .coveragerc | 5 +- .travis.yml | 2 + AUTHORS.rst | 1 + README.rst | 9 ++ docs/managers.rst | 27 ++++ model_utils/managers.py | 126 ++++++++++++++++-- requirements-test.txt | 1 + tests/models.py | 25 +++- tests/settings.py | 11 +- .../test_managers/test_inheritance_manager.py | 31 +++-- tests/test_managers/test_join_manager.py | 38 ++++++ tox.ini | 7 +- 12 files changed, 247 insertions(+), 36 deletions(-) create mode 100644 tests/test_managers/test_join_manager.py diff --git a/.coveragerc b/.coveragerc index 62d6d1c..8708371 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,5 +1,2 @@ [run] -source = model_utils -omit = .* - tests/* - */_* +include = model_utils/*.py diff --git a/.travis.yml b/.travis.yml index 295d123..a7d4622 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,6 +9,8 @@ python: install: pip install tox-travis codecov # positional args ({posargs}) to pass into tox.ini script: tox -- --cov --cov-append +services: + - postgresql after_success: codecov deploy: provider: pypi diff --git a/AUTHORS.rst b/AUTHORS.rst index 1aea33c..459b755 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -22,6 +22,7 @@ | Jarek Glowacki | Javier García Sogo | Jeff Elmore +| Jonathan Sundqvist | Keryn Knight | Martey Dodoo | Matthew Schinckel diff --git a/README.rst b/README.rst index b814c47..6d74e7a 100644 --- a/README.rst +++ b/README.rst @@ -28,6 +28,15 @@ Getting Help Documentation for django-model-utils is available https://django-model-utils.readthedocs.io/ + +Run tests +--------- + +.. code-block + + pip install -e . + py.test + Contributing ============ diff --git a/docs/managers.rst b/docs/managers.rst index 43aa030..b90f3d4 100644 --- a/docs/managers.rst +++ b/docs/managers.rst @@ -86,6 +86,33 @@ it's safe to use as your default manager for the model. .. _contributed by Jeff Elmore: http://jeffelmore.org/2010/11/11/automatic-downcasting-of-inherited-models-in-django/ +JoinManager +----------- + +The ``JoinManager`` will create a temporary table of your current queryset +and join that temporary table with the model of your current queryset. This can +be advantageous if you have to page through your entire DB and using django's +slice mechanism to do that. ``LIMIT .. OFFSET ..`` becomes slower the bigger +offset you use. + +.. code-block:: python + + sliced_qs = Place.objects.all()[2000:2010] + qs = sliced_qs.join() + # qs contains 10 objects, and there will be a much smaller performance hit + # for paging through all of first 2000 objects. + +Alternatively, you can give it a queryset and the manager will create a temporary +table and join that to your current queryset. This can work as a more performant +alternative to using django's ``__in`` as described in the following +(`StackExchange answer`_). + +.. code-block:: python + + big_qs = Restaurant.objects.filter(menu='vegetarian') + qs = Country.objects.filter(country_code='SE').join(big_qs) + +.. _StackExchange answer: https://dba.stackexchange.com/questions/91247/optimizing-a-postgres-query-with-a-large-in .. _QueryManager: diff --git a/model_utils/managers.py b/model_utils/managers.py index b760ffd..afd0c5b 100644 --- a/model_utils/managers.py +++ b/model_utils/managers.py @@ -3,16 +3,15 @@ import django from django.db import models from django.db.models.fields.related import OneToOneField, OneToOneRel from django.db.models.query import QuerySet -try: - from django.db.models.query import BaseIterable, ModelIterable -except ImportError: - # Django 1.8 does not have iterable classes - BaseIterable, ModelIterable = object, object +from django.db.models.query import ModelIterable from django.core.exceptions import ObjectDoesNotExist from django.db.models.constants import LOOKUP_SEP from django.utils.six import string_types +from django.db import connection +from django.db.models.sql.datastructures import Join + class InheritanceIterable(ModelIterable): def __iter__(self): @@ -104,10 +103,6 @@ class InheritanceQuerySetMixin(object): if hasattr(self, name): kwargs[name] = getattr(self, name) - if django.VERSION < (1, 9): - kwargs['klass'] = klass - kwargs['setup'] = setup - return super(InheritanceQuerySetMixin, self)._clone(**kwargs) def annotate(self, *args, **kwargs): @@ -189,10 +184,7 @@ class InheritanceQuerySetMixin(object): if levels: levels -= 1 while parent_link is not None: - if django.VERSION < (1, 9): - related = parent_link.rel - else: - related = parent_link.remote_field + related = parent_link.remote_field ancestry.insert(0, related.get_accessor_name()) if levels or levels is None: parent_model = related.model @@ -308,3 +300,111 @@ class SoftDeletableManagerMixin(object): class SoftDeletableManager(SoftDeletableManagerMixin, models.Manager): pass + + +class JoinQueryset(models.QuerySet): + + def get_quoted_query(self, query): + query, params = query.sql_with_params() + + # Put additional quotes around string. + params = [ + '\'{}\''.format(p) + if isinstance(p, str) else p + for p in params + ] + + # Cast list of parameters to tuple because I got + # "not enough format characters" otherwise. + params = tuple(params) + return query % params + + def join(self, qs=None): + ''' + Join one queryset together with another using a temporary table. If + no queryset is used, it will use the current queryset and join that + to itself. + + `Join` either uses the current queryset and effectively does a self-join to + create a new limited queryset OR it uses a querset given by the user. + + The model of a given queryset needs to contain a valid foreign key to + the current queryset to perform a join. A new queryset is then created. + ''' + to_field = 'id' + + if qs: + fk = [ + fk for fk in qs.model._meta.fields + if getattr(fk, 'related_model', None) == self.model + ] + fk = fk[0] if fk else None + model_set = '{}_set'.format(self.model.__name__.lower()) + key = fk or getattr(qs.model, model_set, None) + + if not key: + raise ValueError('QuerySet is not related to current model') + + try: + fk_column = key.column + except AttributeError: + fk_column = 'id' + to_field = key.field.column + + qs = qs.only(fk_column) + # if we give a qs we need to keep the model qs to not lose anything + new_qs = self + else: + fk_column = 'id' + qs = self.only(fk_column) + new_qs = self.model.objects.all() + + TABLE_NAME = 'temp_stuff' + query = self.get_quoted_query(qs.query) + sql = ''' + DROP TABLE IF EXISTS {table_name}; + DROP INDEX IF EXISTS {table_name}_id; + CREATE TEMPORARY TABLE {table_name} AS {query}; + CREATE INDEX {table_name}_{fk_column} ON {table_name} ({fk_column}); + '''.format(table_name=TABLE_NAME, fk_column=fk_column, query=str(query)) + + with connection.cursor() as cursor: + cursor.execute(sql) + + class TempModel(models.Model): + temp_key = models.ForeignKey( + self.model, + on_delete=models.DO_NOTHING, + db_column=fk_column, + to_field=to_field + ) + + class Meta: + managed = False + db_table = TABLE_NAME + + conn = Join( + table_name=TempModel._meta.db_table, + parent_alias=new_qs.query.get_initial_alias(), + table_alias=None, + join_type='INNER JOIN', + join_field=self.model.tempmodel_set.rel, + nullable=False + ) + new_qs.query.join(conn, reuse=None) + return new_qs + + +class JoinManagerMixin(object): + """ + Manager that adds a method join. This method allows you to join two + querysets together. + """ + _queryset_class = JoinQueryset + + def get_queryset(self): + return self._queryset_class(model=self.model, using=self._db) + + +class JoinManager(JoinManagerMixin, models.Manager): + pass diff --git a/requirements-test.txt b/requirements-test.txt index 493f267..fa21abd 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,2 +1,3 @@ pytest==3.3.1 pytest-django==3.1.2 +psycopg2==2.7.6.1 diff --git a/tests/models.py b/tests/models.py index 80f3a3f..888aba5 100644 --- a/tests/models.py +++ b/tests/models.py @@ -9,7 +9,11 @@ from django.utils.translation import ugettext_lazy as _ from model_utils import Choices from model_utils.fields import SplitField, MonitorField, StatusField -from model_utils.managers import QueryManager, InheritanceManager +from model_utils.managers import ( + QueryManager, + InheritanceManager, + JoinManagerMixin +) from model_utils.models import ( SoftDeletableModel, StatusModel, @@ -370,3 +374,22 @@ class ModelWithCustomDescriptor(models.Model): tracked_regular_field = models.IntegerField() tracker = FieldTracker(fields=['tracked_custom_field', 'tracked_regular_field']) + + +class JoinManager(JoinManagerMixin, models.Manager): + pass + + +class BoxJoinModel(models.Model): + name = models.CharField(max_length=32) + objects = JoinManager() + + +class JoinItemForeignKey(models.Model): + weight = models.IntegerField() + belonging = models.ForeignKey( + BoxJoinModel, + null=True, + on_delete=models.CASCADE + ) + objects = JoinManager() diff --git a/tests/settings.py b/tests/settings.py index b3be03a..a8d231c 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -1,11 +1,16 @@ +import os + INSTALLED_APPS = ( 'model_utils', 'tests', ) DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3' - } + "default": { + "ENGINE": "django.db.backends.postgresql_psycopg2", + "NAME": os.environ.get("DJANGO_DATABASE_NAME_POSTGRES", "modelutils"), + "USER": os.environ.get("DJANGO_DATABASE_USER_POSTGRES", 'postgres'), + "PASSWORD": os.environ.get("DJANGO_DATABASE_PASSWORD_POSTGRES", ""), + }, } SECRET_KEY = 'dummy' diff --git a/tests/test_managers/test_inheritance_manager.py b/tests/test_managers/test_inheritance_manager.py index 7cde8d3..14ae047 100644 --- a/tests/test_managers/test_inheritance_manager.py +++ b/tests/test_managers/test_inheritance_manager.py @@ -123,10 +123,16 @@ class InheritanceManagerTests(TestCase): ensure that the relation names and subclasses are obtained correctly. """ child3 = InheritanceManagerTestChild3.objects.create() - results = InheritanceManagerTestParent.objects.all().select_subclasses() + qs = InheritanceManagerTestParent.objects.all() + results = qs.select_subclasses().order_by('pk') - expected_objs = [self.child1, self.child2, self.grandchild1, - self.grandchild1_2, child3] + expected_objs = [ + self.child1, + self.child2, + self.grandchild1, + self.grandchild1_2, + child3 + ] self.assertEqual(list(results), expected_objs) expected_related_names = [ @@ -146,7 +152,8 @@ class InheritanceManagerTests(TestCase): """ related_name = 'manual_onetoone' child3 = InheritanceManagerTestChild3.objects.create() - results = InheritanceManagerTestParent.objects.all().select_subclasses(related_name) + qs = InheritanceManagerTestParent.objects.all() + results = qs.select_subclasses(related_name).order_by('pk') expected_objs = [InheritanceManagerTestParent(pk=self.child1.pk), InheritanceManagerTestParent(pk=self.child2.pk), @@ -389,14 +396,16 @@ class InheritanceManagerUsingModelsTests(TestCase): """ child3 = InheritanceManagerTestChild3.objects.create() results = InheritanceManagerTestParent.objects.all().select_subclasses( - InheritanceManagerTestChild3) + InheritanceManagerTestChild3).order_by('pk') - expected_objs = [InheritanceManagerTestParent(pk=self.parent1.pk), - InheritanceManagerTestParent(pk=self.child1.pk), - InheritanceManagerTestParent(pk=self.child2.pk), - InheritanceManagerTestParent(pk=self.grandchild1.pk), - InheritanceManagerTestParent(pk=self.grandchild1_2.pk), - child3] + expected_objs = [ + InheritanceManagerTestParent(pk=self.parent1.pk), + InheritanceManagerTestParent(pk=self.child1.pk), + InheritanceManagerTestParent(pk=self.child2.pk), + InheritanceManagerTestParent(pk=self.grandchild1.pk), + InheritanceManagerTestParent(pk=self.grandchild1_2.pk), + child3 + ] self.assertEqual(list(results), expected_objs) expected_related_names = ['manual_onetoone'] diff --git a/tests/test_managers/test_join_manager.py b/tests/test_managers/test_join_manager.py new file mode 100644 index 0000000..b8a8131 --- /dev/null +++ b/tests/test_managers/test_join_manager.py @@ -0,0 +1,38 @@ + +from django.test import TestCase + +from tests.models import JoinItemForeignKey, BoxJoinModel + + +class JoinManagerTest(TestCase): + def setUp(self): + for i in range(20): + BoxJoinModel.objects.create(name='name_{i}'.format(i=i)) + + JoinItemForeignKey.objects.create( + weight=10, belonging=BoxJoinModel.objects.get(name='name_1') + ) + JoinItemForeignKey.objects.create(weight=20) + + def test_self_join(self): + a_slice = BoxJoinModel.objects.all()[0:10] + with self.assertNumQueries(1): + result = a_slice.join() + self.assertEquals(result.count(), 10) + + def test_self_join_with_where_statement(self): + qs = BoxJoinModel.objects.filter(name='name_1') + result = qs.join() + self.assertEquals(result.count(), 1) + + def test_join_with_other_qs(self): + item_qs = JoinItemForeignKey.objects.filter(weight=10) + boxes = BoxJoinModel.objects.all().join(qs=item_qs) + self.assertEquals(boxes.count(), 1) + self.assertEquals(boxes[0].name, 'name_1') + + def test_reverse_join(self): + box_qs = BoxJoinModel.objects.filter(name='name_1') + items = JoinItemForeignKey.objects.all().join(box_qs) + self.assertEquals(items.count(), 1) + self.assertEquals(items[0].weight, 10) diff --git a/tox.ini b/tox.ini index 7fd9cca..c092160 100644 --- a/tox.ini +++ b/tox.ini @@ -1,14 +1,13 @@ [tox] envlist = - py27-django{18,19,110,111} - py34-django{18,19,110,111,200} - py35-django{18,19,110,111,200,201,trunk} + py27-django{19,110,111} + py34-django{19,110,111,200} + py35-django{19,110,111,200,201,trunk} py36-django{111,200,201,trunk} flake8 [testenv] deps = - django18: Django>=1.8,<1.9 django19: Django>=1.9,<1.10 django110: Django>=1.10,<1.11 django111: Django>=1.11,<1.12 From 326bd7bc02f59846af74d53e9da1394f81b894df Mon Sep 17 00:00:00 2001 From: Sebastian Illing Date: Sat, 8 Dec 2018 06:26:36 +0100 Subject: [PATCH 47/52] Fix missing subclasses and annotated in cloned InheritanceQueryset (#335) Bug only occurs in django > 2 --- model_utils/managers.py | 6 +++++- tests/test_managers/test_inheritance_manager.py | 4 ++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/model_utils/managers.py b/model_utils/managers.py index afd0c5b..15b5c7c 100644 --- a/model_utils/managers.py +++ b/model_utils/managers.py @@ -97,7 +97,11 @@ class InheritanceQuerySetMixin(object): def _clone(self, klass=None, setup=False, **kwargs): if django.VERSION >= (2, 0): - return super(InheritanceQuerySetMixin, self)._clone() + qs = super(InheritanceQuerySetMixin, self)._clone() + for name in ['subclasses', '_annotated']: + if hasattr(self, name): + setattr(qs, name, getattr(self, name)) + return qs for name in ['subclasses', '_annotated']: if hasattr(self, name): diff --git a/tests/test_managers/test_inheritance_manager.py b/tests/test_managers/test_inheritance_manager.py index 14ae047..d2b8b4f 100644 --- a/tests/test_managers/test_inheritance_manager.py +++ b/tests/test_managers/test_inheritance_manager.py @@ -460,3 +460,7 @@ class InheritanceManagerRelatedTests(InheritanceManagerTests): qs = InheritanceManagerTestParent.objects.annotate( test_count=models.Count('id')).select_subclasses() self.assertEqual(qs.get(id=self.child1.id).test_count, 1) + + def test_clone_when_inheritance_queryset_selects_subclasses_should_clone_them_too(self): + qs = InheritanceManagerTestParent.objects.select_subclasses() + self.assertEqual(qs.subclasses, qs._clone().subclasses) From 764b7ea78d506cf06828e4034946d86afa67770a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89ric=20Araujo?= Date: Sat, 8 Dec 2018 00:47:54 -0500 Subject: [PATCH 48/52] Add support for reverse iteration of Choices (#314) --- CHANGES.rst | 1 + model_utils/choices.py | 3 +++ tests/test_choices.py | 28 ++++++++++++++++++++++++---- 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 7d71665..4eff3da 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -6,6 +6,7 @@ master (unreleased) - Fix handling of deferred attributes on Django 1.10+, fixes GH-278 - Fix `FieldTracker.has_changed()` and `FieldTracker.previous()` to return correct responses for deferred fields. +- Support `reversed` for all kinds of `Choices` objects, fixes GH-309 - Fix Model instance non picklable GH-330 3.1.2 (2018.05.09) diff --git a/model_utils/choices.py b/model_utils/choices.py index 681e44b..6339503 100644 --- a/model_utils/choices.py +++ b/model_utils/choices.py @@ -102,6 +102,9 @@ class Choices(object): def __iter__(self): return iter(self._doubles) + def __reversed__(self): + return reversed(self._doubles) + def __getattr__(self, attname): try: return self._identifier_map[attname] diff --git a/tests/test_choices.py b/tests/test_choices.py index 29dbb36..986670c 100644 --- a/tests/test_choices.py +++ b/tests/test_choices.py @@ -16,7 +16,12 @@ class ChoicesTests(TestCase): self.assertEqual(self.STATUS['PUBLISHED'], 'PUBLISHED') def test_iteration(self): - self.assertEqual(tuple(self.STATUS), (('DRAFT', 'DRAFT'), ('PUBLISHED', 'PUBLISHED'))) + self.assertEqual(tuple(self.STATUS), + (('DRAFT', 'DRAFT'), ('PUBLISHED', 'PUBLISHED'))) + + def test_reversed(self): + self.assertEqual(tuple(reversed(self.STATUS)), + (('PUBLISHED', 'PUBLISHED'), ('DRAFT', 'DRAFT'))) def test_len(self): self.assertEqual(len(self.STATUS), 2) @@ -78,8 +83,15 @@ class LabelChoicesTests(ChoicesTests): self.assertEqual(tuple(self.STATUS), ( ('DRAFT', 'is draft'), ('PUBLISHED', 'is published'), - ('DELETED', 'DELETED')) - ) + ('DELETED', 'DELETED'), + )) + + def test_reversed(self): + self.assertEqual(tuple(reversed(self.STATUS)), ( + ('DELETED', 'DELETED'), + ('PUBLISHED', 'is published'), + ('DRAFT', 'is draft'), + )) def test_indexing(self): self.assertEqual(self.STATUS['PUBLISHED'], 'is published') @@ -169,7 +181,15 @@ class IdentifierChoicesTests(ChoicesTests): self.assertEqual(tuple(self.STATUS), ( (0, 'is draft'), (1, 'is published'), - (2, 'is deleted'))) + (2, 'is deleted'), + )) + + def test_reversed(self): + self.assertEqual(tuple(reversed(self.STATUS)), ( + (2, 'is deleted'), + (1, 'is published'), + (0, 'is draft'), + )) def test_indexing(self): self.assertEqual(self.STATUS[1], 'is published') From b739f6fe87257ac93d43ea32c2bfbb7230593c41 Mon Sep 17 00:00:00 2001 From: Guilherme Devincenzi Date: Sat, 8 Dec 2018 03:50:10 -0200 Subject: [PATCH 49/52] Add default manager as all_objects for SoftDeletableModel (#326) * Add default manager as all_objects for SoftDeletableModel * Document changes on changelog --- CHANGES.rst | 3 +++ model_utils/models.py | 1 + 2 files changed, 4 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index 4eff3da..cdc681d 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -14,6 +14,9 @@ master (unreleased) * Update InheritanceIterable to inherit from ModelIterable instead of BaseIterable, fixes GH-277. +* Add all_objects Manager for 'SoftDeletableModel' to include soft + deleted objects on queries as per issue GH-255 + 3.1.1 (2017.12.17) ------------------ diff --git a/model_utils/models.py b/model_utils/models.py index c679fc6..2f21695 100644 --- a/model_utils/models.py +++ b/model_utils/models.py @@ -123,6 +123,7 @@ class SoftDeletableModel(models.Model): abstract = True objects = SoftDeletableManager() + all_objects = models.Manager() def delete(self, using=None, soft=True, *args, **kwargs): """ From 25743141bccfda81b04dd1da7d8f24733a9471f8 Mon Sep 17 00:00:00 2001 From: Daniel Andrlik Date: Sat, 8 Dec 2018 01:23:04 -0500 Subject: [PATCH 50/52] Ensure TimeStampedModel modified equals created on initial creation. (#319) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✅ Ensure TimeStampedModel modified equals created on initial creation. Add logic to AutoLastModifiedField which checks to see if the associated created field of the correct cField class type is also present. If the instance has not yet been saved (missing a pk), then set the value to modified to be equal to created. Fixes #247 📚 Update changes and authors list related to changes. * 🚑 Set TimeStampedModel modified to be equal to created during first save. If instance does not yet have a pk, before defaulting the last modified to the current time, iterate over the the fields of the model, and instead use whatever value is found in the first occurance of the AutoCreatedField. Fixes #247 * Move changelog up to unreleased section. --- AUTHORS.rst | 1 + CHANGES.rst | 2 ++ model_utils/fields.py | 5 +++++ tests/test_models/test_timestamped_model.py | 7 +++++++ 4 files changed, 15 insertions(+) diff --git a/AUTHORS.rst b/AUTHORS.rst index 459b755..8d29344 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -47,3 +47,4 @@ | Radosław Jan Ganczarek | Lucas Wiman | Jack Cushman +| Daniel Andrlik diff --git a/CHANGES.rst b/CHANGES.rst index cdc681d..e37c172 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -6,6 +6,8 @@ master (unreleased) - Fix handling of deferred attributes on Django 1.10+, fixes GH-278 - Fix `FieldTracker.has_changed()` and `FieldTracker.previous()` to return correct responses for deferred fields. +- Update AutoLastModifiedField so that at instance creation it will + always be set equal to created to make querying easier. Fixes GH-254 - Support `reversed` for all kinds of `Choices` objects, fixes GH-309 - Fix Model instance non picklable GH-330 diff --git a/model_utils/fields.py b/model_utils/fields.py index d9d9b85..2799eac 100644 --- a/model_utils/fields.py +++ b/model_utils/fields.py @@ -32,6 +32,11 @@ class AutoLastModifiedField(AutoCreatedField): """ def pre_save(self, model_instance, add): value = now() + if not model_instance.pk: + for field in model_instance._meta.get_fields(): + if isinstance(field, AutoCreatedField): + value = getattr(model_instance, field.name) + break setattr(model_instance, self.attname, value) return value diff --git a/tests/test_models/test_timestamped_model.py b/tests/test_models/test_timestamped_model.py index 8760411..cac07f3 100644 --- a/tests/test_models/test_timestamped_model.py +++ b/tests/test_models/test_timestamped_model.py @@ -15,6 +15,13 @@ class TimeStampedModelTests(TestCase): t1 = TimeStamp.objects.create() self.assertEqual(t1.created, datetime(2016, 1, 1)) + def test_created_sets_modified(self): + ''' + Ensure that on creation that modifed is set exactly equal to created. + ''' + t1 = TimeStamp.objects.create() + self.assertEqual(t1.created, t1.modified) + def test_modified(self): with freeze_time(datetime(2016, 1, 1)): t1 = TimeStamp.objects.create() From 6b88c888d3e39aa1ebd72f834d08f44dd6997515 Mon Sep 17 00:00:00 2001 From: Sergey Tikhonov Date: Mon, 10 Dec 2018 18:35:26 +0300 Subject: [PATCH 51/52] fix model.save patched in FieldTracker (#353) * fix model.save patched in FieldTracker * add test for save call with args * update changes --- CHANGES.rst | 1 + model_utils/tracker.py | 4 ++-- tests/settings.py | 1 + tests/test_fields/test_field_tracker.py | 5 +++++ 4 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index e37c172..7e6039d 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -10,6 +10,7 @@ master (unreleased) always be set equal to created to make querying easier. Fixes GH-254 - Support `reversed` for all kinds of `Choices` objects, fixes GH-309 - Fix Model instance non picklable GH-330 +- Fix patched `save` in FieldTracker 3.1.2 (2018.05.09) ------------------ diff --git a/model_utils/tracker.py b/model_utils/tracker.py index 0059224..837e1ce 100644 --- a/model_utils/tracker.py +++ b/model_utils/tracker.py @@ -236,8 +236,8 @@ class FieldTracker(object): def patch_save(self, model): original_save = model.save - def save(instance, **kwargs): - ret = original_save(instance, **kwargs) + def save(instance, *args, **kwargs): + ret = original_save(instance, *args, **kwargs) update_fields = kwargs.get('update_fields') if not update_fields and update_fields is not None: # () or [] fields = update_fields diff --git a/tests/settings.py b/tests/settings.py index a8d231c..e34c891 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -10,6 +10,7 @@ DATABASES = { "NAME": os.environ.get("DJANGO_DATABASE_NAME_POSTGRES", "modelutils"), "USER": os.environ.get("DJANGO_DATABASE_USER_POSTGRES", 'postgres'), "PASSWORD": os.environ.get("DJANGO_DATABASE_PASSWORD_POSTGRES", ""), + "HOST": os.environ.get("DJANGO_DATABASE_HOST_POSTGRES", ""), }, } SECRET_KEY = 'dummy' diff --git a/tests/test_fields/test_field_tracker.py b/tests/test_fields/test_field_tracker.py index 65a2bfe..5b5d5c2 100644 --- a/tests/test_fields/test_field_tracker.py +++ b/tests/test_fields/test_field_tracker.py @@ -85,6 +85,11 @@ class FieldTrackerTests(FieldTrackerTestCase, FieldTrackerCommonTests): self.instance.mutable = [1, 2, 3] self.assertHasChanged(name=True, number=True, mutable=True) + def test_save_with_args(self): + self.instance.number = 1 + self.instance.save(False, False, None, None) + self.assertChanged() + def test_first_save(self): self.assertHasChanged(name=True, number=False, mutable=False) self.assertPrevious(name=None, number=None, mutable=None) From 4d9df911b342707d7cf6cf9143ba5285c5773d1d Mon Sep 17 00:00:00 2001 From: Reece Dunham Date: Thu, 10 Jan 2019 15:38:56 -0500 Subject: [PATCH 52/52] Update year. --- LICENSE.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE.txt b/LICENSE.txt index 0eadf47..01e3613 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,4 +1,4 @@ -Copyright (c) 2009-2015, Carl Meyer and contributors +Copyright (c) 2009-2019, Carl Meyer and contributors All rights reserved. Redistribution and use in source and binary forms, with or without