Commit 9c39ae80 authored by Brian May's avatar Brian May

New upstream version 3.0.0

parent 3896a377
## Problem
Explain the problem you encountered.
## Environment
- Django Model Utils version:
- Django version:
- Python version:
- Other libraries used, if any:
## Code examples
Give code example that demonstrates the issue, or even better, write new tests that fails because of that issue.
## Problem
Explain the problem you are fixing (add the link to the related issue(s), if any).
## Solution
Explain the solution that has been implemented, and what has been changed.
## Commandments
- [ ] Write PEP8 compliant code.
- [ ] Cover it with tests.
- [ ] Update `CHANGES.rst` file to describe the changes, and quote according issue with `GH-<issue_number>`.
- [ ] Pay attention to backward compatibility, or if it breaks it, explain why.
- [ ] Update documentation (if relevant).
......@@ -3,31 +3,22 @@ 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=py27-django110
- TOXENV=py27-django111
- TOXENV=py33-django18
- TOXENV=py34-django110
- TOXENV=py34-django17
- TOXENV=py34-django18
- TOXENV=py34-django19
- TOXENV=py34-django_trunk
- TOXENV=py35-django110
- TOXENV=py34-django110
- TOXENV=py34-django111
- TOXENV=py35-django18
- TOXENV=py35-django19
- TOXENV=py35-django110
- TOXENV=py35-django111
- TOXENV=py35-django_trunk
- TOXENV=py36-django111
- TOXENV=py36-django_trunk
install:
- pip install --upgrade pip setuptools tox virtualenv coveralls
......@@ -37,11 +28,8 @@ script:
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
- env: TOXENV=py36-django111
- env: TOXENV=py36-django_trunk
after_success: coveralls
ad-m <github.com/ad-m>
Alejandro Varas <alej0varas@gmail.com>
Alex Orange <crazycasta@gmail.com>
Alexey Evseev <myhappydo@gmail.com>
Andy Freeland <andy@andyfreeland.net>
Artis Avotins <artis.avotins@gmail.com>
Bram Boogaard <b.boogaard@auto-interactive.nl>
......@@ -30,6 +31,7 @@ Paul McLanahan <paul@mclanahan.net>
Philipp Steinhardt <steinhardt@myvision.de>
Rinat Shigapov <rinatshigapov@gmail.com>
Rodney Folz <rodney@rodneyfolz.com>
Romain Garrigues <github.com/romgar>
rsenkbeil <github.com/rsenkbeil>
Ryan Kaskel <dev@ryankaskel.com>
Simon Meers <simon@simonmeers.com>
......@@ -39,3 +41,4 @@ Travis Swicegood <travis@domain51.com>
Trey Hunner <trey@treyhunner.com>
Karl Wan Nan Wo <karl.wnw@gmail.com>
zyegfryed
Radosław Jan Ganczarek <radoslaw@ganczarek.in>
CHANGES
=======
3.0.0 (2017.04.13)
------------------
* Drop support for Python 2.6.
* Drop support for Django 1.4, 1.5, 1.6, 1.7.
* Exclude tests from the distribution, fixes GH-258.
* Add support for Django 1.11 GH-269
2.6.1 (2017.01.11)
------------------
* Fix infinite recursion with multiple `MonitorField` and `defer()` or `only()`
on Django 1.10+. Thanks Romain Garrigues. Merge of GH-242, fixes GH-241.
* Fix `InheritanceManager` and `SoftDeletableManager` to respect
`self._queryset_class` instead of hardcoding the queryset class. Merge of
GH-250, fixes GH-249.
* Add mixins for `SoftDeletableQuerySet` and `SoftDeletableManager`, as stated
in the the documentation.
* Fix `SoftDeletableModel.delete()` to use the correct database connection.
Merge of GH-239.
* Added boolean keyword argument `soft` to `SoftDeletableModel.delete()` that
revert to default behavior when set to `False`. Merge of GH-240.
* Enforced default manager in `StatusModel` to avoid manager order issues when
using abstract models that redefine `objects` manager. Merge of GH-253, fixes
GH-251.
2.6 (2016.09.19)
----------------
* Added `SoftDeletableModel` abstract class, its manageer
`SoftDeletableManager` and queryset `SoftDeletableQuerySet`.
* Fix issue with field tracker and deferred FileField for Django 1.10.
2.5.2 (2016.08.09)
------------------
......
......@@ -11,9 +11,8 @@ 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-model-utils`` supports `Django`_ 1.8 through 1.10 (latest bugfix
release in each series only) on Python 2.7, 3.3 (Django 1.8 only), 3.4 and 3.5.
.. _Django: http://www.djangoproject.com/
......
......@@ -84,14 +84,7 @@ 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:
......@@ -124,57 +117,19 @@ set the ordering of the ``QuerySet`` returned by the ``QueryManager``
by chaining a call to ``.order_by()`` on the ``QueryManager`` (this is
not required).
SoftDeletableManager
--------------------
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.
Returns only model instances that have the ``is_removed`` field set
to False. Uses ``SoftDeletableQuerySet``, which ensures model instances
won't be removed in bulk, but they will be marked as removed 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``.
add functionality to any manager.
(Note that any manager class using ``InheritanceManagerMixin`` must return a
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``.)
method.
......@@ -47,3 +47,11 @@ returns objects with that status only:
# this query will only return published articles:
Article.published.all()
SoftDeletableModel
------------------
This abstract base class just provides field ``is_removed`` which is
set to True instead of removing the instance. Entities returned in
default manager are limited to not-deleted instances.
......@@ -17,7 +17,7 @@ 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-model-utils`` supports `Django`_ 1.8 through 1.10 (latest bugfix
release in each series only) on Python 2.7, 3.3 (Django 1.8 only), 3.4 and 3.5.
.. _Django: http://www.djangoproject.com/
from .choices import Choices
from .tracker import FieldTracker, ModelTracker
__version__ = '2.5.2'
__version__ = '3.0.0'
......@@ -110,6 +110,9 @@ class MonitorField(models.DateTimeField):
return getattr(instance, self.monitor)
def _save_initial(self, sender, instance, **kwargs):
if django.VERSION >= (1, 10) and self.monitor in instance.get_deferred_fields():
# Fix related to issue #241 to avoid recursive error on double monitor fields
return
setattr(instance, self.monitor_attname,
self.get_monitored_value(instance))
......@@ -225,7 +228,7 @@ class SplitField(models.TextField):
return value.content
def value_to_string(self, obj):
value = self._get_val_from_obj(obj)
value = self.value_from_object(obj)
return value.content
def get_prep_value(self, value):
......@@ -238,30 +241,3 @@ class SplitField(models.TextField):
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
......@@ -3,17 +3,54 @@ import django
from django.db import models
from django.db.models.fields.related import OneToOneField, OneToOneRel
from django.db.models.query import QuerySet
try:
from django.db.models.query import BaseIterable, ModelIterable
except ImportError:
# Django 1.8 does not have iterable classes
BaseIterable = object
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,)
from django.db.models.constants import LOOKUP_SEP
from django.utils.six import string_types
class InheritanceIterable(BaseIterable):
def __iter__(self):
queryset = self.queryset
iter = ModelIterable(queryset)
if getattr(queryset, 'subclasses', False):
extras = tuple(queryset.query.extra.keys())
# sort the subclass names longest first,
# so with 'a' and 'a__b' it goes as deep as possible
subclasses = sorted(queryset.subclasses, key=len, reverse=True)
for obj in iter:
sub_obj = None
for s in subclasses:
sub_obj = queryset._get_sub_obj_recurse(obj, s)
if sub_obj:
break
if not sub_obj:
sub_obj = obj
if getattr(queryset, '_annotated', False):
for k in queryset._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
class InheritanceQuerySetMixin(object):
def __init__(self, *args, **kwargs):
super(InheritanceQuerySetMixin, self).__init__(*args, **kwargs)
if django.VERSION > (1, 8):
self._iterable_class = InheritanceIterable
def select_subclasses(self, *subclasses):
levels = self._get_maximum_depth()
calculated_subclasses = self._get_subclasses_recurse(
......@@ -68,6 +105,7 @@ class InheritanceQuerySetMixin(object):
return qset
def iterator(self):
# Maintained for Django 1.8 compatability
iter = super(InheritanceQuerySetMixin, self).iterator()
if getattr(self, 'subclasses', False):
extras = tuple(self.query.extra.keys())
......@@ -143,10 +181,10 @@ class InheritanceQuerySetMixin(object):
if levels:
levels -= 1
while parent_link is not None:
if django.VERSION < (1, 8):
related = parent_link.related
else:
if django.VERSION < (1, 9):
related = parent_link.rel
else:
related = parent_link.remote_field
ancestry.insert(0, related.get_accessor_name())
if levels or levels is None:
if django.VERSION < (1, 8):
......@@ -192,13 +230,16 @@ class InheritanceQuerySetMixin(object):
return levels
class InheritanceQuerySet(InheritanceQuerySetMixin, QuerySet):
pass
class InheritanceManagerMixin(object):
use_for_related_fields = True
_queryset_class = InheritanceQuerySet
def get_queryset(self):
return InheritanceQuerySet(self.model)
get_query_set = get_queryset
return self._queryset_class(self.model)
def select_subclasses(self, *subclasses):
return self.get_queryset().select_subclasses(*subclasses)
......@@ -207,10 +248,6 @@ class InheritanceManagerMixin(object):
return self.get_queryset().get_subclass(*args, **kwargs)
class InheritanceQuerySet(InheritanceQuerySetMixin, QuerySet):
pass
class InheritanceManager(InheritanceManagerMixin, models.Manager):
pass
......@@ -231,16 +268,51 @@ class QueryManagerMixin(object):
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)
qs = super(QueryManagerMixin, self).get_queryset().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
class SoftDeletableQuerySetMixin(object):
"""
QuerySet for SoftDeletableModel. Instead of removing instance sets
its ``is_removed`` field to True.
"""
def delete(self):
"""
Soft delete objects from queryset (set their ``is_removed``
field to True)
"""
self.update(is_removed=True)
class SoftDeletableQuerySet(SoftDeletableQuerySetMixin, QuerySet):
pass
class SoftDeletableManagerMixin(object):
"""
Manager that limits the queryset by default to show only not removed
instances of model.
"""
_queryset_class = SoftDeletableQuerySet
def get_queryset(self):
"""
Return queryset limited to not removed entries.
"""
kwargs = {'model': self.model, 'using': self._db}
if hasattr(self, '_hints'):
kwargs['hints'] = self._hints
return self._queryset_class(**kwargs).filter(is_removed=False)
class SoftDeletableManager(SoftDeletableManagerMixin, models.Manager):
pass
from __future__ import unicode_literals
import django
from django.core.exceptions import ImproperlyConfigured
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.managers import QueryManager, SoftDeletableManager
from model_utils.fields import AutoCreatedField, AutoLastModifiedField, \
StatusField, MonitorField
......@@ -64,6 +64,11 @@ def add_status_query_managers(sender, **kwargs):
"""
if not issubclass(sender, StatusModel):
return
if django.VERSION >= (1, 10):
# First, get current manager name...
default_manager = sender._meta.default_manager
for value, display in getattr(sender, 'STATUS', ()):
if _field_exists(sender, value):
raise ImproperlyConfigured(
......@@ -73,6 +78,10 @@ def add_status_query_managers(sender, **kwargs):
)
sender.add_to_class(value, QueryManager(status=value))
if django.VERSION >= (1, 10):
# ...then, put it back, as add_to_class is modifying the default manager!
sender._meta.default_manager_name = default_manager.name
def add_timeframed_query_manager(sender, **kwargs):
"""
......@@ -99,3 +108,29 @@ 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]
class SoftDeletableModel(models.Model):
"""
An abstract base class model with a ``is_removed`` field that
marks entries that are not going to be used anymore, but are
kept in db for any reason.
Default manager returns only not-removed entries.
"""
is_removed = models.BooleanField(default=False)
class Meta:
abstract = True
objects = SoftDeletableManager()
def delete(self, using=None, soft=True, *args, **kwargs):
"""
Soft delete object (set its ``is_removed`` field to True).
Actually delete object if setting ``soft`` to False.
"""
if soft:
self.is_removed = True
self.save(using=using)
else:
return super(SoftDeletableModel, self).delete(using=using, *args, **kwargs)
......@@ -2,11 +2,33 @@ 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.fields.files import FileDescriptor
from django.db.models.query_utils import DeferredAttribute
class DescriptorMixin(object):
tracker_instance = None
def __get__(self, instance, owner):
if instance is None:
return self
was_deferred = False
field_name = self._get_field_name()
if field_name in instance._deferred_fields:
instance._deferred_fields.remove(field_name)
was_deferred = True
value = super(DescriptorMixin, self).__get__(instance, owner)
if was_deferred:
self.tracker_instance.saved_data[field_name] = deepcopy(value)
return value
def _get_field_name(self):
return self.field_name
class FieldInstanceTracker(object):
def __init__(self, instance, fields, field_map):
self.instance = instance
......@@ -62,33 +84,48 @@ 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):
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]
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()
field_tracker = DeferredAttributeTracker(
field_obj.field_name, model)
setattr(self.instance.__class__, field, field_tracker)
class DeferredAttributeTracker(DescriptorMixin, DeferredAttribute):
tracker_instance = self
class FileDescriptorTracker(DescriptorMixin, FileDescriptor):
tracker_instance = self
def _get_field_name(self):
return self.field.name
if django.VERSION >= (1, 8):
self.instance._deferred_fields = self.instance.get_deferred_fields()
for field in self.instance._deferred_fields:
if django.VERSION >= (1, 10):
field_obj = getattr(self.instance.__class__, field)
else:
field_obj = self.instance.__class__.__dict__.get(field)
if isinstance(field_obj, FileDescriptor):
field_tracker = FileDescriptorTracker(field_obj.field)
setattr(self.instance.__class__, field, field_tracker)
else:
field_tracker = DeferredAttributeTracker(
field_obj.field_name, None)
setattr(self.instance.__class__, field, field_tracker)
else:
for field in self.fields:
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):
......
# Dependencies for development of django-model-utils
tox
sphinx
twine
......@@ -5,40 +5,32 @@ import os, sys
from django.conf import settings
import django
DEFAULT_SETTINGS = dict(
INSTALLED_APPS=(
'model_utils',
'model_utils.tests',
),
'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()
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']
from django.test.runner import DiscoverRunner
runner_class = DiscoverRunner
test_args = ['tests']
failures = runner_class(
verbosity=1, interactive=True, failfast=False).run_tests(test_args)
......
......@@ -24,13 +24,14 @@ def get_version(root_path):
setup(
name='django-model-utils',
version=get_version(HERE),
license="BSD",
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(),