fix conflict

This commit is contained in:
Zach Cheung 2019-01-11 17:26:06 +08:00
commit af1824e200
26 changed files with 426 additions and 51 deletions

View file

@ -1,5 +1,2 @@
[run]
source = model_utils
omit = .*
tests/*
*/_*
include = model_utils/*.py

View file

@ -9,6 +9,8 @@ python:
install: pip install tox-travis codecov
# positional args ({posargs}) to pass into tox.ini
script: tox -- --cov --cov-append
services:
- postgresql
after_success: codecov
deploy:
provider: pypi

View file

@ -22,6 +22,7 @@
| Jarek Glowacki <github.com/jarekwg>
| Javier García Sogo <jgsogo@gmail.com>
| Jeff Elmore <jeffelmore.org>
| Jonathan Sundqvist <jonathan@argpar.se>
| Keryn Knight <kerynknight.com>
| Martey Dodoo <martey+django-model-utils@mobolic.com>
| Matthew Schinckel <matt@schinckel.net>
@ -47,3 +48,4 @@
| Lucas Wiman <lucas.wiman@gmail.com>
| Jack Cushman <jcushman@law.harvard.edu>
| Zach Cheung <kuroro.zhang@gmail.com>
| Daniel Andrlik <daniel@andrlik.org>

View file

@ -7,12 +7,20 @@ master (unreleased)
- Fix `FieldTracker.has_changed()` and `FieldTracker.previous()` to return
correct responses for deferred fields.
- Add Simplified Chinese translations.
- Update AutoLastModifiedField so that at instance creation it will
always be set equal to created to make querying easier. Fixes GH-254
- Support `reversed` for all kinds of `Choices` objects, fixes GH-309
- Fix Model instance non picklable GH-330
- Fix patched `save` in FieldTracker
3.1.2 (2018.05.09)
------------------
* Update InheritanceIterable to inherit from
ModelIterable instead of BaseIterable, fixes GH-277.
* Add all_objects Manager for 'SoftDeletableModel' to include soft
deleted objects on queries as per issue GH-255
3.1.1 (2017.12.17)
------------------

View file

@ -1,4 +1,4 @@
Copyright (c) 2009-2015, Carl Meyer and contributors
Copyright (c) 2009-2019, Carl Meyer and contributors
All rights reserved.
Redistribution and use in source and binary forms, with or without

View file

@ -14,7 +14,7 @@ django-model-utils
Django model mixins and utilities.
``django-model-utils`` supports `Django`_ 1.8 to 2.0.
``django-model-utils`` supports `Django`_ 1.8 to 2.1.
.. _Django: http://www.djangoproject.com/
@ -28,6 +28,15 @@ Getting Help
Documentation for django-model-utils is available
https://django-model-utils.readthedocs.io/
Run tests
---------
.. code-block
pip install -e .
py.test
Contributing
============

View file

@ -86,6 +86,33 @@ it's safe to use as your default manager for the model.
.. _contributed by Jeff Elmore: http://jeffelmore.org/2010/11/11/automatic-downcasting-of-inherited-models-in-django/
JoinManager
-----------
The ``JoinManager`` will create a temporary table of your current queryset
and join that temporary table with the model of your current queryset. This can
be advantageous if you have to page through your entire DB and using django's
slice mechanism to do that. ``LIMIT .. OFFSET ..`` becomes slower the bigger
offset you use.
.. code-block:: python
sliced_qs = Place.objects.all()[2000:2010]
qs = sliced_qs.join()
# qs contains 10 objects, and there will be a much smaller performance hit
# for paging through all of first 2000 objects.
Alternatively, you can give it a queryset and the manager will create a temporary
table and join that to your current queryset. This can work as a more performant
alternative to using django's ``__in`` as described in the following
(`StackExchange answer`_).
.. code-block:: python
big_qs = Restaurant.objects.filter(menu='vegetarian')
qs = Country.objects.filter(country_code='SE').join(big_qs)
.. _StackExchange answer: https://dba.stackexchange.com/questions/91247/optimizing-a-postgres-query-with-a-large-in
.. _QueryManager:

View file

@ -1,4 +1,4 @@
from .choices import Choices # noqa:F401
from .tracker import FieldTracker, ModelTracker # noqa:F401
__version__ = '3.1.2'
__version__ = '3.2.0'

View file

@ -102,6 +102,9 @@ class Choices(object):
def __iter__(self):
return iter(self._doubles)
def __reversed__(self):
return reversed(self._doubles)
def __getattr__(self, attname):
try:
return self._identifier_map[attname]

View file

@ -32,6 +32,11 @@ class AutoLastModifiedField(AutoCreatedField):
"""
def pre_save(self, model_instance, add):
value = now()
if not model_instance.pk:
for field in model_instance._meta.get_fields():
if isinstance(field, AutoCreatedField):
value = getattr(model_instance, field.name)
break
setattr(model_instance, self.attname, value)
return value

Binary file not shown.

View file

@ -0,0 +1,46 @@
# Czech translations of django-model-utils
#
# This file is distributed under the same license as the django-model-utils package.
#
# Translators:
# ------------
# Václav Dohnal <vaclav.dohnal@gmail.com>, 2018.
#
msgid ""
msgstr ""
"Project-Id-Version: django-model-utils\n"
"Report-Msgid-Bugs-To: https://github.com/jazzband/django-model-utils/issues\n"
"POT-Creation-Date: 2018-05-04 13:40+0200\n"
"PO-Revision-Date: 2018-05-04 13:46+0200\n"
"Language: cs\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=3; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2;\n"
"Last-Translator: Václav Dohnal <vaclav.dohnal@gmail.com>\n"
"Language-Team: N/A\n"
"X-Generator: Poedit 2.0.7\n"
#: .\models.py:24
msgid "created"
msgstr "vytvořeno"
#: .\models.py:25
msgid "modified"
msgstr "upraveno"
#: .\models.py:37
msgid "start"
msgstr "začátek"
#: .\models.py:38
msgid "end"
msgstr "konec"
#: .\models.py:53
msgid "status"
msgstr "stav"
#: .\models.py:54
msgid "status changed"
msgstr "změna stavu"

Binary file not shown.

View file

@ -0,0 +1,43 @@
# This file is distributed under the same license as the django-model-utils package.
#
# Translators:
# Arseny Sysolyatin <arseny.sysolyatin@gmail.com>, 2017.
msgid ""
msgstr ""
"Project-Id-Version: django-model-utils\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2017-05-22 19:46+0300\n"
"PO-Revision-Date: 2017-05-22 19:46+0300\n"
"Last-Translator: Arseny Sysolyatin <arseny.sysolyatin@gmail.com>\n"
"Language-Team: \n"
"Language: ru\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=4; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n"
"%10<=4 && (n%100<12 || n%100>14) ? 1 : n%10==0 || (n%10>=5 && n%10<=9) || (n"
"%100>=11 && n%100<=14)? 2 : 3);\n"
#: models.py:24
msgid "created"
msgstr "создано"
#: models.py:25
msgid "modified"
msgstr "изменено"
#: models.py:37
msgid "start"
msgstr "начало"
#: models.py:38
msgid "end"
msgstr "конец"
#: models.py:53
msgid "status"
msgstr "статус"
#: models.py:54
msgid "status changed"
msgstr "статус изменен"

View file

@ -3,16 +3,15 @@ import django
from django.db import models
from django.db.models.fields.related import OneToOneField, OneToOneRel
from django.db.models.query import QuerySet
try:
from django.db.models.query import BaseIterable, ModelIterable
except ImportError:
# Django 1.8 does not have iterable classes
BaseIterable, ModelIterable = object, object
from django.db.models.query import ModelIterable
from django.core.exceptions import ObjectDoesNotExist
from django.db.models.constants import LOOKUP_SEP
from django.utils.six import string_types
from django.db import connection
from django.db.models.sql.datastructures import Join
class InheritanceIterable(ModelIterable):
def __iter__(self):
@ -98,16 +97,16 @@ class InheritanceQuerySetMixin(object):
def _clone(self, klass=None, setup=False, **kwargs):
if django.VERSION >= (2, 0):
return super(InheritanceQuerySetMixin, self)._clone()
qs = super(InheritanceQuerySetMixin, self)._clone()
for name in ['subclasses', '_annotated']:
if hasattr(self, name):
setattr(qs, name, getattr(self, name))
return qs
for name in ['subclasses', '_annotated']:
if hasattr(self, name):
kwargs[name] = getattr(self, name)
if django.VERSION < (1, 9):
kwargs['klass'] = klass
kwargs['setup'] = setup
return super(InheritanceQuerySetMixin, self)._clone(**kwargs)
def annotate(self, *args, **kwargs):
@ -189,10 +188,7 @@ class InheritanceQuerySetMixin(object):
if levels:
levels -= 1
while parent_link is not None:
if django.VERSION < (1, 9):
related = parent_link.rel
else:
related = parent_link.remote_field
related = parent_link.remote_field
ancestry.insert(0, related.get_accessor_name())
if levels or levels is None:
parent_model = related.model
@ -308,3 +304,111 @@ class SoftDeletableManagerMixin(object):
class SoftDeletableManager(SoftDeletableManagerMixin, models.Manager):
pass
class JoinQueryset(models.QuerySet):
def get_quoted_query(self, query):
query, params = query.sql_with_params()
# Put additional quotes around string.
params = [
'\'{}\''.format(p)
if isinstance(p, str) else p
for p in params
]
# Cast list of parameters to tuple because I got
# "not enough format characters" otherwise.
params = tuple(params)
return query % params
def join(self, qs=None):
'''
Join one queryset together with another using a temporary table. If
no queryset is used, it will use the current queryset and join that
to itself.
`Join` either uses the current queryset and effectively does a self-join to
create a new limited queryset OR it uses a querset given by the user.
The model of a given queryset needs to contain a valid foreign key to
the current queryset to perform a join. A new queryset is then created.
'''
to_field = 'id'
if qs:
fk = [
fk for fk in qs.model._meta.fields
if getattr(fk, 'related_model', None) == self.model
]
fk = fk[0] if fk else None
model_set = '{}_set'.format(self.model.__name__.lower())
key = fk or getattr(qs.model, model_set, None)
if not key:
raise ValueError('QuerySet is not related to current model')
try:
fk_column = key.column
except AttributeError:
fk_column = 'id'
to_field = key.field.column
qs = qs.only(fk_column)
# if we give a qs we need to keep the model qs to not lose anything
new_qs = self
else:
fk_column = 'id'
qs = self.only(fk_column)
new_qs = self.model.objects.all()
TABLE_NAME = 'temp_stuff'
query = self.get_quoted_query(qs.query)
sql = '''
DROP TABLE IF EXISTS {table_name};
DROP INDEX IF EXISTS {table_name}_id;
CREATE TEMPORARY TABLE {table_name} AS {query};
CREATE INDEX {table_name}_{fk_column} ON {table_name} ({fk_column});
'''.format(table_name=TABLE_NAME, fk_column=fk_column, query=str(query))
with connection.cursor() as cursor:
cursor.execute(sql)
class TempModel(models.Model):
temp_key = models.ForeignKey(
self.model,
on_delete=models.DO_NOTHING,
db_column=fk_column,
to_field=to_field
)
class Meta:
managed = False
db_table = TABLE_NAME
conn = Join(
table_name=TempModel._meta.db_table,
parent_alias=new_qs.query.get_initial_alias(),
table_alias=None,
join_type='INNER JOIN',
join_field=self.model.tempmodel_set.rel,
nullable=False
)
new_qs.query.join(conn, reuse=None)
return new_qs
class JoinManagerMixin(object):
"""
Manager that adds a method join. This method allows you to join two
querysets together.
"""
_queryset_class = JoinQueryset
def get_queryset(self):
return self._queryset_class(model=self.model, using=self._db)
class JoinManager(JoinManagerMixin, models.Manager):
pass

View file

@ -123,6 +123,7 @@ class SoftDeletableModel(models.Model):
abstract = True
objects = SoftDeletableManager()
all_objects = models.Manager()
def delete(self, using=None, soft=True, *args, **kwargs):
"""

View file

@ -207,6 +207,7 @@ class FieldTracker(object):
def contribute_to_class(self, cls, name):
self.name = name
self.attname = '_%s' % name
self.patch_save(cls)
models.signals.class_prepared.connect(self.finalize_class, sender=cls)
def finalize_class(self, sender, **kwargs):
@ -230,14 +231,13 @@ class FieldTracker(object):
tracker = self.tracker_class(instance, self.fields, self.field_map)
setattr(instance, self.attname, tracker)
tracker.set_saved_fields()
self.patch_save(instance)
instance._instance_intialized = True
def patch_save(self, instance):
original_save = instance.save
def patch_save(self, model):
original_save = model.save
def save(**kwargs):
ret = original_save(**kwargs)
def save(instance, *args, **kwargs):
ret = original_save(instance, *args, **kwargs)
update_fields = kwargs.get('update_fields')
if not update_fields and update_fields is not None: # () or []
fields = update_fields
@ -253,7 +253,7 @@ class FieldTracker(object):
)
return ret
instance.save = save
model.save = save
def __get__(self, instance, owner):
if instance is None:

View file

@ -1,2 +1,3 @@
pytest==3.3.1
pytest-django==3.1.2
psycopg2==2.7.6.1

View file

@ -9,7 +9,11 @@ from django.utils.translation import ugettext_lazy as _
from model_utils import Choices
from model_utils.fields import SplitField, MonitorField, StatusField
from model_utils.managers import QueryManager, InheritanceManager
from model_utils.managers import (
QueryManager,
InheritanceManager,
JoinManagerMixin
)
from model_utils.models import (
SoftDeletableModel,
StatusModel,
@ -370,3 +374,22 @@ class ModelWithCustomDescriptor(models.Model):
tracked_regular_field = models.IntegerField()
tracker = FieldTracker(fields=['tracked_custom_field', 'tracked_regular_field'])
class JoinManager(JoinManagerMixin, models.Manager):
pass
class BoxJoinModel(models.Model):
name = models.CharField(max_length=32)
objects = JoinManager()
class JoinItemForeignKey(models.Model):
weight = models.IntegerField()
belonging = models.ForeignKey(
BoxJoinModel,
null=True,
on_delete=models.CASCADE
)
objects = JoinManager()

View file

@ -1,10 +1,22 @@
import os
INSTALLED_APPS = (
'model_utils',
'tests',
)
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3'
}
"default": {
"ENGINE": "django.db.backends.postgresql_psycopg2",
"NAME": os.environ.get("DJANGO_DATABASE_NAME_POSTGRES", "modelutils"),
"USER": os.environ.get("DJANGO_DATABASE_USER_POSTGRES", 'postgres'),
"PASSWORD": os.environ.get("DJANGO_DATABASE_PASSWORD_POSTGRES", ""),
"HOST": os.environ.get("DJANGO_DATABASE_HOST_POSTGRES", ""),
},
}
SECRET_KEY = 'dummy'
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
}
}

View file

@ -16,7 +16,12 @@ class ChoicesTests(TestCase):
self.assertEqual(self.STATUS['PUBLISHED'], 'PUBLISHED')
def test_iteration(self):
self.assertEqual(tuple(self.STATUS), (('DRAFT', 'DRAFT'), ('PUBLISHED', 'PUBLISHED')))
self.assertEqual(tuple(self.STATUS),
(('DRAFT', 'DRAFT'), ('PUBLISHED', 'PUBLISHED')))
def test_reversed(self):
self.assertEqual(tuple(reversed(self.STATUS)),
(('PUBLISHED', 'PUBLISHED'), ('DRAFT', 'DRAFT')))
def test_len(self):
self.assertEqual(len(self.STATUS), 2)
@ -78,8 +83,15 @@ class LabelChoicesTests(ChoicesTests):
self.assertEqual(tuple(self.STATUS), (
('DRAFT', 'is draft'),
('PUBLISHED', 'is published'),
('DELETED', 'DELETED'))
)
('DELETED', 'DELETED'),
))
def test_reversed(self):
self.assertEqual(tuple(reversed(self.STATUS)), (
('DELETED', 'DELETED'),
('PUBLISHED', 'is published'),
('DRAFT', 'is draft'),
))
def test_indexing(self):
self.assertEqual(self.STATUS['PUBLISHED'], 'is published')
@ -169,7 +181,15 @@ class IdentifierChoicesTests(ChoicesTests):
self.assertEqual(tuple(self.STATUS), (
(0, 'is draft'),
(1, 'is published'),
(2, 'is deleted')))
(2, 'is deleted'),
))
def test_reversed(self):
self.assertEqual(tuple(reversed(self.STATUS)), (
(2, 'is deleted'),
(1, 'is published'),
(0, 'is draft'),
))
def test_indexing(self):
self.assertEqual(self.STATUS[1], 'is published')

View file

@ -3,7 +3,7 @@ from __future__ import unicode_literals
import django
from django.core.exceptions import FieldError
from django.test import TestCase
from django.core.cache import cache
from model_utils import FieldTracker
from model_utils.tracker import DescriptorWrapper
from tests.models import (
@ -85,6 +85,11 @@ class FieldTrackerTests(FieldTrackerTestCase, FieldTrackerCommonTests):
self.instance.mutable = [1, 2, 3]
self.assertHasChanged(name=True, number=True, mutable=True)
def test_save_with_args(self):
self.instance.number = 1
self.instance.save(False, False, None, None)
self.assertChanged()
def test_first_save(self):
self.assertHasChanged(name=True, number=False, mutable=False)
self.assertPrevious(name=None, number=None, mutable=None)
@ -639,6 +644,16 @@ class ModelTrackerTests(FieldTrackerTests):
tracked_class = ModelTracked
def test_cache_compatible(self):
cache.set('key', self.instance)
instance = cache.get('key')
instance.number = 1
instance.name = 'cached'
instance.save()
self.assertChanged()
instance.number = 2
self.assertHasChanged(number=True)
def test_pre_save_changed(self):
self.assertChanged()
self.instance.name = 'new age'

View file

@ -123,10 +123,16 @@ class InheritanceManagerTests(TestCase):
ensure that the relation names and subclasses are obtained correctly.
"""
child3 = InheritanceManagerTestChild3.objects.create()
results = InheritanceManagerTestParent.objects.all().select_subclasses()
qs = InheritanceManagerTestParent.objects.all()
results = qs.select_subclasses().order_by('pk')
expected_objs = [self.child1, self.child2, self.grandchild1,
self.grandchild1_2, child3]
expected_objs = [
self.child1,
self.child2,
self.grandchild1,
self.grandchild1_2,
child3
]
self.assertEqual(list(results), expected_objs)
expected_related_names = [
@ -146,7 +152,8 @@ class InheritanceManagerTests(TestCase):
"""
related_name = 'manual_onetoone'
child3 = InheritanceManagerTestChild3.objects.create()
results = InheritanceManagerTestParent.objects.all().select_subclasses(related_name)
qs = InheritanceManagerTestParent.objects.all()
results = qs.select_subclasses(related_name).order_by('pk')
expected_objs = [InheritanceManagerTestParent(pk=self.child1.pk),
InheritanceManagerTestParent(pk=self.child2.pk),
@ -389,14 +396,16 @@ class InheritanceManagerUsingModelsTests(TestCase):
"""
child3 = InheritanceManagerTestChild3.objects.create()
results = InheritanceManagerTestParent.objects.all().select_subclasses(
InheritanceManagerTestChild3)
InheritanceManagerTestChild3).order_by('pk')
expected_objs = [InheritanceManagerTestParent(pk=self.parent1.pk),
InheritanceManagerTestParent(pk=self.child1.pk),
InheritanceManagerTestParent(pk=self.child2.pk),
InheritanceManagerTestParent(pk=self.grandchild1.pk),
InheritanceManagerTestParent(pk=self.grandchild1_2.pk),
child3]
expected_objs = [
InheritanceManagerTestParent(pk=self.parent1.pk),
InheritanceManagerTestParent(pk=self.child1.pk),
InheritanceManagerTestParent(pk=self.child2.pk),
InheritanceManagerTestParent(pk=self.grandchild1.pk),
InheritanceManagerTestParent(pk=self.grandchild1_2.pk),
child3
]
self.assertEqual(list(results), expected_objs)
expected_related_names = ['manual_onetoone']
@ -451,3 +460,7 @@ class InheritanceManagerRelatedTests(InheritanceManagerTests):
qs = InheritanceManagerTestParent.objects.annotate(
test_count=models.Count('id')).select_subclasses()
self.assertEqual(qs.get(id=self.child1.id).test_count, 1)
def test_clone_when_inheritance_queryset_selects_subclasses_should_clone_them_too(self):
qs = InheritanceManagerTestParent.objects.select_subclasses()
self.assertEqual(qs.subclasses, qs._clone().subclasses)

View file

@ -0,0 +1,38 @@
from django.test import TestCase
from tests.models import JoinItemForeignKey, BoxJoinModel
class JoinManagerTest(TestCase):
def setUp(self):
for i in range(20):
BoxJoinModel.objects.create(name='name_{i}'.format(i=i))
JoinItemForeignKey.objects.create(
weight=10, belonging=BoxJoinModel.objects.get(name='name_1')
)
JoinItemForeignKey.objects.create(weight=20)
def test_self_join(self):
a_slice = BoxJoinModel.objects.all()[0:10]
with self.assertNumQueries(1):
result = a_slice.join()
self.assertEquals(result.count(), 10)
def test_self_join_with_where_statement(self):
qs = BoxJoinModel.objects.filter(name='name_1')
result = qs.join()
self.assertEquals(result.count(), 1)
def test_join_with_other_qs(self):
item_qs = JoinItemForeignKey.objects.filter(weight=10)
boxes = BoxJoinModel.objects.all().join(qs=item_qs)
self.assertEquals(boxes.count(), 1)
self.assertEquals(boxes[0].name, 'name_1')
def test_reverse_join(self):
box_qs = BoxJoinModel.objects.filter(name='name_1')
items = JoinItemForeignKey.objects.all().join(box_qs)
self.assertEquals(items.count(), 1)
self.assertEquals(items[0].weight, 10)

View file

@ -15,6 +15,13 @@ class TimeStampedModelTests(TestCase):
t1 = TimeStamp.objects.create()
self.assertEqual(t1.created, datetime(2016, 1, 1))
def test_created_sets_modified(self):
'''
Ensure that on creation that modifed is set exactly equal to created.
'''
t1 = TimeStamp.objects.create()
self.assertEqual(t1.created, t1.modified)
def test_modified(self):
with freeze_time(datetime(2016, 1, 1)):
t1 = TimeStamp.objects.create()

View file

@ -1,14 +1,13 @@
[tox]
envlist =
py27-django{18,19,110,111}
py34-django{18,19,110,111,200}
py35-django{18,19,110,111,200,201,trunk}
py27-django{19,110,111}
py34-django{19,110,111,200}
py35-django{19,110,111,200,201,trunk}
py36-django{111,200,201,trunk}
flake8
[testenv]
deps =
django18: Django>=1.8,<1.9
django19: Django>=1.9,<1.10
django110: Django>=1.10,<1.11
django111: Django>=1.11,<1.12