From 5911d56800eda17d3433338dfa759a2f465d7359 Mon Sep 17 00:00:00 2001 From: andrey-skvortsov Date: Mon, 19 Feb 2018 19:34:40 +0000 Subject: [PATCH 1/7] Bump to v1.10.0 --- CHANGES.rst | 7 +++++++ docs/conf.py | 4 ++-- parler/__init__.py | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 6ddd79a9..8e4b5010 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,13 @@ Changelog ========= +Changes in 1.10.0 (2018-02-19) +----------------------------- + +* Add support select related for transalted models with active and default languages +* Add force_select_related_translations to QuerySet + + Changes in 1.9.2 (2018-02-12) ----------------------------- diff --git a/docs/conf.py b/docs/conf.py index 4f52e433..77b8bae0 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -62,9 +62,9 @@ # built documents. # # The short X.Y version. -version = '1.9.2' +version = '1.10.0' # The full version, including alpha/beta/rc tags. -release = '1.9.2' +release = '1.10.0' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/parler/__init__.py b/parler/__init__.py index 207bf787..2f4d9b44 100644 --- a/parler/__init__.py +++ b/parler/__init__.py @@ -1,5 +1,5 @@ # following PEP 440 -__version__ = "1.9.2" +__version__ = "1.10.0" __all__ = ( 'is_multilingual_project', From e39979ace39454a9f020efea6ebcec0a52de01ae Mon Sep 17 00:00:00 2001 From: andrey-skvortsov Date: Thu, 22 Feb 2018 13:30:58 +0000 Subject: [PATCH 2/7] Add select related for translations for Django v1.9, 1.10, 1.11 --- parler/managers.py | 48 ++++++++++++++++++-------------- parler/models.py | 7 ++--- parler/tests/test_query_count.py | 17 +++++------ parler/tests/testapp/models.py | 15 ++++++++++ 4 files changed, 53 insertions(+), 34 deletions(-) diff --git a/parler/managers.py b/parler/managers.py index f880bb8a..1a25d66b 100644 --- a/parler/managers.py +++ b/parler/managers.py @@ -37,16 +37,18 @@ class TranslatableQuerySet(QuerySet): When using this class in combination with *django-polymorphic*, make sure this class is first in the chain of inherited classes. - When force_select_related_translations set to True in your classes it will always - adds active and default languages to select_related. It could break values_list method in django 1.9+ - You can always add translated models to select_related manually. When you call it with rel_name e.g: 'translations' - it automatically adds active and default virtual composite FKs. + When force_select_related_translations set to True it will always adds translated models with active and + default languages by using virtual composite FKs. + In light version QS with force select related False you can always add translated models to select_related manually. + When you call select_related with translations rel_name e.g: 'translations' it automatically adds active and + default translated models to select_related. """ - force_select_related_translations = False + force_select_related_translations = True def __init__(self, *args, **kwargs): super(TranslatableQuerySet, self).__init__(*args, **kwargs) + self._use_values = False self._language = None def select_related(self, *fields): @@ -64,12 +66,19 @@ def select_related(self, *fields): fields = set(fields).union(fields_to_add).difference(fields_to_exclude) return super(TranslatableQuerySet, self).select_related(*tuple(fields)) + if (1, 9) <= django.VERSION: + def _values(self, *fields): + result = super(TranslatableQuerySet, self)._values(*fields) + result._use_values = True + return result + def _clone(self, klass=None, setup=False, **kw): if django.VERSION < (1, 9): kw['klass'] = klass kw['setup'] = setup c = super(TranslatableQuerySet, self)._clone(**kw) c._language = self._language + c._use_values = self._use_values return c def create(self, **kwargs): @@ -96,30 +105,27 @@ def _add_active_default_select_related(self): self.query.add_select_related(related_to_add) @property - def select_related_not_applicable(self): + def select_related_is_applicable(self): # type: () -> Union[bool, None] """ - Returns is select_related not applicable for current qs. - Currently determine only for django ver 1.8, for others returns None + Returns is select_related is applicable for current qs. + We can not use select_related with values_list, this function checks it. """ result = None if self.model._meta.proxy: - return True + return False - if (1, 7) < django.VERSION < (1, 9): + if (1, 8) <= django.VERSION < (1, 9): ValuesListQuerySet = getattr(django.db.models.query, 'ValuesListQuerySet') - result = isinstance(self, ValuesListQuerySet) + result = not isinstance(self, ValuesListQuerySet) + if (1, 9) <= django.VERSION: + result = not self._use_values return result def _fetch_all(self): - # For django ver > 1.8 when values_list method is used - # _iterable_class (FlatValuesListIterable, ValuesListIterable) is known only in iteration stage not here yet - # TODO: figure out how to determine non qs methods or - # place _add_active_default_select_related in some other place - if self.force_select_related_translations and not self.select_related_not_applicable: + if self.force_select_related_translations and self.select_related_is_applicable: self._add_active_default_select_related() - # Make sure the current language is assigned when Django fetches the data. # This low-level method is overwritten as that works better across Django versions. # Alternatives include: @@ -263,12 +269,12 @@ def active_translations(self, language_code=None, **translated_fields): return self.all().active_translations(language_code, **translated_fields) -class TranslatableAutoSelectRelatedQuerySet(TranslatableQuerySet): - force_select_related_translations = True +class TranslatableLightSelectRelatedQuerySet(TranslatableQuerySet): + force_select_related_translations = False -class TranslatableAutoSelectRelatedManager(TranslatableManager): - queryset_class = TranslatableAutoSelectRelatedQuerySet +class TranslatableLightSelectRelatedManager(TranslatableManager): + queryset_class = TranslatableLightSelectRelatedQuerySet # Export the names in django-hvad style too: diff --git a/parler/models.py b/parler/models.py index c8e466e3..e81543f7 100644 --- a/parler/models.py +++ b/parler/models.py @@ -78,7 +78,7 @@ class Meta: is_missing, ) from parler.fields import TranslatedField, LanguageCodeDescriptor, TranslatedFieldDescriptor -from parler.managers import TranslatableManager, TranslatableAutoSelectRelatedManager +from parler.managers import TranslatableManager from parler.utils import compat from parler.utils.i18n import (normalize_language_code, get_language, get_language_settings, get_language_title, get_null_language_error) @@ -890,10 +890,7 @@ class Meta: abstract = True # change the default manager to the translation manager - if (1, 8) <= django.VERSION < (1, 9): - objects = TranslatableAutoSelectRelatedManager() - else: - objects = TranslatableManager() + objects = TranslatableManager() class TranslatedFieldsModelBase(ModelBase): diff --git a/parler/tests/test_query_count.py b/parler/tests/test_query_count.py index c4bb3dfa..f0bab544 100644 --- a/parler/tests/test_query_count.py +++ b/parler/tests/test_query_count.py @@ -7,7 +7,7 @@ from parler import appsettings from .utils import AppTestCase, override_parler_settings -from .testapp.models import SimpleModel, DateTimeModel +from .testapp.models import SimpleModel, SimpleLightModel, DateTimeModel try: from unittest import skipIf @@ -40,6 +40,7 @@ def setUpClass(cls): for country in cls.country_list: SimpleModel.objects.create(_current_language=cls.conf_fallback, tr_title=country) + SimpleLightModel.objects.create(_current_language=cls.conf_fallback, tr_title=country) DateTimeModel.objects.create(_current_language=cls.conf_fallback, @@ -72,8 +73,8 @@ def test_uncached_queries(self): with override_parler_settings(PARLER_ENABLE_CACHING=False): self.assertNumTranslatedQueries(1 + len(self.country_list), SimpleModel.objects.all()) - @skipIf(not (1, 8) <= django.VERSION < (1, 9), 'Test for django ver 1.8') - def test_uncached_queries_with_auto_select_related(self): + @skipIf(not (1, 8) <= django.VERSION < (2, 0), 'Test for django ver 1.8, 1.9, 1.10, 1.11') + def test_uncached_queries_with_force_select_related(self): """ Test that uncached queries work, albeit slowly. """ @@ -81,14 +82,14 @@ def test_uncached_queries_with_auto_select_related(self): self.assertNumTranslatedQueries(1, SimpleModel.objects.all().select_related('translations')) self.assertNumTranslatedQueries(1, SimpleModel.objects.all()) - @skipIf(not (1, 9) <= django.VERSION < (2, 0), 'Test for django ver 1.9, 1.10, 1.11') - def test_uncached_queries_with_select_related(self): + @skipIf(not (1, 8) <= django.VERSION < (2, 0), 'Test for django ver 1.8, 1.9, 1.10, 1.11') + def test_uncached_queries_with_using_select_related(self): """ Test that uncached queries work, albeit slowly. """ with override_parler_settings(PARLER_ENABLE_CACHING=False): - self.assertNumTranslatedQueries(1, SimpleModel.objects.all().select_related('translations')) - self.assertNumTranslatedQueries(1 + len(self.country_list), SimpleModel.objects.all()) + self.assertNumTranslatedQueries(1, SimpleLightModel.objects.all().select_related('translations')) + self.assertNumTranslatedQueries(1 + len(self.country_list), SimpleLightModel.objects.all()) def test_iteration_with_non_qs_methods(self): """ @@ -120,7 +121,7 @@ def test_model_cache_queries(self): with override_parler_settings(PARLER_ENABLE_CACHING=False): qs = SimpleModel.objects.all() - if (1, 8) <= django.VERSION < (1, 9): + if (1, 8) <= django.VERSION < (2, 0): self.assertNumTranslatedQueries(1, qs) else: self.assertNumTranslatedQueries(1 + len(self.country_list), qs) diff --git a/parler/tests/testapp/models.py b/parler/tests/testapp/models.py index 000f2c34..0b35ba97 100644 --- a/parler/tests/testapp/models.py +++ b/parler/tests/testapp/models.py @@ -2,6 +2,7 @@ from django.db import models from django.utils.encoding import python_2_unicode_compatible from parler.fields import TranslatedField +from parler.managers import TranslatableLightSelectRelatedManager from parler.models import TranslatableModel, TranslatedFields, TranslatedFieldsModel from parler.utils.context import switch_language @@ -33,6 +34,20 @@ def __str__(self): return self.tr_title +@python_2_unicode_compatible +class SimpleLightModel(TranslatableModel): + shared = models.CharField(max_length=200, default='') + + translations = TranslatedFields( + tr_title = models.CharField("Translated Title", max_length=200) + ) + + objects = TranslatableLightSelectRelatedManager() + + def __str__(self): + return self.tr_title + + class CleanCharField(models.CharField): def clean(self, value, model_instance): From 5d7ac0e2c0421025773bf548bb646254ff808bc1 Mon Sep 17 00:00:00 2001 From: andrey-skvortsov Date: Wed, 28 Feb 2018 13:04:03 +0000 Subject: [PATCH 3/7] Add native support translated fields in queryset.only() --- .travis.yml | 2 +- example/article/tests.py | 1 - parler/fields.py | 40 -------------- parler/managers.py | 48 ++++++++++++----- parler/models.py | 36 +++++++++---- parler/tests/test_model_construction.py | 9 +--- parler/tests/test_query_count.py | 8 +-- parler/tests/test_querysets.py | 69 +++++++++++++++++++++++++ parler/utils/fields.py | 10 ++-- setup.py | 5 +- tox.ini | 4 +- 11 files changed, 149 insertions(+), 83 deletions(-) create mode 100644 parler/tests/test_querysets.py diff --git a/.travis.yml b/.travis.yml index 6f40ca8e..82506b0b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -35,7 +35,7 @@ before_install: - pip install codecov install: - pip install -U pip wheel setuptools -- pip install django-composite-foreignkey +- pip install -e git+https://github.com/onysos/django-composite-foreignkey.git@7ac6b5fa7a54ddc6f527f648d87fec87540ea00e#egg=django-composite-foreignkey-1.0.1 - travis_retry pip install $DJANGO -e . script: - coverage run --rcfile=.coveragerc runtests.py diff --git a/example/article/tests.py b/example/article/tests.py index f89e963e..0775caa2 100644 --- a/example/article/tests.py +++ b/example/article/tests.py @@ -255,7 +255,6 @@ def test_admin_delete_translation(self): self.assertEqual(200, resp.status_code) self.assertTemplateUsed(resp, 'admin/parler/deletion_not_allowed.html') - @expectedFailure def test_admin_delete_translation_unavailable(self): """ To be fixed : when trying to delete the last language when a translation diff --git a/parler/fields.py b/parler/fields.py index 8e981e1d..bc38844e 100644 --- a/parler/fields.py +++ b/parler/fields.py @@ -13,10 +13,6 @@ import django from django.forms.forms import pretty_name -from parler.utils.i18n import get_language - -if (1, 8) <= django.VERSION < (2, 0): - from compositefk.fields import RawFieldValue, CompositeOneToOneField # TODO: inherit RelatedField? @@ -166,39 +162,3 @@ def __set__(self, instance, value): def __delete__(self, instance): raise AttributeError("The 'language_code' attribute cannot be deleted!") - - -class DONOTHING(object): - pass - - -if (1, 8) <= django.VERSION < (2, 0): - class CompositeOneToOneVirtualField(CompositeOneToOneField): - """ - Class to fix problem with creation repetitive migrations - """ - def deconstruct(self): - name, path, args, kwargs = super(CompositeOneToOneVirtualField, self).deconstruct() - if 'to_fields' in kwargs: - kwargs['to_fields'] = {'master_id': None, 'language_code': None} # hack: Need always the same dict - if "on_delete" in kwargs: - kwargs['on_delete'] = DONOTHING # hack: Need always the same global object with __module__ attr - if "null_if_equal" in kwargs: - del kwargs['null_if_equal'] - return name, path, args, kwargs - - - class RawActiveLangFieldValue(RawFieldValue): - """ - Raw value with active language - """ - def __init__(self): - super(RawActiveLangFieldValue, self).__init__(None) - - @property - def value(self): - return get_language() - - @value.setter - def value(self, value): - pass diff --git a/parler/managers.py b/parler/managers.py index 1a25d66b..11357adb 100644 --- a/parler/managers.py +++ b/parler/managers.py @@ -9,7 +9,7 @@ from django.utils import six from parler import appsettings from parler.utils import get_active_language_choices -from parler.utils.fields import get_extra_related_translalation_paths +from parler.utils.fields import get_extra_related_translation_paths class SelectRelatedTranslationsQuerySetMixin(object): @@ -24,7 +24,7 @@ class SelectRelatedTranslationsQuerySetMixin(object): def select_related(self, *fields): extra_paths = [] for field in fields: - extra_paths += get_extra_related_translalation_paths(self.model, field) + extra_paths += get_extra_related_translation_paths(self.model, field) if extra_paths: fields = tuple(set(extra_paths)) + fields return super(SelectRelatedTranslationsQuerySetMixin, self).select_related(*fields) @@ -52,20 +52,38 @@ def __init__(self, *args, **kwargs): self._language = None def select_related(self, *fields): + """ + Updates select_related to have active and default always together + Replaces main field refer to translations ('translations') with 'translations_active' and 'translations_default' + """ fields_to_add = set() - fields_to_exclude = set([None]) # if rel_name_active, rel_name_default is None + fields_to_exclude = set() for extension in self.model._parler_meta: - if extension.rel_name in fields: + select_related_translations_fields = extension.get_select_related_translations_fields() + fields_to_search = set(select_related_translations_fields + [extension.rel_name]) + if fields_to_search.intersection(fields): fields_to_exclude.add(extension.rel_name) # Can not select related OneToMany field - fields_to_add.add(extension.rel_name_active) - fields_to_add.add(extension.rel_name_default) - if extension.rel_name_active in fields: - fields_to_add.add(extension.rel_name_default) - if extension.rel_name_default in fields: - fields_to_add.add(extension.rel_name_active) + fields_to_add.update(select_related_translations_fields) fields = set(fields).union(fields_to_add).difference(fields_to_exclude) return super(TranslatableQuerySet, self).select_related(*tuple(fields)) + def only(self, *fields): + """ + Replaces translated fields with 'translations_active' and 'translations_default' + pretending they are in original model so we can use .only + for translated fields as usual: .objects.only('some_translated_field') + """ + fields_to_add = set() + fields_to_exclude = set() + for extension in self.model._parler_meta: + select_related_translations_fields = extension.get_select_related_translations_fields() + translated_fields = set(extension.get_translated_fields()).intersection(fields) + if translated_fields: + fields_to_exclude.update(translated_fields) # Can not select related field form translated model (o2m) + fields_to_add.update(select_related_translations_fields) + fields = set(fields).union(fields_to_add).difference(fields_to_exclude) + return super(TranslatableQuerySet, self).only(*tuple(fields)) + if (1, 9) <= django.VERSION: def _values(self, *fields): result = super(TranslatableQuerySet, self)._values(*fields) @@ -89,15 +107,17 @@ def create(self, **kwargs): return super(TranslatableQuerySet, self).create(**kwargs) def _add_active_default_select_related(self): + """ + Auto-adds select_related for active and default languages. + Takes in account deferred fields. + """ existing, defer = self.query.deferred_loading related_to_add = set() for extension in self.model._parler_meta: if not extension.rel_name: continue - if extension.rel_name_active: - related_to_add.add(extension.rel_name_active) - if extension.rel_name_default: - related_to_add.add(extension.rel_name_default) + select_related_translations_fields = extension.get_select_related_translations_fields() + related_to_add.update(select_related_translations_fields) if defer: related_to_add = related_to_add.difference(existing) elif existing: diff --git a/parler/models.py b/parler/models.py index e81543f7..194a68ba 100644 --- a/parler/models.py +++ b/parler/models.py @@ -94,9 +94,8 @@ class Meta: else: from django.db.models.fields.related import ReverseSingleRelatedObjectDescriptor as ForwardManyToOneDescriptor -if (1, 8) <= django.VERSION < (2, 0): - from compositefk.fields import RawFieldValue - from parler.fields import CompositeOneToOneVirtualField, RawActiveLangFieldValue +if django.VERSION >= (1, 8): + from compositefk.fields import RawFieldValue, FunctionBasedFieldValue, CompositeOneToOneField __all__ = ( @@ -143,32 +142,39 @@ def create_translations_composite_fk(shared_model, related_name, translated_mode Note: django-composite-foreignkey does not work in django 1.7 and 2+ """ - if not (1, 8) <= django.VERSION < (2, 0): + if django.VERSION < (1, 8): return meta = shared_model._parler_meta._get_extension_by_related_name(related_name) meta.rel_name_active = related_name + '_active' meta.rel_name_default = related_name + '_default' - translations_active = CompositeOneToOneVirtualField( + translations_active = CompositeOneToOneField( translated_model, null=True, on_delete=models.DO_NOTHING, - related_name='master_active', + related_name='+', to_fields={ 'master_id': shared_model._meta.pk.name, - 'language_code': RawActiveLangFieldValue() + 'language_code': FunctionBasedFieldValue(get_language) }) + # Needs hack here. + # Set one_to_one = False as Django treat this field as a reversed + # see: django.db.models.sql.query.is_reverse_o2o + # Django does not include this field to 'must query fields', so it became deferred field if it used with only. + # To be able use the field in select_related field must be not deferred. + translations_active.one_to_one = False translations_active.contribute_to_class(shared_model, meta.rel_name_active) - translations_default = CompositeOneToOneVirtualField( + translations_default = CompositeOneToOneField( translated_model, null=True, on_delete=models.DO_NOTHING, - related_name='master_default', + related_name='+', to_fields={ 'master_id': shared_model._meta.pk.name, 'language_code': RawFieldValue(appsettings.PARLER_LANGUAGES.get_default_language()) }) + translations_default.one_to_one = False translations_default.contribute_to_class(shared_model, meta.rel_name_default) @@ -1175,6 +1181,14 @@ def __repr__(self): self.model.__name__ ) + def get_select_related_translations_fields(self): + result = [] + if self.rel_name_active: + result.append(self.rel_name_active) + if self.rel_name_default: + result.append(self.rel_name_default) + return result + class ParlerOptions(object): """ @@ -1298,6 +1312,10 @@ def get_translated_fields(self, related_name=None): meta = self._get_extension_by_related_name(related_name) return meta.get_translated_fields() + def get_select_related_translations_fields(self, related_name=None): + meta = self._get_extension_by_related_name(related_name) + return meta.get_select_related_translations_fields() + def get_model_by_field(self, name): """ Find the :class:`TranslatedFieldsModel` that contains the given field. diff --git a/parler/tests/test_model_construction.py b/parler/tests/test_model_construction.py index 7233eff4..02050107 100644 --- a/parler/tests/test_model_construction.py +++ b/parler/tests/test_model_construction.py @@ -1,18 +1,11 @@ from functools import wraps - +from unittest import expectedFailure, skipIf import django from django.db import models from django.db.models import Manager from django.utils import six from parler.models import TranslatableModel from parler.models import TranslatedFields - -try: - from unittest import expectedFailure, skipIf -except ImportError: - # python<2.7 - from django.utils.unittest import expectedFailure, skipIf - from .utils import AppTestCase from .testapp.models import ManualModel, ManualModelTranslations, SimpleModel, Level1, Level2, ProxyBase, ProxyModel, DoubleModel, RegularModel, CharModel diff --git a/parler/tests/test_query_count.py b/parler/tests/test_query_count.py index f0bab544..c2cea752 100644 --- a/parler/tests/test_query_count.py +++ b/parler/tests/test_query_count.py @@ -65,7 +65,7 @@ def test_qs(): with translation.override(language_code): self.assertNumQueries(num, test_qs) - @skipIf((1, 8) <= django.VERSION < (2, 0), 'Test for django ver 1.7, 2') + @skipIf((1, 8) <= django.VERSION , 'Test for django ver 1.7') def test_uncached_queries(self): """ Test that uncached queries work, albeit slowly. @@ -73,7 +73,7 @@ def test_uncached_queries(self): with override_parler_settings(PARLER_ENABLE_CACHING=False): self.assertNumTranslatedQueries(1 + len(self.country_list), SimpleModel.objects.all()) - @skipIf(not (1, 8) <= django.VERSION < (2, 0), 'Test for django ver 1.8, 1.9, 1.10, 1.11') + @skipIf(django.VERSION < (1, 8), 'Test for django ver > 1.7') def test_uncached_queries_with_force_select_related(self): """ Test that uncached queries work, albeit slowly. @@ -82,7 +82,7 @@ def test_uncached_queries_with_force_select_related(self): self.assertNumTranslatedQueries(1, SimpleModel.objects.all().select_related('translations')) self.assertNumTranslatedQueries(1, SimpleModel.objects.all()) - @skipIf(not (1, 8) <= django.VERSION < (2, 0), 'Test for django ver 1.8, 1.9, 1.10, 1.11') + @skipIf(django.VERSION < (1, 8), 'Test for django ver > 1.7') def test_uncached_queries_with_using_select_related(self): """ Test that uncached queries work, albeit slowly. @@ -121,7 +121,7 @@ def test_model_cache_queries(self): with override_parler_settings(PARLER_ENABLE_CACHING=False): qs = SimpleModel.objects.all() - if (1, 8) <= django.VERSION < (2, 0): + if (1, 8) <= django.VERSION: self.assertNumTranslatedQueries(1, qs) else: self.assertNumTranslatedQueries(1 + len(self.country_list), qs) diff --git a/parler/tests/test_querysets.py b/parler/tests/test_querysets.py new file mode 100644 index 00000000..583db04e --- /dev/null +++ b/parler/tests/test_querysets.py @@ -0,0 +1,69 @@ +from __future__ import absolute_import, unicode_literals +from unittest import skipIf +import django +from django.utils import translation +from .utils import AppTestCase +from .testapp.models import SimpleModel, SimpleLightModel + + +class QuerySetsTests(AppTestCase): + def setUp(self): + super(QuerySetsTests, self).setUp() + self.title = 'TITLE_XX' + self.id = SimpleModel.objects.create(tr_title=self.title).pk + self.light_model_id = SimpleLightModel.objects.create(tr_title=self.title).pk + + def assertNumTranslatedQueries(self, num, qs): + def test_qs(): + for obj in qs: + str(obj.tr_title) + self.assertNumQueries(num, test_qs) + + @skipIf(django.VERSION < (1, 8), 'Test for django ver > 1.7') + def test_select_related_light_model(self): + with translation.override('ca-fr'): + qs = SimpleLightModel.objects.select_related('translations').filter(pk=self.light_model_id) + + self.assertNumTranslatedQueries(1, qs.select_related('translations')) + self.assertNumTranslatedQueries(1, qs.select_related('translations_active')) + + x = SimpleLightModel.objects.select_related('translations').get(pk=self.light_model_id) + self.assertEqual(x.tr_title, self.title) + + x = SimpleLightModel.objects.select_related('translations_active').get(pk=self.light_model_id) + self.assertEqual(x.tr_title, self.title) + + @skipIf(django.VERSION < (1, 8), 'Test for django ver > 1.7') + def test_select_related_force_model(self): + with translation.override('ca-fr'): + qs = SimpleModel.objects.select_related('translations').filter(pk=self.id) + + self.assertNumTranslatedQueries(1, qs.select_related('translations')) + self.assertNumTranslatedQueries(1, qs.select_related('translations_active')) + + x = SimpleModel.objects.select_related('translations').get(pk=self.id) + self.assertEqual(x.tr_title, self.title) + + x = SimpleModel.objects.select_related('translations_active').get(pk=self.id) + self.assertEqual(x.tr_title, self.title) + + @skipIf(django.VERSION < (1, 8), 'Test for django ver > 1.7') + def test_only(self): + with translation.override('ca-fr'): + qs = SimpleModel.objects.all().only('id') + self.assertNumTranslatedQueries(2, qs) # needs query for ca-fr + + qs = SimpleModel.objects.all().only('id', 'translations_default') # needs query for ca-fr + self.assertNumTranslatedQueries(2, qs) + + qs = SimpleModel.objects.all().only('id', 'translations_active') # active 'ca-fr' should been select_related + self.assertNumTranslatedQueries(1, qs) + + qs = SimpleModel.objects.all().only('id', 'tr_title') # should be replaced with active and default + self.assertNumTranslatedQueries(1, qs) + + x = SimpleModel.objects.all().only('id').get(pk=self.id) + self.assertEqual(x.tr_title, self.title) + + x = SimpleModel.objects.all().only('id', 'tr_title').get(pk=self.id) + self.assertEqual(x.tr_title, self.title) diff --git a/parler/utils/fields.py b/parler/utils/fields.py index c027d1d3..8256b849 100644 --- a/parler/utils/fields.py +++ b/parler/utils/fields.py @@ -9,13 +9,15 @@ class NotRelationField(Exception): def get_model_from_relation(field): # type: (django.db.models.fields.Field) -> models.Model - if hasattr(field, 'get_path_info'): - return field.get_path_info()[-1].to_opts.model - else: + try: + path_info = field.get_path_info() + except AttributeError: raise NotRelationField + else: + return path_info[-1].to_opts.model -def get_extra_related_translalation_paths(model, path): +def get_extra_related_translation_paths(model, path): # type: (models.Model, str) -> List[str] """ Returns paths with active and default transalation models for all Translatable models in path diff --git a/setup.py b/setup.py index 76649aaf..1b343f9e 100755 --- a/setup.py +++ b/setup.py @@ -40,7 +40,7 @@ def find_version(*parts): install_requires=[ 'Django (>=1.7)', - 'django-composite-foreignkey (>=1.0.0.a10)', + 'django-composite-foreignkey (==1.0.1)', ], description='Simple Django model translations without nasty hacks, featuring nice admin integration.', @@ -80,5 +80,8 @@ def find_version(*parts): 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', 'Topic :: Software Development :: Libraries :: Application Frameworks', 'Topic :: Software Development :: Libraries :: Python Modules', + ], + dependency_links=[ + 'git+https://github.com/onysos/django-composite-foreignkey.git@7ac6b5fa7a54ddc6f527f648d87fec87540ea00e#egg=django-composite-foreignkey-1.0.1' ] ) diff --git a/tox.ini b/tox.ini index a0183848..3b8bac83 100644 --- a/tox.ini +++ b/tox.ini @@ -10,7 +10,9 @@ envlist= docs, [testenv] -deps = django-polymorphic +deps = + django-polymorphic + django-composite-foreignkey: https://github.com/onysos/django-composite-foreignkey.git == 1.0.1 django17: Django >= 1.7,<1.8 django18: Django >= 1.8,<1.9 django19: Django >= 1.9,<1.10 From da47ae0b896758192df417714d318915ceb57cb6 Mon Sep 17 00:00:00 2001 From: Shun Liang Date: Sat, 10 Mar 2018 11:06:53 +0000 Subject: [PATCH 4/7] Unbump the version and use PyPI release of django-composite-foreignkey --- .travis.yml | 2 +- CHANGES.rst | 7 ------- docs/conf.py | 4 ++-- parler/__init__.py | 2 +- setup.py | 7 ++----- tox.ini | 2 +- 6 files changed, 7 insertions(+), 17 deletions(-) diff --git a/.travis.yml b/.travis.yml index 82506b0b..6f40ca8e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -35,7 +35,7 @@ before_install: - pip install codecov install: - pip install -U pip wheel setuptools -- pip install -e git+https://github.com/onysos/django-composite-foreignkey.git@7ac6b5fa7a54ddc6f527f648d87fec87540ea00e#egg=django-composite-foreignkey-1.0.1 +- pip install django-composite-foreignkey - travis_retry pip install $DJANGO -e . script: - coverage run --rcfile=.coveragerc runtests.py diff --git a/CHANGES.rst b/CHANGES.rst index 8e4b5010..6ddd79a9 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,13 +1,6 @@ Changelog ========= -Changes in 1.10.0 (2018-02-19) ------------------------------ - -* Add support select related for transalted models with active and default languages -* Add force_select_related_translations to QuerySet - - Changes in 1.9.2 (2018-02-12) ----------------------------- diff --git a/docs/conf.py b/docs/conf.py index 77b8bae0..4f52e433 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -62,9 +62,9 @@ # built documents. # # The short X.Y version. -version = '1.10.0' +version = '1.9.2' # The full version, including alpha/beta/rc tags. -release = '1.10.0' +release = '1.9.2' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/parler/__init__.py b/parler/__init__.py index 2f4d9b44..207bf787 100644 --- a/parler/__init__.py +++ b/parler/__init__.py @@ -1,5 +1,5 @@ # following PEP 440 -__version__ = "1.10.0" +__version__ = "1.9.2" __all__ = ( 'is_multilingual_project', diff --git a/setup.py b/setup.py index 1b343f9e..091d2273 100755 --- a/setup.py +++ b/setup.py @@ -40,7 +40,7 @@ def find_version(*parts): install_requires=[ 'Django (>=1.7)', - 'django-composite-foreignkey (==1.0.1)', + 'django-composite-foreignkey (>=1.0.1)', ], description='Simple Django model translations without nasty hacks, featuring nice admin integration.', @@ -55,7 +55,7 @@ def find_version(*parts): packages=find_packages(exclude=('example*',)), include_package_data=True, - test_suite = 'runtests', + test_suite='runtests', zip_safe=False, classifiers=[ @@ -81,7 +81,4 @@ def find_version(*parts): 'Topic :: Software Development :: Libraries :: Application Frameworks', 'Topic :: Software Development :: Libraries :: Python Modules', ], - dependency_links=[ - 'git+https://github.com/onysos/django-composite-foreignkey.git@7ac6b5fa7a54ddc6f527f648d87fec87540ea00e#egg=django-composite-foreignkey-1.0.1' - ] ) diff --git a/tox.ini b/tox.ini index 3b8bac83..6b0bb874 100644 --- a/tox.ini +++ b/tox.ini @@ -12,7 +12,7 @@ envlist= [testenv] deps = django-polymorphic - django-composite-foreignkey: https://github.com/onysos/django-composite-foreignkey.git == 1.0.1 + django-composite-foreignkey: >= 1.0.1 django17: Django >= 1.7,<1.8 django18: Django >= 1.8,<1.9 django19: Django >= 1.9,<1.10 From ad65fd842a6c66ca5b5a988905d1c81695b55bfb Mon Sep 17 00:00:00 2001 From: Shun Liang Date: Sun, 11 Mar 2018 16:18:48 +0000 Subject: [PATCH 5/7] Only use the extra path of the last field for select_related --- parler/models.py | 2 +- parler/utils/fields.py | 52 +++++++++++++++++++++++------------------- 2 files changed, 29 insertions(+), 25 deletions(-) diff --git a/parler/models.py b/parler/models.py index 194a68ba..2cb630f2 100644 --- a/parler/models.py +++ b/parler/models.py @@ -140,7 +140,7 @@ def create_translations_composite_fk(shared_model, related_name, translated_mode to get data in certain (active) language. Does not cover fallback languages, for which you can use prefetch_related - Note: django-composite-foreignkey does not work in django 1.7 and 2+ + Note: django-composite-foreignkey only works for Django version >= 1.8 """ if django.VERSION < (1, 8): return diff --git a/parler/utils/fields.py b/parler/utils/fields.py index 8256b849..20b184a6 100644 --- a/parler/utils/fields.py +++ b/parler/utils/fields.py @@ -1,38 +1,42 @@ from django.db.models.constants import LOOKUP_SEP from django.db import models -import django.db.models.fields +from django.db.models.fields.related import RelatedField +try: + from django.db.models.fields.reverse_related import ForeignObjectRel +except ImportError: + from django.db.models.fields.related import ForeignObjectRel -class NotRelationField(Exception): - pass +def _get_last_field_from_path(model, path): + # type: (models.Model, str) -> models.fields.Field + path_parts = path.split(LOOKUP_SEP) + option = model._meta -def get_model_from_relation(field): - # type: (django.db.models.fields.Field) -> models.Model - try: + for part in path_parts[:-1]: + field = option.get_field(part) path_info = field.get_path_info() - except AttributeError: - raise NotRelationField - else: - return path_info[-1].to_opts.model + option = path_info[-1].to_opts + + last_part = path_parts[-1] + return option.get_field(last_part) def get_extra_related_translation_paths(model, path): # type: (models.Model, str) -> List[str] """ - Returns paths with active and default transalation models for all Translatable models in path + Returns paths with active and default translation models for all Translatable models in path """ from parler.models import TranslatableModel - pieces = path.split(LOOKUP_SEP) - parent = model - current_path = '' - extra_paths = [] - for piece in pieces: - field = parent._meta.get_field(piece) - parent = get_model_from_relation(field) - current_path += LOOKUP_SEP + piece if current_path else piece - if issubclass(parent, TranslatableModel): - for extension in parent._parler_meta: - extra_paths.append(current_path + LOOKUP_SEP + extension.rel_name_active) - extra_paths.append(current_path + LOOKUP_SEP + extension.rel_name_default) - return extra_paths + + last_field = _get_last_field_from_path(model=model, path=path) + is_last_field_related_field = isinstance(last_field, RelatedField) or isinstance(last_field, ForeignObjectRel) + + if is_last_field_related_field and issubclass(last_field.related_model, TranslatableModel): + extra_paths = [] + for extension in last_field.related_model._parler_meta: + extra_paths.append(path + LOOKUP_SEP + extension.rel_name_active) + extra_paths.append(path + LOOKUP_SEP + extension.rel_name_default) + return extra_paths + + return [] From f5d1c129a863e750f7b8b9ccf1476b9407e54a76 Mon Sep 17 00:00:00 2001 From: Shun Liang Date: Sun, 11 Mar 2018 22:20:25 +0000 Subject: [PATCH 6/7] Add test cases for SelectRelatedTranslationsQuerySetMixin --- parler/tests/test_querysets.py | 43 ++++++++++++++++++++++++++++++---- parler/tests/testapp/models.py | 27 ++++++++++++++++++++- 2 files changed, 65 insertions(+), 5 deletions(-) diff --git a/parler/tests/test_querysets.py b/parler/tests/test_querysets.py index 583db04e..a7cc27ee 100644 --- a/parler/tests/test_querysets.py +++ b/parler/tests/test_querysets.py @@ -3,7 +3,8 @@ import django from django.utils import translation from .utils import AppTestCase -from .testapp.models import SimpleModel, SimpleLightModel +from .testapp.models import SimpleModel, SimpleLightModel, SimpleRelatedModel, TranslatedSimpleRelatedModel, \ + AnotherRelatedModel class QuerySetsTests(AppTestCase): @@ -11,6 +12,12 @@ def setUp(self): super(QuerySetsTests, self).setUp() self.title = 'TITLE_XX' self.id = SimpleModel.objects.create(tr_title=self.title).pk + + +@skipIf(django.VERSION < (1, 8), 'Test for django ver > 1.7') +class TranslatableQuerySetTests(QuerySetsTests): + def setUp(self): + super(TranslatableQuerySetTests, self).setUp() self.light_model_id = SimpleLightModel.objects.create(tr_title=self.title).pk def assertNumTranslatedQueries(self, num, qs): @@ -19,7 +26,6 @@ def test_qs(): str(obj.tr_title) self.assertNumQueries(num, test_qs) - @skipIf(django.VERSION < (1, 8), 'Test for django ver > 1.7') def test_select_related_light_model(self): with translation.override('ca-fr'): qs = SimpleLightModel.objects.select_related('translations').filter(pk=self.light_model_id) @@ -33,7 +39,6 @@ def test_select_related_light_model(self): x = SimpleLightModel.objects.select_related('translations_active').get(pk=self.light_model_id) self.assertEqual(x.tr_title, self.title) - @skipIf(django.VERSION < (1, 8), 'Test for django ver > 1.7') def test_select_related_force_model(self): with translation.override('ca-fr'): qs = SimpleModel.objects.select_related('translations').filter(pk=self.id) @@ -47,7 +52,6 @@ def test_select_related_force_model(self): x = SimpleModel.objects.select_related('translations_active').get(pk=self.id) self.assertEqual(x.tr_title, self.title) - @skipIf(django.VERSION < (1, 8), 'Test for django ver > 1.7') def test_only(self): with translation.override('ca-fr'): qs = SimpleModel.objects.all().only('id') @@ -67,3 +71,34 @@ def test_only(self): x = SimpleModel.objects.all().only('id', 'tr_title').get(pk=self.id) self.assertEqual(x.tr_title, self.title) + + +@skipIf(django.VERSION < (1, 8), 'Test for django ver > 1.7') +class SelectRelatedTranslationsQuerySetMixinTest(QuerySetsTests): + def setUp(self): + super(SelectRelatedTranslationsQuerySetMixinTest, self).setUp() + self.related_model_id = SimpleRelatedModel.objects.create( + some_attribute='Test', some_reference_id=self.id + ).pk + self.translated_related_model_id = TranslatedSimpleRelatedModel.objects.create( + tr_attribute='Test', some_reference_id=self.id + ).pk + self.another_related_model_id = AnotherRelatedModel.objects.create( + another_attribute='AnotherTest', another_reference_id=self.translated_related_model_id + ).pk + + def test_select_related__one_degree_relation(self): + with translation.override('ca-fr'): + qs = SimpleRelatedModel.objects.select_related('some_reference') + self.assertEqual(qs.query.select_related, { + 'some_reference': {'translations_active': {}, 'translations_default': {}} + }) + + def test_select_related__two_degree_relation(self): + with translation.override('ca-fr'): + qs = AnotherRelatedModel.objects.select_related( + 'another_reference__some_reference' + ) + self.assertEqual(qs.query.select_related, { + 'another_reference': {'some_reference': {'translations_active': {}, 'translations_default': {}}} + }) diff --git a/parler/tests/testapp/models.py b/parler/tests/testapp/models.py index 0b35ba97..84717295 100644 --- a/parler/tests/testapp/models.py +++ b/parler/tests/testapp/models.py @@ -2,7 +2,7 @@ from django.db import models from django.utils.encoding import python_2_unicode_compatible from parler.fields import TranslatedField -from parler.managers import TranslatableLightSelectRelatedManager +from parler.managers import TranslatableLightSelectRelatedManager, SelectRelatedTranslationsQuerySetMixin from parler.models import TranslatableModel, TranslatedFields, TranslatedFieldsModel from parler.utils.context import switch_language @@ -48,6 +48,31 @@ def __str__(self): return self.tr_title +class SimpleRelatedModelManager(SelectRelatedTranslationsQuerySetMixin, models.Manager): + pass + + +class SimpleRelatedModel(models.Model): + some_attribute = models.CharField(max_length=200, default='') + some_reference = models.ForeignKey(SimpleModel, on_delete=models.CASCADE) + + objects = SimpleRelatedModelManager() + + +class TranslatedSimpleRelatedModel(TranslatableModel): + translations = TranslatedFields( + tr_attribute = models.CharField("Translated Attribute", max_length=200) + ) + some_reference = models.ForeignKey(SimpleModel, on_delete=models.CASCADE) + + +class AnotherRelatedModel(models.Model): + another_attribute = models.CharField(max_length=200, default='') + another_reference = models.OneToOneField(TranslatedSimpleRelatedModel, on_delete=models.CASCADE) + + objects = SimpleRelatedModelManager() + + class CleanCharField(models.CharField): def clean(self, value, model_instance): From c06382f819711bdc2eb59fb59ef2ea4633a2600b Mon Sep 17 00:00:00 2001 From: Shun Liang Date: Mon, 12 Mar 2018 12:30:24 +0000 Subject: [PATCH 7/7] Rename force_select_related_translations in TranslatableQuerySet to select_related_translations --- parler/managers.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/parler/managers.py b/parler/managers.py index 11357adb..5538a86e 100644 --- a/parler/managers.py +++ b/parler/managers.py @@ -37,14 +37,14 @@ class TranslatableQuerySet(QuerySet): When using this class in combination with *django-polymorphic*, make sure this class is first in the chain of inherited classes. - When force_select_related_translations set to True it will always adds translated models with active and + When select_related_translations set to True it will always adds translated models with active and default languages by using virtual composite FKs. In light version QS with force select related False you can always add translated models to select_related manually. When you call select_related with translations rel_name e.g: 'translations' it automatically adds active and default translated models to select_related. """ - force_select_related_translations = True + select_related_translations = True def __init__(self, *args, **kwargs): super(TranslatableQuerySet, self).__init__(*args, **kwargs) @@ -144,7 +144,7 @@ def select_related_is_applicable(self): return result def _fetch_all(self): - if self.force_select_related_translations and self.select_related_is_applicable: + if self.select_related_translations and self.select_related_is_applicable: self._add_active_default_select_related() # Make sure the current language is assigned when Django fetches the data. # This low-level method is overwritten as that works better across Django versions. @@ -290,7 +290,7 @@ def active_translations(self, language_code=None, **translated_fields): class TranslatableLightSelectRelatedQuerySet(TranslatableQuerySet): - force_select_related_translations = False + select_related_translations = False class TranslatableLightSelectRelatedManager(TranslatableManager):