summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorBrian May <bam@debian.org>2016-09-11 02:56:36 (GMT)
committerBrian May <bam@debian.org>2016-09-11 02:56:36 (GMT)
commit5195610be09060e35706d6e3b135795d692f0314 (patch)
tree09a401aa6bdf24b9d66c1db106a22a95902da064
downloaddjango-model-utils-5195610be09060e35706d6e3b135795d692f0314.zip
django-model-utils-5195610be09060e35706d6e3b135795d692f0314.tar.gz
django-model-utils-5195610be09060e35706d6e3b135795d692f0314.tar.bz2
django-model-utils (2.5.2-2) unstable; urgency=medium
[ Ben Finney ] * Improve package description. Closes: #834116, Closes: #834117. [ Brian May ] * New upstream release. * Fix FTBFS: Closes: #828651. * Update standards version to 3.9.8. # imported from the archive
-rw-r--r--.coveragerc4
-rw-r--r--.editorconfig17
-rw-r--r--.gitignore11
-rw-r--r--.hgignore7
-rw-r--r--.hgtags6
-rw-r--r--.travis.yml47
-rw-r--r--AUTHORS.rst41
-rw-r--r--CHANGES.rst335
-rw-r--r--CONTRIBUTING.rst69
-rw-r--r--LICENSE.txt28
-rw-r--r--MANIFEST.in8
-rw-r--r--Makefile21
-rw-r--r--README.rst39
-rw-r--r--debian/.git-dpm11
-rw-r--r--debian/changelog96
-rw-r--r--debian/clean1
-rw-r--r--debian/compat1
-rw-r--r--debian/control46
-rw-r--r--debian/copyright53
-rw-r--r--debian/docs3
-rw-r--r--debian/patches/0001-Fix-FieldTracker-failing-with-Django-1.10.patch85
-rw-r--r--debian/patches/series1
-rwxr-xr-xdebian/rules15
-rw-r--r--debian/source/format1
-rw-r--r--debian/watch3
-rw-r--r--docs/Makefile177
-rw-r--r--docs/conf.py256
-rw-r--r--docs/fields.rst156
-rw-r--r--docs/index.rst38
-rw-r--r--docs/make.bat242
-rw-r--r--docs/managers.rst180
-rw-r--r--docs/models.rst49
-rw-r--r--docs/setup.rst23
-rw-r--r--docs/utilities.rst225
-rw-r--r--model_utils/__init__.py4
-rw-r--r--model_utils/choices.py153
-rw-r--r--model_utils/fields.py267
-rw-r--r--model_utils/locale/de/LC_MESSAGES/django.mobin0 -> 760 bytes
-rw-r--r--model_utils/locale/de/LC_MESSAGES/django.po53
-rw-r--r--model_utils/managers.py246
-rw-r--r--model_utils/models.py101
-rw-r--r--model_utils/tests/__init__.py0
-rw-r--r--model_utils/tests/fields.py43
-rw-r--r--model_utils/tests/models.py300
-rw-r--r--model_utils/tests/tests.py1873
-rw-r--r--model_utils/tracker.py197
-rw-r--r--requirements.txt3
-rwxr-xr-xruntests.py49
-rw-r--r--setup.cfg7
-rw-r--r--setup.py57
-rw-r--r--tox.ini29
-rwxr-xr-xtranslations.py45
-rwxr-xr-xupdate_travis_envs.sh13
53 files changed, 5735 insertions, 0 deletions
diff --git a/.coveragerc b/.coveragerc
new file mode 100644
index 0000000..65aaf4d
--- /dev/null
+++ b/.coveragerc
@@ -0,0 +1,4 @@
+[run]
+source = model_utils
+omit = model_utils/tests/*
+branch = 1
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..058a363
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,17 @@
+# http://editorconfig.org
+
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+[*.{py,rst,ini}]
+indent_style = space
+indent_size = 4
+
+[*.yml]
+indent_style = space
+indent_size = 2
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..5f1c259
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,11 @@
+dist/*
+django_model_utils.egg-info/*
+HGREV
+.coverage
+.tox/
+Django-*.egg
+*.pyc
+htmlcov/
+docs/_build/
+.idea/
+.eggs/
diff --git a/.hgignore b/.hgignore
new file mode 100644
index 0000000..cfb8076
--- /dev/null
+++ b/.hgignore
@@ -0,0 +1,7 @@
+^dist/
+^django_model_utils\.egg-info/
+^HGREV$
+^\.coverage$
+^\.tox/
+^Django.*\.egg$
+^htmlcov/
diff --git a/.hgtags b/.hgtags
new file mode 100644
index 0000000..21c952e
--- /dev/null
+++ b/.hgtags
@@ -0,0 +1,6 @@
+b5efc435bb7e21b0d7ba422d28d174ccca3b3322 0.2.0
+71b54b8b44fa2456beebd51c474ba55ad625486a 0.3.1
+1e6f730f8c3a648c9fb70844a68fcfa663608600 0.4.0
+004dbee634cb661c52acac034063989e521c4bb8 0.5.0
+bd164041e5fabd64de19c38fefe9af9237a2a59e 1.0.0
+92792fb14a51b580e5cc8991e815f3b3b57a6204 1.1.0
diff --git a/.travis.yml b/.travis.yml
new file mode 100644
index 0000000..6ea0c92
--- /dev/null
+++ b/.travis.yml
@@ -0,0 +1,47 @@
+language: python
+
+python: 2.7
+
+env:
+ - TOXENV=py26-django14
+ - TOXENV=py26-django15
+ - TOXENV=py26-django16
+ - TOXENV=py27-django110
+ - TOXENV=py27-django14
+ - TOXENV=py27-django15
+ - TOXENV=py27-django15_nosouth
+ - TOXENV=py27-django16
+ - TOXENV=py27-django17
+ - TOXENV=py27-django18
+ - TOXENV=py27-django19
+ - TOXENV=py27-django_trunk
+ - TOXENV=py33-django15
+ - TOXENV=py33-django16
+ - TOXENV=py33-django17
+ - TOXENV=py33-django18
+ - TOXENV=py34-django110
+ - TOXENV=py34-django17
+ - TOXENV=py34-django18
+ - TOXENV=py34-django19
+ - TOXENV=py34-django_trunk
+ - TOXENV=py35-django110
+ - TOXENV=py35-django18
+ - TOXENV=py35-django19
+ - TOXENV=py35-django_trunk
+
+install:
+ - pip install --upgrade pip setuptools tox virtualenv coveralls
+
+script:
+ - tox
+
+matrix:
+ allow_failures:
+ - env: TOXENV=py27-django110
+ - env: TOXENV=py34-django110
+ - env: TOXENV=py35-django110
+ - env: TOXENV=py27-django_trunk
+ - env: TOXENV=py34-django_trunk
+ - env: TOXENV=py35-django_trunk
+
+after_success: coveralls
diff --git a/AUTHORS.rst b/AUTHORS.rst
new file mode 100644
index 0000000..668453e
--- /dev/null
+++ b/AUTHORS.rst
@@ -0,0 +1,41 @@
+ad-m <github.com/ad-m>
+Alejandro Varas <alej0varas@gmail.com>
+Alex Orange <crazycasta@gmail.com>
+Andy Freeland <andy@andyfreeland.net>
+Artis Avotins <artis.avotins@gmail.com>
+Bram Boogaard <b.boogaard@auto-interactive.nl>
+Carl Meyer <carl@dirtcircle.com>
+Curtis Maloney <curtis@tinbrain.net>
+Den Lesnov
+Dmytro Kyrychuk <dmytro.kyrychuck@gmail.com>
+Donald Stufft <donald.stufft@gmail.com>
+Douglas Meehan <dmeehan@gmail.com>
+Facundo Gaich <facugaich@gmail.com>
+Felipe Prenholato <philipe.rp@gmail.com>
+Filipe Ximenes <filipeximenes@gmail.com>
+Gregor Müllegger <gregor@muellegger.de>
+ivirabyan
+James Oakley <jfunk@funktronics.ca>
+Jannis Leidel <jannis@leidel.info>
+Jarek Glowacki <github.com/jarekwg>
+Javier García Sogo <jgsogo@gmail.com>
+Jeff Elmore <jeffelmore.org>
+Keryn Knight <kerynknight.com>
+Matthew Schinckel <matt@schinckel.net>
+Michael van Tellingen <michaelvantellingen@gmail.com>
+Mike Bryant <mike@mikebryant.me.uk>
+Mikhail Silonov <silonov.pro>
+Patryk Zawadzki <patrys@room-303.com>
+Paul McLanahan <paul@mclanahan.net>
+Philipp Steinhardt <steinhardt@myvision.de>
+Rinat Shigapov <rinatshigapov@gmail.com>
+Rodney Folz <rodney@rodneyfolz.com>
+rsenkbeil <github.com/rsenkbeil>
+Ryan Kaskel <dev@ryankaskel.com>
+Simon Meers <simon@simonmeers.com>
+sayane
+Tony Aldridge <zaragopha@hotmail.com>
+Travis Swicegood <travis@domain51.com>
+Trey Hunner <trey@treyhunner.com>
+Karl Wan Nan Wo <karl.wnw@gmail.com>
+zyegfryed
diff --git a/CHANGES.rst b/CHANGES.rst
new file mode 100644
index 0000000..fa0bd0e
--- /dev/null
+++ b/CHANGES.rst
@@ -0,0 +1,335 @@
+CHANGES
+=======
+
+2.5.2 (2016.08.09)
+------------------
+
+* Include `runtests.py` in sdist.
+
+
+2.5.1 (2016.08.03)
+------------------
+
+* Fix `InheritanceQuerySet` raising an `AttributeError` exception
+ under Django 1.9.
+
+* Django 1.10 support regressed with changes between pre-alpha and final
+ release; 1.10 currently not supported.
+
+
+2.5 (2016.04.18)
+----------------
+
+* Drop support for Python 3.2.
+
+* Add support for Django 1.10 pre-alpha.
+
+* Track foreign keys on parent models properly when a tracker
+ is defined on a child model. Fixes GH-214.
+
+
+2.4 (2015.12.03)
+----------------
+
+* Remove `PassThroughManager`. Use Django's built-in `QuerySet.as_manager()`
+ and/or `Manager.from_queryset()` utilities instead.
+
+* Add support for Django 1.9.
+
+
+2.3.1 (2015-07-20)
+------------------
+
+* Remove all translation-related automation in `setup.py`. Fixes GH-178 and
+ GH-179. Thanks Joe Weiss, Matt Molyneaux, and others for the reports.
+
+
+2.3 (2015.07.17)
+----------------
+
+* Keep track of deferred fields on model instance instead of on
+ FieldInstanceTracker instance. Fixes accessing deferred fields for multiple
+ instances of a model from the same queryset. Thanks Bram Boogaard. Merge of
+ GH-151.
+
+* Fix Django 1.7 migrations compatibility for SplitField. Thanks ad-m. Merge of
+ GH-157; fixes GH-156.
+
+* Add German translations.
+
+* Django 1.8 compatibility.
+
+
+2.2 (2014.07.31)
+----------------
+
+* Revert GH-130, restoring ability to access ``FieldTracker`` changes in
+ overridden ``save`` methods or ``post_save`` handlers. This reopens GH-83
+ (inability to pickle models with ``FieldTracker``) until a solution can be
+ found that doesn't break behavior otherwise. Thanks Brian May for the
+ report. Fixes GH-143.
+
+
+2.1.1 (2014.07.28)
+------------------
+
+* ASCII-fold all non-ASCII characters in changelog; again. Argh. Apologies to
+ those whose names are mangled by this change. It seems that distutils makes
+ it impossible to handle non-ASCII content reliably under Python 3 in a
+ setup.py long_description, when the system encoding may be ASCII. Thanks
+ Brian May for the report. Fixes GH-141.
+
+
+2.1.0 (2014.07.25)
+------------------
+
+* Add support for Django's built-in migrations to ``MonitorField`` and
+ ``StatusField``.
+
+* ``PassThroughManager`` now has support for seeing exposed methods via
+ ``dir``, allowing `IPython`_ tab completion to be useful. Merge of GH-104,
+ fixes GH-55.
+
+* Add pickle support for models using ``FieldTracker``. Thanks Ondrej Slintak
+ for the report. Thanks Matthew Schinckel for the fix. Merge of GH-130,
+ fixes GH-83.
+
+.. _IPython: http://ipython.org/
+
+
+2.0.3 (2014.03.19)
+-------------------
+
+* Fix ``get_query_set`` vs ``get_queryset`` in ``PassThroughManager`` for
+ Django <1.6. Fixes issues with related managers not filtering by relation
+ properly. Thanks whop, Bojan Mihelac, Daniel Shapiro, and Matthew Schinckel
+ for the report; Matthew for the fix. Merge of GH-121.
+
+* Fix ``FieldTracker`` with deferred model attributes. Thanks Michael van
+ Tellingen. Merge of GH-115.
+
+* Fix ``InheritanceManager`` with self-referential FK; avoid infinite
+ recursion. Thanks rsenkbeil. Merge of GH-114.
+
+2.0.2 (2014.02.19)
+-------------------
+
+* ASCII-fold all non-ASCII characters in changelog. Apologies to those whose
+ names are mangled by this change. It seems that distutils makes it impossible
+ to handle non-ASCII content reliably under Python 3 in a setup.py
+ long_description, when the system encoding may be ASCII. Thanks Simone Dalla
+ for the report. Fixes GH-113.
+
+
+2.0.1 (2014.02.11)
+-------------------
+
+* Fix dependency to be on "Django" rather than "django", which plays better
+ with static PyPI mirrors. Thanks Travis Swicegood.
+
+* Fix issue with attempt to access ``__slots__`` when copying
+ ``PassThroughManager``. Thanks Patryk Zawadzki. Merge of GH-105.
+
+* Improve ``InheritanceManager`` so any attributes added by using extra(select)
+ will be propagated onto children. Thanks Curtis Maloney. Merge of GH-101,
+ fixes GH-34.
+
+* Added ``InheritanceManagerMixin``, ``InheritanceQuerySetMixin``,
+ ``PassThroughManagerMixin``, and ``QueryManagerMixin`` to allow composing
+ their functionality with other custom manager/queryset subclasses (e.g. those
+ in GeoDjango). Thanks Douglas Meehan!
+
+
+2.0 (2014.01.06)
+----------------
+
+* BACKWARDS-INCOMPATIBLE: Indexing into a ``Choices`` instance now translates
+ database representations to human-readable choice names, rather than simply
+ indexing into an array of choice tuples. (Indexing into ``Choices`` was
+ previously not documented.) If you have code that is relying on indexing or
+ slicing ``Choices``, the simplest workaround is to change e.g. ``STATUS[1:]``
+ to ``list(STATUS)[1:]``.
+
+* Fixed bug with checking for field name conflicts for added query managers on
+ `StatusModel`.
+
+* Can pass `choices_name` to `StatusField` to use a different name for
+ choices class attribute. ``STATUS`` is used by default.
+
+* Can pass model subclasses, rather than strings, into
+ `select_subclasses()`. Thanks Keryn Knight. Merge of GH-79.
+
+* Deepcopying a `Choices` instance no longer fails with infinite recursion in
+ `getattr`. Thanks Leden. Merge of GH-75.
+
+* `get_subclass()` method is now available on both managers and
+ querysets. Thanks Travis Swicegood. Merge of GH-82.
+
+* Fix bug in `InheritanceManager` with grandchild classes on Django 1.6+;
+ `select_subclasses('child', 'child__grandchild')` would only ever get to the
+ child class. Thanks Keryn Knight for report and proposed fix.
+
+* MonitorField now accepts a 'when' parameter. It will update only when the field
+ changes to one of the values specified.
+
+
+1.5.0 (2013.08.29)
+------------------
+
+* `Choices` now accepts option-groupings. Fixes GH-14.
+
+* `Choices` can now be added to other `Choices` or to any iterable, and can be
+ compared for equality with itself. Thanks Tony Aldridge. (Merge of GH-76.)
+
+* `Choices` now `__contains__` its Python identifier values. Thanks Keryn
+ Knight. (Merge of GH-69).
+
+* Fixed a bug causing ``KeyError`` when saving with the parameter
+ ``update_fields`` in which there are untracked fields. Thanks Mikhail
+ Silonov. (Merge of GH-70, fixes GH-71).
+
+* Fixed ``FieldTracker`` usage on inherited models. Fixes GH-57.
+
+* Added mutable field support to ``FieldTracker`` (Merge of GH-73, fixes GH-74)
+
+
+1.4.0 (2013.06.03)
+------------------
+
+- Introduced ``FieldTracker`` as replacement for ``ModelTracker``, which is now
+ deprecated.
+
+- ``PassThroughManager.for_queryset_class()`` no longer ignores superclass
+ ``get_query_set``. Thanks Andy Freeland.
+
+- Fixed ``InheritanceManager`` bug with grandchildren in Django 1.6. Thanks
+ CrazyCasta.
+
+- Fixed lack of ``get_FOO_display`` method for ``StatusField``. Fixes GH-41.
+
+
+1.3.1 (2013.04.11)
+------------------
+
+- Added explicit default to ``BooleanField`` in tests, for Django trunk
+ compatibility.
+
+- Fixed intermittent ``StatusField`` bug. Fixes GH-29.
+
+- Added Python 3 support.
+
+- Dropped support for Django 1.2 and 1.3. Django 1.4.2+ required.
+
+
+1.3.0 (2013.03.27)
+------------------
+
+- Allow specifying default value for a ``StatusField``. Thanks Felipe
+ Prenholato.
+
+- Fix calling ``create()`` on a ``RelatedManager`` that subclasses a dynamic
+ ``PassThroughManager``. Thanks SeiryuZ for the report. Fixes GH-24.
+
+- Add workaround for https://code.djangoproject.com/ticket/16855 in
+ InheritanceQuerySet to avoid overriding prior calls to
+ ``select_related()``. Thanks ivirabyan.
+
+- Added support for arbitrary levels of model inheritance in
+ InheritanceManager. Thanks ivirabyan. (This feature only works in Django
+ 1.6+ due to https://code.djangoproject.com/ticket/16572).
+
+- Added ``ModelTracker`` for tracking field changes between model saves. Thanks
+ Trey Hunner.
+
+
+1.2.0 (2013.01.27)
+------------------
+
+- Moved primary development from `Bitbucket`_ to `GitHub`_. Bitbucket mirror
+ will continue to receive updates; Bitbucket issue tracker will be closed once
+ all issues tracked in it are resolved.
+
+.. _BitBucket: https://bitbucket.org/carljm/django-model-utils/overview
+.. _GitHub: https://github.com/carljm/django-model-utils/
+
+- Removed deprecated ``ChoiceEnum``, ``InheritanceCastModel``,
+ ``InheritanceCastManager``, and ``manager_from``.
+
+- Fixed pickling of ``PassThroughManager``. Thanks Rinat Shigapov.
+
+- Set ``use_for_related_fields = True`` on ``QueryManager``.
+
+- Added ``__len__`` method to ``Choices``. Thanks Ryan Kaskel and James Oakley.
+
+- Fixed ``InheritanceQuerySet`` on Django 1.5. Thanks Javier Garcia Sogo.
+
+1.1.0 (2012.04.13)
+------------------
+
+- Updated AutoCreatedField, AutoLastModifiedField, MonitorField, and
+ TimeFramedModel to use ``django.utils.timezone.now`` on Django 1.4.
+ Thanks Donald Stufft.
+
+- Fixed annotation of InheritanceQuerysets. Thanks Jeff Elmore and Facundo
+ Gaich.
+
+- Dropped support for Python 2.5 and Django 1.1. Both are no longer supported
+ even for security fixes, and should not be used.
+
+- Added ``PassThroughManager.for_queryset_class()``, which fixes use of
+ ``PassThroughManager`` with related fields. Thanks Ryan Kaskel for report and
+ fix.
+
+- Added ``InheritanceManager.get_subclass()``. Thanks smacker.
+
+1.0.0 (2011.06.16)
+------------------
+
+- Fixed using SplitField on an abstract base model.
+
+- Fixed issue #8, adding ``use_for_related_fields = True`` to
+ ``InheritanceManager``.
+
+- Added ``PassThroughManager``. Thanks Paul McLanahan.
+
+- Added pending-deprecation warnings for ``InheritanceCastModel``,
+ ``manager_from``, and Django 1.1 support. Removed documentation for the
+ deprecated utilities. Bumped ``ChoiceEnum`` from pending-deprecation to
+ deprecation.
+
+- Fixed issue #6, bug with InheritanceManager and descriptor fields (e.g.
+ FileField). Thanks zyegfryed for the fix and sayane for tests.
+
+0.6.0 (2011.02.18)
+------------------
+
+- updated SplitField to define get_prep_value rather than get_db_prep_value.
+ This avoids deprecation warnings on Django trunk/1.3, but makes SplitField
+ incompatible with Django versions prior to 1.2.
+
+- added InheritanceManager, a better approach to selecting subclass instances
+ for Django 1.2+. Thanks Jeff Elmore.
+
+- added InheritanceCastManager and InheritanceCastQuerySet, to allow bulk
+ casting of a queryset to child types. Thanks Gregor Muellegger.
+
+0.5.0 (2010.09.24)
+------------------
+
+- added manager_from (thanks George Sakkis)
+- added StatusField, MonitorField, TimeFramedModel, and StatusModel
+ (thanks Jannis Leidel)
+- deprecated ChoiceEnum and replaced with Choices
+
+0.4.0 (2010.03.16)
+------------------
+
+- added SplitField
+- added ChoiceEnum
+- added South support for custom model fields
+
+0.3.0
+-----
+
+* Added ``QueryManager``
+
diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst
new file mode 100644
index 0000000..10fbcca
--- /dev/null
+++ b/CONTRIBUTING.rst
@@ -0,0 +1,69 @@
+Contributing
+============
+
+Below is a list of tips for submitting issues and pull requests.
+
+Submitting Issues
+-----------------
+
+Issues are easier to reproduce/resolve when they have:
+
+- A pull request with a failing test demonstrating the issue
+- A code example that produces the issue consistently
+- A traceback (when applicable)
+
+
+Pull Requests
+-------------
+
+When creating a pull request:
+
+- Write tests
+- Note user-facing changes in the `CHANGES`_ file
+- Update the documentation
+- Add yourself to the `AUTHORS`_ file
+- If you have added or changed translated strings, run ``make messages`` to
+ update the ``.po`` translation files, and update translations for any
+ languages you know. Then run ``make compilemessages`` to compile the ``.mo``
+ files. If your pull request leaves some translations incomplete, please
+ mention that in the pull request and commit message.
+
+.. _AUTHORS: AUTHORS.rst
+.. _CHANGES: CHANGES.rst
+
+
+Translations
+------------
+
+If you are able to provide translations for a new language or to update an
+existing translation file, make sure to run makemessages beforehand::
+
+ python django-admin.py makemessages -l ISO_LANGUAGE_CODE
+
+This command will collect all translation strings from the source directory
+and create or update the translation file for the given language. Now open the
+translation file (.po) with a text-editor and start editing.
+After you finished editing add yourself to the list of translators.
+If you have created a new translation, make sure to copy the header from one
+of the existing translation files.
+
+
+Testing
+-------
+
+Please add tests for your code and ensure existing tests don't break. To run
+the tests against your code::
+
+ python setup.py test
+
+Please use tox to test the code against supported Python and Django versions.
+First install tox::
+
+ pip install tox coverage
+
+To run tox and generate a coverage report (in ``htmlcov`` directory)::
+
+ make test
+
+**Please note**: Before a pull request can be merged, all tests must pass and
+code/branch coverage in tests must be 100%.
diff --git a/LICENSE.txt b/LICENSE.txt
new file mode 100644
index 0000000..0eadf47
--- /dev/null
+++ b/LICENSE.txt
@@ -0,0 +1,28 @@
+Copyright (c) 2009-2015, Carl Meyer and contributors
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+ * Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+ * Redistributions in binary form must reproduce the above
+ copyright notice, this list of conditions and the following
+ disclaimer in the documentation and/or other materials provided
+ with the distribution.
+ * Neither the name of the author nor the names of other
+ contributors may be used to endorse or promote products derived
+ from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/MANIFEST.in b/MANIFEST.in
new file mode 100644
index 0000000..351a82c
--- /dev/null
+++ b/MANIFEST.in
@@ -0,0 +1,8 @@
+include AUTHORS.rst
+include CHANGES.rst
+include LICENSE.txt
+include MANIFEST.in
+include README.rst
+include TODO.rst
+recursive-include locale django.po
+include runtests.py
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..4785478
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,21 @@
+all: init docs test
+
+init:
+ python setup.py develop
+ pip install tox coverage Sphinx
+
+test:
+ coverage erase
+ tox
+ coverage html
+
+docs: documentation
+
+documentation:
+ python setup.py build_sphinx
+
+messages:
+ python translations.py make
+
+compilemessages:
+ python translations.py compile
diff --git a/README.rst b/README.rst
new file mode 100644
index 0000000..001712f
--- /dev/null
+++ b/README.rst
@@ -0,0 +1,39 @@
+==================
+django-model-utils
+==================
+
+.. image:: https://secure.travis-ci.org/carljm/django-model-utils.png?branch=master
+ :target: http://travis-ci.org/carljm/django-model-utils
+.. image:: https://coveralls.io/repos/carljm/django-model-utils/badge.png?branch=master
+ :target: https://coveralls.io/r/carljm/django-model-utils
+.. image:: https://img.shields.io/pypi/v/django-model-utils.svg
+ :target: https://crate.io/packages/django-model-utils
+
+Django model mixins and utilities.
+
+``django-model-utils`` supports `Django`_ 1.4 through 1.9 (latest bugfix
+release in each series only) on Python 2.6 (through Django 1.6 only), 2.7, 3.3
+(through Django 1.8 only), 3.4 and 3.5.
+
+.. _Django: http://www.djangoproject.com/
+
+This app is available on `PyPI`_.
+
+.. _PyPI: https://pypi.python.org/pypi/django-model-utils/
+
+
+Getting Help
+============
+
+Documentation for django-model-utils is available at https://django-model-utils.readthedocs.io/
+
+
+Contributing
+============
+
+Please file bugs and send pull requests to the `GitHub repository`_ and `issue
+tracker`_. See `CONTRIBUTING.rst`_ for details.
+
+.. _GitHub repository: https://github.com/carljm/django-model-utils/
+.. _issue tracker: https://github.com/carljm/django-model-utils/issues
+.. _CONTRIBUTING.rst: https://github.com/carljm/django-model-utils/blob/master/CONTRIBUTING.rst
diff --git a/debian/.git-dpm b/debian/.git-dpm
new file mode 100644
index 0000000..917088b
--- /dev/null
+++ b/debian/.git-dpm
@@ -0,0 +1,11 @@
+# see git-dpm(1) from git-dpm package
+777393fbb8b0684d2a5fe1095c4ff98965eb1238
+777393fbb8b0684d2a5fe1095c4ff98965eb1238
+3896a3775fa50d001bf7fe4104550e3055fb2cc0
+3896a3775fa50d001bf7fe4104550e3055fb2cc0
+django-model-utils_2.5.2.orig.tar.gz
+d441ebadb9f6594fe5c385fa516bad022d604a61
+41124
+debianTag="debian/%e%v"
+patchedTag="patched/%e%v"
+upstreamTag="upstream/%e%u"
diff --git a/debian/changelog b/debian/changelog
new file mode 100644
index 0000000..1f47f53
--- /dev/null
+++ b/debian/changelog
@@ -0,0 +1,96 @@
+django-model-utils (2.5.2-2) unstable; urgency=medium
+
+ [ Ben Finney ]
+ * Improve package description. Closes: #834116, Closes: #834117.
+
+ [ Brian May ]
+ * New upstream release.
+ * Fix FTBFS: Closes: #828651.
+ * Update standards version to 3.9.8.
+
+ -- Brian May <bam@debian.org> Sun, 11 Sep 2016 12:56:36 +1000
+
+django-model-utils (2.4-1) unstable; urgency=medium
+
+ * New upstream issues.
+ * Fixes FTBFS with Django 1.9. Closes: #806350.
+
+ -- Brian May <bam@debian.org> Thu, 03 Dec 2015 09:03:21 +1100
+
+django-model-utils (2.3.1-3) unstable; urgency=medium
+
+ * Run tests for Python 3.4 and Python 3.5.
+
+ -- Brian May <bam@debian.org> Mon, 26 Oct 2015 13:26:34 +1100
+
+django-model-utils (2.3.1-2) unstable; urgency=medium
+
+ * Add VCS-* headers.
+ * Update Standards-Version to 3.9.6.
+
+ -- Brian May <bam@debian.org> Mon, 12 Oct 2015 18:03:27 +1100
+
+django-model-utils (2.3.1-1) unstable; urgency=medium
+
+ * New upstream version.
+ * Swap maintainer and uploaders. Maintainer now set to DPMT.
+
+ -- Brian May <bam@debian.org> Mon, 12 Oct 2015 16:28:31 +1100
+
+django-model-utils (2.0.3-2) unstable; urgency=low
+
+ * Python3 package.
+
+ -- Brian May <bam@debian.org> Thu, 03 Jul 2014 16:07:56 +1000
+
+django-model-utils (2.0.3-1) unstable; urgency=low
+
+ * New upstream release. Closes: #739578.
+ * Take over package from Jonas Smedegaard. Thanks to his contribution to
+ Debian.
+ * Remove reference to git repository, it doesn't appear to exist.
+ * Update to dh_python2.
+
+ -- Brian May <bam@debian.org> Fri, 02 May 2014 09:58:50 +1000
+
+django-model-utils (1.3.1-1) unstable; urgency=low
+
+ [ upstream ]
+ * New release.
+ + Moved primary development from Bitbucket to GitHub.
+ + Removed deprecated ChoiceEnum, InheritanceCastModel,
+ InheritanceCastManager, and manager_from.
+ + Fixed pickling of PassThroughManager.
+ + Set use_for_related_fields = True on QueryManager.
+ + Added __len__ method to Choices.
+ + Fixed InheritanceQuerySet on Django 1.5.
+ + Allow specifying default value for a StatusField.
+ + Fix calling create() on a RelatedManager that subclasses a dynamic
+ PassThroughManager.
+ + Add workaround in InheritanceQuerySet to avoid overriding prior
+ calls to select_related().
+ + Added support for arbitrary levels of model inheritance in
+ InheritanceManager.
+ + Added ModelTracker for tracking field changes between model saves.
+ + Added explicit default to BooleanField in tests, for Django trunk
+ compatibility.
+ + Fix intermittent StatusField bug.
+ Closes: bug#711358. Thanks to Jakub Wilk.
+
+ [ Jonas Smedegaard ]
+ * Update README.source to emphasize control.in file as *not* a
+ show-stopper for contributions, referring to wiki page for details.
+ * Update upstream Homepage and source to github.
+ * Bump standards-version to 3.9.4.
+ * Extend coverage for main copyright holder.
+ * Bump packaging license to GPL-3+, and extend coverage to include
+ current year.
+
+ -- Jonas Smedegaard <dr@jones.dk> Thu, 06 Jun 2013 21:12:17 +0200
+
+django-model-utils (1.1.0-1) unstable; urgency=low
+
+ * Initial packaging release.
+ Closes: bug#694583.
+
+ -- Jonas Smedegaard <dr@jones.dk> Wed, 28 Nov 2012 02:27:06 +0100
diff --git a/debian/clean b/debian/clean
new file mode 100644
index 0000000..45149aa
--- /dev/null
+++ b/debian/clean
@@ -0,0 +1 @@
+*.egg-info/*
diff --git a/debian/compat b/debian/compat
new file mode 100644
index 0000000..45a4fb7
--- /dev/null
+++ b/debian/compat
@@ -0,0 +1 @@
+8
diff --git a/debian/control b/debian/control
new file mode 100644
index 0000000..6fa84e1
--- /dev/null
+++ b/debian/control
@@ -0,0 +1,46 @@
+Source: django-model-utils
+Section: python
+Priority: extra
+Maintainer: Debian Python Modules Team <python-modules-team@lists.alioth.debian.org>
+Uploaders: Brian May <bam@debian.org>
+Build-Depends: debhelper (>=8.1.0), dh-python,
+ python-all, python-setuptools, python-django,
+ python3-all (>= 2.6.6-3~), python3-setuptools, python3-django
+Standards-Version: 3.9.8
+Homepage: https://github.com/carljm/django-model-utils/
+Vcs-Git: https://anonscm.debian.org/git/python-modules/packages/django-model-utils.git
+Vcs-Browser: https://anonscm.debian.org/cgit/python-modules/packages/django-model-utils.git
+
+Package: python-django-model-utils
+Architecture: all
+Depends: ${misc:Depends}, ${python:Depends}
+Description: Django model mixins and utilities — Python 2
+ Django is a high-level web application framework that loosely follows
+ the model-view-controller design pattern.
+ .
+ The ‘django-model-utils’ library provides some mixins and utilities
+ for Django:
+ .
+ * QueryManager: one-line definition of Managers returning a
+ custom QuerySet.
+ * InheritanceCastModel: more efficient use of model inheritance
+ * TimeStampedModel: self-updating ‘created’ and ‘modified’ fields.
+ .
+ This package installs the library for Python 2.
+
+Package: python3-django-model-utils
+Architecture: all
+Depends: ${misc:Depends}, ${python3:Depends}
+Description: Django model mixins and utilities — Python 3
+ Django is a high-level web application framework that loosely follows
+ the model-view-controller design pattern.
+ .
+ The ‘django-model-utils’ library provides some mixins and utilities
+ for Django:
+ .
+ * QueryManager: one-line definition of Managers returning a
+ custom QuerySet.
+ * InheritanceCastModel: more efficient use of model inheritance
+ * TimeStampedModel: self-updating ‘created’ and ‘modified’ fields.
+ .
+ This package installs the library for Python 3.
diff --git a/debian/copyright b/debian/copyright
new file mode 100644
index 0000000..81f88e8
--- /dev/null
+++ b/debian/copyright
@@ -0,0 +1,53 @@
+Format: http://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
+Upstream-Name: django-model-utils
+Upstream-Contact: https://github.com/carljm/django-model-utils/issues/
+Source: https://github.com/carljm/django-model-utils/downloads/
+ git://github.com/carljm/django-model-utils/
+
+Files: *
+Copyright: 2009-2013, Carl Meyer <carl@dirtcircle.com>
+License: BSD-3-clause
+
+Files: debian/*
+Copyright: 2014, Brian May <bam@debian.org>
+License: GPL-3+
+
+License: BSD-3-clause
+ Redistribution and use in source and binary forms, with or without
+ modification, are permitted provided that the following conditions are
+ met:
+ .
+ * Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+ * Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions and the following disclaimer in the
+ documentation and/or other materials provided with the distribution.
+ * Neither the name of the author nor the names of other contributors
+ may be used to endorse or promote products derived from this
+ software without specific prior written permission.
+ .
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
+ IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
+ TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
+ PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+License: GPL-3+
+ This file is free software; you can redistribute it and/or modify it
+ under the terms of the GNU General Public License as published by the
+ Free Software Foundation; either version 3 of the License, or (at your
+ option) any later version.
+ .
+ Comment:
+ .
+ On Debian systems the 'GNU General Public License' version 3 is located
+ in '/usr/share/common-licenses/GPL-3'.
+ .
+ You should have received a copy of the GNU General Public License along
+ with this program. If not, see <http://www.gnu.org/licenses/>.
diff --git a/debian/docs b/debian/docs
new file mode 100644
index 0000000..17075a9
--- /dev/null
+++ b/debian/docs
@@ -0,0 +1,3 @@
+AUTHORS.rst
+CONTRIBUTING.rst
+README.rst
diff --git a/debian/patches/0001-Fix-FieldTracker-failing-with-Django-1.10.patch b/debian/patches/0001-Fix-FieldTracker-failing-with-Django-1.10.patch
new file mode 100644
index 0000000..52cc7fb
--- /dev/null
+++ b/debian/patches/0001-Fix-FieldTracker-failing-with-Django-1.10.patch
@@ -0,0 +1,85 @@
+From 777393fbb8b0684d2a5fe1095c4ff98965eb1238 Mon Sep 17 00:00:00 2001
+From: Brian May <bam@debian.org>
+Date: Sun, 11 Sep 2016 12:21:37 +1000
+Subject: Fix FieldTracker failing with Django 1.10
+
+From upstream pull request
+https://github.com/carljm/django-model-utils/issues/232
+---
+ model_utils/tracker.py | 41 ++++++++++++++++++++++++++++-------------
+ 1 file changed, 28 insertions(+), 13 deletions(-)
+
+diff --git a/model_utils/tracker.py b/model_utils/tracker.py
+index a9c7f70..6aa7d8a 100644
+--- a/model_utils/tracker.py
++++ b/model_utils/tracker.py
+@@ -2,8 +2,9 @@ from __future__ import unicode_literals
+
+ from copy import deepcopy
+
+-from django.db import models
++import django
+ from django.core.exceptions import FieldError
++from django.db import models
+ from django.db.models.query_utils import DeferredAttribute
+
+
+@@ -62,12 +63,14 @@ class FieldInstanceTracker(object):
+ )
+
+ def init_deferred_fields(self):
+- self.instance._deferred_fields = []
++ self.instance._deferred_fields = set()
+ if hasattr(self.instance, '_deferred') and not self.instance._deferred:
+ return
+
+ class DeferredAttributeTracker(DeferredAttribute):
+ def __get__(field, instance, owner):
++ if instance is None:
++ return field
+ data = instance.__dict__
+ if data.get(field.field_name, field) is field:
+ instance._deferred_fields.remove(field.field_name)
+@@ -76,19 +79,31 @@ class FieldInstanceTracker(object):
+ self.saved_data[field.field_name] = deepcopy(value)
+ return data[field.field_name]
+
+- for field in self.fields:
+- field_obj = self.instance.__class__.__dict__.get(field)
+- if isinstance(field_obj, DeferredAttribute):
+- self.instance._deferred_fields.append(field)
+-
+- # Django 1.4
+- model = None
+- if hasattr(field_obj, 'model_ref'):
+- model = field_obj.model_ref()
+-
++ 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)
+ field_tracker = DeferredAttributeTracker(
+- field_obj.field_name, model)
++ field_obj.field_name, None)
+ setattr(self.instance.__class__, field, field_tracker)
++ else:
++ for field in self.fields:
++ 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)
+
+
+ class FieldTracker(object):
diff --git a/debian/patches/series b/debian/patches/series
new file mode 100644
index 0000000..8f175f2
--- /dev/null
+++ b/debian/patches/series
@@ -0,0 +1 @@
+0001-Fix-FieldTracker-failing-with-Django-1.10.patch
diff --git a/debian/rules b/debian/rules
new file mode 100755
index 0000000..2ed2758
--- /dev/null
+++ b/debian/rules
@@ -0,0 +1,15 @@
+#!/usr/bin/make -f
+# -*- makefile -*-
+
+export PYBUILD_NAME=django-model-utils
+
+%:
+ dh $@ --with python2,python3 --buildsystem=pybuild
+
+.PHONY: override_dh_auto_test
+override_dh_auto_test:
+ dh_auto_test -- --system=custom --test-args="{interpreter} ./runtests.py"
+
+.PHONY: override_dh_installchangelogs
+override_dh_installchangelogs:
+ dh_installchangelogs CHANGES.rst
diff --git a/debian/source/format b/debian/source/format
new file mode 100644
index 0000000..163aaf8
--- /dev/null
+++ b/debian/source/format
@@ -0,0 +1 @@
+3.0 (quilt)
diff --git a/debian/watch b/debian/watch
new file mode 100644
index 0000000..be0f26f
--- /dev/null
+++ b/debian/watch
@@ -0,0 +1,3 @@
+# Run the "uscan" command to check for upstream updates and more.
+version=3
+https://github.com/carljm/django-model-utils/tags .*/archive/v?(\d[\d\.]+).tar.gz
diff --git a/docs/Makefile b/docs/Makefile
new file mode 100644
index 0000000..452f59f
--- /dev/null
+++ b/docs/Makefile
@@ -0,0 +1,177 @@
+# Makefile for Sphinx documentation
+#
+
+# You can set these variables from the command line.
+SPHINXOPTS =
+SPHINXBUILD = sphinx-build
+PAPER =
+BUILDDIR = _build
+
+# User-friendly check for sphinx-build
+ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1)
+$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/)
+endif
+
+# Internal variables.
+PAPEROPT_a4 = -D latex_paper_size=a4
+PAPEROPT_letter = -D latex_paper_size=letter
+ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
+# the i18n builder cannot share the environment and doctrees with the others
+I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
+
+.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext
+
+help:
+ @echo "Please use \`make <target>' where <target> is one of"
+ @echo " html to make standalone HTML files"
+ @echo " dirhtml to make HTML files named index.html in directories"
+ @echo " singlehtml to make a single large HTML file"
+ @echo " pickle to make pickle files"
+ @echo " json to make JSON files"
+ @echo " htmlhelp to make HTML files and a HTML help project"
+ @echo " qthelp to make HTML files and a qthelp project"
+ @echo " devhelp to make HTML files and a Devhelp project"
+ @echo " epub to make an epub"
+ @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter"
+ @echo " latexpdf to make LaTeX files and run them through pdflatex"
+ @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx"
+ @echo " text to make text files"
+ @echo " man to make manual pages"
+ @echo " texinfo to make Texinfo files"
+ @echo " info to make Texinfo files and run them through makeinfo"
+ @echo " gettext to make PO message catalogs"
+ @echo " changes to make an overview of all changed/added/deprecated items"
+ @echo " xml to make Docutils-native XML files"
+ @echo " pseudoxml to make pseudoxml-XML files for display purposes"
+ @echo " linkcheck to check all external links for integrity"
+ @echo " doctest to run all doctests embedded in the documentation (if enabled)"
+
+clean:
+ rm -rf $(BUILDDIR)/*
+
+html:
+ $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
+ @echo
+ @echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
+
+dirhtml:
+ $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml
+ @echo
+ @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml."
+
+singlehtml:
+ $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml
+ @echo
+ @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml."
+
+pickle:
+ $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle
+ @echo
+ @echo "Build finished; now you can process the pickle files."
+
+json:
+ $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json
+ @echo
+ @echo "Build finished; now you can process the JSON files."
+
+htmlhelp:
+ $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp
+ @echo
+ @echo "Build finished; now you can run HTML Help Workshop with the" \
+ ".hhp project file in $(BUILDDIR)/htmlhelp."
+
+qthelp:
+ $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp
+ @echo
+ @echo "Build finished; now you can run "qcollectiongenerator" with the" \
+ ".qhcp project file in $(BUILDDIR)/qthelp, like this:"
+ @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/django-model-utils.qhcp"
+ @echo "To view the help file:"
+ @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/django-model-utils.qhc"
+
+devhelp:
+ $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp
+ @echo
+ @echo "Build finished."
+ @echo "To view the help file:"
+ @echo "# mkdir -p $$HOME/.local/share/devhelp/django-model-utils"
+ @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/django-model-utils"
+ @echo "# devhelp"
+
+epub:
+ $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub
+ @echo
+ @echo "Build finished. The epub file is in $(BUILDDIR)/epub."
+
+latex:
+ $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
+ @echo
+ @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex."
+ @echo "Run \`make' in that directory to run these through (pdf)latex" \
+ "(use \`make latexpdf' here to do that automatically)."
+
+latexpdf:
+ $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
+ @echo "Running LaTeX files through pdflatex..."
+ $(MAKE) -C $(BUILDDIR)/latex all-pdf
+ @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
+
+latexpdfja:
+ $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
+ @echo "Running LaTeX files through platex and dvipdfmx..."
+ $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja
+ @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
+
+text:
+ $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text
+ @echo
+ @echo "Build finished. The text files are in $(BUILDDIR)/text."
+
+man:
+ $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man
+ @echo
+ @echo "Build finished. The manual pages are in $(BUILDDIR)/man."
+
+texinfo:
+ $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
+ @echo
+ @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo."
+ @echo "Run \`make' in that directory to run these through makeinfo" \
+ "(use \`make info' here to do that automatically)."
+
+info:
+ $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
+ @echo "Running Texinfo files through makeinfo..."
+ make -C $(BUILDDIR)/texinfo info
+ @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo."
+
+gettext:
+ $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale
+ @echo
+ @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale."
+
+changes:
+ $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes
+ @echo
+ @echo "The overview file is in $(BUILDDIR)/changes."
+
+linkcheck:
+ $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck
+ @echo
+ @echo "Link check complete; look for any errors in the above output " \
+ "or in $(BUILDDIR)/linkcheck/output.txt."
+
+doctest:
+ $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest
+ @echo "Testing of doctests in the sources finished, look at the " \
+ "results in $(BUILDDIR)/doctest/output.txt."
+
+xml:
+ $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml
+ @echo
+ @echo "Build finished. The XML files are in $(BUILDDIR)/xml."
+
+pseudoxml:
+ $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml
+ @echo
+ @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml."
diff --git a/docs/conf.py b/docs/conf.py
new file mode 100644
index 0000000..9f0c4e7
--- /dev/null
+++ b/docs/conf.py
@@ -0,0 +1,256 @@
+# -*- coding: utf-8 -*-
+#
+# django-model-utils documentation build configuration file, created by
+# sphinx-quickstart on Wed Jul 31 22:27:07 2013.
+#
+# This file is execfile()d with the current directory set to its containing dir.
+#
+# Note that not all possible configuration values are present in this
+# autogenerated file.
+#
+# All configuration values have a default; values that are commented out
+# serve to show the default.
+
+import sys, os
+
+# If extensions (or modules to document with autodoc) are in another directory,
+# add these directories to sys.path here. If the directory is relative to the
+# documentation root, use os.path.abspath to make it absolute, like shown here.
+#sys.path.insert(0, os.path.abspath('.'))
+
+# -- General configuration -----------------------------------------------------
+
+# If your documentation needs a minimal Sphinx version, state it here.
+#needs_sphinx = '1.0'
+
+# Add any Sphinx extension module names here, as strings. They can be extensions
+# coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
+extensions = []
+
+# Add any paths that contain templates here, relative to this directory.
+templates_path = ['_templates']
+
+# The suffix of source filenames.
+source_suffix = '.rst'
+
+# The encoding of source files.
+#source_encoding = 'utf-8-sig'
+
+# The master toctree document.
+master_doc = 'index'
+
+# General information about the project.
+project = u'django-model-utils'
+copyright = u'2015, Carl Meyer'
+
+parent_dir = os.path.dirname(os.path.dirname(__file__))
+
+def get_version():
+ with open(os.path.join(parent_dir, 'model_utils', '__init__.py')) as f:
+ for line in f:
+ if line.startswith('__version__ ='):
+ return line.split('=')[1].strip().strip('"\'')
+
+# The version info for the project you're documenting, acts as replacement for
+# |version| and |release|, also used in various other places throughout the
+# built documents.
+#
+# The full version, including alpha/beta/rc tags.
+release = get_version()
+# The short X.Y version.
+version = release
+
+# The language for content autogenerated by Sphinx. Refer to documentation
+# for a list of supported languages.
+#language = None
+
+# There are two options for replacing |today|: either, you set today to some
+# non-false value, then it is used:
+#today = ''
+# Else, today_fmt is used as the format for a strftime call.
+#today_fmt = '%B %d, %Y'
+
+# List of patterns, relative to source directory, that match files and
+# directories to ignore when looking for source files.
+exclude_patterns = ['_build']
+
+# The reST default role (used for this markup: `text`) to use for all documents.
+#default_role = None
+
+# If true, '()' will be appended to :func: etc. cross-reference text.
+#add_function_parentheses = True
+
+# If true, the current module name will be prepended to all description
+# unit titles (such as .. function::).
+#add_module_names = True
+
+# If true, sectionauthor and moduleauthor directives will be shown in the
+# output. They are ignored by default.
+#show_authors = False
+
+# The name of the Pygments (syntax highlighting) style to use.
+pygments_style = 'sphinx'
+
+# A list of ignored prefixes for module index sorting.
+#modindex_common_prefix = []
+
+# If true, keep warnings as "system message" paragraphs in the built documents.
+#keep_warnings = False
+
+
+# -- Options for HTML output ---------------------------------------------------
+
+# The theme to use for HTML and HTML Help pages. See the documentation for
+# a list of builtin themes.
+html_theme = 'default'
+
+# Theme options are theme-specific and customize the look and feel of a theme
+# further. For a list of options available for each theme, see the
+# documentation.
+#html_theme_options = {}
+
+# Add any paths that contain custom themes here, relative to this directory.
+#html_theme_path = []
+
+# The name for this set of Sphinx documents. If None, it defaults to
+# "<project> v<release> documentation".
+#html_title = None
+
+# A shorter title for the navigation bar. Default is the same as html_title.
+#html_short_title = None
+
+# The name of an image file (relative to this directory) to place at the top
+# of the sidebar.
+#html_logo = None
+
+# The name of an image file (within the static path) to use as favicon of the
+# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32
+# pixels large.
+#html_favicon = None
+
+# Add any paths that contain custom static files (such as style sheets) here,
+# relative to this directory. They are copied after the builtin static files,
+# so a file named "default.css" will overwrite the builtin "default.css".
+html_static_path = ['_static']
+
+# If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
+# using the given strftime format.
+#html_last_updated_fmt = '%b %d, %Y'
+
+# If true, SmartyPants will be used to convert quotes and dashes to
+# typographically correct entities.
+#html_use_smartypants = True
+
+# Custom sidebar templates, maps document names to template names.
+#html_sidebars = {}
+
+# Additional templates that should be rendered to pages, maps page names to
+# template names.
+#html_additional_pages = {}
+
+# If false, no module index is generated.
+#html_domain_indices = True
+
+# If false, no index is generated.
+#html_use_index = True
+
+# If true, the index is split into individual pages for each letter.
+#html_split_index = False
+
+# If true, links to the reST sources are added to the pages.
+#html_show_sourcelink = True
+
+# If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
+#html_show_sphinx = True
+
+# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
+#html_show_copyright = True
+
+# If true, an OpenSearch description file will be output, and all pages will
+# contain a <link> tag referring to it. The value of this option must be the
+# base URL from which the finished HTML is served.
+#html_use_opensearch = ''
+
+# This is the file name suffix for HTML files (e.g. ".xhtml").
+#html_file_suffix = None
+
+# Output file base name for HTML help builder.
+htmlhelp_basename = 'django-model-utilsdoc'
+
+
+# -- Options for LaTeX output --------------------------------------------------
+
+latex_elements = {
+# The paper size ('letterpaper' or 'a4paper').
+#'papersize': 'letterpaper',
+
+# The font size ('10pt', '11pt' or '12pt').
+#'pointsize': '10pt',
+
+# Additional stuff for the LaTeX preamble.
+#'preamble': '',
+}
+
+# Grouping the document tree into LaTeX files. List of tuples
+# (source start file, target name, title, author, documentclass [howto/manual]).
+latex_documents = [
+ ('index', 'django-model-utils.tex', u'django-model-utils Documentation',
+ u'Carl Meyer', 'manual'),
+]
+
+# The name of an image file (relative to this directory) to place at the top of
+# the title page.
+#latex_logo = None
+
+# For "manual" documents, if this is true, then toplevel headings are parts,
+# not chapters.
+#latex_use_parts = False
+
+# If true, show page references after internal links.
+#latex_show_pagerefs = False
+
+# If true, show URL addresses after external links.
+#latex_show_urls = False
+
+# Documents to append as an appendix to all manuals.
+#latex_appendices = []
+
+# If false, no module index is generated.
+#latex_domain_indices = True
+
+
+# -- Options for manual page output --------------------------------------------
+
+# One entry per manual page. List of tuples
+# (source start file, name, description, authors, manual section).
+man_pages = [
+ ('index', 'django-model-utils', u'django-model-utils Documentation',
+ [u'Carl Meyer'], 1)
+]
+
+# If true, show URL addresses after external links.
+#man_show_urls = False
+
+
+# -- Options for Texinfo output ------------------------------------------------
+
+# Grouping the document tree into Texinfo files. List of tuples
+# (source start file, target name, title, author,
+# dir menu entry, description, category)
+texinfo_documents = [
+ ('index', 'django-model-utils', u'django-model-utils Documentation',
+ u'Carl Meyer', 'django-model-utils', 'One line description of project.',
+ 'Miscellaneous'),
+]
+
+# Documents to append as an appendix to all manuals.
+#texinfo_appendices = []
+
+# If false, no module index is generated.
+#texinfo_domain_indices = True
+
+# How to display URL addresses: 'footnote', 'no', or 'inline'.
+#texinfo_show_urls = 'footnote'
+
+# If true, do not generate a @detailmenu in the "Top" node's menu.
+#texinfo_no_detailmenu = False
diff --git a/docs/fields.rst b/docs/fields.rst
new file mode 100644
index 0000000..02ca6ef
--- /dev/null
+++ b/docs/fields.rst
@@ -0,0 +1,156 @@
+Fields
+======
+
+.. _StatusField:
+
+StatusField
+-----------
+
+A simple convenience for giving a model a set of "states."
+``StatusField`` is a ``CharField`` subclass that expects to find a
+class attribute called ``STATUS`` on its model or you can pass
+``choices_name`` to use a different attribute name, and uses that as
+its ``choices``. Also sets a default ``max_length`` of 100, and sets
+its default value to the first item in the ``STATUS`` choices:
+
+.. code-block:: python
+
+ from model_utils.fields import StatusField
+ from model_utils import Choices
+
+ class Article(models.Model):
+ STATUS = Choices('draft', 'published')
+ # ...
+ status = StatusField()
+
+(The ``STATUS`` class attribute does not have to be a :ref:`Choices`
+instance, it can be an ordinary list of two-tuples).
+
+Using a different name for the model's choices class attribute
+
+.. code-block:: python
+
+ from model_utils.fields import StatusField
+ from model_utils import Choices
+
+ class Article(models.Model):
+ ANOTHER_CHOICES = Choices('draft', 'published')
+ # ...
+ another_field = StatusField(choices_name='ANOTHER_CHOICES')
+
+``StatusField`` does not set ``db_index=True`` automatically; if you
+expect to frequently filter on your status field (and it will have
+enough selectivity to make an index worthwhile) you may want to add this
+yourself.
+
+
+.. _MonitorField:
+
+MonitorField
+------------
+
+A ``DateTimeField`` subclass that monitors another field on the model,
+and updates itself to the current date-time whenever the monitored
+field changes:
+
+.. code-block:: python
+
+ from model_utils.fields import MonitorField, StatusField
+
+ class Article(models.Model):
+ STATUS = Choices('draft', 'published')
+
+ status = StatusField()
+ status_changed = MonitorField(monitor='status')
+
+(A ``MonitorField`` can monitor any type of field for changes, not only a
+``StatusField``.)
+
+If a list is passed to the ``when`` parameter, the field will only
+update when it matches one of the specified values:
+
+.. code-block:: python
+
+ from model_utils.fields import MonitorField, StatusField
+
+ class Article(models.Model):
+ STATUS = Choices('draft', 'published')
+
+ status = StatusField()
+ published_at = MonitorField(monitor='status', when=['published'])
+
+
+SplitField
+----------
+
+A ``TextField`` subclass that automatically pulls an excerpt out of
+its content (based on a "split here" marker or a default number of
+initial paragraphs) and stores both its content and excerpt values in
+the database.
+
+A ``SplitField`` is easy to add to any model definition:
+
+.. code-block:: python
+
+ from django.db import models
+ from model_utils.fields import SplitField
+
+ class Article(models.Model):
+ title = models.CharField(max_length=100)
+ body = SplitField()
+
+``SplitField`` automatically creates an extra non-editable field
+``_body_excerpt`` to store the excerpt. This field doesn't need to be
+accessed directly; see below.
+
+
+Accessing a SplitField on a model
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+When accessing an attribute of a model that was declared as a
+``SplitField``, a ``SplitText`` object is returned. The ``SplitText``
+object has three attributes:
+
+``content``:
+ The full field contents.
+``excerpt``:
+ The excerpt of ``content`` (read-only).
+``has_more``:
+ True if the excerpt and content are different, False otherwise.
+
+This object also has a ``__unicode__`` method that returns the full
+content, allowing ``SplitField`` attributes to appear in templates
+without having to access ``content`` directly.
+
+Assuming the ``Article`` model above:
+
+.. code-block:: pycon
+
+ >>> a = Article.objects.all()[0]
+ >>> a.body.content
+ u'some text\n\n<!-- split -->\n\nmore text'
+ >>> a.body.excerpt
+ u'some text\n'
+ >>> unicode(a.body)
+ u'some text\n\n<!-- split -->\n\nmore text'
+
+Assignment to ``a.body`` is equivalent to assignment to
+``a.body.content``.
+
+.. note::
+
+ a.body.excerpt is only updated when a.save() is called
+
+
+Customized excerpting
+~~~~~~~~~~~~~~~~~~~~~
+
+By default, ``SplitField`` looks for the marker ``<!-- split -->``
+alone on a line and takes everything before that marker as the
+excerpt. This marker can be customized by setting the ``SPLIT_MARKER``
+setting.
+
+If no marker is found in the content, the first two paragraphs (where
+paragraphs are blocks of text separated by a blank line) are taken to
+be the excerpt. This number can be customized by setting the
+``SPLIT_DEFAULT_PARAGRAPHS`` setting.
diff --git a/docs/index.rst b/docs/index.rst
new file mode 100644
index 0000000..9b6d2bb
--- /dev/null
+++ b/docs/index.rst
@@ -0,0 +1,38 @@
+==================
+django-model-utils
+==================
+
+Django model mixins and utilities.
+
+
+Contents
+========
+
+.. toctree::
+ :maxdepth: 3
+
+ setup
+ fields
+ models
+ managers
+ utilities
+
+
+Contributing
+============
+
+Please file bugs and send pull requests to the `GitHub repository`_ and `issue
+tracker`_.
+
+.. _GitHub repository: https://github.com/carljm/django-model-utils/
+.. _issue tracker: https://github.com/carljm/django-model-utils/issues
+
+
+
+Indices and tables
+==================
+
+* :ref:`genindex`
+* :ref:`modindex`
+* :ref:`search`
+
diff --git a/docs/make.bat b/docs/make.bat
new file mode 100644
index 0000000..fb1a0ef
--- /dev/null
+++ b/docs/make.bat
@@ -0,0 +1,242 @@
+@ECHO OFF
+
+REM Command file for Sphinx documentation
+
+if "%SPHINXBUILD%" == "" (
+ set SPHINXBUILD=sphinx-build
+)
+set BUILDDIR=_build
+set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% .
+set I18NSPHINXOPTS=%SPHINXOPTS% .
+if NOT "%PAPER%" == "" (
+ set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS%
+ set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS%
+)
+
+if "%1" == "" goto help
+
+if "%1" == "help" (
+ :help
+ echo.Please use `make ^<target^>` where ^<target^> is one of
+ echo. html to make standalone HTML files
+ echo. dirhtml to make HTML files named index.html in directories
+ echo. singlehtml to make a single large HTML file
+ echo. pickle to make pickle files
+ echo. json to make JSON files
+ echo. htmlhelp to make HTML files and a HTML help project
+ echo. qthelp to make HTML files and a qthelp project
+ echo. devhelp to make HTML files and a Devhelp project
+ echo. epub to make an epub
+ echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter
+ echo. text to make text files
+ echo. man to make manual pages
+ echo. texinfo to make Texinfo files
+ echo. gettext to make PO message catalogs
+ echo. changes to make an overview over all changed/added/deprecated items
+ echo. xml to make Docutils-native XML files
+ echo. pseudoxml to make pseudoxml-XML files for display purposes
+ echo. linkcheck to check all external links for integrity
+ echo. doctest to run all doctests embedded in the documentation if enabled
+ goto end
+)
+
+if "%1" == "clean" (
+ for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i
+ del /q /s %BUILDDIR%\*
+ goto end
+)
+
+
+%SPHINXBUILD% 2> nul
+if errorlevel 9009 (
+ echo.
+ echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
+ echo.installed, then set the SPHINXBUILD environment variable to point
+ echo.to the full path of the 'sphinx-build' executable. Alternatively you
+ echo.may add the Sphinx directory to PATH.
+ echo.
+ echo.If you don't have Sphinx installed, grab it from
+ echo.http://sphinx-doc.org/
+ exit /b 1
+)
+
+if "%1" == "html" (
+ %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html
+ if errorlevel 1 exit /b 1
+ echo.
+ echo.Build finished. The HTML pages are in %BUILDDIR%/html.
+ goto end
+)
+
+if "%1" == "dirhtml" (
+ %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml
+ if errorlevel 1 exit /b 1
+ echo.
+ echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml.
+ goto end
+)
+
+if "%1" == "singlehtml" (
+ %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml
+ if errorlevel 1 exit /b 1
+ echo.
+ echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml.
+ goto end
+)
+
+if "%1" == "pickle" (
+ %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle
+ if errorlevel 1 exit /b 1
+ echo.
+ echo.Build finished; now you can process the pickle files.
+ goto end
+)
+
+if "%1" == "json" (
+ %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json
+ if errorlevel 1 exit /b 1
+ echo.
+ echo.Build finished; now you can process the JSON files.
+ goto end
+)
+
+if "%1" == "htmlhelp" (
+ %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp
+ if errorlevel 1 exit /b 1
+ echo.
+ echo.Build finished; now you can run HTML Help Workshop with the ^
+.hhp project file in %BUILDDIR%/htmlhelp.
+ goto end
+)
+
+if "%1" == "qthelp" (
+ %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp
+ if errorlevel 1 exit /b 1
+ echo.
+ echo.Build finished; now you can run "qcollectiongenerator" with the ^
+.qhcp project file in %BUILDDIR%/qthelp, like this:
+ echo.^> qcollectiongenerator %BUILDDIR%\qthelp\django-model-utils.qhcp
+ echo.To view the help file:
+ echo.^> assistant -collectionFile %BUILDDIR%\qthelp\django-model-utils.ghc
+ goto end
+)
+
+if "%1" == "devhelp" (
+ %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp
+ if errorlevel 1 exit /b 1
+ echo.
+ echo.Build finished.
+ goto end
+)
+
+if "%1" == "epub" (
+ %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub
+ if errorlevel 1 exit /b 1
+ echo.
+ echo.Build finished. The epub file is in %BUILDDIR%/epub.
+ goto end
+)
+
+if "%1" == "latex" (
+ %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex
+ if errorlevel 1 exit /b 1
+ echo.
+ echo.Build finished; the LaTeX files are in %BUILDDIR%/latex.
+ goto end
+)
+
+if "%1" == "latexpdf" (
+ %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex
+ cd %BUILDDIR%/latex
+ make all-pdf
+ cd %BUILDDIR%/..
+ echo.
+ echo.Build finished; the PDF files are in %BUILDDIR%/latex.
+ goto end
+)
+
+if "%1" == "latexpdfja" (
+ %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex
+ cd %BUILDDIR%/latex
+ make all-pdf-ja
+ cd %BUILDDIR%/..
+ echo.
+ echo.Build finished; the PDF files are in %BUILDDIR%/latex.
+ goto end
+)
+
+if "%1" == "text" (
+ %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text
+ if errorlevel 1 exit /b 1
+ echo.
+ echo.Build finished. The text files are in %BUILDDIR%/text.
+ goto end
+)
+
+if "%1" == "man" (
+ %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man
+ if errorlevel 1 exit /b 1
+ echo.
+ echo.Build finished. The manual pages are in %BUILDDIR%/man.
+ goto end
+)
+
+if "%1" == "texinfo" (
+ %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo
+ if errorlevel 1 exit /b 1
+ echo.
+ echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo.
+ goto end
+)
+
+if "%1" == "gettext" (
+ %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale
+ if errorlevel 1 exit /b 1
+ echo.
+ echo.Build finished. The message catalogs are in %BUILDDIR%/locale.
+ goto end
+)
+
+if "%1" == "changes" (
+ %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes
+ if errorlevel 1 exit /b 1
+ echo.
+ echo.The overview file is in %BUILDDIR%/changes.
+ goto end
+)
+
+if "%1" == "linkcheck" (
+ %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck
+ if errorlevel 1 exit /b 1
+ echo.
+ echo.Link check complete; look for any errors in the above output ^
+or in %BUILDDIR%/linkcheck/output.txt.
+ goto end
+)
+
+if "%1" == "doctest" (
+ %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest
+ if errorlevel 1 exit /b 1
+ echo.
+ echo.Testing of doctests in the sources finished, look at the ^
+results in %BUILDDIR%/doctest/output.txt.
+ goto end
+)
+
+if "%1" == "xml" (
+ %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml
+ if errorlevel 1 exit /b 1
+ echo.
+ echo.Build finished. The XML files are in %BUILDDIR%/xml.
+ goto end
+)
+
+if "%1" == "pseudoxml" (
+ %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml
+ if errorlevel 1 exit /b 1
+ echo.
+ echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml.
+ goto end
+)
+
+:end
diff --git a/docs/managers.rst b/docs/managers.rst
new file mode 100644
index 0000000..2669a1c
--- /dev/null
+++ b/docs/managers.rst
@@ -0,0 +1,180 @@
+Model Managers
+==============
+
+InheritanceManager
+------------------
+
+This manager (`contributed by Jeff Elmore`_) should be attached to a base model
+class in a model-inheritance tree. It allows queries on that base model to
+return heterogenous results of the actual proper subtypes, without any
+additional queries.
+
+For instance, if you have a ``Place`` model with subclasses ``Restaurant`` and
+``Bar``, you may want to query all Places:
+
+.. code-block:: python
+
+ nearby_places = Place.objects.filter(location='here')
+
+But when you iterate over ``nearby_places``, you'll get only ``Place``
+instances back, even for objects that are "really" ``Restaurant`` or ``Bar``.
+If you attach an ``InheritanceManager`` to ``Place``, you can just call the
+``select_subclasses()`` method on the ``InheritanceManager`` or any
+``QuerySet`` from it, and the resulting objects will be instances of
+``Restaurant`` or ``Bar``:
+
+.. code-block:: python
+
+ from model_utils.managers import InheritanceManager
+
+ class Place(models.Model):
+ # ...
+ objects = InheritanceManager()
+
+ class Restaurant(Place):
+ # ...
+
+ class Bar(Place):
+ # ...
+
+ nearby_places = Place.objects.filter(location='here').select_subclasses()
+ for place in nearby_places:
+ # "place" will automatically be an instance of Place, Restaurant, or Bar
+
+The database query performed will have an extra join for each subclass; if you
+want to reduce the number of joins and you only need particular subclasses to
+be returned as their actual type, you can pass subclass names to
+``select_subclasses()``, much like the built-in ``select_related()`` method:
+
+.. code-block:: python
+
+ nearby_places = Place.objects.select_subclasses("restaurant")
+ # restaurants will be Restaurant instances, bars will still be Place instances
+
+ nearby_places = Place.objects.select_subclasses("restaurant", "bar")
+ # all Places will be converted to Restaurant and Bar instances.
+
+It is also possible to use the subclasses themselves as arguments to
+``select_subclasses``, leaving it to calculate the relationship for you:
+
+.. code-block:: python
+
+ nearby_places = Place.objects.select_subclasses(Restaurant)
+ # restaurants will be Restaurant instances, bars will still be Place instances
+
+ nearby_places = Place.objects.select_subclasses(Restaurant, Bar)
+ # all Places will be converted to Restaurant and Bar instances.
+
+It is even possible to mix and match the two:
+
+.. code-block:: python
+
+ nearby_places = Place.objects.select_subclasses(Restaurant, "bar")
+ # all Places will be converted to Restaurant and Bar instances.
+
+``InheritanceManager`` also provides a subclass-fetching alternative to the
+``get()`` method:
+
+.. code-block:: python
+
+ place = Place.objects.get_subclass(id=some_id)
+ # "place" will automatically be an instance of Place, Restaurant, or Bar
+
+If you don't explicitly call ``select_subclasses()`` or ``get_subclass()``,
+an ``InheritanceManager`` behaves identically to a normal ``Manager``; so
+it's safe to use as your default manager for the model.
+
+.. note::
+
+ Due to `Django bug #16572`_, on Django versions prior to 1.6
+ ``InheritanceManager`` only supports a single level of model inheritance;
+ it won't work for grandchild models.
+
+.. _contributed by Jeff Elmore: http://jeffelmore.org/2010/11/11/automatic-downcasting-of-inherited-models-in-django/
+.. _Django bug #16572: https://code.djangoproject.com/ticket/16572
+
+
+.. _QueryManager:
+
+QueryManager
+------------
+
+Many custom model managers do nothing more than return a QuerySet that
+is filtered in some way. ``QueryManager`` allows you to express this
+pattern with a minimum of boilerplate:
+
+.. code-block:: python
+
+ from django.db import models
+ from model_utils.managers import QueryManager
+
+ class Post(models.Model):
+ ...
+ published = models.BooleanField()
+ pub_date = models.DateField()
+ ...
+
+ objects = models.Manager()
+ public = QueryManager(published=True).order_by('-pub_date')
+
+The kwargs passed to ``QueryManager`` will be passed as-is to the
+``QuerySet.filter()`` method. You can also pass a ``Q`` object to
+``QueryManager`` to express more complex conditions. Note that you can
+set the ordering of the ``QuerySet`` returned by the ``QueryManager``
+by chaining a call to ``.order_by()`` on the ``QueryManager`` (this is
+not required).
+
+
+PassThroughManager
+------------------
+
+`PassThroughManager` was removed in django-model-utils 2.4. Use Django's
+built-in `QuerySet.as_manager()` and/or `Manager.from_queryset()` utilities
+instead.
+
+Mixins
+------
+
+Each of the above manager classes has a corresponding mixin that can be used to
+add functionality to any manager. For example, to create a GeoDjango
+``GeoManager`` that includes "pass through" functionality, you can write the
+following code:
+
+.. code-block:: python
+
+ from django.contrib.gis.db import models
+ from django.contrib.gis.db.models.query import GeoQuerySet
+
+ from model_utils.managers import PassThroughManagerMixin
+
+ class PassThroughGeoManager(PassThroughManagerMixin, models.GeoManager):
+ pass
+
+ class LocationQuerySet(GeoQuerySet):
+ def within_boundary(self, geom):
+ return self.filter(point__within=geom)
+
+ def public(self):
+ return self.filter(public=True)
+
+ class Location(models.Model):
+ point = models.PointField()
+ public = models.BooleanField(default=True)
+ objects = PassThroughGeoManager.for_queryset_class(LocationQuerySet)()
+
+ Location.objects.public()
+ Location.objects.within_boundary(geom=geom)
+ Location.objects.within_boundary(geom=geom).public()
+
+
+Now you have a "pass through manager" that can also take advantage of
+GeoDjango's spatial lookups. You can similarly add additional functionality to
+any manager by composing that manager with ``InheritanceManagerMixin`` or
+``QueryManagerMixin``.
+
+(Note that any manager class using ``InheritanceManagerMixin`` must return a
+``QuerySet`` class using ``InheritanceQuerySetMixin`` from its ``get_queryset``
+method. This means that if composing ``InheritanceManagerMixin`` and
+``PassThroughManagerMixin``, the ``QuerySet`` class passed to
+``PassThroughManager.for_queryset_class`` must inherit
+``InheritanceQuerySetMixin``.)
diff --git a/docs/models.rst b/docs/models.rst
new file mode 100644
index 0000000..7a05c79
--- /dev/null
+++ b/docs/models.rst
@@ -0,0 +1,49 @@
+Models
+======
+
+TimeFramedModel
+---------------
+
+An abstract base class for any model that expresses a time-range. Adds
+``start`` and ``end`` nullable DateTimeFields, and a ``timeframed``
+manager that returns only objects for whom the current date-time lies
+within their time range.
+
+
+TimeStampedModel
+----------------
+
+This abstract base class just provides self-updating ``created`` and
+``modified`` fields on any model that inherits from it.
+
+
+StatusModel
+-----------
+
+Pulls together :ref:`StatusField`, :ref:`MonitorField` and :ref:`QueryManager`
+into an abstract base class for any model with a "status."
+
+Just provide a ``STATUS`` class-attribute (a :ref:`Choices` object or a
+list of two-tuples), and your model will have a ``status`` field with
+those choices, a ``status_changed`` field containing the date-time the
+``status`` was last changed, and a manager for each status that
+returns objects with that status only:
+
+.. code-block:: python
+
+ from model_utils.models import StatusModel
+ from model_utils import Choices
+
+ class Article(StatusModel):
+ STATUS = Choices('draft', 'published')
+
+ # ...
+
+ a = Article()
+ a.status = Article.STATUS.published
+
+ # this save will update a.status_changed
+ a.save()
+
+ # this query will only return published articles:
+ Article.published.all()
diff --git a/docs/setup.rst b/docs/setup.rst
new file mode 100644
index 0000000..5621649
--- /dev/null
+++ b/docs/setup.rst
@@ -0,0 +1,23 @@
+=====
+Setup
+=====
+
+Installation
+============
+
+Install from PyPI with ``pip``::
+
+ pip install django-model-utils
+
+To use ``django-model-utils`` in your Django project, just import and
+use the utility classes described in this documentation; there is no need to
+modify your ``INSTALLED_APPS`` setting.
+
+
+Dependencies
+============
+
+``django-model-utils`` supports `Django`_ 1.4.2 and later on Python 2.6, 2.7,
+3.2, and 3.3.
+
+.. _Django: http://www.djangoproject.com/
diff --git a/docs/utilities.rst b/docs/utilities.rst
new file mode 100644
index 0000000..44824f5
--- /dev/null
+++ b/docs/utilities.rst
@@ -0,0 +1,225 @@
+=======================
+Miscellaneous Utilities
+=======================
+
+.. _Choices:
+
+Choices
+=======
+
+``Choices`` provides some conveniences for setting ``choices`` on a Django model field:
+
+.. code-block:: python
+
+ from model_utils import Choices
+
+ class Article(models.Model):
+ STATUS = Choices('draft', 'published')
+ status = models.CharField(choices=STATUS, default=STATUS.draft, max_length=20)
+
+A ``Choices`` object is initialized with any number of choices. In the
+simplest case, each choice is a string; that string will be used both
+as the database representation of the choice, and the human-readable
+representation. Note that you can access options as attributes on the
+``Choices`` object: ``STATUS.draft``.
+
+But you may want your human-readable versions translated, in which
+case you need to separate the human-readable version from the DB
+representation. In this case you can provide choices as two-tuples:
+
+.. code-block:: python
+
+ from model_utils import Choices
+
+ class Article(models.Model):
+ STATUS = Choices(('draft', _('draft')), ('published', _('published')))
+ status = models.CharField(choices=STATUS, default=STATUS.draft, max_length=20)
+
+But what if your database representation of choices is constrained in
+a way that would hinder readability of your code? For instance, you
+may need to use an ``IntegerField`` rather than a ``CharField``, or
+you may want the database to order the values in your field in some
+specific way. In this case, you can provide your choices as triples,
+where the first element is the database representation, the second is
+a valid Python identifier you will use in your code as a constant, and
+the third is the human-readable version:
+
+.. code-block:: python
+
+ from model_utils import Choices
+
+ class Article(models.Model):
+ STATUS = Choices((0, 'draft', _('draft')), (1, 'published', _('published')))
+ status = models.IntegerField(choices=STATUS, default=STATUS.draft)
+
+You can index into a ``Choices`` instance to translate a database
+representation to its display name:
+
+.. code-block:: python
+
+ status_display = Article.STATUS[article.status]
+
+Option groups can also be used with ``Choices``; in that case each
+argument is a tuple consisting of the option group name and a list of
+options, where each option in the list is either a string, a two-tuple,
+or a triple as outlined above. For example:
+
+.. code-block:: python
+
+ from model_utils import Choices
+
+ class Article(models.Model):
+ STATUS = Choices(('Visible', ['new', 'archived']), ('Invisible', ['draft', 'deleted']))
+
+Choices can be concatenated with the ``+`` operator, both to other Choices
+instances and other iterable objects that could be converted into Choices:
+
+.. code-block:: python
+
+ from model_utils import Choices
+
+ GENERIC_CHOICES = Choices((0, 'draft', _('draft')), (1, 'published', _('published')))
+
+ class Article(models.Model):
+ STATUS = GENERIC_CHOICES + [(2, 'featured', _('featured'))]
+ status = models.IntegerField(choices=STATUS, default=STATUS.draft)
+
+
+Field Tracker
+=============
+
+A ``FieldTracker`` can be added to a model to track changes in model fields. A
+``FieldTracker`` allows querying for field changes since a model instance was
+last saved. An example of applying ``FieldTracker`` to a model:
+
+.. code-block:: python
+
+ from django.db import models
+ from model_utils import FieldTracker
+
+ class Post(models.Model):
+ title = models.CharField(max_length=100)
+ body = models.TextField()
+
+ tracker = FieldTracker()
+
+.. note::
+
+ ``django-model-utils`` 1.3.0 introduced the ``ModelTracker`` object for
+ tracking changes to model field values. Unfortunately ``ModelTracker``
+ suffered from some serious flaws in its handling of ``ForeignKey`` fields,
+ potentially resulting in many extra database queries if a ``ForeignKey``
+ field was tracked. In order to avoid breaking API backwards-compatibility,
+ ``ModelTracker`` retains the previous behavior but is deprecated, and
+ ``FieldTracker`` has been introduced to provide better ``ForeignKey``
+ handling. All uses of ``ModelTracker`` should be replaced by
+ ``FieldTracker``.
+
+ Summary of differences between ``ModelTracker`` and ``FieldTracker``:
+
+ * The previous value returned for a tracked ``ForeignKey`` field will now
+ be the raw ID rather than the full object (avoiding extra database
+ queries). (GH-43)
+
+ * The ``changed()`` method no longer returns the empty dictionary for all
+ unsaved instances; rather, ``None`` is considered to be the initial value
+ of all fields if the model has never been saved, thus ``changed()`` on an
+ unsaved instance will return a dictionary containing all fields whose
+ current value is not ``None``.
+
+ * The ``has_changed()`` method no longer crashes after an object's first
+ save. (GH-53).
+
+
+Accessing a field tracker
+-------------------------
+
+There are multiple methods available for checking for changes in model fields.
+
+
+previous
+~~~~~~~~
+Returns the value of the given field during the last save:
+
+.. code-block:: pycon
+
+ >>> a = Post.objects.create(title='First Post')
+ >>> a.title = 'Welcome'
+ >>> a.tracker.previous('title')
+ u'First Post'
+
+Returns ``None`` when the model instance isn't saved yet.
+
+
+has_changed
+~~~~~~~~~~~
+Returns ``True`` if the given field has changed since the last save. The ``has_changed`` method expects a single field:
+
+.. code-block:: pycon
+
+ >>> a = Post.objects.create(title='First Post')
+ >>> a.title = 'Welcome'
+ >>> a.tracker.has_changed('title')
+ True
+ >>> a.tracker.has_changed('body')
+ False
+
+The ``has_changed`` method relies on ``previous`` to determine whether a
+field's values has changed.
+
+
+changed
+~~~~~~~
+Returns a dictionary of all fields that have been changed since the last save
+and the values of the fields during the last save:
+
+.. code-block:: pycon
+
+ >>> a = Post.objects.create(title='First Post')
+ >>> a.title = 'Welcome'
+ >>> a.body = 'First post!'
+ >>> a.tracker.changed()
+ {'title': 'First Post', 'body': ''}
+
+The ``changed`` method relies on ``has_changed`` to determine which fields
+have changed.
+
+
+Tracking specific fields
+------------------------
+
+A fields parameter can be given to ``FieldTracker`` to limit tracking to
+specific fields:
+
+.. code-block:: python
+
+ from django.db import models
+ from model_utils import FieldTracker
+
+ class Post(models.Model):
+ title = models.CharField(max_length=100)
+ body = models.TextField()
+
+ title_tracker = FieldTracker(fields=['title'])
+
+An example using the model specified above:
+
+.. code-block:: pycon
+
+ >>> a = Post.objects.create(title='First Post')
+ >>> a.body = 'First post!'
+ >>> a.title_tracker.changed()
+ {'title': None}
+
+
+Checking changes using signals
+------------------------------
+
+The field tracker methods may also be used in ``pre_save`` and ``post_save``
+signal handlers to identify field changes on model save.
+
+.. NOTE::
+
+ Due to the implementation of ``FieldTracker``, ``post_save`` signal
+ handlers relying on field tracker methods should only be registered after
+ model creation.
diff --git a/model_utils/__init__.py b/model_utils/__init__.py
new file mode 100644
index 0000000..e42aa57
--- /dev/null
+++ b/model_utils/__init__.py
@@ -0,0 +1,4 @@
+from .choices import Choices
+from .tracker import FieldTracker, ModelTracker
+
+__version__ = '2.5.2'
diff --git a/model_utils/choices.py b/model_utils/choices.py
new file mode 100644
index 0000000..d48ba90
--- /dev/null
+++ b/model_utils/choices.py
@@ -0,0 +1,153 @@
+from __future__ import unicode_literals
+
+import copy
+
+
+class Choices(object):
+ """
+ A class to encapsulate handy functionality for lists of choices
+ for a Django model field.
+
+ Each argument to ``Choices`` is a choice, represented as either a
+ string, a two-tuple, or a three-tuple.
+
+ If a single string is provided, that string is used as the
+ database representation of the choice as well as the
+ human-readable presentation.
+
+ If a two-tuple is provided, the first item is used as the database
+ representation and the second the human-readable presentation.
+
+ If a triple is provided, the first item is the database
+ representation, the second a valid Python identifier that can be
+ used as a readable label in code, and the third the human-readable
+ presentation. This is most useful when the database representation
+ must sacrifice readability for some reason: to achieve a specific
+ ordering, to use an integer rather than a character field, etc.
+
+ Regardless of what representation of each choice is originally
+ given, when iterated over or indexed into, a ``Choices`` object
+ behaves as the standard Django choices list of two-tuples.
+
+ If the triple form is used, the Python identifier names can be
+ accessed as attributes on the ``Choices`` object, returning the
+ database representation. (If the single or two-tuple forms are
+ used and the database representation happens to be a valid Python
+ identifier, the database representation itself is available as an
+ attribute on the ``Choices`` object, returning itself.)
+
+ Option groups can also be used with ``Choices``; in that case each
+ argument is a tuple consisting of the option group name and a list
+ of options, where each option in the list is either a string, a
+ two-tuple, or a triple as outlined above.
+
+ """
+
+ def __init__(self, *choices):
+ # list of choices expanded to triples - can include optgroups
+ self._triples = []
+ # list of choices as (db, human-readable) - can include optgroups
+ self._doubles = []
+ # dictionary mapping db representation to human-readable
+ self._display_map = {}
+ # dictionary mapping Python identifier to db representation
+ self._identifier_map = {}
+ # set of db representations
+ self._db_values = set()
+
+ 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]
+ self._db_values.add(triple[0])
+ 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
+ if double_collector is None:
+ double_collector = self._doubles
+
+ store = lambda c: self._store(c, triple_collector, double_collector)
+
+ for choice in choices:
+ if isinstance(choice, (list, tuple)):
+ if len(choice) == 3:
+ store(choice)
+ elif len(choice) == 2:
+ if isinstance(choice[1], (list, tuple)):
+ # option group
+ group_name = choice[0]
+ subchoices = choice[1]
+ tc = []
+ triple_collector.append((group_name, tc))
+ dc = []
+ double_collector.append((group_name, dc))
+ self._process(subchoices, tc, dc)
+ else:
+ store((choice[0], choice[0], choice[1]))
+ else:
+ raise ValueError(
+ "Choices can't take a list of length %s, only 2 or 3"
+ % len(choice)
+ )
+ 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
+ else:
+ 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/model_utils/fields.py b/model_utils/fields.py
new file mode 100644
index 0000000..805c707
--- /dev/null
+++ b/model_utils/fields.py
@@ -0,0 +1,267 @@
+from __future__ import unicode_literals
+
+import django
+from django.db import models
+from django.conf import settings
+from django.utils.encoding import python_2_unicode_compatible
+from django.utils.timezone import now
+
+DEFAULT_CHOICES_NAME = 'STATUS'
+
+
+class AutoCreatedField(models.DateTimeField):
+ """
+ A DateTimeField that automatically populates itself at
+ object creation.
+
+ By default, sets editable=False, default=datetime.now.
+
+ """
+ def __init__(self, *args, **kwargs):
+ kwargs.setdefault('editable', False)
+ kwargs.setdefault('default', now)
+ super(AutoCreatedField, self).__init__(*args, **kwargs)
+
+
+class AutoLastModifiedField(AutoCreatedField):
+ """
+ A DateTimeField that updates itself on each save() of the model.
+
+ By default, sets editable=False and default=datetime.now.
+
+ """
+ def pre_save(self, model_instance, add):
+ value = now()
+ setattr(model_instance, self.attname, value)
+ return value
+
+
+class StatusField(models.CharField):
+ """
+ A CharField that looks for a ``STATUS`` class-attribute and
+ automatically uses that as ``choices``. The first option in
+ ``STATUS`` is set as the default.
+
+ Also has a default max_length so you don't have to worry about
+ setting that.
+
+ Also features a ``no_check_for_status`` argument to make sure
+ South can handle this field when it freezes a model.
+ """
+ def __init__(self, *args, **kwargs):
+ kwargs.setdefault('max_length', 100)
+ self.check_for_status = not kwargs.pop('no_check_for_status', False)
+ self.choices_name = kwargs.pop('choices_name', DEFAULT_CHOICES_NAME)
+ super(StatusField, self).__init__(*args, **kwargs)
+
+ def prepare_class(self, sender, **kwargs):
+ if not sender._meta.abstract and self.check_for_status:
+ assert hasattr(sender, self.choices_name), \
+ "To use StatusField, the model '%s' must have a %s choices class attribute." \
+ % (sender.__name__, self.choices_name)
+ self._choices = getattr(sender, self.choices_name)
+ if django.VERSION >= (1, 9, 0):
+ self.choices = self._choices
+ if not self.has_default():
+ self.default = tuple(getattr(sender, self.choices_name))[0][0] # set first as default
+
+ def contribute_to_class(self, cls, name):
+ models.signals.class_prepared.connect(self.prepare_class, sender=cls)
+ # we don't set the real choices until class_prepared (so we can rely on
+ # the STATUS class attr being available), but we need to set some dummy
+ # choices now so the super method will add the get_FOO_display method
+ self._choices = [(0, 'dummy')]
+ if django.VERSION >= (1, 9, 0):
+ self.choices = self._choices
+ super(StatusField, self).contribute_to_class(cls, name)
+
+ def deconstruct(self):
+ name, path, args, kwargs = super(StatusField, self).deconstruct()
+ kwargs['no_check_for_status'] = True
+ return name, path, args, kwargs
+
+
+class MonitorField(models.DateTimeField):
+ """
+ A DateTimeField that monitors another field on the same model and
+ sets itself to the current date/time whenever the monitored field
+ changes.
+
+ """
+ def __init__(self, *args, **kwargs):
+ kwargs.setdefault('default', now)
+ monitor = kwargs.pop('monitor', None)
+ if not monitor:
+ raise TypeError(
+ '%s requires a "monitor" argument' % self.__class__.__name__)
+ self.monitor = monitor
+ when = kwargs.pop('when', None)
+ if when is not None:
+ when = set(when)
+ self.when = when
+ super(MonitorField, self).__init__(*args, **kwargs)
+
+ def contribute_to_class(self, cls, name):
+ self.monitor_attname = '_monitor_%s' % name
+ models.signals.post_init.connect(self._save_initial, sender=cls)
+ super(MonitorField, self).contribute_to_class(cls, name)
+
+ def get_monitored_value(self, instance):
+ return getattr(instance, self.monitor)
+
+ def _save_initial(self, sender, instance, **kwargs):
+ setattr(instance, self.monitor_attname,
+ self.get_monitored_value(instance))
+
+ def pre_save(self, model_instance, add):
+ value = now()
+ previous = getattr(model_instance, self.monitor_attname, None)
+ current = self.get_monitored_value(model_instance)
+ if previous != current:
+ if self.when is None or current in self.when:
+ setattr(model_instance, self.attname, value)
+ self._save_initial(model_instance.__class__, model_instance)
+ return super(MonitorField, self).pre_save(model_instance, add)
+
+ def deconstruct(self):
+ name, path, args, kwargs = super(MonitorField, self).deconstruct()
+ kwargs['monitor'] = self.monitor
+ if self.when is not None:
+ kwargs['when'] = self.when
+ return name, path, args, kwargs
+
+
+SPLIT_MARKER = getattr(settings, 'SPLIT_MARKER', '<!-- split -->')
+
+# the number of paragraphs after which to split if no marker
+SPLIT_DEFAULT_PARAGRAPHS = getattr(settings, 'SPLIT_DEFAULT_PARAGRAPHS', 2)
+
+_excerpt_field_name = lambda name: '_%s_excerpt' % name
+
+def get_excerpt(content):
+ excerpt = []
+ default_excerpt = []
+ paras_seen = 0
+ for line in content.splitlines():
+ if not line.strip():
+ paras_seen += 1
+ if paras_seen < SPLIT_DEFAULT_PARAGRAPHS:
+ default_excerpt.append(line)
+ if line.strip() == SPLIT_MARKER:
+ return '\n'.join(excerpt)
+ excerpt.append(line)
+
+ return '\n'.join(default_excerpt)
+
+@python_2_unicode_compatible
+class SplitText(object):
+ def __init__(self, instance, field_name, excerpt_field_name):
+ # instead of storing actual values store a reference to the instance
+ # along with field names, this makes assignment possible
+ self.instance = instance
+ self.field_name = field_name
+ self.excerpt_field_name = excerpt_field_name
+
+ # content is read/write
+ def _get_content(self):
+ return self.instance.__dict__[self.field_name]
+ def _set_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):
+ return getattr(self.instance, self.excerpt_field_name)
+ excerpt = property(_get_excerpt)
+
+ # has_more is a boolean property
+ def _get_has_more(self):
+ return self.excerpt.strip() != self.content.strip()
+ has_more = property(_get_has_more)
+
+ def __str__(self):
+ return self.content
+
+class SplitDescriptor(object):
+ def __init__(self, field):
+ self.field = field
+ self.excerpt_field_name = _excerpt_field_name(self.field.name)
+
+ def __get__(self, instance, owner):
+ if instance is None:
+ raise AttributeError('Can only be accessed via an instance.')
+ content = instance.__dict__[self.field.name]
+ if content is None:
+ return None
+ return SplitText(instance, self.field.name, self.excerpt_field_name)
+
+ def __set__(self, obj, value):
+ if isinstance(value, SplitText):
+ obj.__dict__[self.field.name] = value.content
+ setattr(obj, self.excerpt_field_name, value.excerpt)
+ 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
+ # SplitField can't try to add an _excerpt field, because the
+ # _excerpt field itself is frozen as well. See introspection
+ # rules below.
+ self.add_excerpt_field = not kwargs.pop('no_excerpt_field', False)
+ super(SplitField, self).__init__(*args, **kwargs)
+
+ def contribute_to_class(self, cls, name):
+ if self.add_excerpt_field and not cls._meta.abstract:
+ excerpt_field = models.TextField(editable=False)
+ cls.add_to_class(_excerpt_field_name(name), excerpt_field)
+ super(SplitField, self).contribute_to_class(cls, name)
+ setattr(cls, self.name, SplitDescriptor(self))
+
+ def pre_save(self, model_instance, add):
+ value = super(SplitField, self).pre_save(model_instance, add)
+ excerpt = get_excerpt(value.content)
+ setattr(model_instance, _excerpt_field_name(self.attname), excerpt)
+ return value.content
+
+ def value_to_string(self, obj):
+ value = self._get_val_from_obj(obj)
+ return value.content
+
+ def get_prep_value(self, value):
+ try:
+ return value.content
+ except AttributeError:
+ return value
+
+ def deconstruct(self):
+ name, path, args, kwargs = super(SplitField, self).deconstruct()
+ kwargs['no_excerpt_field'] = True
+ return name, path, args, kwargs
+
+# allow South to handle these fields smoothly
+try:
+ from south.modelsinspector import add_introspection_rules
+ # For a normal MarkupField, the add_excerpt_field attribute is
+ # always True, which means no_excerpt_field arg will always be
+ # True in a frozen MarkupField, which is what we want.
+ add_introspection_rules(rules=[
+ (
+ (SplitField,),
+ [],
+ {'no_excerpt_field': ('add_excerpt_field', {})}
+ ),
+ (
+ (MonitorField,),
+ [],
+ {'monitor': ('monitor', {})}
+ ),
+ (
+ (StatusField,),
+ [],
+ {'no_check_for_status': ('check_for_status', {})}
+ ),
+ ], patterns=['model_utils\.fields\.'])
+except ImportError:
+ pass
+
diff --git a/model_utils/locale/de/LC_MESSAGES/django.mo b/model_utils/locale/de/LC_MESSAGES/django.mo
new file mode 100644
index 0000000..7b80928
--- /dev/null
+++ b/model_utils/locale/de/LC_MESSAGES/django.mo
Binary files differ
diff --git a/model_utils/locale/de/LC_MESSAGES/django.po b/model_utils/locale/de/LC_MESSAGES/django.po
new file mode 100644
index 0000000..342b3cf
--- /dev/null
+++ b/model_utils/locale/de/LC_MESSAGES/django.po
@@ -0,0 +1,53 @@
+# This file is distributed under the same license as the django-model-utils package.
+#
+# Translators:
+# Philipp Steinhardt <steinhardt@myvision.de>, 2015.
+msgid ""
+msgstr ""
+"Project-Id-Version: django-model-utils\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2015-07-20 10:17-0600\n"
+"PO-Revision-Date: 2015-07-01 10:12+0200\n"
+"Last-Translator: Philipp Steinhardt <steinhardt@myvision.de>\n"
+"Language-Team: \n"
+"Language: de\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=(n != 1);\n"
+
+#: models.py:20
+msgid "created"
+msgstr "erstellt"
+
+#: models.py:21
+msgid "modified"
+msgstr "bearbeitet"
+
+#: models.py:33
+msgid "start"
+msgstr "Beginn"
+
+#: models.py:34
+msgid "end"
+msgstr "Ende"
+
+#: models.py:49
+msgid "status"
+msgstr "Status"
+
+#: models.py:50
+msgid "status changed"
+msgstr "Status geändert"
+
+#: tests/models.py:106 tests/models.py:115 tests/models.py:124
+msgid "active"
+msgstr "aktiv"
+
+#: tests/models.py:107 tests/models.py:116 tests/models.py:125
+msgid "deleted"
+msgstr "gelöscht"
+
+#: tests/models.py:108 tests/models.py:117 tests/models.py:126
+msgid "on hold"
+msgstr "wartend"
diff --git a/model_utils/managers.py b/model_utils/managers.py
new file mode 100644
index 0000000..4f06d2e
--- /dev/null
+++ b/model_utils/managers.py
@@ -0,0 +1,246 @@
+from __future__ import unicode_literals
+import django
+from django.db import models
+from django.db.models.fields.related import OneToOneField, OneToOneRel
+from django.db.models.query import QuerySet
+from django.core.exceptions import ObjectDoesNotExist
+
+try:
+ from django.db.models.constants import LOOKUP_SEP
+ from django.utils.six import string_types
+except ImportError: # Django < 1.5
+ from django.db.models.sql.constants import LOOKUP_SEP
+ string_types = (basestring,)
+
+
+class InheritanceQuerySetMixin(object):
+ def select_subclasses(self, *subclasses):
+ levels = self._get_maximum_depth()
+ calculated_subclasses = self._get_subclasses_recurse(
+ self.model, levels=levels)
+ # if none were passed in, we can just short circuit and select all
+ if not subclasses:
+ subclasses = calculated_subclasses
+ else:
+ verified_subclasses = []
+ for subclass in subclasses:
+ # special case for passing in the same model as the queryset
+ # is bound against. Rather than raise an error later, we know
+ # we can allow this through.
+ if subclass is self.model:
+ continue
+
+ if not isinstance(subclass, string_types):
+ subclass = self._get_ancestors_path(
+ subclass, levels=levels)
+
+ if subclass in calculated_subclasses:
+ verified_subclasses.append(subclass)
+ else:
+ 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
+ previous_select_related = self.query.select_related
+ new_qs = self.select_related(*subclasses)
+ previous_is_dict = isinstance(previous_select_related, dict)
+ new_is_dict = isinstance(new_qs.query.select_related, dict)
+ if previous_is_dict and new_is_dict:
+ new_qs.query.select_related.update(previous_select_related)
+ new_qs.subclasses = subclasses
+ return new_qs
+
+ def _clone(self, klass=None, setup=False, **kwargs):
+ for name in ['subclasses', '_annotated']:
+ 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):
+ qset = super(InheritanceQuerySetMixin, self).annotate(*args, **kwargs)
+ qset._annotated = [a.default_alias for a in args] + list(kwargs.keys())
+ return qset
+
+ def iterator(self):
+ iter = super(InheritanceQuerySetMixin, self).iterator()
+ if getattr(self, 'subclasses', False):
+ extras = tuple(self.query.extra.keys())
+ # sort the subclass names longest first,
+ # so with 'a' and 'a__b' it goes as deep as possible
+ subclasses = sorted(self.subclasses, key=len, reverse=True)
+ for obj in iter:
+ sub_obj = None
+ for s in subclasses:
+ sub_obj = self._get_sub_obj_recurse(obj, s)
+ if sub_obj:
+ break
+ if not sub_obj:
+ sub_obj = obj
+
+ if getattr(self, '_annotated', False):
+ for k in self._annotated:
+ setattr(sub_obj, k, getattr(obj, k))
+
+ for k in extras:
+ setattr(sub_obj, k, getattr(obj, k))
+
+ yield sub_obj
+ else:
+ for obj in iter:
+ yield obj
+
+ def _get_subclasses_recurse(self, model, levels=None):
+ """
+ Given a Model class, find all related objects, exploring children
+ 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)]
+
+ rels = [
+ rel for rel in related_objects
+ if isinstance(rel.field, OneToOneField)
+ and issubclass(rel.field.model, model)
+ and model is not rel.field.model
+ ]
+
+ subclasses = []
+ if levels:
+ levels -= 1
+ for rel in rels:
+ if levels or levels is None:
+ for subclass in self._get_subclasses_recurse(
+ rel.field.model, levels=levels):
+ subclasses.append(
+ rel.get_accessor_name() + LOOKUP_SEP + subclass)
+ subclasses.append(rel.get_accessor_name())
+ return subclasses
+
+ def _get_ancestors_path(self, model, levels=None):
+ """
+ Serves as an opposite to _get_subclasses_recurse, instead walking from
+ the Model class up the Model's ancestry and constructing the desired
+ select_related string backwards.
+ """
+ if not issubclass(model, self.model):
+ raise ValueError(
+ "%r is not a subclass of %r" % (model, self.model))
+
+ ancestry = []
+ # should be a OneToOneField or None
+ parent_link = model._meta.get_ancestor_link(self.model)
+ if levels:
+ levels -= 1
+ while parent_link is not None:
+ if django.VERSION < (1, 8):
+ related = parent_link.related
+ else:
+ related = parent_link.rel
+ 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_link = parent_model._meta.get_ancestor_link(
+ self.model)
+ else:
+ parent_link = None
+ return LOOKUP_SEP.join(ancestry)
+
+ def _get_sub_obj_recurse(self, obj, s):
+ rel, _, s = s.partition(LOOKUP_SEP)
+
+ # Django 1.9: If a primitive type gets passed to this recursive function,
+ # return None as non-models are not part of inheritance.
+ if not isinstance(obj, models.Model):
+ return None
+
+ try:
+ node = getattr(obj, rel)
+ except ObjectDoesNotExist:
+ return None
+ if s:
+ child = self._get_sub_obj_recurse(node, s)
+ return child
+ else:
+ return node
+
+ 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 InheritanceManagerMixin(object):
+ use_for_related_fields = True
+
+ def get_queryset(self):
+ return InheritanceQuerySet(self.model)
+
+ get_query_set = get_queryset
+
+ def select_subclasses(self, *subclasses):
+ return self.get_queryset().select_subclasses(*subclasses)
+
+ def get_subclass(self, *args, **kwargs):
+ return self.get_queryset().get_subclass(*args, **kwargs)
+
+
+class InheritanceQuerySet(InheritanceQuerySetMixin, QuerySet):
+ pass
+
+
+class InheritanceManager(InheritanceManagerMixin, models.Manager):
+ pass
+
+
+class QueryManagerMixin(object):
+ use_for_related_fields = True
+
+ def __init__(self, *args, **kwargs):
+ if args:
+ self._q = args[0]
+ else:
+ self._q = models.Q(**kwargs)
+ self._order_by = None
+ super(QueryManagerMixin, self).__init__()
+
+ def order_by(self, *args):
+ self._order_by = args
+ return self
+
+ def get_queryset(self):
+ try:
+ qs = super(QueryManagerMixin, self).get_queryset().filter(self._q)
+ except AttributeError:
+ qs = super(QueryManagerMixin, self).get_query_set().filter(self._q)
+ if self._order_by is not None:
+ return qs.order_by(*self._order_by)
+ return qs
+
+ get_query_set = get_queryset
+
+
+class QueryManager(QueryManagerMixin, models.Manager):
+ pass
diff --git a/model_utils/models.py b/model_utils/models.py
new file mode 100644
index 0000000..3db4073
--- /dev/null
+++ b/model_utils/models.py
@@ -0,0 +1,101 @@
+from __future__ import unicode_literals
+
+import django
+from django.db import models
+from django.utils.translation import ugettext_lazy as _
+from django.core.exceptions import ImproperlyConfigured
+if django.VERSION >= (1, 9, 0):
+ from django.db.models.functions import Now
+ now = Now()
+else:
+ from django.utils.timezone import now
+
+from model_utils.managers import QueryManager
+from model_utils.fields import AutoCreatedField, AutoLastModifiedField, \
+ StatusField, MonitorField
+
+
+class TimeStampedModel(models.Model):
+ """
+ An abstract base class model that provides self-updating
+ ``created`` and ``modified`` fields.
+
+ """
+ created = AutoCreatedField(_('created'))
+ modified = AutoLastModifiedField(_('modified'))
+
+ class Meta:
+ abstract = True
+
+
+class TimeFramedModel(models.Model):
+ """
+ An abstract base class model that provides ``start``
+ and ``end`` fields to record a timeframe.
+
+ """
+ start = models.DateTimeField(_('start'), null=True, blank=True)
+ end = models.DateTimeField(_('end'), null=True, blank=True)
+
+ class Meta:
+ abstract = True
+
+
+class StatusModel(models.Model):
+ """
+ An abstract base class model with a ``status`` field that
+ automatically uses a ``STATUS`` class attribute of choices, a
+ ``status_changed`` date-time field that records when ``status``
+ was last modified, and an automatically-added manager for each
+ status that returns objects with that status only.
+
+ """
+ status = StatusField(_('status'))
+ status_changed = MonitorField(_('status changed'), monitor='status')
+
+ class Meta:
+ abstract = True
+
+
+def add_status_query_managers(sender, **kwargs):
+ """
+ Add a Querymanager for each status item dynamically.
+
+ """
+ if not issubclass(sender, StatusModel):
+ return
+ for value, display in getattr(sender, 'STATUS', ()):
+ if _field_exists(sender, value):
+ raise ImproperlyConfigured(
+ "StatusModel: Model '%s' has a field named '%s' which "
+ "conflicts with a status of the same name."
+ % (sender.__name__, value)
+ )
+ sender.add_to_class(value, QueryManager(status=value))
+
+
+def add_timeframed_query_manager(sender, **kwargs):
+ """
+ Add a QueryManager for a specific timeframe.
+
+ """
+ if not issubclass(sender, TimeFramedModel):
+ return
+ if _field_exists(sender, 'timeframed'):
+ raise ImproperlyConfigured(
+ "Model '%s' has a field named 'timeframed' "
+ "which conflicts with the TimeFramedModel manager."
+ % sender.__name__
+ )
+ sender.add_to_class('timeframed', QueryManager(
+ (models.Q(start__lte=now) | models.Q(start__isnull=True)) &
+ (models.Q(end__gte=now) | models.Q(end__isnull=True))
+ ))
+
+
+models.signals.class_prepared.connect(add_status_query_managers)
+models.signals.class_prepared.connect(add_timeframed_query_manager)
+
+
+def _field_exists(model_class, field_name):
+ return field_name in [f.attname for f in model_class._meta.local_fields]
diff --git a/model_utils/tests/__init__.py b/model_utils/tests/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/model_utils/tests/__init__.py
diff --git a/model_utils/tests/fields.py b/model_utils/tests/fields.py
new file mode 100644
index 0000000..7c29aa4
--- /dev/null
+++ b/model_utils/tests/fields.py
@@ -0,0 +1,43 @@
+import django
+from django.db import models
+from django.utils.six import with_metaclass, string_types
+
+
+def mutable_from_db(value):
+ if value == '':
+ return None
+ try:
+ if isinstance(value, string_types):
+ return [int(i) for i in value.split(',')]
+ except ValueError:
+ pass
+ return value
+
+
+def mutable_to_db(value):
+ if value is None:
+ return ''
+ if isinstance(value, list):
+ value = ','.join((str(i) for i in value))
+ return str(value)
+
+
+if django.VERSION >= (1, 9, 0):
+ class MutableField(models.TextField):
+ def to_python(self, value):
+ return mutable_from_db(value)
+
+ def from_db_value(self, value, expression, connection, context):
+ return mutable_from_db(value)
+
+ def get_db_prep_save(self, value, connection):
+ value = super(MutableField, self).get_db_prep_save(value, connection)
+ return mutable_to_db(value)
+else:
+ class MutableField(with_metaclass(models.SubfieldBase, models.TextField)):
+ def to_python(self, value):
+ return mutable_from_db(value)
+
+ def get_db_prep_save(self, value, connection):
+ value = mutable_to_db(value)
+ return super(MutableField, self).get_db_prep_save(value, connection)
diff --git a/model_utils/tests/models.py b/model_utils/tests/models.py
new file mode 100644
index 0000000..c77c034
--- /dev/null
+++ b/model_utils/tests/models.py
@@ -0,0 +1,300 @@
+from __future__ import unicode_literals
+
+from django.db import models
+from django.utils.encoding import python_2_unicode_compatible
+from django.utils.translation import ugettext_lazy as _
+
+from model_utils.models import TimeStampedModel, StatusModel, TimeFramedModel
+from model_utils.tracker import FieldTracker, ModelTracker
+from model_utils.managers import QueryManager, InheritanceManager
+from model_utils.fields import SplitField, MonitorField, StatusField
+from model_utils.tests.fields import MutableField
+from model_utils import Choices
+
+
+
+class InheritanceManagerTestRelated(models.Model):
+ pass
+
+
+
+@python_2_unicode_compatible
+class InheritanceManagerTestParent(models.Model):
+ # FileField is just a handy descriptor-using field. Refs #6.
+ non_related_field_using_descriptor = models.FileField(upload_to="test")
+ related = models.ForeignKey(
+ InheritanceManagerTestRelated, related_name="imtests", null=True)
+ normal_field = models.TextField()
+ related_self = models.OneToOneField("self", related_name="imtests_self", null=True)
+ objects = InheritanceManager()
+
+ def __unicode__(self):
+ return unicode(self.pk)
+
+ def __str__(self):
+ return "%s(%s)" % (
+ self.__class__.__name__[len('InheritanceManagerTest'):],
+ self.pk,
+ )
+
+
+
+class InheritanceManagerTestChild1(InheritanceManagerTestParent):
+ non_related_field_using_descriptor_2 = models.FileField(upload_to="test")
+ normal_field_2 = models.TextField()
+ objects = InheritanceManager()
+
+
+
+class InheritanceManagerTestGrandChild1(InheritanceManagerTestChild1):
+ text_field = models.TextField()
+
+
+
+class InheritanceManagerTestGrandChild1_2(InheritanceManagerTestChild1):
+ text_field = models.TextField()
+
+
+
+class InheritanceManagerTestChild2(InheritanceManagerTestParent):
+ non_related_field_using_descriptor_2 = models.FileField(upload_to="test")
+ normal_field_2 = models.TextField()
+
+
+
+class InheritanceManagerTestChild3(InheritanceManagerTestParent):
+ parent_ptr = models.OneToOneField(
+ InheritanceManagerTestParent, related_name='manual_onetoone',
+ parent_link=True)
+
+
+class TimeStamp(TimeStampedModel):
+ pass
+
+
+
+class TimeFrame(TimeFramedModel):
+ pass
+
+
+
+class TimeFrameManagerAdded(TimeFramedModel):
+ pass
+
+
+
+class Monitored(models.Model):
+ name = models.CharField(max_length=25)
+ name_changed = MonitorField(monitor="name")
+
+
+
+class MonitorWhen(models.Model):
+ name = models.CharField(max_length=25)
+ name_changed = MonitorField(monitor="name", when=["Jose", "Maria"])
+
+
+
+class MonitorWhenEmpty(models.Model):
+ name = models.CharField(max_length=25)
+ name_changed = MonitorField(monitor="name", when=[])
+
+
+
+class Status(StatusModel):
+ STATUS = Choices(
+ ("active", _("active")),
+ ("deleted", _("deleted")),
+ ("on_hold", _("on hold")),
+ )
+
+
+
+class StatusPlainTuple(StatusModel):
+ STATUS = (
+ ("active", _("active")),
+ ("deleted", _("deleted")),
+ ("on_hold", _("on hold")),
+ )
+
+
+
+class StatusManagerAdded(StatusModel):
+ STATUS = (
+ ("active", _("active")),
+ ("deleted", _("deleted")),
+ ("on_hold", _("on hold")),
+ )
+
+
+
+class Post(models.Model):
+ published = models.BooleanField(default=False)
+ confirmed = models.BooleanField(default=False)
+ order = models.IntegerField()
+
+ objects = models.Manager()
+ public = QueryManager(published=True)
+ public_confirmed = QueryManager(models.Q(published=True) &
+ models.Q(confirmed=True))
+ public_reversed = QueryManager(published=True).order_by("-order")
+
+ class Meta:
+ ordering = ("order",)
+
+
+
+class Article(models.Model):
+ title = models.CharField(max_length=50)
+ body = SplitField()
+
+
+
+class SplitFieldAbstractParent(models.Model):
+ content = SplitField()
+
+
+ class Meta:
+ abstract = True
+
+
+
+class NoRendered(models.Model):
+ """
+ Test that the no_excerpt_field keyword arg works. This arg should
+ never be used except by the South model-freezing.
+
+ """
+ body = SplitField(no_excerpt_field=True)
+
+
+
+class AuthorMixin(object):
+ def by_author(self, name):
+ return self.filter(author=name)
+
+
+
+class PublishedMixin(object):
+ def published(self):
+ return self.filter(published=True)
+
+
+
+def unpublished(self):
+ return self.filter(published=False)
+
+
+
+class ByAuthorQuerySet(models.query.QuerySet, AuthorMixin):
+ pass
+
+
+
+class FeaturedManager(models.Manager):
+ def get_queryset(self):
+ kwargs = {}
+ if hasattr(self, "_db"):
+ kwargs["using"] = self._db
+ return ByAuthorQuerySet(self.model, **kwargs).filter(feature=True)
+
+ get_query_set = get_queryset
+
+
+class Tracked(models.Model):
+ name = models.CharField(max_length=20)
+ number = models.IntegerField()
+ mutable = MutableField(default=None)
+
+ tracker = FieldTracker()
+
+
+class TrackedFK(models.Model):
+ fk = models.ForeignKey('Tracked')
+
+ tracker = FieldTracker()
+ custom_tracker = FieldTracker(fields=['fk_id'])
+ custom_tracker_without_id = FieldTracker(fields=['fk'])
+
+
+class TrackedNotDefault(models.Model):
+ name = models.CharField(max_length=20)
+ number = models.IntegerField()
+
+ name_tracker = FieldTracker(fields=['name'])
+
+
+class TrackedNonFieldAttr(models.Model):
+ number = models.FloatField()
+
+ @property
+ def rounded(self):
+ return round(self.number) if self.number is not None else None
+
+ tracker = FieldTracker(fields=['rounded'])
+
+
+class TrackedMultiple(models.Model):
+ name = models.CharField(max_length=20)
+ number = models.IntegerField()
+
+ name_tracker = FieldTracker(fields=['name'])
+ number_tracker = FieldTracker(fields=['number'])
+
+
+class InheritedTracked(Tracked):
+ name2 = models.CharField(max_length=20)
+
+
+class InheritedTrackedFK(TrackedFK):
+ custom_tracker = FieldTracker(fields=['fk_id'])
+ custom_tracker_without_id = FieldTracker(fields=['fk'])
+
+
+class ModelTracked(models.Model):
+ name = models.CharField(max_length=20)
+ number = models.IntegerField()
+ mutable = MutableField(default=None)
+
+ tracker = ModelTracker()
+
+
+class ModelTrackedFK(models.Model):
+ fk = models.ForeignKey('ModelTracked')
+
+ tracker = ModelTracker()
+ custom_tracker = ModelTracker(fields=['fk_id'])
+ custom_tracker_without_id = ModelTracker(fields=['fk'])
+
+
+class ModelTrackedNotDefault(models.Model):
+ name = models.CharField(max_length=20)
+ number = models.IntegerField()
+
+ name_tracker = ModelTracker(fields=['name'])
+
+
+class ModelTrackedMultiple(models.Model):
+ name = models.CharField(max_length=20)
+ number = models.IntegerField()
+
+ name_tracker = ModelTracker(fields=['name'])
+ number_tracker = ModelTracker(fields=['number'])
+
+class InheritedModelTracked(ModelTracked):
+ name2 = models.CharField(max_length=20)
+
+
+class StatusFieldDefaultFilled(models.Model):
+ STATUS = Choices((0, "no", "No"), (1, "yes", "Yes"))
+ status = StatusField(default=STATUS.yes)
+
+
+class StatusFieldDefaultNotFilled(models.Model):
+ STATUS = Choices((0, "no", "No"), (1, "yes", "Yes"))
+ status = StatusField()
+
+
+class StatusFieldChoicesName(models.Model):
+ NAMED_STATUS = Choices((0, "no", "No"), (1, "yes", "Yes"))
+ status = StatusField(choices_name='NAMED_STATUS')
diff --git a/model_utils/tests/tests.py b/model_utils/tests/tests.py
new file mode 100644
index 0000000..14b6329
--- /dev/null
+++ b/model_utils/tests/tests.py
@@ -0,0 +1,1873 @@
+from __future__ import unicode_literals
+
+from datetime import datetime, timedelta
+try:
+ from unittest import skipUnless
+except ImportError: # Python 2.6
+ from django.utils.unittest import skipUnless
+
+import django
+from django.db import models
+from django.db.models.fields import FieldDoesNotExist
+from django.utils.six import text_type
+from django.core.exceptions import ImproperlyConfigured, FieldError
+from django.core.management import call_command
+from django.test import TestCase
+
+from model_utils import Choices, FieldTracker
+from model_utils.fields import get_excerpt, MonitorField, StatusField
+from model_utils.managers import QueryManager
+from model_utils.models import StatusModel, TimeFramedModel
+from model_utils.tests.models import (
+ InheritanceManagerTestRelated, InheritanceManagerTestGrandChild1,
+ InheritanceManagerTestGrandChild1_2,
+ InheritanceManagerTestParent, InheritanceManagerTestChild1,
+ InheritanceManagerTestChild2, TimeStamp, Post, Article, Status,
+ StatusPlainTuple, TimeFrame, Monitored, MonitorWhen, MonitorWhenEmpty, StatusManagerAdded,
+ TimeFrameManagerAdded, SplitFieldAbstractParent,
+ ModelTracked, ModelTrackedFK, ModelTrackedNotDefault, ModelTrackedMultiple, InheritedModelTracked,
+ Tracked, TrackedFK, InheritedTrackedFK, TrackedNotDefault, TrackedNonFieldAttr, TrackedMultiple,
+ InheritedTracked, StatusFieldDefaultFilled, StatusFieldDefaultNotFilled,
+ InheritanceManagerTestChild3, StatusFieldChoicesName)
+
+
+class MigrationsTests(TestCase):
+ @skipUnless(django.VERSION >= (1, 7, 0), "test only applies to Django 1.7+")
+ def test_makemigrations(self):
+ call_command('makemigrations', dry_run=True)
+
+
+class GetExcerptTests(TestCase):
+ def test_split(self):
+ e = get_excerpt("some content\n\n<!-- split -->\n\nsome more")
+ self.assertEqual(e, 'some content\n')
+
+
+ def test_auto_split(self):
+ e = get_excerpt("para one\n\npara two\n\npara three")
+ self.assertEqual(e, 'para one\n\npara two')
+
+
+ def test_middle_of_para(self):
+ e = get_excerpt("some text\n<!-- split -->\nmore text")
+ self.assertEqual(e, 'some text')
+
+
+ def test_middle_of_line(self):
+ e = get_excerpt("some text <!-- split --> more text")
+ self.assertEqual(e, "some text <!-- split --> more text")
+
+
+
+class SplitFieldTests(TestCase):
+ full_text = 'summary\n\n<!-- split -->\n\nmore'
+ excerpt = 'summary\n'
+
+
+ def setUp(self):
+ self.post = Article.objects.create(
+ title='example post', body=self.full_text)
+
+
+ def test_unicode_content(self):
+ self.assertEqual(text_type(self.post.body), self.full_text)
+
+
+ def test_excerpt(self):
+ self.assertEqual(self.post.body.excerpt, self.excerpt)
+
+
+ def test_content(self):
+ self.assertEqual(self.post.body.content, self.full_text)
+
+
+ def test_has_more(self):
+ self.assertTrue(self.post.body.has_more)
+
+
+ def test_not_has_more(self):
+ post = Article.objects.create(title='example 2',
+ body='some text\n\nsome more\n')
+ self.assertFalse(post.body.has_more)
+
+
+ def test_load_back(self):
+ post = Article.objects.get(pk=self.post.pk)
+ self.assertEqual(post.body.content, self.post.body.content)
+ self.assertEqual(post.body.excerpt, self.post.body.excerpt)
+
+
+ def test_assign_to_body(self):
+ new_text = 'different\n\n<!-- split -->\n\nother'
+ self.post.body = new_text
+ self.post.save()
+ self.assertEqual(text_type(self.post.body), new_text)
+
+
+ def test_assign_to_content(self):
+ new_text = 'different\n\n<!-- split -->\n\nother'
+ self.post.body.content = new_text
+ self.post.save()
+ self.assertEqual(text_type(self.post.body), new_text)
+
+
+ def test_assign_to_excerpt(self):
+ with self.assertRaises(AttributeError):
+ self.post.body.excerpt = 'this should fail'
+
+
+ def test_access_via_class(self):
+ with self.assertRaises(AttributeError):
+ Article.body
+
+
+ def test_none(self):
+ a = Article(title='Some Title', body=None)
+ self.assertEqual(a.body, None)
+
+
+ def test_assign_splittext(self):
+ a = Article(title='Some Title')
+ a.body = self.post.body
+ self.assertEqual(a.body.excerpt, 'summary\n')
+
+
+ def test_value_to_string(self):
+ f = self.post._meta.get_field('body')
+ self.assertEqual(f.value_to_string(self.post), self.full_text)
+
+
+ def test_abstract_inheritance(self):
+ class Child(SplitFieldAbstractParent):
+ pass
+
+ self.assertEqual(
+ [f.name for f in Child._meta.fields],
+ ["id", "content", "_content_excerpt"])
+
+
+
+class MonitorFieldTests(TestCase):
+ def setUp(self):
+ self.instance = Monitored(name='Charlie')
+ self.created = self.instance.name_changed
+
+
+ def test_save_no_change(self):
+ self.instance.save()
+ self.assertEqual(self.instance.name_changed, self.created)
+
+
+ def test_save_changed(self):
+ self.instance.name = 'Maria'
+ self.instance.save()
+ self.assertTrue(self.instance.name_changed > self.created)
+
+
+ def test_double_save(self):
+ self.instance.name = 'Jose'
+ self.instance.save()
+ changed = self.instance.name_changed
+ self.instance.save()
+ self.assertEqual(self.instance.name_changed, changed)
+
+
+ def test_no_monitor_arg(self):
+ with self.assertRaises(TypeError):
+ MonitorField()
+
+
+
+class MonitorWhenFieldTests(TestCase):
+ """
+ Will record changes only when name is 'Jose' or 'Maria'
+ """
+ def setUp(self):
+ self.instance = MonitorWhen(name='Charlie')
+ self.created = self.instance.name_changed
+
+
+ def test_save_no_change(self):
+ self.instance.save()
+ self.assertEqual(self.instance.name_changed, self.created)
+
+
+ def test_save_changed_to_Jose(self):
+ self.instance.name = 'Jose'
+ self.instance.save()
+ self.assertTrue(self.instance.name_changed > self.created)
+
+
+ def test_save_changed_to_Maria(self):
+ self.instance.name = 'Maria'
+ self.instance.save()
+ self.assertTrue(self.instance.name_changed > self.created)
+
+
+ def test_save_changed_to_Pedro(self):
+ self.instance.name = 'Pedro'
+ self.instance.save()
+ self.assertEqual(self.instance.name_changed, self.created)
+
+
+ def test_double_save(self):
+ self.instance.name = 'Jose'
+ self.instance.save()
+ changed = self.instance.name_changed
+ self.instance.save()
+ self.assertEqual(self.instance.name_changed, changed)
+
+
+
+class MonitorWhenEmptyFieldTests(TestCase):
+ """
+ Monitor should never be updated id when is an empty list.
+ """
+ def setUp(self):
+ self.instance = MonitorWhenEmpty(name='Charlie')
+ self.created = self.instance.name_changed
+
+
+ def test_save_no_change(self):
+ self.instance.save()
+ self.assertEqual(self.instance.name_changed, self.created)
+
+
+ def test_save_changed_to_Jose(self):
+ self.instance.name = 'Jose'
+ self.instance.save()
+ self.assertEqual(self.instance.name_changed, self.created)
+
+
+ def test_save_changed_to_Maria(self):
+ self.instance.name = 'Maria'
+ self.instance.save()
+ self.assertEqual(self.instance.name_changed, self.created)
+
+
+
+class StatusFieldTests(TestCase):
+
+ def test_status_with_default_filled(self):
+ instance = StatusFieldDefaultFilled()
+ self.assertEqual(instance.status, instance.STATUS.yes)
+
+ def test_status_with_default_not_filled(self):
+ instance = StatusFieldDefaultNotFilled()
+ self.assertEqual(instance.status, instance.STATUS.no)
+
+ def test_no_check_for_status(self):
+ field = StatusField(no_check_for_status=True)
+ # this model has no STATUS attribute, so checking for it would error
+ field.prepare_class(Article)
+
+ def test_get_status_display(self):
+ instance = StatusFieldDefaultFilled()
+ self.assertEqual(instance.get_status_display(), "Yes")
+
+ def test_choices_name(self):
+ StatusFieldChoicesName()
+
+
+class ChoicesTests(TestCase):
+ def setUp(self):
+ self.STATUS = Choices('DRAFT', 'PUBLISHED')
+
+
+ def test_getattr(self):
+ self.assertEqual(self.STATUS.DRAFT, 'DRAFT')
+
+
+ def test_indexing(self):
+ self.assertEqual(self.STATUS['PUBLISHED'], 'PUBLISHED')
+
+
+ def test_iteration(self):
+ self.assertEqual(tuple(self.STATUS), (('DRAFT', 'DRAFT'), ('PUBLISHED', 'PUBLISHED')))
+
+
+ def test_len(self):
+ self.assertEqual(len(self.STATUS), 2)
+
+
+ def test_repr(self):
+ self.assertEqual(repr(self.STATUS), "Choices" + repr((
+ ('DRAFT', 'DRAFT', 'DRAFT'),
+ ('PUBLISHED', 'PUBLISHED', 'PUBLISHED'),
+ )))
+
+
+ def test_wrong_length_tuple(self):
+ with self.assertRaises(ValueError):
+ Choices(('a',))
+
+
+ def test_contains_value(self):
+ self.assertTrue('PUBLISHED' in self.STATUS)
+ self.assertTrue('DRAFT' in self.STATUS)
+
+
+ def test_doesnt_contain_value(self):
+ self.assertFalse('UNPUBLISHED' in self.STATUS)
+
+ def test_deepcopy(self):
+ import copy
+ self.assertEqual(list(self.STATUS),
+ list(copy.deepcopy(self.STATUS)))
+
+
+ def test_equality(self):
+ self.assertEqual(self.STATUS, Choices('DRAFT', 'PUBLISHED'))
+
+
+ def test_inequality(self):
+ self.assertNotEqual(self.STATUS, ['DRAFT', 'PUBLISHED'])
+ self.assertNotEqual(self.STATUS, Choices('DRAFT'))
+
+
+ def test_composability(self):
+ self.assertEqual(Choices('DRAFT') + Choices('PUBLISHED'), self.STATUS)
+ self.assertEqual(Choices('DRAFT') + ('PUBLISHED',), self.STATUS)
+ self.assertEqual(('DRAFT',) + Choices('PUBLISHED'), self.STATUS)
+
+
+ def test_option_groups(self):
+ c = Choices(('group a', ['one', 'two']), ['group b', ('three',)])
+ self.assertEqual(
+ list(c),
+ [
+ ('group a', [('one', 'one'), ('two', 'two')]),
+ ('group b', [('three', 'three')]),
+ ],
+ )
+
+
+class LabelChoicesTests(ChoicesTests):
+ def setUp(self):
+ self.STATUS = Choices(
+ ('DRAFT', 'is draft'),
+ ('PUBLISHED', 'is published'),
+ 'DELETED',
+ )
+
+
+ def test_iteration(self):
+ self.assertEqual(tuple(self.STATUS), (
+ ('DRAFT', 'is draft'),
+ ('PUBLISHED', 'is published'),
+ ('DELETED', 'DELETED'))
+ )
+
+
+ def test_indexing(self):
+ self.assertEqual(self.STATUS['PUBLISHED'], 'is published')
+
+
+ def test_default(self):
+ self.assertEqual(self.STATUS.DELETED, 'DELETED')
+
+
+ def test_provided(self):
+ self.assertEqual(self.STATUS.DRAFT, 'DRAFT')
+
+
+ def test_len(self):
+ self.assertEqual(len(self.STATUS), 3)
+
+
+ def test_equality(self):
+ self.assertEqual(self.STATUS, Choices(
+ ('DRAFT', 'is draft'),
+ ('PUBLISHED', 'is published'),
+ 'DELETED',
+ ))
+
+
+ def test_inequality(self):
+ self.assertNotEqual(self.STATUS, [
+ ('DRAFT', 'is draft'),
+ ('PUBLISHED', 'is published'),
+ 'DELETED'
+ ])
+ self.assertNotEqual(self.STATUS, Choices('DRAFT'))
+
+
+ def test_repr(self):
+ self.assertEqual(repr(self.STATUS), "Choices" + repr((
+ ('DRAFT', 'DRAFT', 'is draft'),
+ ('PUBLISHED', 'PUBLISHED', 'is published'),
+ ('DELETED', 'DELETED', 'DELETED'),
+ )))
+
+
+ def test_contains_value(self):
+ self.assertTrue('PUBLISHED' in self.STATUS)
+ self.assertTrue('DRAFT' in self.STATUS)
+ # This should be True, because both the display value
+ # and the internal representation are both DELETED.
+ self.assertTrue('DELETED' in self.STATUS)
+
+
+ def test_doesnt_contain_value(self):
+ self.assertFalse('UNPUBLISHED' in self.STATUS)
+
+
+ def test_doesnt_contain_display_value(self):
+ self.assertFalse('is draft' in self.STATUS)
+
+
+ def test_composability(self):
+ self.assertEqual(
+ Choices(('DRAFT', 'is draft',)) + Choices(('PUBLISHED', 'is published'), 'DELETED'),
+ self.STATUS
+ )
+
+ self.assertEqual(
+ (('DRAFT', 'is draft',),) + Choices(('PUBLISHED', 'is published'), 'DELETED'),
+ self.STATUS
+ )
+
+ self.assertEqual(
+ Choices(('DRAFT', 'is draft',)) + (('PUBLISHED', 'is published'), 'DELETED'),
+ self.STATUS
+ )
+
+
+ def test_option_groups(self):
+ c = Choices(
+ ('group a', [(1, 'one'), (2, 'two')]),
+ ['group b', ((3, 'three'),)]
+ )
+ self.assertEqual(
+ list(c),
+ [
+ ('group a', [(1, 'one'), (2, 'two')]),
+ ('group b', [(3, 'three')]),
+ ],
+ )
+
+
+
+class IdentifierChoicesTests(ChoicesTests):
+ def setUp(self):
+ self.STATUS = Choices(
+ (0, 'DRAFT', 'is draft'),
+ (1, 'PUBLISHED', 'is published'),
+ (2, 'DELETED', 'is deleted'))
+
+
+ def test_iteration(self):
+ self.assertEqual(tuple(self.STATUS), (
+ (0, 'is draft'),
+ (1, 'is published'),
+ (2, 'is deleted')))
+
+
+ def test_indexing(self):
+ self.assertEqual(self.STATUS[1], 'is published')
+
+
+ def test_getattr(self):
+ self.assertEqual(self.STATUS.DRAFT, 0)
+
+
+ def test_len(self):
+ self.assertEqual(len(self.STATUS), 3)
+
+
+ def test_repr(self):
+ self.assertEqual(repr(self.STATUS), "Choices" + repr((
+ (0, 'DRAFT', 'is draft'),
+ (1, 'PUBLISHED', 'is published'),
+ (2, 'DELETED', 'is deleted'),
+ )))
+
+
+ def test_contains_value(self):
+ self.assertTrue(0 in self.STATUS)
+ self.assertTrue(1 in self.STATUS)
+ self.assertTrue(2 in self.STATUS)
+
+
+ def test_doesnt_contain_value(self):
+ self.assertFalse(3 in self.STATUS)
+
+
+ def test_doesnt_contain_display_value(self):
+ self.assertFalse('is draft' in self.STATUS)
+
+
+ def test_doesnt_contain_python_attr(self):
+ self.assertFalse('PUBLISHED' in self.STATUS)
+
+
+ def test_equality(self):
+ self.assertEqual(self.STATUS, Choices(
+ (0, 'DRAFT', 'is draft'),
+ (1, 'PUBLISHED', 'is published'),
+ (2, 'DELETED', 'is deleted')
+ ))
+
+
+ def test_inequality(self):
+ self.assertNotEqual(self.STATUS, [
+ (0, 'DRAFT', 'is draft'),
+ (1, 'PUBLISHED', 'is published'),
+ (2, 'DELETED', 'is deleted')
+ ])
+ self.assertNotEqual(self.STATUS, Choices('DRAFT'))
+
+
+ def test_composability(self):
+ self.assertEqual(
+ Choices(
+ (0, 'DRAFT', 'is draft'),
+ (1, 'PUBLISHED', 'is published')
+ ) + Choices(
+ (2, 'DELETED', 'is deleted'),
+ ),
+ self.STATUS
+ )
+
+ self.assertEqual(
+ Choices(
+ (0, 'DRAFT', 'is draft'),
+ (1, 'PUBLISHED', 'is published')
+ ) + (
+ (2, 'DELETED', 'is deleted'),
+ ),
+ self.STATUS
+ )
+
+ self.assertEqual(
+ (
+ (0, 'DRAFT', 'is draft'),
+ (1, 'PUBLISHED', 'is published')
+ ) + Choices(
+ (2, 'DELETED', 'is deleted'),
+ ),
+ self.STATUS
+ )
+
+
+ def test_option_groups(self):
+ c = Choices(
+ ('group a', [(1, 'ONE', 'one'), (2, 'TWO', 'two')]),
+ ['group b', ((3, 'THREE', 'three'),)]
+ )
+ self.assertEqual(
+ list(c),
+ [
+ ('group a', [(1, 'one'), (2, 'two')]),
+ ('group b', [(3, 'three')]),
+ ],
+ )
+
+
+class InheritanceManagerTests(TestCase):
+ def setUp(self):
+ self.child1 = InheritanceManagerTestChild1.objects.create()
+ self.child2 = InheritanceManagerTestChild2.objects.create()
+ self.grandchild1 = InheritanceManagerTestGrandChild1.objects.create()
+ self.grandchild1_2 = \
+ InheritanceManagerTestGrandChild1_2.objects.create()
+
+
+ def get_manager(self):
+ return InheritanceManagerTestParent.objects
+
+
+ def test_normal(self):
+ children = set([
+ InheritanceManagerTestParent(pk=self.child1.pk),
+ InheritanceManagerTestParent(pk=self.child2.pk),
+ InheritanceManagerTestParent(pk=self.grandchild1.pk),
+ InheritanceManagerTestParent(pk=self.grandchild1_2.pk),
+ ])
+ self.assertEqual(set(self.get_manager().all()), children)
+
+
+ def test_select_all_subclasses(self):
+ children = set([self.child1, self.child2])
+ if django.VERSION >= (1, 6, 0):
+ children.add(self.grandchild1)
+ children.add(self.grandchild1_2)
+ else:
+ children.add(InheritanceManagerTestChild1(pk=self.grandchild1.pk))
+ children.add(InheritanceManagerTestChild1(pk=self.grandchild1_2.pk))
+ self.assertEqual(
+ set(self.get_manager().select_subclasses()), children)
+
+
+ def test_select_subclasses_invalid_relation(self):
+ """
+ If an invalid relation string is provided, we can provide the user
+ with a list which is valid, rather than just have the select_related()
+ raise an AttributeError further in.
+ """
+ regex = '^.+? is not in the discovered subclasses, tried:.+$'
+ with self.assertRaisesRegexp(ValueError, regex):
+ self.get_manager().select_subclasses('user')
+
+
+ def test_select_specific_subclasses(self):
+ children = set([
+ self.child1,
+ InheritanceManagerTestParent(pk=self.child2.pk),
+ InheritanceManagerTestChild1(pk=self.grandchild1.pk),
+ InheritanceManagerTestChild1(pk=self.grandchild1_2.pk),
+ ])
+ self.assertEqual(
+ set(
+ self.get_manager().select_subclasses(
+ "inheritancemanagertestchild1")
+ ),
+ children,
+ )
+
+
+ @skipUnless(django.VERSION >= (1, 6, 0), "test only applies to Django 1.6+")
+ def test_select_specific_grandchildren(self):
+ children = set([
+ InheritanceManagerTestParent(pk=self.child1.pk),
+ InheritanceManagerTestParent(pk=self.child2.pk),
+ self.grandchild1,
+ InheritanceManagerTestParent(pk=self.grandchild1_2.pk),
+ ])
+ self.assertEqual(
+ set(
+ self.get_manager().select_subclasses(
+ "inheritancemanagertestchild1__inheritancemanagertestgrandchild1"
+ )
+ ),
+ children,
+ )
+
+
+ @skipUnless(django.VERSION >= (1, 6, 0), "test only applies to Django 1.6+")
+ def test_children_and_grandchildren(self):
+ children = set([
+ self.child1,
+ InheritanceManagerTestParent(pk=self.child2.pk),
+ self.grandchild1,
+ InheritanceManagerTestChild1(pk=self.grandchild1_2.pk),
+ ])
+ self.assertEqual(
+ set(
+ self.get_manager().select_subclasses(
+ "inheritancemanagertestchild1",
+ "inheritancemanagertestchild1__inheritancemanagertestgrandchild1"
+ )
+ ),
+ children,
+ )
+
+
+ def test_get_subclass(self):
+ self.assertEqual(
+ self.get_manager().get_subclass(pk=self.child1.pk),
+ self.child1)
+
+
+ def test_get_subclass_on_queryset(self):
+ self.assertEqual(
+ self.get_manager().all().get_subclass(pk=self.child1.pk),
+ self.child1)
+
+
+ def test_prior_select_related(self):
+ with self.assertNumQueries(1):
+ obj = self.get_manager().select_related(
+ "inheritancemanagertestchild1").select_subclasses(
+ "inheritancemanagertestchild2").get(pk=self.child1.pk)
+ obj.inheritancemanagertestchild1
+
+
+ @skipUnless(django.VERSION >= (1, 6, 0), "test only applies to Django 1.6+")
+ def test_version_determining_any_depth(self):
+ self.assertIsNone(self.get_manager().all()._get_maximum_depth())
+
+
+ @skipUnless(django.VERSION < (1, 6, 0), "test only applies to Django < 1.6")
+ def test_version_determining_only_child_depth(self):
+ self.assertEqual(1, self.get_manager().all()._get_maximum_depth())
+
+
+ @skipUnless(django.VERSION < (1, 6, 0), "test only applies to Django < 1.6")
+ def test_manually_specifying_parent_fk_only_children(self):
+ """
+ given a Model which inherits from another Model, but also declares
+ the OneToOne link manually using `related_name` and `parent_link`,
+ ensure that the relation names and subclasses are obtained correctly.
+ """
+ child3 = InheritanceManagerTestChild3.objects.create()
+ results = InheritanceManagerTestParent.objects.all().select_subclasses()
+
+ expected_objs = [self.child1, self.child2,
+ InheritanceManagerTestChild1(pk=self.grandchild1.pk),
+ InheritanceManagerTestChild1(pk=self.grandchild1_2.pk),
+ child3]
+ self.assertEqual(list(results), expected_objs)
+
+ expected_related_names = [
+ 'inheritancemanagertestchild1',
+ 'inheritancemanagertestchild2',
+ 'manual_onetoone', # this was set via parent_link & related_name
+ ]
+ self.assertEqual(set(results.subclasses),
+ set(expected_related_names))
+
+
+ @skipUnless(django.VERSION >= (1, 6, 0), "test only applies to Django 1.6+")
+ def test_manually_specifying_parent_fk_including_grandchildren(self):
+ """
+ given a Model which inherits from another Model, but also declares
+ the OneToOne link manually using `related_name` and `parent_link`,
+ ensure that the relation names and subclasses are obtained correctly.
+ """
+ child3 = InheritanceManagerTestChild3.objects.create()
+ results = InheritanceManagerTestParent.objects.all().select_subclasses()
+
+ expected_objs = [self.child1, self.child2, self.grandchild1,
+ self.grandchild1_2, child3]
+ self.assertEqual(list(results), expected_objs)
+
+ expected_related_names = [
+ 'inheritancemanagertestchild1__inheritancemanagertestgrandchild1',
+ 'inheritancemanagertestchild1__inheritancemanagertestgrandchild1_2',
+ 'inheritancemanagertestchild1',
+ 'inheritancemanagertestchild2',
+ 'manual_onetoone', # this was set via parent_link & related_name
+ ]
+ self.assertEqual(set(results.subclasses),
+ set(expected_related_names))
+
+
+ def test_manually_specifying_parent_fk_single_subclass(self):
+ """
+ Using a string related_name when the relation is manually defined
+ instead of implicit should still work in the same way.
+ """
+ related_name = 'manual_onetoone'
+ child3 = InheritanceManagerTestChild3.objects.create()
+ results = InheritanceManagerTestParent.objects.all().select_subclasses(related_name)
+
+ expected_objs = [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 = [related_name]
+ self.assertEqual(set(results.subclasses),
+ set(expected_related_names))
+
+
+ def test_filter_on_values_queryset(self):
+ queryset = InheritanceManagerTestChild1.objects.values('id').filter(pk=self.child1.pk)
+ self.assertEqual(list(queryset), [{'id': self.child1.pk}])
+
+
+ @skipUnless(django.VERSION >= (1, 9, 0), "test only applies to Django 1.9+")
+ def test_dj19_values_list_on_select_subclasses(self):
+ """
+ Using `select_subclasses` in conjunction with `values_list()` raised an
+ exception in `_get_sub_obj_recurse()` because the result of `values_list()`
+ is either a `tuple` or primitive objects if `flat=True` is specified,
+ because no type checking was done prior to fetching child nodes.
+
+ Django versions below 1.9 are not affected by this bug.
+ """
+
+ # Querysets are cast to lists to force immediate evaluation.
+ # No exceptions must be thrown.
+
+ # No argument to select_subclasses
+ objs_1 = list(
+ self.get_manager().
+ select_subclasses().
+ values_list('id')
+ )
+
+ # String argument to select_subclasses
+ objs_2 = list(
+ self.get_manager().
+ select_subclasses(
+ "inheritancemanagertestchild2"
+ ).
+ values_list('id')
+ )
+
+ # String argument to select_subclasses
+ objs_3 = list(
+ self.get_manager().
+ select_subclasses(
+ InheritanceManagerTestChild2
+ ).
+ values_list('id')
+ )
+
+ assert all((
+ isinstance(objs_1, list),
+ isinstance(objs_2, list),
+ isinstance(objs_3, list),
+ ))
+
+ assert objs_1 == objs_2 == objs_3
+
+
+class InheritanceManagerUsingModelsTests(TestCase):
+
+ def setUp(self):
+ self.parent1 = InheritanceManagerTestParent.objects.create()
+ self.child1 = InheritanceManagerTestChild1.objects.create()
+ self.child2 = InheritanceManagerTestChild2.objects.create()
+ self.grandchild1 = InheritanceManagerTestGrandChild1.objects.create()
+ self.grandchild1_2 = InheritanceManagerTestGrandChild1_2.objects.create()
+
+
+ def test_select_subclass_by_child_model(self):
+ """
+ Confirm that passing a child model works the same as passing the
+ select_related manually
+ """
+ objs = InheritanceManagerTestParent.objects.select_subclasses(
+ "inheritancemanagertestchild1").order_by('pk')
+ objsmodels = InheritanceManagerTestParent.objects.select_subclasses(
+ InheritanceManagerTestChild1).order_by('pk')
+ self.assertEqual(objs.subclasses, objsmodels.subclasses)
+ self.assertEqual(list(objs), list(objsmodels))
+
+
+ @skipUnless(django.VERSION >= (1, 6, 0), "test only applies to Django 1.6+")
+ def test_select_subclass_by_grandchild_model(self):
+ """
+ Confirm that passing a grandchild model works the same as passing the
+ select_related manually
+ """
+ objs = InheritanceManagerTestParent.objects.select_subclasses(
+ "inheritancemanagertestchild1__inheritancemanagertestgrandchild1")\
+ .order_by('pk')
+ objsmodels = InheritanceManagerTestParent.objects.select_subclasses(
+ InheritanceManagerTestGrandChild1).order_by('pk')
+ self.assertEqual(objs.subclasses, objsmodels.subclasses)
+ self.assertEqual(list(objs), list(objsmodels))
+
+
+ @skipUnless(django.VERSION >= (1, 6, 0), "test only applies to Django 1.6+")
+ def test_selecting_all_subclasses_specifically_grandchildren(self):
+ """
+ A bare select_subclasses() should achieve the same results as doing
+ select_subclasses and specifying all possible subclasses.
+ This test checks grandchildren, so only works on 1.6>=
+ """
+ objs = InheritanceManagerTestParent.objects.select_subclasses().order_by('pk')
+ objsmodels = InheritanceManagerTestParent.objects.select_subclasses(
+ InheritanceManagerTestChild1, InheritanceManagerTestChild2,
+ InheritanceManagerTestChild3,
+ InheritanceManagerTestGrandChild1,
+ InheritanceManagerTestGrandChild1_2).order_by('pk')
+ self.assertEqual(set(objs.subclasses), set(objsmodels.subclasses))
+ self.assertEqual(list(objs), list(objsmodels))
+
+
+ def test_selecting_all_subclasses_specifically_children(self):
+ """
+ A bare select_subclasses() should achieve the same results as doing
+ select_subclasses and specifying all possible subclasses.
+
+ Note: This is sort of the same test as
+ `test_selecting_all_subclasses_specifically_grandchildren` but it
+ specifically switches what models are used because that happens
+ behind the scenes in a bare select_subclasses(), so we need to
+ emulate it.
+ """
+ objs = InheritanceManagerTestParent.objects.select_subclasses().order_by('pk')
+
+ if django.VERSION >= (1, 6, 0):
+ models = (InheritanceManagerTestChild1,
+ InheritanceManagerTestChild2,
+ InheritanceManagerTestChild3,
+ InheritanceManagerTestGrandChild1,
+ InheritanceManagerTestGrandChild1_2)
+ else:
+ models = (InheritanceManagerTestChild1,
+ InheritanceManagerTestChild2,
+ InheritanceManagerTestChild3)
+
+ objsmodels = InheritanceManagerTestParent.objects.select_subclasses(
+ *models).order_by('pk')
+ # order shouldn't matter, I don't think, as long as the resulting
+ # queryset (when cast to a list) is the same.
+ self.assertEqual(set(objs.subclasses), set(objsmodels.subclasses))
+ self.assertEqual(list(objs), list(objsmodels))
+
+
+ def test_select_subclass_just_self(self):
+ """
+ Passing in the same model as the manager/queryset is bound against
+ (ie: the root parent) should have no effect on the result set.
+ """
+ objsmodels = InheritanceManagerTestParent.objects.select_subclasses(
+ InheritanceManagerTestParent).order_by('pk')
+ self.assertEqual([], objsmodels.subclasses)
+ self.assertEqual(list(objsmodels), [
+ 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),
+ ])
+
+
+ def test_select_subclass_invalid_related_model(self):
+ """
+ Confirming that giving a stupid model doesn't work.
+ """
+ regex = '^.+? is not a subclass of .+$'
+ with self.assertRaisesRegexp(ValueError, regex):
+ InheritanceManagerTestParent.objects.select_subclasses(
+ TimeFrame).order_by('pk')
+
+
+
+ @skipUnless(django.VERSION >= (1, 6, 0), "test only applies to Django 1.6+")
+ def test_mixing_strings_and_classes_with_grandchildren(self):
+ """
+ Given arguments consisting of both strings and model classes,
+ ensure the right resolutions take place, accounting for the extra
+ depth (grandchildren etc) 1.6> allows.
+ """
+ objs = InheritanceManagerTestParent.objects.select_subclasses(
+ "inheritancemanagertestchild2",
+ InheritanceManagerTestGrandChild1_2).order_by('pk')
+ expecting = ['inheritancemanagertestchild1__inheritancemanagertestgrandchild1_2',
+ 'inheritancemanagertestchild2']
+ self.assertEqual(set(objs.subclasses), set(expecting))
+ expecting2 = [
+ InheritanceManagerTestParent(pk=self.parent1.pk),
+ InheritanceManagerTestParent(pk=self.child1.pk),
+ InheritanceManagerTestChild2(pk=self.child2.pk),
+ InheritanceManagerTestParent(pk=self.grandchild1.pk),
+ InheritanceManagerTestGrandChild1_2(pk=self.grandchild1_2.pk),
+ ]
+ self.assertEqual(list(objs), expecting2)
+
+
+ def test_mixing_strings_and_classes_with_children(self):
+ """
+ Given arguments consisting of both strings and model classes,
+ ensure the right resolutions take place, walking down as far as
+ children.
+ """
+ objs = InheritanceManagerTestParent.objects.select_subclasses(
+ "inheritancemanagertestchild2",
+ InheritanceManagerTestChild1).order_by('pk')
+ expecting = ['inheritancemanagertestchild1',
+ 'inheritancemanagertestchild2']
+
+ self.assertEqual(set(objs.subclasses), set(expecting))
+ expecting2 = [
+ InheritanceManagerTestParent(pk=self.parent1.pk),
+ InheritanceManagerTestChild1(pk=self.child1.pk),
+ InheritanceManagerTestChild2(pk=self.child2.pk),
+ InheritanceManagerTestChild1(pk=self.grandchild1.pk),
+ InheritanceManagerTestChild1(pk=self.grandchild1_2.pk),
+ ]
+ self.assertEqual(list(objs), expecting2)
+
+
+ def test_duplications(self):
+ """
+ Check that even if the same thing is provided as a string and a model
+ that the right results are retrieved.
+ """
+ # mixing strings and models which evaluate to the same thing is fine.
+ objs = InheritanceManagerTestParent.objects.select_subclasses(
+ "inheritancemanagertestchild2",
+ InheritanceManagerTestChild2).order_by('pk')
+ self.assertEqual(list(objs), [
+ InheritanceManagerTestParent(pk=self.parent1.pk),
+ InheritanceManagerTestParent(pk=self.child1.pk),
+ InheritanceManagerTestChild2(pk=self.child2.pk),
+ InheritanceManagerTestParent(pk=self.grandchild1.pk),
+ InheritanceManagerTestParent(pk=self.grandchild1_2.pk),
+ ])
+
+
+ @skipUnless(django.VERSION >= (1, 6, 0), "test only applies to Django 1.6+")
+ def test_child_doesnt_accidentally_get_parent(self):
+ """
+ Given a Child model which also has an InheritanceManager,
+ none of the returned objects should be Parent objects.
+ """
+ objs = InheritanceManagerTestChild1.objects.select_subclasses(
+ InheritanceManagerTestGrandChild1).order_by('pk')
+ self.assertEqual([
+ InheritanceManagerTestChild1(pk=self.child1.pk),
+ InheritanceManagerTestGrandChild1(pk=self.grandchild1.pk),
+ InheritanceManagerTestChild1(pk=self.grandchild1_2.pk),
+ ], list(objs))
+
+
+ def test_manually_specifying_parent_fk_only_specific_child(self):
+ """
+ given a Model which inherits from another Model, but also declares
+ the OneToOne link manually using `related_name` and `parent_link`,
+ ensure that the relation names and subclasses are obtained correctly.
+ """
+ child3 = InheritanceManagerTestChild3.objects.create()
+ results = InheritanceManagerTestParent.objects.all().select_subclasses(
+ InheritanceManagerTestChild3)
+
+ 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']
+ self.assertEqual(set(results.subclasses),
+ set(expected_related_names))
+
+ def test_extras_descend(self):
+ """
+ Ensure that extra(select=) values are copied onto sub-classes.
+ """
+ results = InheritanceManagerTestParent.objects.select_subclasses().extra(
+ select={'foo': 'id + 1'}
+ )
+ self.assertTrue(all(result.foo == (result.id + 1) for result in results))
+
+
+class InheritanceManagerRelatedTests(InheritanceManagerTests):
+ def setUp(self):
+ self.related = InheritanceManagerTestRelated.objects.create()
+ self.child1 = InheritanceManagerTestChild1.objects.create(
+ related=self.related)
+ self.child2 = InheritanceManagerTestChild2.objects.create(
+ related=self.related)
+ self.grandchild1 = InheritanceManagerTestGrandChild1.objects.create(related=self.related)
+ self.grandchild1_2 = InheritanceManagerTestGrandChild1_2.objects.create(related=self.related)
+
+
+ def get_manager(self):
+ return self.related.imtests
+
+
+ def test_get_method_with_select_subclasses(self):
+ self.assertEqual(
+ InheritanceManagerTestParent.objects.select_subclasses().get(
+ id=self.child1.id),
+ self.child1)
+
+
+ def test_annotate_with_select_subclasses(self):
+ qs = InheritanceManagerTestParent.objects.select_subclasses().annotate(
+ models.Count('id'))
+ self.assertEqual(qs.get(id=self.child1.id).id__count, 1)
+
+
+ def test_annotate_with_named_arguments_with_select_subclasses(self):
+ qs = InheritanceManagerTestParent.objects.select_subclasses().annotate(
+ test_count=models.Count('id'))
+ self.assertEqual(qs.get(id=self.child1.id).test_count, 1)
+
+
+ def test_annotate_before_select_subclasses(self):
+ qs = InheritanceManagerTestParent.objects.annotate(
+ models.Count('id')).select_subclasses()
+ self.assertEqual(qs.get(id=self.child1.id).id__count, 1)
+
+
+ def test_annotate_with_named_arguments_before_select_subclasses(self):
+ qs = InheritanceManagerTestParent.objects.annotate(
+ test_count=models.Count('id')).select_subclasses()
+ self.assertEqual(qs.get(id=self.child1.id).test_count, 1)
+
+
+
+class TimeStampedModelTests(TestCase):
+ def test_created(self):
+ t1 = TimeStamp.objects.create()
+ t2 = TimeStamp.objects.create()
+ self.assertTrue(t2.created > t1.created)
+
+
+ def test_modified(self):
+ t1 = TimeStamp.objects.create()
+ t2 = TimeStamp.objects.create()
+ t1.save()
+ self.assertTrue(t2.modified < t1.modified)
+
+
+
+class TimeFramedModelTests(TestCase):
+ def setUp(self):
+ self.now = datetime.now()
+
+
+ def test_not_yet_begun(self):
+ TimeFrame.objects.create(start=self.now+timedelta(days=2))
+ self.assertEqual(TimeFrame.timeframed.count(), 0)
+
+
+ def test_finished(self):
+ TimeFrame.objects.create(end=self.now-timedelta(days=1))
+ self.assertEqual(TimeFrame.timeframed.count(), 0)
+
+
+ def test_no_end(self):
+ TimeFrame.objects.create(start=self.now-timedelta(days=10))
+ self.assertEqual(TimeFrame.timeframed.count(), 1)
+
+
+ def test_no_start(self):
+ TimeFrame.objects.create(end=self.now+timedelta(days=2))
+ self.assertEqual(TimeFrame.timeframed.count(), 1)
+
+
+ def test_within_range(self):
+ TimeFrame.objects.create(start=self.now-timedelta(days=1),
+ end=self.now+timedelta(days=1))
+ self.assertEqual(TimeFrame.timeframed.count(), 1)
+
+
+
+class TimeFrameManagerAddedTests(TestCase):
+ def test_manager_available(self):
+ self.assertTrue(isinstance(TimeFrameManagerAdded.timeframed, QueryManager))
+
+
+ def test_conflict_error(self):
+ with self.assertRaises(ImproperlyConfigured):
+ class ErrorModel(TimeFramedModel):
+ timeframed = models.BooleanField()
+
+
+
+class StatusModelTests(TestCase):
+ def setUp(self):
+ self.model = Status
+ self.on_hold = Status.STATUS.on_hold
+ self.active = Status.STATUS.active
+
+
+ def test_created(self):
+ c1 = self.model.objects.create()
+ c2 = self.model.objects.create()
+ self.assertTrue(c2.status_changed > c1.status_changed)
+ self.assertEqual(self.model.active.count(), 2)
+ self.assertEqual(self.model.deleted.count(), 0)
+
+
+ def test_modification(self):
+ t1 = self.model.objects.create()
+ date_created = t1.status_changed
+ t1.status = self.on_hold
+ t1.save()
+ self.assertEqual(self.model.active.count(), 0)
+ self.assertEqual(self.model.on_hold.count(), 1)
+ self.assertTrue(t1.status_changed > date_created)
+ date_changed = t1.status_changed
+ t1.save()
+ self.assertEqual(t1.status_changed, date_changed)
+ date_active_again = t1.status_changed
+ t1.status = self.active
+ t1.save()
+ self.assertTrue(t1.status_changed > date_active_again)
+
+
+
+class StatusModelPlainTupleTests(StatusModelTests):
+ def setUp(self):
+ self.model = StatusPlainTuple
+ self.on_hold = StatusPlainTuple.STATUS[2][0]
+ self.active = StatusPlainTuple.STATUS[0][0]
+
+
+
+class StatusManagerAddedTests(TestCase):
+ def test_manager_available(self):
+ self.assertTrue(isinstance(StatusManagerAdded.active, QueryManager))
+
+
+ def test_conflict_error(self):
+ with self.assertRaises(ImproperlyConfigured):
+ class ErrorModel(StatusModel):
+ STATUS = (
+ ('active', 'Is Active'),
+ ('deleted', 'Is Deleted'),
+ )
+ active = models.BooleanField()
+
+
+
+class QueryManagerTests(TestCase):
+ def setUp(self):
+ data = ((True, True, 0),
+ (True, False, 4),
+ (False, False, 2),
+ (False, True, 3),
+ (True, True, 1),
+ (True, False, 5))
+ for p, c, o in data:
+ Post.objects.create(published=p, confirmed=c, order=o)
+
+
+ def test_passing_kwargs(self):
+ qs = Post.public.all()
+ self.assertEqual([p.order for p in qs], [0, 1, 4, 5])
+
+
+ def test_passing_Q(self):
+ qs = Post.public_confirmed.all()
+ self.assertEqual([p.order for p in qs], [0, 1])
+
+
+ def test_ordering(self):
+ qs = Post.public_reversed.all()
+ self.assertEqual([p.order for p in qs], [5, 4, 1, 0])
+
+
+
+try:
+ from south.modelsinspector import introspector
+except ImportError:
+ introspector = None
+
+@skipUnless(introspector, 'South is not installed')
+class SouthFreezingTests(TestCase):
+ def test_introspector_adds_no_excerpt_field(self):
+ mf = Article._meta.get_field('body')
+ args, kwargs = introspector(mf)
+ self.assertEqual(kwargs['no_excerpt_field'], 'True')
+
+
+ def test_no_excerpt_field_works(self):
+ from .models import NoRendered
+ with self.assertRaises(FieldDoesNotExist):
+ NoRendered._meta.get_field('_body_excerpt')
+
+ def test_status_field_no_check_for_status(self):
+ sf = StatusFieldDefaultFilled._meta.get_field('status')
+ args, kwargs = introspector(sf)
+ self.assertEqual(kwargs['no_check_for_status'], 'True')
+
+
+class FieldTrackerTestCase(TestCase):
+
+ tracker = None
+
+ def assertHasChanged(self, **kwargs):
+ tracker = kwargs.pop('tracker', self.tracker)
+ for field, value in kwargs.items():
+ if value is None:
+ with self.assertRaises(FieldError):
+ tracker.has_changed(field)
+ else:
+ self.assertEqual(tracker.has_changed(field), value)
+
+ def assertPrevious(self, **kwargs):
+ tracker = kwargs.pop('tracker', self.tracker)
+ for field, value in kwargs.items():
+ self.assertEqual(tracker.previous(field), value)
+
+ def assertChanged(self, **kwargs):
+ tracker = kwargs.pop('tracker', self.tracker)
+ self.assertEqual(tracker.changed(), kwargs)
+
+ def assertCurrent(self, **kwargs):
+ tracker = kwargs.pop('tracker', self.tracker)
+ self.assertEqual(tracker.current(), kwargs)
+
+ def update_instance(self, **kwargs):
+ for field, value in kwargs.items():
+ setattr(self.instance, field, value)
+ self.instance.save()
+
+
+class FieldTrackerCommonTests(object):
+
+ def test_pre_save_previous(self):
+ self.assertPrevious(name=None, number=None)
+ self.instance.name = 'new age'
+ self.instance.number = 8
+ self.assertPrevious(name=None, number=None)
+
+
+class FieldTrackerTests(FieldTrackerTestCase, FieldTrackerCommonTests):
+
+ tracked_class = Tracked
+
+ def setUp(self):
+ self.instance = self.tracked_class()
+ self.tracker = self.instance.tracker
+
+ def test_descriptor(self):
+ self.assertTrue(isinstance(self.tracked_class.tracker, FieldTracker))
+
+ def test_pre_save_changed(self):
+ self.assertChanged(name=None)
+ self.instance.name = 'new age'
+ self.assertChanged(name=None)
+ self.instance.number = 8
+ self.assertChanged(name=None, number=None)
+ self.instance.name = ''
+ self.assertChanged(name=None, number=None)
+ self.instance.mutable = [1,2,3]
+ self.assertChanged(name=None, number=None, mutable=None)
+
+ def test_pre_save_has_changed(self):
+ self.assertHasChanged(name=True, number=False, mutable=False)
+ self.instance.name = 'new age'
+ 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.assertHasChanged(name=True, number=True, mutable=True)
+
+ def test_first_save(self):
+ self.assertHasChanged(name=True, number=False, mutable=False)
+ self.assertPrevious(name=None, number=None, mutable=None)
+ self.assertCurrent(name='', number=None, id=None, mutable=None)
+ self.assertChanged(name=None)
+ self.instance.name = 'retro'
+ self.instance.number = 4
+ 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.assertChanged(name=None, number=None, mutable=None)
+ # Django 1.4 doesn't have update_fields
+ if django.VERSION >= (1, 5, 0):
+ 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.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.assertHasChanged(name=False, number=False, mutable=False)
+ self.instance.name = 'new age'
+ self.assertHasChanged(name=True, number=False)
+ self.instance.number = 8
+ self.assertHasChanged(name=True, number=True)
+ self.instance.mutable[1] = 4
+ self.assertHasChanged(name=True, number=True, mutable=True)
+ self.instance.name = 'retro'
+ 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.instance.name = 'new age'
+ 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])
+
+ def test_post_save_changed(self):
+ self.update_instance(name='retro', number=4, mutable=[1,2,3])
+ self.assertChanged()
+ self.instance.name = 'new age'
+ self.assertChanged(name='retro')
+ self.instance.number = 8
+ self.assertChanged(name='retro', number=4)
+ self.instance.name = 'retro'
+ self.assertChanged(number=4)
+ self.instance.mutable[1] = 4
+ self.assertChanged(number=4, mutable=[1,2,3])
+ self.instance.mutable = [1,2,3]
+ self.assertChanged(number=4)
+
+ def test_current(self):
+ self.assertCurrent(id=None, name='', number=None, mutable=None)
+ self.instance.name = 'new age'
+ 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] = 4
+ 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])
+
+ @skipUnless(
+ django.VERSION >= (1, 5, 0), "Django 1.4 doesn't have update_fields")
+ def test_update_fields(self):
+ 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.save(update_fields=[])
+ 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.instance.save(update_fields=['number'])
+ 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)
+ self.assertEqual(in_db.name, self.instance.name)
+ self.assertEqual(in_db.number, self.instance.number)
+ self.assertEqual(in_db.mutable, self.instance.mutable)
+
+ def test_with_deferred(self):
+ self.instance.name = 'new age'
+ self.instance.number = 1
+ self.instance.save()
+ item = list(self.tracked_class.objects.only('name').all())[0]
+ self.assertTrue(item._deferred_fields)
+
+ self.assertEqual(item.tracker.previous('number'), None)
+ self.assertTrue('number' in item._deferred_fields)
+
+ 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'))
+
+ item.number = 2
+ self.assertTrue(item.tracker.has_changed('number'))
+
+
+class FieldTrackerMultipleInstancesTests(TestCase):
+
+ def test_with_deferred_fields_access_multiple(self):
+ Tracked.objects.create(pk=1, name='foo', number=1)
+ Tracked.objects.create(pk=2, name='bar', number=2)
+
+ queryset = Tracked.objects.only('id')
+
+ for instance in queryset:
+ instance.name
+
+
+class FieldTrackedModelCustomTests(FieldTrackerTestCase,
+ FieldTrackerCommonTests):
+
+ tracked_class = TrackedNotDefault
+
+ def setUp(self):
+ self.instance = self.tracked_class()
+ self.tracker = self.instance.name_tracker
+
+ def test_pre_save_changed(self):
+ self.assertChanged(name=None)
+ self.instance.name = 'new age'
+ self.assertChanged(name=None)
+ self.instance.number = 8
+ self.assertChanged(name=None)
+ self.instance.name = ''
+ self.assertChanged(name=None)
+
+ def test_first_save(self):
+ self.assertHasChanged(name=True, number=None)
+ self.assertPrevious(name=None, number=None)
+ self.assertCurrent(name='')
+ self.assertChanged(name=None)
+ self.instance.name = 'retro'
+ self.instance.number = 4
+ self.assertHasChanged(name=True, number=None)
+ self.assertPrevious(name=None, number=None)
+ self.assertCurrent(name='retro')
+ self.assertChanged(name=None)
+
+ def test_pre_save_has_changed(self):
+ self.assertHasChanged(name=True, number=None)
+ self.instance.name = 'new age'
+ self.assertHasChanged(name=True, number=None)
+ self.instance.number = 7
+ self.assertHasChanged(name=True, number=None)
+
+ def test_post_save_has_changed(self):
+ self.update_instance(name='retro', number=4)
+ self.assertHasChanged(name=False, number=None)
+ self.instance.name = 'new age'
+ self.assertHasChanged(name=True, number=None)
+ self.instance.number = 8
+ self.assertHasChanged(name=True, number=None)
+ self.instance.name = 'retro'
+ self.assertHasChanged(name=False, number=None)
+
+ def test_post_save_previous(self):
+ self.update_instance(name='retro', number=4)
+ self.instance.name = 'new age'
+ self.assertPrevious(name='retro', number=None)
+
+ def test_post_save_changed(self):
+ self.update_instance(name='retro', number=4)
+ self.assertChanged()
+ self.instance.name = 'new age'
+ self.assertChanged(name='retro')
+ self.instance.number = 8
+ self.assertChanged(name='retro')
+ self.instance.name = 'retro'
+ self.assertChanged()
+
+ def test_current(self):
+ self.assertCurrent(name='')
+ self.instance.name = 'new age'
+ self.assertCurrent(name='new age')
+ self.instance.number = 8
+ self.assertCurrent(name='new age')
+ self.instance.save()
+ self.assertCurrent(name='new age')
+
+ @skipUnless(
+ django.VERSION >= (1, 5, 0), "Django 1.4 doesn't have update_fields")
+ def test_update_fields(self):
+ self.update_instance(name='retro', number=4)
+ self.assertChanged()
+ self.instance.name = 'new age'
+ self.instance.number = 8
+ self.instance.save(update_fields=['name', 'number'])
+ self.assertChanged()
+
+
+class FieldTrackedModelAttributeTests(FieldTrackerTestCase):
+
+ tracked_class = TrackedNonFieldAttr
+
+ def setUp(self):
+ self.instance = self.tracked_class()
+ self.tracker = self.instance.tracker
+
+ def test_previous(self):
+ self.assertPrevious(rounded=None)
+ self.instance.number = 7.5
+ self.assertPrevious(rounded=None)
+ self.instance.save()
+ self.assertPrevious(rounded=8)
+ self.instance.number = 7.2
+ self.assertPrevious(rounded=8)
+ self.instance.save()
+ self.assertPrevious(rounded=7)
+
+ def test_has_changed(self):
+ self.assertHasChanged(rounded=False)
+ self.instance.number = 7.5
+ self.assertHasChanged(rounded=True)
+ self.instance.save()
+ self.assertHasChanged(rounded=False)
+ self.instance.number = 7.2
+ self.assertHasChanged(rounded=True)
+ self.instance.number = 7.8
+ self.assertHasChanged(rounded=False)
+
+ def test_changed(self):
+ self.assertChanged()
+ self.instance.number = 7.5
+ self.assertPrevious(rounded=None)
+ self.instance.save()
+ self.assertPrevious()
+ self.instance.number = 7.8
+ self.assertPrevious()
+ self.instance.number = 7.2
+ self.assertPrevious(rounded=8)
+ self.instance.save()
+ self.assertPrevious()
+
+ def test_current(self):
+ self.assertCurrent(rounded=None)
+ self.instance.number = 7.5
+ self.assertCurrent(rounded=8)
+ self.instance.save()
+ self.assertCurrent(rounded=8)
+
+
+class FieldTrackedModelMultiTests(FieldTrackerTestCase,
+ FieldTrackerCommonTests):
+
+ tracked_class = TrackedMultiple
+
+ def setUp(self):
+ self.instance = self.tracked_class()
+ self.trackers = [self.instance.name_tracker,
+ self.instance.number_tracker]
+
+ def test_pre_save_changed(self):
+ self.tracker = self.instance.name_tracker
+ self.assertChanged(name=None)
+ self.instance.name = 'new age'
+ self.assertChanged(name=None)
+ self.instance.number = 8
+ self.assertChanged(name=None)
+ self.instance.name = ''
+ self.assertChanged(name=None)
+ self.tracker = self.instance.number_tracker
+ self.assertChanged(number=None)
+ self.instance.name = 'new age'
+ self.assertChanged(number=None)
+ self.instance.number = 8
+ self.assertChanged(number=None)
+
+ def test_pre_save_has_changed(self):
+ self.tracker = self.instance.name_tracker
+ self.assertHasChanged(name=True, number=None)
+ self.instance.name = 'new age'
+ self.assertHasChanged(name=True, number=None)
+ self.tracker = self.instance.number_tracker
+ self.assertHasChanged(name=None, number=False)
+ self.instance.name = 'new age'
+ self.assertHasChanged(name=None, number=False)
+
+ def test_pre_save_previous(self):
+ for tracker in self.trackers:
+ self.tracker = tracker
+ super(FieldTrackedModelMultiTests, self).test_pre_save_previous()
+
+ def test_post_save_has_changed(self):
+ self.update_instance(name='retro', number=4)
+ self.assertHasChanged(tracker=self.trackers[0], name=False, number=None)
+ self.assertHasChanged(tracker=self.trackers[1], name=None, number=False)
+ self.instance.name = 'new age'
+ self.assertHasChanged(tracker=self.trackers[0], name=True, number=None)
+ self.assertHasChanged(tracker=self.trackers[1], name=None, number=False)
+ self.instance.number = 8
+ self.assertHasChanged(tracker=self.trackers[0], name=True, number=None)
+ self.assertHasChanged(tracker=self.trackers[1], name=None, number=True)
+ self.instance.name = 'retro'
+ self.instance.number = 4
+ self.assertHasChanged(tracker=self.trackers[0], name=False, number=None)
+ self.assertHasChanged(tracker=self.trackers[1], name=None, number=False)
+
+ def test_post_save_previous(self):
+ self.update_instance(name='retro', number=4)
+ self.instance.name = 'new age'
+ self.instance.number = 8
+ self.assertPrevious(tracker=self.trackers[0], name='retro', number=None)
+ self.assertPrevious(tracker=self.trackers[1], name=None, number=4)
+
+ def test_post_save_changed(self):
+ self.update_instance(name='retro', number=4)
+ self.assertChanged(tracker=self.trackers[0])
+ self.assertChanged(tracker=self.trackers[1])
+ self.instance.name = 'new age'
+ self.assertChanged(tracker=self.trackers[0], name='retro')
+ self.assertChanged(tracker=self.trackers[1])
+ self.instance.number = 8
+ self.assertChanged(tracker=self.trackers[0], name='retro')
+ self.assertChanged(tracker=self.trackers[1], number=4)
+ self.instance.name = 'retro'
+ self.instance.number = 4
+ self.assertChanged(tracker=self.trackers[0])
+ self.assertChanged(tracker=self.trackers[1])
+
+ def test_current(self):
+ self.assertCurrent(tracker=self.trackers[0], name='')
+ self.assertCurrent(tracker=self.trackers[1], number=None)
+ self.instance.name = 'new age'
+ self.assertCurrent(tracker=self.trackers[0], name='new age')
+ self.assertCurrent(tracker=self.trackers[1], number=None)
+ self.instance.number = 8
+ self.assertCurrent(tracker=self.trackers[0], name='new age')
+ self.assertCurrent(tracker=self.trackers[1], number=8)
+ self.instance.save()
+ self.assertCurrent(tracker=self.trackers[0], name='new age')
+ self.assertCurrent(tracker=self.trackers[1], number=8)
+
+
+class FieldTrackerForeignKeyTests(FieldTrackerTestCase):
+
+ fk_class = Tracked
+ tracked_class = TrackedFK
+
+ def setUp(self):
+ self.old_fk = self.fk_class.objects.create(number=8)
+ self.instance = self.tracked_class.objects.create(fk=self.old_fk)
+
+ def test_default(self):
+ self.tracker = self.instance.tracker
+ self.assertChanged()
+ self.assertPrevious()
+ self.assertCurrent(id=self.instance.id, fk_id=self.old_fk.id)
+ self.instance.fk = self.fk_class.objects.create(number=8)
+ self.assertChanged(fk_id=self.old_fk.id)
+ self.assertPrevious(fk_id=self.old_fk.id)
+ self.assertCurrent(id=self.instance.id, fk_id=self.instance.fk_id)
+
+ def test_custom(self):
+ self.tracker = self.instance.custom_tracker
+ self.assertChanged()
+ self.assertPrevious()
+ self.assertCurrent(fk_id=self.old_fk.id)
+ self.instance.fk = self.fk_class.objects.create(number=8)
+ self.assertChanged(fk_id=self.old_fk.id)
+ self.assertPrevious(fk_id=self.old_fk.id)
+ self.assertCurrent(fk_id=self.instance.fk_id)
+
+ def test_custom_without_id(self):
+ with self.assertNumQueries(1):
+ self.tracked_class.objects.get()
+ self.tracker = self.instance.custom_tracker_without_id
+ self.assertChanged()
+ self.assertPrevious()
+ self.assertCurrent(fk=self.old_fk.id)
+ self.instance.fk = self.fk_class.objects.create(number=8)
+ self.assertChanged(fk=self.old_fk.id)
+ self.assertPrevious(fk=self.old_fk.id)
+ self.assertCurrent(fk=self.instance.fk_id)
+
+
+class InheritedFieldTrackerTests(FieldTrackerTests):
+
+ tracked_class = InheritedTracked
+
+ def test_child_fields_not_tracked(self):
+ self.name2 = 'test'
+ self.assertEqual(self.tracker.previous('name2'), None)
+ self.assertRaises(FieldError, self.tracker.has_changed, 'name2')
+
+
+class FieldTrackerInheritedForeignKeyTests(FieldTrackerForeignKeyTests):
+
+ tracked_class = InheritedTrackedFK
+
+
+class ModelTrackerTests(FieldTrackerTests):
+
+ tracked_class = ModelTracked
+
+ def test_pre_save_changed(self):
+ self.assertChanged()
+ self.instance.name = 'new age'
+ self.assertChanged()
+ self.instance.number = 8
+ self.assertChanged()
+ self.instance.name = ''
+ self.assertChanged()
+ self.instance.mutable = [1,2,3]
+ self.assertChanged()
+
+ def test_first_save(self):
+ self.assertHasChanged(name=True, number=True, mutable=True)
+ self.assertPrevious(name=None, number=None, mutable=None)
+ self.assertCurrent(name='', number=None, id=None, mutable=None)
+ self.assertChanged()
+ self.instance.name = 'retro'
+ self.instance.number = 4
+ 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.assertChanged()
+ # Django 1.4 doesn't have update_fields
+ if django.VERSION >= (1, 5, 0):
+ 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.assertChanged()
+ with self.assertRaises(ValueError):
+ self.instance.save(update_fields=['number'])
+
+ def test_pre_save_has_changed(self):
+ self.assertHasChanged(name=True, number=True)
+ self.instance.name = 'new age'
+ self.assertHasChanged(name=True, number=True)
+ self.instance.number = 7
+ self.assertHasChanged(name=True, number=True)
+
+
+class ModelTrackedModelCustomTests(FieldTrackedModelCustomTests):
+
+ tracked_class = ModelTrackedNotDefault
+
+ def test_first_save(self):
+ self.assertHasChanged(name=True, number=True)
+ self.assertPrevious(name=None, number=None)
+ self.assertCurrent(name='')
+ self.assertChanged()
+ self.instance.name = 'retro'
+ self.instance.number = 4
+ self.assertHasChanged(name=True, number=True)
+ self.assertPrevious(name=None, number=None)
+ self.assertCurrent(name='retro')
+ self.assertChanged()
+
+ def test_pre_save_has_changed(self):
+ self.assertHasChanged(name=True, number=True)
+ self.instance.name = 'new age'
+ self.assertHasChanged(name=True, number=True)
+ self.instance.number = 7
+ self.assertHasChanged(name=True, number=True)
+
+ def test_pre_save_changed(self):
+ self.assertChanged()
+ self.instance.name = 'new age'
+ self.assertChanged()
+ self.instance.number = 8
+ self.assertChanged()
+ self.instance.name = ''
+ self.assertChanged()
+
+
+class ModelTrackedModelMultiTests(FieldTrackedModelMultiTests):
+
+ tracked_class = ModelTrackedMultiple
+
+ def test_pre_save_has_changed(self):
+ self.tracker = self.instance.name_tracker
+ self.assertHasChanged(name=True, number=True)
+ self.instance.name = 'new age'
+ self.assertHasChanged(name=True, number=True)
+ self.tracker = self.instance.number_tracker
+ self.assertHasChanged(name=True, number=True)
+ self.instance.name = 'new age'
+ self.assertHasChanged(name=True, number=True)
+
+ def test_pre_save_changed(self):
+ self.tracker = self.instance.name_tracker
+ self.assertChanged()
+ self.instance.name = 'new age'
+ self.assertChanged()
+ self.instance.number = 8
+ self.assertChanged()
+ self.instance.name = ''
+ self.assertChanged()
+ self.tracker = self.instance.number_tracker
+ self.assertChanged()
+ self.instance.name = 'new age'
+ self.assertChanged()
+ self.instance.number = 8
+ self.assertChanged()
+
+
+class ModelTrackerForeignKeyTests(FieldTrackerForeignKeyTests):
+
+ fk_class = ModelTracked
+ tracked_class = ModelTrackedFK
+
+ def test_custom_without_id(self):
+ with self.assertNumQueries(2):
+ self.tracked_class.objects.get()
+ self.tracker = self.instance.custom_tracker_without_id
+ self.assertChanged()
+ self.assertPrevious()
+ self.assertCurrent(fk=self.old_fk)
+ self.instance.fk = self.fk_class.objects.create(number=8)
+ self.assertNotEqual(self.instance.fk, self.old_fk)
+ self.assertChanged(fk=self.old_fk)
+ self.assertPrevious(fk=self.old_fk)
+ self.assertCurrent(fk=self.instance.fk)
+
+
+class InheritedModelTrackerTests(ModelTrackerTests):
+
+ tracked_class = InheritedModelTracked
+
+ def test_child_fields_not_tracked(self):
+ self.name2 = 'test'
+ self.assertEqual(self.tracker.previous('name2'), None)
+ self.assertTrue(self.tracker.has_changed('name2'))
diff --git a/model_utils/tracker.py b/model_utils/tracker.py
new file mode 100644
index 0000000..6aa7d8a
--- /dev/null
+++ b/model_utils/tracker.py
@@ -0,0 +1,197 @@
+from __future__ import unicode_literals
+
+from copy import deepcopy
+
+import django
+from django.core.exceptions import FieldError
+from django.db import models
+from django.db.models.query_utils import DeferredAttribute
+
+
+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()
+
+ def get_field_value(self, field):
+ return getattr(self.instance, self.field_map[field])
+
+ def set_saved_fields(self, fields=None):
+ if not self.instance.pk:
+ self.saved_data = {}
+ elif fields is None:
+ self.saved_data = self.current()
+ else:
+ self.saved_data.update(**self.current(fields=fields))
+
+ # preventing mutable fields side effects
+ for field, field_value in self.saved_data.items():
+ self.saved_data[field] = deepcopy(field_value)
+
+ def current(self, fields=None):
+ """Returns dict of current values for all tracked fields"""
+ if fields is None:
+ if self.instance._deferred_fields:
+ fields = [
+ field for field in self.fields
+ if field not in self.instance._deferred_fields
+ ]
+ else:
+ fields = self.fields
+
+ return dict((f, self.get_field_value(f)) for f in fields)
+
+ def has_changed(self, field):
+ """Returns ``True`` if field has changed from currently saved value"""
+ if field in self.fields:
+ 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"""
+ return self.saved_data.get(field)
+
+ def changed(self):
+ """Returns dict of fields that changed since save (with old values)"""
+ return dict(
+ (field, self.previous(field))
+ for field in self.fields
+ if self.has_changed(field)
+ )
+
+ def init_deferred_fields(self):
+ self.instance._deferred_fields = set()
+ if hasattr(self.instance, '_deferred') and not self.instance._deferred:
+ return
+
+ class DeferredAttributeTracker(DeferredAttribute):
+ def __get__(field, instance, owner):
+ if instance is None:
+ return field
+ data = instance.__dict__
+ if data.get(field.field_name, field) is field:
+ instance._deferred_fields.remove(field.field_name)
+ value = super(DeferredAttributeTracker, field).__get__(
+ instance, owner)
+ self.saved_data[field.field_name] = deepcopy(value)
+ return data[field.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)
+ field_tracker = DeferredAttributeTracker(
+ field_obj.field_name, None)
+ setattr(self.instance.__class__, field, field_tracker)
+ else:
+ for field in self.fields:
+ 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)
+
+
+class FieldTracker(object):
+
+ tracker_class = FieldInstanceTracker
+
+ def __init__(self, fields=None):
+ self.fields = fields
+
+ def get_field_map(self, cls):
+ """Returns dict mapping fields names to model attribute names"""
+ field_map = dict((field, field) for field in self.fields)
+ all_fields = dict((f.name, f.attname) for f in cls._meta.fields)
+ field_map.update(**dict((k, v) for (k, v) in all_fields.items()
+ if k in field_map))
+ return field_map
+
+ def contribute_to_class(self, cls, name):
+ self.name = name
+ self.attname = '_%s' % name
+ models.signals.class_prepared.connect(self.finalize_class, sender=cls)
+
+ def finalize_class(self, sender, **kwargs):
+ if self.fields is None:
+ self.fields = (field.attname for field in sender._meta.fields)
+ self.fields = set(self.fields)
+ self.field_map = self.get_field_map(sender)
+ models.signals.post_init.connect(self.initialize_tracker)
+ self.model_class = sender
+ setattr(sender, self.name, self)
+
+ def initialize_tracker(self, sender, instance, **kwargs):
+ if not isinstance(instance, self.model_class):
+ return # Only init instances of given model (including children)
+ tracker = self.tracker_class(instance, self.fields, self.field_map)
+ setattr(instance, self.attname, tracker)
+ tracker.set_saved_fields()
+ self.patch_save(instance)
+
+ def patch_save(self, instance):
+ original_save = instance.save
+ def save(**kwargs):
+ ret = original_save(**kwargs)
+ update_fields = kwargs.get('update_fields')
+ if not update_fields and update_fields is not None: # () or []
+ fields = update_fields
+ elif update_fields is None:
+ fields = None
+ else:
+ fields = (
+ field for field in update_fields if
+ field in self.fields
+ )
+ getattr(instance, self.attname).set_saved_fields(
+ fields=fields
+ )
+ return ret
+ instance.save = save
+
+ def __get__(self, instance, owner):
+ if instance is None:
+ return self
+ else:
+ return getattr(instance, self.attname)
+
+
+class ModelInstanceTracker(FieldInstanceTracker):
+
+ def has_changed(self, field):
+ """Returns ``True`` if field has changed from currently saved value"""
+ if not self.instance.pk:
+ return True
+ elif field in self.saved_data:
+ return self.previous(field) != self.get_field_value(field)
+ else:
+ raise FieldError('field "%s" not tracked' % field)
+
+ def changed(self):
+ """Returns dict of fields that changed since save (with old values)"""
+ if not self.instance.pk:
+ return {}
+ saved = self.saved_data.items()
+ current = self.current()
+ return dict((k, v) for k, v in saved if v != current[k])
+
+
+class ModelTracker(FieldTracker):
+ tracker_class = ModelInstanceTracker
+
+ def get_field_map(self, cls):
+ return dict((field, field) for field in self.fields)
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..082f7fb
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,3 @@
+tox
+sphinx
+twine
diff --git a/runtests.py b/runtests.py
new file mode 100755
index 0000000..a1f4d93
--- /dev/null
+++ b/runtests.py
@@ -0,0 +1,49 @@
+#!/usr/bin/env python
+
+import os, sys
+
+from django.conf import settings
+import django
+
+
+DEFAULT_SETTINGS = dict(
+ INSTALLED_APPS=(
+ 'model_utils',
+ 'model_utils.tests',
+ ),
+ DATABASES={
+ "default": {
+ "ENGINE": "django.db.backends.sqlite3"
+ }
+ },
+ SILENCED_SYSTEM_CHECKS=["1_7.W001"],
+ )
+
+
+def runtests():
+ if not settings.configured:
+ settings.configure(**DEFAULT_SETTINGS)
+
+ # Compatibility with Django 1.7's stricter initialization
+ if hasattr(django, 'setup'):
+ django.setup()
+
+ parent = os.path.dirname(os.path.abspath(__file__))
+ sys.path.insert(0, parent)
+
+ try:
+ from django.test.runner import DiscoverRunner
+ runner_class = DiscoverRunner
+ test_args = ['model_utils.tests']
+ except ImportError:
+ from django.test.simple import DjangoTestSuiteRunner
+ runner_class = DjangoTestSuiteRunner
+ test_args = ['tests']
+
+ failures = runner_class(
+ verbosity=1, interactive=True, failfast=False).run_tests(test_args)
+ sys.exit(failures)
+
+
+if __name__ == '__main__':
+ runtests()
diff --git a/setup.cfg b/setup.cfg
new file mode 100644
index 0000000..7d5a6f7
--- /dev/null
+++ b/setup.cfg
@@ -0,0 +1,7 @@
+[build_sphinx]
+source-dir = docs/
+build-dir = docs/_build
+all_files = 1
+
+[wheel]
+universal = 1
diff --git a/setup.py b/setup.py
new file mode 100644
index 0000000..d894b58
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,57 @@
+import os
+from setuptools import setup, find_packages
+
+
+def long_desc(root_path):
+ FILES = ['README.rst', 'CHANGES.rst']
+ for filename in FILES:
+ filepath = os.path.realpath(os.path.join(root_path, filename))
+ if os.path.isfile(filepath):
+ with open(filepath, mode='r') as f:
+ yield f.read()
+
+
+HERE = os.path.abspath(os.path.dirname(__file__))
+long_description = "\n\n".join(long_desc(HERE))
+
+
+def get_version(root_path):
+ with open(os.path.join(root_path, 'model_utils', '__init__.py')) as f:
+ for line in f:
+ if line.startswith('__version__ ='):
+ return line.split('=')[1].strip().strip('"\'')
+
+setup(
+ name='django-model-utils',
+ version=get_version(HERE),
+ description='Django model mixins and utilities',
+ long_description=long_description,
+ author='Carl Meyer',
+ author_email='carl@oddbird.net',
+ url='https://github.com/carljm/django-model-utils/',
+ packages=find_packages(),
+ install_requires=['Django>=1.4.2'],
+ classifiers=[
+ 'Development Status :: 5 - Production/Stable',
+ 'Environment :: Web Environment',
+ 'Intended Audience :: Developers',
+ 'License :: OSI Approved :: BSD License',
+ 'Operating System :: OS Independent',
+ 'Programming Language :: Python',
+ 'Programming Language :: Python :: 2.6',
+ 'Programming Language :: Python :: 2.7',
+ 'Programming Language :: Python :: 3',
+ 'Programming Language :: Python :: 3.3',
+ 'Programming Language :: Python :: 3.4',
+ 'Programming Language :: Python :: 3.5',
+ 'Framework :: Django',
+ ],
+ zip_safe=False,
+ tests_require=["Django>=1.4.2"],
+ test_suite='runtests.runtests',
+ package_data={
+ 'model_utils': [
+ 'locale/*/LC_MESSAGES/django.po','locale/*/LC_MESSAGES/django.mo'
+ ],
+ },
+)
diff --git a/tox.ini b/tox.ini
new file mode 100644
index 0000000..b096f7a
--- /dev/null
+++ b/tox.ini
@@ -0,0 +1,29 @@
+[tox]
+envlist =
+ py26-django{14,15,16},
+ py27-django{14,19,110,_trunk}, py27-django15_nosouth,
+ py{27,33}-django{15,16,17,18},
+ py34-django{17,18,19,110,_trunk},
+ py35-django{18,19,110,_trunk},
+
+[testenv]
+basepython =
+ py26: python2.6
+ py27: python2.7
+ py33: python3.3
+ py34: python3.4
+ py35: python3.5
+
+deps =
+ coverage == 3.6
+ django14: Django>=1.4,<1.5
+ django15{,_nosouth}: Django>=1.5,<1.6
+ django16: Django>=1.6,<1.7
+ django17: Django>=1.7,<1.8
+ django18: Django>=1.8,<1.9
+ django19: Django>=1.9,<1.10
+ django110: Django>=1.10,<1.11
+ django_trunk: https://github.com/django/django/tarball/master
+ django{14,15,16}: South==1.0.2
+
+commands = coverage run -a setup.py test
diff --git a/translations.py b/translations.py
new file mode 100755
index 0000000..58b107f
--- /dev/null
+++ b/translations.py
@@ -0,0 +1,45 @@
+#!/usr/bin/env python
+
+import os
+import sys
+
+from django.conf import settings
+import django
+
+
+DEFAULT_SETTINGS = dict(
+ INSTALLED_APPS=(
+ 'model_utils',
+ 'model_utils.tests',
+ ),
+ DATABASES={
+ "default": {
+ "ENGINE": "django.db.backends.sqlite3"
+ }
+ },
+ SILENCED_SYSTEM_CHECKS=["1_7.W001"],
+ )
+
+
+def run(command):
+ if not settings.configured:
+ settings.configure(**DEFAULT_SETTINGS)
+
+ # Compatibility with Django 1.7's stricter initialization
+ if hasattr(django, 'setup'):
+ django.setup()
+
+ parent = os.path.dirname(os.path.abspath(__file__))
+ appdir = os.path.join(parent, 'model_utils')
+ os.chdir(appdir)
+
+ from django.core.management import call_command
+
+ call_command('%smessages' % command)
+
+
+if __name__ == '__main__':
+ if (len(sys.argv)) < 2 or (sys.argv[1] not in {'make', 'compile'}):
+ print("Run `translations.py make` or `translations.py compile`.")
+ sys.exit(1)
+ run(sys.argv[1])
diff --git a/update_travis_envs.sh b/update_travis_envs.sh
new file mode 100755
index 0000000..8b5d559
--- /dev/null
+++ b/update_travis_envs.sh
@@ -0,0 +1,13 @@
+#!/bin/sh
+
+# Updates .travis.yml envs based on tox.ini configuration.
+
+# Removing old environment list
+cp ./.travis.yml ./.travis.yml.bak
+cat ./.travis.yml.bak | grep -v "^ - TOXENV=" > ./.travis.yml
+
+# Inserting envs based on list generated by tox
+for env_name in $(tox --listenvs | sort -r); do
+ sed -i "/^env:$/a\
+\ \ - TOXENV=${env_name}" ./.travis.yml;
+done