diff --git a/.travis.yml b/.travis.yml index 6f40ca8e..ff52e7ef 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 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 f880bb8a..95b9df26 100644 --- a/parler/managers.py +++ b/parler/managers.py @@ -9,7 +9,11 @@ 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 + + +if (1, 8) <= django.VERSION < (1, 9): + from django.db.models.query import ValuesListQuerySet, ValuesQuerySet class SelectRelatedTranslationsQuerySetMixin(object): @@ -24,46 +28,164 @@ 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) -class TranslatableQuerySet(QuerySet): +class AutoAddSelectRelatedQuerySetMixin(object): + """ + Mixin auto adds select related models from the list self.select_related_to_auto_add + if it possible: QuerySet not for update and not returns values/values_list + + select_related is not compatible with values/values_list method and raise error in Django 1.8+ if it used + so we check if select_related is applicable for the queryset + + Set related fields in your qs like: + class YourQuerySet(AutoAddSelectRelatedQuerySetMixIn, query.QuerySet): + select_related_fields_to_auto_add = { + 'field1': ['related_model1__field2, 'related_model2__field3, ...], + 'field2': ['related_model3__field4, 'related_model4__field5, ...] + } + ... + + Can be used for translated and normal model querysets to automate adding select related of any needed models to qs + """ + select_related_fields_to_auto_add = dict() # type: Dict[str, List[str]] + + if django.VERSION < (1, 8): + @property + def select_related_is_applicable(self): + return False + + elif (1, 8) <= django.VERSION < (1, 9): + @property + def select_related_is_applicable(self): + if self.model._meta.proxy: + return False + return not isinstance(self, ValuesListQuerySet) and not isinstance(self, ValuesQuerySet) + + elif django.VERSION >= (1, 9): + def __init__(self, *args, **kwargs): + super(AutoAddSelectRelatedQuerySetMixin, self).__init__(*args, **kwargs) + self._use_values = False # Will use _use_values as a flag if values/values_list is used + + def _values(self, *fields): + result = super(AutoAddSelectRelatedQuerySetMixin, self)._values(*fields) + result._use_values = True + return result + + def _clone(self, **kwargs): + c = super(AutoAddSelectRelatedQuerySetMixin, self)._clone(**kwargs) + c._use_values = self._use_values + return c + + @property + def select_related_is_applicable(self): + if self.model._meta.proxy: + return False + return not self._use_values + + def _add_select_related(self): + """ + Adds select related fields based on select_related_fields_to_auto_add structure in format Dict[str, List[str]] + If there are not used only/defer on queryset: query.deferred_loading = (None, False) + we count all select_related_fields_to_auto_add are selecting and add all related fields, + else add only subset of them as intersection with query deferred field set + """ + existing, defer = self.query.deferred_loading + + used_fields = set(self.select_related_fields_to_auto_add.keys()) + related_fields = set() + + if defer: + used_fields = used_fields.difference(existing) + elif existing: + used_fields = used_fields.intersection(existing) + + for field, related_field_list in six.iteritems(self.select_related_fields_to_auto_add): + if field in used_fields: + related_fields.update(related_field_list) + + if not defer and existing: + existing.update(related_fields) + + self.query.add_select_related(related_fields) + + def _fetch_all(self): + # Add select_related only once just before run db-query + if self.select_related_is_applicable and not self._for_write: + self._add_select_related() + super(AutoAddSelectRelatedQuerySetMixin, self)._fetch_all() + + def iterator(self): + # Add select_related only once just before run db-query + if self.select_related_is_applicable and not self._for_write: + self._add_select_related() + return super(AutoAddSelectRelatedQuerySetMixin, self).iterator() + + +class TranslatableQuerySet(AutoAddSelectRelatedQuerySetMixin, QuerySet): """ An enhancement of the QuerySet which sets the objects language before they are returned. 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._language = None + if not self.force_select_related_translations: + return + fields_dict = self.select_related_fields_to_auto_add.copy() + for extension in self.model._parler_meta: + fields_dict[extension.rel_name_active] = [extension.rel_name_active] + fields_dict[extension.rel_name_default] = [extension.rel_name_default] + self.select_related_fields_to_auto_add = fields_dict def select_related(self, *fields): + """ + 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() + # List fields to replace with select_related_translations_fields + fields_to_search = set(extension.get_translated_fields() + [extension.rel_name]) + if fields_to_search.intersection(fields): + fields_to_exclude.update(fields_to_search) + 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)) + def _clone(self, klass=None, setup=False, **kw): if django.VERSION < (1, 9): kw['klass'] = klass @@ -79,47 +201,7 @@ def create(self, **kwargs): kwargs['_current_language'] = self._language return super(TranslatableQuerySet, self).create(**kwargs) - def _add_active_default_select_related(self): - 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) - if defer: - related_to_add = related_to_add.difference(existing) - elif existing: - related_to_add = related_to_add.intersection(existing) - self.query.add_select_related(related_to_add) - - @property - def select_related_not_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 - """ - result = None - if self.model._meta.proxy: - return True - - if (1, 7) < django.VERSION < (1, 9): - ValuesListQuerySet = getattr(django.db.models.query, 'ValuesListQuerySet') - result = isinstance(self, ValuesListQuerySet) - - 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: - 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 +345,39 @@ 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 LightTranslatableQuerySet(TranslatableQuerySet): + force_select_related_translations = False + + +class LightTranslatableManager(TranslatableManager): + """ + Translatable manager does not auto add select related translation models + """ + queryset_class = LightTranslatableQuerySet + +class DeepTranslatableQuerySet(SelectRelatedTranslationsQuerySetMixin, TranslatableQuerySet): + pass -class TranslatableAutoSelectRelatedManager(TranslatableManager): - queryset_class = TranslatableAutoSelectRelatedQuerySet + +class DeepTranslatableManager(TranslatableManager): + """ + Translatable manager does auto add select related translation models (for active and default languages) + for current model and all translatable models used in select_related method call + """ + queryset_class = DeepTranslatableQuerySet + + +class AutoAddTranslationsQuerySet(SelectRelatedTranslationsQuerySetMixin, models.query.QuerySet): + pass + + +class AutoAddTranslationsManager(models.Manager.from_queryset(AutoAddTranslationsQuerySet)): + """ + Manager does auto add select related translation models (for active and default languages) + for all translatable models used in select_related method call + """ + queryset_class = AutoAddTranslationsQuerySet # Export the names in django-hvad style too: diff --git a/parler/models.py b/parler/models.py index c8e466e3..194a68ba 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) @@ -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) @@ -890,10 +896,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): @@ -1178,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): """ @@ -1301,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 c4bb3dfa..4eb83da2 100644 --- a/parler/tests/test_query_count.py +++ b/parler/tests/test_query_count.py @@ -4,16 +4,9 @@ from django.core.cache import cache from django.utils import translation from django.utils.timezone import now -from parler import appsettings - +from unittest import skipIf from .utils import AppTestCase, override_parler_settings -from .testapp.models import SimpleModel, DateTimeModel - -try: - from unittest import skipIf -except ImportError: - # python<2.7 - from django.utils.unittest import skipIf +from .testapp.models import SimpleModel, SimpleLightModel, DateTimeModel class QueryCountTests(AppTestCase): @@ -40,6 +33,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, @@ -64,7 +58,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(django.VERSION >= (1, 8), 'Test for django ver 1.7') def test_uncached_queries(self): """ Test that uncached queries work, albeit slowly. @@ -72,8 +66,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(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. """ @@ -81,14 +75,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(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. """ 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 +114,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 django.VERSION >= (1, 8): 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..44ae04ba --- /dev/null +++ b/parler/tests/test_querysets.py @@ -0,0 +1,193 @@ +from __future__ import absolute_import, unicode_literals +from unittest import skipIf +import django + +from parler import appsettings +from django.utils import translation +from .utils import AppTestCase +from .testapp.models import SimpleModel, SimpleLightModel, SimpleModelA, SimpleModelB, SimpleModelC, \ + SimpleNonTranslatableModelC + + +@skipIf(django.VERSION < (1, 8), 'Test for django ver > 1.7') +class QuerySetsTests(AppTestCase): + def setUp(self): + super(QuerySetsTests, self).setUp() + self.title = 'TITLE_XX' + self.shared = 'SHARED' + self.id = SimpleModel.objects.create(tr_title=self.title, shared=self.shared).pk + self.light_model_id = SimpleLightModel.objects.create(tr_title=self.title, shared=self.shared).pk + self.PARLER_ENABLE_CACHING = appsettings.PARLER_ENABLE_CACHING + appsettings.PARLER_ENABLE_CACHING = False + self.qs = SimpleModel.objects.all() + self.light_qs = SimpleLightModel.objects.all() + + def tearDown(self): + appsettings.PARLER_ENABLE_CACHING = self.PARLER_ENABLE_CACHING + super(QuerySetsTests, self).tearDown() + + def assertNumTranslatedQueries(self, num, qs): + def test_qs(): + for obj in qs: + title = str(obj.tr_title) + self.assertEqual(title, self.title) + self.assertNumQueries(num, test_qs) + + def test_auto_adds_select_related(self): + self.assertNumTranslatedQueries(1, self.qs) + + def test_auto_adds_select_related_fallback(self): + with translation.override('ca-fr'): + self.assertNumTranslatedQueries(1, self.qs) + + def test_not_auto_adds_select_related_with_no_force(self): + # needs additional query for en + self.assertNumTranslatedQueries(2, self.light_qs.all()) + with translation.override('ca-fr'): + # needs 2 additional queries for ca-fr and en (fallback) + self.assertNumTranslatedQueries(3, self.light_qs.all()) + + def test_select_related_light_model(self): + with translation.override('ca-fr'): + self.assertNumTranslatedQueries(1, self.light_qs.select_related('translations')) + self.assertNumTranslatedQueries(1, self.light_qs.select_related('translations_active')) + + def test_select_related_force_model(self): + with translation.override('ca-fr'): + self.assertNumTranslatedQueries(1, self.qs.select_related('translations')) + self.assertNumTranslatedQueries(1, self.qs.select_related('translations_active')) + + def test_only(self): + with translation.override('ca-fr'): + # needs 2 additional queries for ca-fr and en + self.assertNumTranslatedQueries(3, self.qs.only('id')) + + # needs query for ca-fr (active) + self.assertNumTranslatedQueries(2, self.qs.only('id', 'translations_default')) + + # needs query for en (default) + self.assertNumTranslatedQueries(2, self.qs.only('id', 'translations_active')) + + self.assertNumTranslatedQueries(1, self.qs.only('id', 'translations_active', 'translations_default')) + + # no needs additional, should be replaced with active and default + self.assertNumTranslatedQueries(1, self.qs.only('id', 'tr_title')) + + # no needs additional, should be replaced with active and default + self.assertNumTranslatedQueries(1, self.qs.only('id', 'translations')) + + def test_not_auto_adds_select_related_when_update(self): + qs = self.qs.select_for_update().filter(pk=self.id) + self.assertNumTranslatedQueries(2, qs) + + def test_auto_adds_select_related_with_iterators(self): + self.assertNumTranslatedQueries(1, self.qs.iterator()) + + def test_defer__related_not_auto_adds(self): + with translation.override('ca-fr'): + self.assertNumTranslatedQueries(3, self.qs.defer('translations_default', 'translations_active')) + self.assertNumTranslatedQueries(2, self.qs.defer('translations_active')) + self.assertNumTranslatedQueries(2, self.qs.defer('translations_default')) + + def test_values_list(self): + with translation.override('ca-fr'): + values_list = self.qs.values_list('id', 'shared') + self.assertListEqual(list(values_list), [(self.id, self.shared)]) + values_list = self.qs.values_list('shared', flat=True) + self.assertListEqual(list(values_list), [self.shared]) + + def test_values_list_with_translations(self): + with translation.override('ca-fr'): + values_list = self.qs.values_list('id', 'translations__tr_title', 'shared') + self.assertListEqual(list(values_list), [(self.id, self.title, self.shared)]) + values_list = self.qs.values_list('id', 'translations_default__tr_title', 'shared') + self.assertListEqual(list(values_list), [(self.id, self.title, self.shared)]) + values_list = self.qs.values_list('id', 'translations_active__tr_title', 'shared') + self.assertListEqual(list(values_list), [(self.id, None, self.shared)]) + + def test_values(self): + with translation.override('ca-fr'): + values = self.qs.values('id', 'shared') + self.assertEqual(len(values), 1) + self.assertDictEqual(values[0], { + 'id': self.id, + 'shared': self.shared, + }) + + def test_values_with_translations(self): + with translation.override('ca-fr'): + values = self.qs.values('id', 'translations__tr_title', 'shared') + self.assertEqual(len(values), 1) + self.assertDictEqual(values[0], { + 'id': self.id, + 'translations__tr_title': self.title, + 'shared': self.shared, + }) + values = self.qs.values('id', 'translations_default__tr_title', 'shared') + self.assertEqual(len(values), 1) + self.assertDictEqual(values[0], { + 'id': self.id, + 'translations_default__tr_title': self.title, + 'shared': self.shared, + }) + values = self.qs.values('id', 'translations_active__tr_title', 'shared') + self.assertEqual(len(values), 1) + self.assertDictEqual(values[0], { + 'id': self.id, + 'translations_active__tr_title': None, + 'shared': self.shared, + }) + + +@skipIf(django.VERSION < (1, 8), 'Test for django ver > 1.7') +class SelectRelatedTranslationsQuerySetMixinTests(AppTestCase): + def setUp(self): + super(SelectRelatedTranslationsQuerySetMixinTests, self).setUp() + self.model_a = SimpleModelA.objects.create(model_a_title='TITLE_A') + self.model_b = SimpleModelB.objects.create(model_b_title='TITLE_B', model_a=self.model_a) + self.model_c = SimpleModelC.objects.create(model_c_title='TITLE_C', model_b=self.model_b) + self.model_c_non_translatable = SimpleNonTranslatableModelC.objects.create( + model_c_title='TITLE_C', + model_b=self.model_b, + ) + self.PARLER_ENABLE_CACHING = appsettings.PARLER_ENABLE_CACHING + appsettings.PARLER_ENABLE_CACHING = False + self.qs = SimpleModelC.objects.all() + self.qs_non_translatable = SimpleNonTranslatableModelC.objects.all() + + def tearDown(self): + appsettings.PARLER_ENABLE_CACHING = self.PARLER_ENABLE_CACHING + super(SelectRelatedTranslationsQuerySetMixinTests, self).tearDown() + + def assertTitle(self, obj): + self.assertEqual(obj.tr_title, self.title) + + def assertNumTranslatedQueries(self, num, qs): + def test_qs(): + for obj in qs: + title_c = obj.model_c_title + title_b = obj.model_b.model_b_title + title_a = obj.model_b.model_a.model_a_title + self.assertEqual(title_c, 'TITLE_C') + self.assertEqual(title_b, 'TITLE_B') + self.assertEqual(title_a, 'TITLE_A') + self.assertNumQueries(num, test_qs) + + def test_without_select_related(self): + with translation.override('ca-fr'): + # should be 2 additionaly query from testapp_simplemodela and testapp_simplemodelb, + # and 4 from testapp_simplemodela_translation and testapp_simplemodelb_translation in en and ca-fr languages + self.assertNumTranslatedQueries(7, self.qs) + self.assertNumTranslatedQueries(7, self.qs_non_translatable) + + def test_select_related_in_one_levels(self): + with translation.override('ca-fr'): + # should be 1 additionaly query from testapp_simplemodela and + # and 2 from testapp_simplemodela_translation in en and ca-fr languages + self.assertNumTranslatedQueries(4, self.qs.select_related('model_b')) + self.assertNumTranslatedQueries(4, self.qs_non_translatable.select_related('model_b')) + + def test_select_related_in_two_levels(self): + with translation.override('ca-fr'): + self.assertNumTranslatedQueries(1, self.qs.select_related('model_b__model_a')) + self.assertNumTranslatedQueries(1, self.qs_non_translatable.select_related('model_b__model_a')) diff --git a/parler/tests/testapp/models.py b/parler/tests/testapp/models.py index 000f2c34..323bf006 100644 --- a/parler/tests/testapp/models.py +++ b/parler/tests/testapp/models.py @@ -1,7 +1,10 @@ from __future__ import unicode_literals + from django.db import models from django.utils.encoding import python_2_unicode_compatible + from parler.fields import TranslatedField +from parler.managers import LightTranslatableManager, DeepTranslatableManager, AutoAddTranslationsManager from parler.models import TranslatableModel, TranslatedFields, TranslatedFieldsModel from parler.utils.context import switch_language @@ -33,6 +36,68 @@ 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 = LightTranslatableManager() + + def __str__(self): + return self.tr_title + + +@python_2_unicode_compatible +class SimpleModelA(TranslatableModel): + translations = TranslatedFields( + model_a_title = models.CharField("ModelA Translated Title", max_length=200) + ) + + def __str__(self): + return self.model_a_title + + +@python_2_unicode_compatible +class SimpleModelB(TranslatableModel): + model_a = models.ForeignKey(SimpleModelA, on_delete=models.CASCADE) + + translations = TranslatedFields( + model_b_title = models.CharField("ModelB Translated Title", max_length=200) + ) + + def __str__(self): + return self.model_b_title + + +@python_2_unicode_compatible +class SimpleModelC(TranslatableModel): + model_b = models.ForeignKey(SimpleModelB, on_delete=models.CASCADE) + + translations = TranslatedFields( + model_c_title = models.CharField("ModelC Translated Title", max_length=200) + ) + + objects = DeepTranslatableManager() + + def __str__(self): + return self.model_c_title + + +@python_2_unicode_compatible +class SimpleNonTranslatableModelC(models.Model): + model_b = models.ForeignKey(SimpleModelB, on_delete=models.CASCADE) + + model_c_title = models.CharField("ModelC Translated Title", max_length=200) + + objects = AutoAddTranslationsManager() + + def __str__(self): + return self.model_c_title + + class CleanCharField(models.CharField): def clean(self, value, model_instance): 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..5249bbb4 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.', diff --git a/tox.ini b/tox.ini index a0183848..104ba470 100644 --- a/tox.ini +++ b/tox.ini @@ -10,7 +10,9 @@ envlist= docs, [testenv] -deps = django-polymorphic +deps = + django-polymorphic + django-composite-foreignkey >= 1.0.1 django17: Django >= 1.7,<1.8 django18: Django >= 1.8,<1.9 django19: Django >= 1.9,<1.10