diff --git a/.coveragerc b/.coveragerc index 62d6d1c..8708371 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,5 +1,2 @@ [run] -source = model_utils -omit = .* - tests/* - */_* +include = model_utils/*.py diff --git a/.travis.yml b/.travis.yml index 295d123..a7d4622 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,6 +9,8 @@ python: install: pip install tox-travis codecov # positional args ({posargs}) to pass into tox.ini script: tox -- --cov --cov-append +services: + - postgresql after_success: codecov deploy: provider: pypi diff --git a/AUTHORS.rst b/AUTHORS.rst index 8193d32..295ec18 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -22,6 +22,7 @@ | Jarek Glowacki | Javier García Sogo | Jeff Elmore +| Jonathan Sundqvist | Keryn Knight | Martey Dodoo | Matthew Schinckel @@ -47,3 +48,4 @@ | Lucas Wiman | Jack Cushman | Zach Cheung +| Daniel Andrlik diff --git a/CHANGES.rst b/CHANGES.rst index eae6f8e..4ea6812 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -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) ------------------ diff --git a/LICENSE.txt b/LICENSE.txt index 0eadf47..01e3613 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,4 +1,4 @@ -Copyright (c) 2009-2015, Carl Meyer and contributors +Copyright (c) 2009-2019, Carl Meyer and contributors All rights reserved. Redistribution and use in source and binary forms, with or without diff --git a/README.rst b/README.rst index 3014eb0..6d74e7a 100644 --- a/README.rst +++ b/README.rst @@ -14,7 +14,7 @@ django-model-utils Django model mixins and utilities. -``django-model-utils`` supports `Django`_ 1.8 to 2.0. +``django-model-utils`` supports `Django`_ 1.8 to 2.1. .. _Django: http://www.djangoproject.com/ @@ -28,6 +28,15 @@ Getting Help Documentation for django-model-utils is available https://django-model-utils.readthedocs.io/ + +Run tests +--------- + +.. code-block + + pip install -e . + py.test + Contributing ============ diff --git a/docs/managers.rst b/docs/managers.rst index 43aa030..b90f3d4 100644 --- a/docs/managers.rst +++ b/docs/managers.rst @@ -86,6 +86,33 @@ it's safe to use as your default manager for the model. .. _contributed by Jeff Elmore: http://jeffelmore.org/2010/11/11/automatic-downcasting-of-inherited-models-in-django/ +JoinManager +----------- + +The ``JoinManager`` will create a temporary table of your current queryset +and join that temporary table with the model of your current queryset. This can +be advantageous if you have to page through your entire DB and using django's +slice mechanism to do that. ``LIMIT .. OFFSET ..`` becomes slower the bigger +offset you use. + +.. code-block:: python + + sliced_qs = Place.objects.all()[2000:2010] + qs = sliced_qs.join() + # qs contains 10 objects, and there will be a much smaller performance hit + # for paging through all of first 2000 objects. + +Alternatively, you can give it a queryset and the manager will create a temporary +table and join that to your current queryset. This can work as a more performant +alternative to using django's ``__in`` as described in the following +(`StackExchange answer`_). + +.. code-block:: python + + big_qs = Restaurant.objects.filter(menu='vegetarian') + qs = Country.objects.filter(country_code='SE').join(big_qs) + +.. _StackExchange answer: https://dba.stackexchange.com/questions/91247/optimizing-a-postgres-query-with-a-large-in .. _QueryManager: diff --git a/model_utils/__init__.py b/model_utils/__init__.py index 54f1b02..2fa87c0 100644 --- a/model_utils/__init__.py +++ b/model_utils/__init__.py @@ -1,4 +1,4 @@ from .choices import Choices # noqa:F401 from .tracker import FieldTracker, ModelTracker # noqa:F401 -__version__ = '3.1.2' +__version__ = '3.2.0' diff --git a/model_utils/choices.py b/model_utils/choices.py index 681e44b..6339503 100644 --- a/model_utils/choices.py +++ b/model_utils/choices.py @@ -102,6 +102,9 @@ class Choices(object): def __iter__(self): return iter(self._doubles) + def __reversed__(self): + return reversed(self._doubles) + def __getattr__(self, attname): try: return self._identifier_map[attname] diff --git a/model_utils/fields.py b/model_utils/fields.py index d9d9b85..2799eac 100644 --- a/model_utils/fields.py +++ b/model_utils/fields.py @@ -32,6 +32,11 @@ class AutoLastModifiedField(AutoCreatedField): """ def pre_save(self, model_instance, add): value = now() + if not model_instance.pk: + for field in model_instance._meta.get_fields(): + if isinstance(field, AutoCreatedField): + value = getattr(model_instance, field.name) + break setattr(model_instance, self.attname, value) return value diff --git a/model_utils/locale/cs/LC_MESSAGES/django.mo b/model_utils/locale/cs/LC_MESSAGES/django.mo new file mode 100644 index 0000000..758c32d Binary files /dev/null and b/model_utils/locale/cs/LC_MESSAGES/django.mo differ diff --git a/model_utils/locale/cs/LC_MESSAGES/django.po b/model_utils/locale/cs/LC_MESSAGES/django.po new file mode 100644 index 0000000..eae5e9a --- /dev/null +++ b/model_utils/locale/cs/LC_MESSAGES/django.po @@ -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 , 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 \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" diff --git a/model_utils/locale/ru/LC_MESSAGES/django.mo b/model_utils/locale/ru/LC_MESSAGES/django.mo new file mode 100644 index 0000000..8edb2d8 Binary files /dev/null and b/model_utils/locale/ru/LC_MESSAGES/django.mo differ diff --git a/model_utils/locale/ru/LC_MESSAGES/django.po b/model_utils/locale/ru/LC_MESSAGES/django.po new file mode 100644 index 0000000..bd5d90c --- /dev/null +++ b/model_utils/locale/ru/LC_MESSAGES/django.po @@ -0,0 +1,43 @@ +# This file is distributed under the same license as the django-model-utils package. +# +# Translators: +# Arseny Sysolyatin , 2017. +msgid "" +msgstr "" +"Project-Id-Version: django-model-utils\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2017-05-22 19:46+0300\n" +"PO-Revision-Date: 2017-05-22 19:46+0300\n" +"Last-Translator: Arseny Sysolyatin \n" +"Language-Team: \n" +"Language: ru\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=4; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n" +"%10<=4 && (n%100<12 || n%100>14) ? 1 : n%10==0 || (n%10>=5 && n%10<=9) || (n" +"%100>=11 && n%100<=14)? 2 : 3);\n" + +#: models.py:24 +msgid "created" +msgstr "создано" + +#: models.py:25 +msgid "modified" +msgstr "изменено" + +#: models.py:37 +msgid "start" +msgstr "начало" + +#: models.py:38 +msgid "end" +msgstr "конец" + +#: models.py:53 +msgid "status" +msgstr "статус" + +#: models.py:54 +msgid "status changed" +msgstr "статус изменен" diff --git a/model_utils/managers.py b/model_utils/managers.py index b760ffd..15b5c7c 100644 --- a/model_utils/managers.py +++ b/model_utils/managers.py @@ -3,16 +3,15 @@ import django from django.db import models from django.db.models.fields.related import OneToOneField, OneToOneRel from django.db.models.query import QuerySet -try: - from django.db.models.query import BaseIterable, ModelIterable -except ImportError: - # Django 1.8 does not have iterable classes - BaseIterable, ModelIterable = object, object +from django.db.models.query import ModelIterable from django.core.exceptions import ObjectDoesNotExist from django.db.models.constants import LOOKUP_SEP from django.utils.six import string_types +from django.db import connection +from django.db.models.sql.datastructures import Join + class InheritanceIterable(ModelIterable): def __iter__(self): @@ -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 diff --git a/model_utils/models.py b/model_utils/models.py index c679fc6..2f21695 100644 --- a/model_utils/models.py +++ b/model_utils/models.py @@ -123,6 +123,7 @@ class SoftDeletableModel(models.Model): abstract = True objects = SoftDeletableManager() + all_objects = models.Manager() def delete(self, using=None, soft=True, *args, **kwargs): """ diff --git a/model_utils/tracker.py b/model_utils/tracker.py index 8ce10dc..837e1ce 100644 --- a/model_utils/tracker.py +++ b/model_utils/tracker.py @@ -207,6 +207,7 @@ class FieldTracker(object): def contribute_to_class(self, cls, name): self.name = name self.attname = '_%s' % name + self.patch_save(cls) models.signals.class_prepared.connect(self.finalize_class, sender=cls) def finalize_class(self, sender, **kwargs): @@ -230,14 +231,13 @@ class FieldTracker(object): tracker = self.tracker_class(instance, self.fields, self.field_map) setattr(instance, self.attname, tracker) tracker.set_saved_fields() - self.patch_save(instance) instance._instance_intialized = True - def patch_save(self, instance): - original_save = instance.save + def patch_save(self, model): + original_save = model.save - def save(**kwargs): - ret = original_save(**kwargs) + def save(instance, *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: diff --git a/requirements-test.txt b/requirements-test.txt index 493f267..fa21abd 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,2 +1,3 @@ pytest==3.3.1 pytest-django==3.1.2 +psycopg2==2.7.6.1 diff --git a/tests/models.py b/tests/models.py index 80f3a3f..888aba5 100644 --- a/tests/models.py +++ b/tests/models.py @@ -9,7 +9,11 @@ from django.utils.translation import ugettext_lazy as _ from model_utils import Choices from model_utils.fields import SplitField, MonitorField, StatusField -from model_utils.managers import QueryManager, InheritanceManager +from model_utils.managers import ( + QueryManager, + InheritanceManager, + JoinManagerMixin +) from model_utils.models import ( SoftDeletableModel, StatusModel, @@ -370,3 +374,22 @@ class ModelWithCustomDescriptor(models.Model): tracked_regular_field = models.IntegerField() tracker = FieldTracker(fields=['tracked_custom_field', 'tracked_regular_field']) + + +class JoinManager(JoinManagerMixin, models.Manager): + pass + + +class BoxJoinModel(models.Model): + name = models.CharField(max_length=32) + objects = JoinManager() + + +class JoinItemForeignKey(models.Model): + weight = models.IntegerField() + belonging = models.ForeignKey( + BoxJoinModel, + null=True, + on_delete=models.CASCADE + ) + objects = JoinManager() diff --git a/tests/settings.py b/tests/settings.py index 8817e83..e34c891 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -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', + } +} diff --git a/tests/test_choices.py b/tests/test_choices.py index 29dbb36..986670c 100644 --- a/tests/test_choices.py +++ b/tests/test_choices.py @@ -16,7 +16,12 @@ class ChoicesTests(TestCase): self.assertEqual(self.STATUS['PUBLISHED'], 'PUBLISHED') def test_iteration(self): - self.assertEqual(tuple(self.STATUS), (('DRAFT', 'DRAFT'), ('PUBLISHED', 'PUBLISHED'))) + self.assertEqual(tuple(self.STATUS), + (('DRAFT', 'DRAFT'), ('PUBLISHED', 'PUBLISHED'))) + + def test_reversed(self): + self.assertEqual(tuple(reversed(self.STATUS)), + (('PUBLISHED', 'PUBLISHED'), ('DRAFT', 'DRAFT'))) def test_len(self): self.assertEqual(len(self.STATUS), 2) @@ -78,8 +83,15 @@ class LabelChoicesTests(ChoicesTests): self.assertEqual(tuple(self.STATUS), ( ('DRAFT', 'is draft'), ('PUBLISHED', 'is published'), - ('DELETED', 'DELETED')) - ) + ('DELETED', 'DELETED'), + )) + + def test_reversed(self): + self.assertEqual(tuple(reversed(self.STATUS)), ( + ('DELETED', 'DELETED'), + ('PUBLISHED', 'is published'), + ('DRAFT', 'is draft'), + )) def test_indexing(self): self.assertEqual(self.STATUS['PUBLISHED'], 'is published') @@ -169,7 +181,15 @@ class IdentifierChoicesTests(ChoicesTests): self.assertEqual(tuple(self.STATUS), ( (0, 'is draft'), (1, 'is published'), - (2, 'is deleted'))) + (2, 'is deleted'), + )) + + def test_reversed(self): + self.assertEqual(tuple(reversed(self.STATUS)), ( + (2, 'is deleted'), + (1, 'is published'), + (0, 'is draft'), + )) def test_indexing(self): self.assertEqual(self.STATUS[1], 'is published') diff --git a/tests/test_fields/test_field_tracker.py b/tests/test_fields/test_field_tracker.py index 43f12f5..5b5d5c2 100644 --- a/tests/test_fields/test_field_tracker.py +++ b/tests/test_fields/test_field_tracker.py @@ -3,7 +3,7 @@ from __future__ import unicode_literals import django from django.core.exceptions import FieldError from django.test import TestCase - +from django.core.cache import cache from model_utils import FieldTracker from model_utils.tracker import DescriptorWrapper from tests.models import ( @@ -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' diff --git a/tests/test_managers/test_inheritance_manager.py b/tests/test_managers/test_inheritance_manager.py index 7cde8d3..d2b8b4f 100644 --- a/tests/test_managers/test_inheritance_manager.py +++ b/tests/test_managers/test_inheritance_manager.py @@ -123,10 +123,16 @@ class InheritanceManagerTests(TestCase): ensure that the relation names and subclasses are obtained correctly. """ child3 = InheritanceManagerTestChild3.objects.create() - results = InheritanceManagerTestParent.objects.all().select_subclasses() + qs = InheritanceManagerTestParent.objects.all() + results = qs.select_subclasses().order_by('pk') - expected_objs = [self.child1, self.child2, self.grandchild1, - self.grandchild1_2, child3] + expected_objs = [ + self.child1, + self.child2, + self.grandchild1, + self.grandchild1_2, + child3 + ] self.assertEqual(list(results), expected_objs) expected_related_names = [ @@ -146,7 +152,8 @@ class InheritanceManagerTests(TestCase): """ related_name = 'manual_onetoone' child3 = InheritanceManagerTestChild3.objects.create() - results = InheritanceManagerTestParent.objects.all().select_subclasses(related_name) + qs = InheritanceManagerTestParent.objects.all() + results = qs.select_subclasses(related_name).order_by('pk') expected_objs = [InheritanceManagerTestParent(pk=self.child1.pk), InheritanceManagerTestParent(pk=self.child2.pk), @@ -389,14 +396,16 @@ class InheritanceManagerUsingModelsTests(TestCase): """ child3 = InheritanceManagerTestChild3.objects.create() results = InheritanceManagerTestParent.objects.all().select_subclasses( - InheritanceManagerTestChild3) + InheritanceManagerTestChild3).order_by('pk') - expected_objs = [InheritanceManagerTestParent(pk=self.parent1.pk), - InheritanceManagerTestParent(pk=self.child1.pk), - InheritanceManagerTestParent(pk=self.child2.pk), - InheritanceManagerTestParent(pk=self.grandchild1.pk), - InheritanceManagerTestParent(pk=self.grandchild1_2.pk), - child3] + expected_objs = [ + InheritanceManagerTestParent(pk=self.parent1.pk), + InheritanceManagerTestParent(pk=self.child1.pk), + InheritanceManagerTestParent(pk=self.child2.pk), + InheritanceManagerTestParent(pk=self.grandchild1.pk), + InheritanceManagerTestParent(pk=self.grandchild1_2.pk), + child3 + ] self.assertEqual(list(results), expected_objs) expected_related_names = ['manual_onetoone'] @@ -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) diff --git a/tests/test_managers/test_join_manager.py b/tests/test_managers/test_join_manager.py new file mode 100644 index 0000000..b8a8131 --- /dev/null +++ b/tests/test_managers/test_join_manager.py @@ -0,0 +1,38 @@ + +from django.test import TestCase + +from tests.models import JoinItemForeignKey, BoxJoinModel + + +class JoinManagerTest(TestCase): + def setUp(self): + for i in range(20): + BoxJoinModel.objects.create(name='name_{i}'.format(i=i)) + + JoinItemForeignKey.objects.create( + weight=10, belonging=BoxJoinModel.objects.get(name='name_1') + ) + JoinItemForeignKey.objects.create(weight=20) + + def test_self_join(self): + a_slice = BoxJoinModel.objects.all()[0:10] + with self.assertNumQueries(1): + result = a_slice.join() + self.assertEquals(result.count(), 10) + + def test_self_join_with_where_statement(self): + qs = BoxJoinModel.objects.filter(name='name_1') + result = qs.join() + self.assertEquals(result.count(), 1) + + def test_join_with_other_qs(self): + item_qs = JoinItemForeignKey.objects.filter(weight=10) + boxes = BoxJoinModel.objects.all().join(qs=item_qs) + self.assertEquals(boxes.count(), 1) + self.assertEquals(boxes[0].name, 'name_1') + + def test_reverse_join(self): + box_qs = BoxJoinModel.objects.filter(name='name_1') + items = JoinItemForeignKey.objects.all().join(box_qs) + self.assertEquals(items.count(), 1) + self.assertEquals(items[0].weight, 10) diff --git a/tests/test_models/test_timestamped_model.py b/tests/test_models/test_timestamped_model.py index 8760411..cac07f3 100644 --- a/tests/test_models/test_timestamped_model.py +++ b/tests/test_models/test_timestamped_model.py @@ -15,6 +15,13 @@ class TimeStampedModelTests(TestCase): t1 = TimeStamp.objects.create() self.assertEqual(t1.created, datetime(2016, 1, 1)) + def test_created_sets_modified(self): + ''' + Ensure that on creation that modifed is set exactly equal to created. + ''' + t1 = TimeStamp.objects.create() + self.assertEqual(t1.created, t1.modified) + def test_modified(self): with freeze_time(datetime(2016, 1, 1)): t1 = TimeStamp.objects.create() diff --git a/tox.ini b/tox.ini index 7fd9cca..c092160 100644 --- a/tox.ini +++ b/tox.ini @@ -1,14 +1,13 @@ [tox] envlist = - py27-django{18,19,110,111} - py34-django{18,19,110,111,200} - py35-django{18,19,110,111,200,201,trunk} + py27-django{19,110,111} + py34-django{19,110,111,200} + py35-django{19,110,111,200,201,trunk} py36-django{111,200,201,trunk} flake8 [testenv] deps = - django18: Django>=1.8,<1.9 django19: Django>=1.9,<1.10 django110: Django>=1.10,<1.11 django111: Django>=1.11,<1.12