diff --git a/modeltranslation/manager.py b/modeltranslation/manager.py new file mode 100644 index 0000000..6e2612f --- /dev/null +++ b/modeltranslation/manager.py @@ -0,0 +1,146 @@ +""" +The idea of MultilingualManager is taken from +django-linguo by Zach Mathew + +https://github.com/zmathew/django-linguo +""" +from django.db import models +from django.db.models.fields.related import RelatedField +from django.utils.translation import get_language +from django.utils.tree import Node + +from modeltranslation.utils import build_localized_fieldname +from modeltranslation import settings + + +_registry = {} + + +def get_translatable_fields_for_model(model): + from modeltranslation import translator + if model not in _registry: + try: + _registry[model] = dict( + translator.translator.get_options_for_model(model).localized_fieldnames) + except translator.NotRegistered: + _registry[model] = None + return _registry[model] + + +def rewrite_lookup_key(model, lookup_key): + translatable_fields = get_translatable_fields_for_model(model) + if translatable_fields is not None: + pieces = lookup_key.split('__') + # If we are doing a lookup on a translatable field, + # we want to rewrite it to the actual field name + # For example, we want to rewrite "name__startswith" to "name_fr__startswith" + if pieces[0] in translatable_fields: + lookup_key = build_localized_fieldname(pieces[0], get_language()) + + remaining_lookup = '__'.join(pieces[1:]) + if remaining_lookup: + lookup_key = '%s__%s' % (lookup_key, remaining_lookup) + + pieces = lookup_key.split('__') + if len(pieces) > 1: + # Check if we are doing a lookup to a related trans model + fields_to_trans_models = get_fields_to_translatable_models(model) + for field_to_trans, transmodel in fields_to_trans_models: + if pieces[0] == field_to_trans: + sub_lookup = '__'.join(pieces[1:]) + if sub_lookup: + sub_lookup = rewrite_lookup_key(transmodel, sub_lookup) + lookup_key = '%s__%s' % (pieces[0], sub_lookup) + break + + return lookup_key + + +def get_fields_to_translatable_models(model): + results = [] + for field_name in model._meta.get_all_field_names(): + field_object, modelclass, direct, m2m = model._meta.get_field_by_name(field_name) + if direct and isinstance(field_object, RelatedField): + if get_translatable_fields_for_model(field_object.related.parent_model) is not None: + results.append((field_name, field_object.related.parent_model)) + return results + + +class MultilingualQuerySet(models.query.QuerySet): + def __init__(self, *args, **kwargs): + super(MultilingualQuerySet, self).__init__(*args, **kwargs) + if self.model and (not self.query.order_by): + if self.model._meta.ordering: + # If we have default ordering specified on the model, set it now so that + # it can be rewritten. Otherwise sql.compiler will grab it directly from _meta + ordering = [] + for key in self.model._meta.ordering: + ordering.append(rewrite_lookup_key(self.model, key)) + self.query.add_ordering(*ordering) + + # This method was not present in django-linguo + def _rewrite_q(self, q): + "Rewrite field names inside Q call." + if isinstance(q, tuple) and len(q) == 2: + return rewrite_lookup_key(self.model, q[0]), q[1] + if isinstance(q, Node): + q.children = map(self._rewrite_q, q.children) + return q + + # This method was not present in django-linguo + def _rewrite_f(self, q): + "Rewrite field names inside F call." + if isinstance(q, models.F): + q.name = rewrite_lookup_key(self.model, q.name) + return q + if isinstance(q, Node): + q.children = map(self._rewrite_f, q.children) + return q + + def _filter_or_exclude(self, negate, *args, **kwargs): + args = map(self._rewrite_q, args) + for key, val in kwargs.items(): + new_key = rewrite_lookup_key(self.model, key) + del kwargs[key] + kwargs[new_key] = self._rewrite_f(val) + return super(MultilingualQuerySet, self)._filter_or_exclude(negate, *args, **kwargs) + + def order_by(self, *field_names): + new_args = [] + for key in field_names: + new_args.append(rewrite_lookup_key(self.model, key)) + return super(MultilingualQuerySet, self).order_by(*new_args) + + def update(self, **kwargs): + for key, val in kwargs.items(): + new_key = rewrite_lookup_key(self.model, key) + del kwargs[key] + kwargs[new_key] = self._rewrite_f(val) + return super(MultilingualQuerySet, self).update(**kwargs) + update.alters_data = True + + # This method was not present in django-linguo + def create(self, **kwargs): + populate = kwargs.pop('_populate', settings.AUTO_POPULATE) + if populate: + translatable_fields = get_translatable_fields_for_model(self.model) + if translatable_fields is not None: + for key, val in kwargs.items(): + if key in translatable_fields: + # Try to add value in every language + for new_key in translatable_fields[key]: + kwargs.setdefault(new_key, val) + else: + # If not use populate feature, then perform normal rewriting + for key, val in kwargs.items(): + new_key = rewrite_lookup_key(self.model, key) + del kwargs[key] + kwargs[new_key] = val + return super(MultilingualQuerySet, self).create(**kwargs) + + +class MultilingualManager(models.Manager): + use_for_related_fields = True + + def get_query_set(self): + return MultilingualQuerySet(self.model) diff --git a/modeltranslation/settings.py b/modeltranslation/settings.py index 763ca10..12d2d1d 100644 --- a/modeltranslation/settings.py +++ b/modeltranslation/settings.py @@ -32,3 +32,6 @@ ENABLE_REGISTRATIONS = getattr( # Modeltranslation specific debug setting DEBUG = getattr( settings, 'MODELTRANSLATION_DEBUG', settings.DEBUG) + +AUTO_POPULATE = getattr( + settings, 'MODELTRANSLATION_AUTO_POPULATE', False) diff --git a/modeltranslation/tests/__init__.py b/modeltranslation/tests/__init__.py index b0e337b..8ed1371 100644 --- a/modeltranslation/tests/__init__.py +++ b/modeltranslation/tests/__init__.py @@ -16,6 +16,7 @@ from django.contrib.admin.sites import AdminSite from django.contrib.auth.models import User from django.core.exceptions import ValidationError from django.core.files.base import ContentFile +from django.db.models import Q, F from django.db.models.loading import AppCache from django.test import TestCase from django.utils.datastructures import SortedDict @@ -28,7 +29,7 @@ from modeltranslation.admin import (TranslationAdmin, from modeltranslation.tests.models import ( AbstractModelB, MultitableModelA, DataModel, FallbackModel, FallbackModel2, FileFieldsModel, OtherFieldsModel, TestModel, MultitableBModelA, MultitableModelC, - MultitableDTestModel) + MultitableDTestModel, ManagerTestModel, CustomManagerTestModel) from modeltranslation.tests.translation import FallbackModel2TranslationOptions from modeltranslation.tests.test_settings import TEST_SETTINGS @@ -36,6 +37,10 @@ try: from django.test.utils import override_settings except ImportError: from modeltranslation.tests.utils import override_settings +try: + from django.utils.translation import override +except ImportError: + from modeltranslation.tests.utils import override # NOQA # None of the following tests really depend on the content of the request, # so we'll just pass in None. @@ -151,7 +156,7 @@ class ModeltranslationTest(ModeltranslationTestBase): self.failUnless(translator.translator) # Check that all models are registered for translation - self.failUnlessEqual(len(translator.translator._registry), 10) + self.failUnlessEqual(len(translator.translator._registry), 12) # Try to unregister a model that is not registered self.assertRaises(translator.NotRegistered, @@ -215,7 +220,7 @@ class ModeltranslationTest(ModeltranslationTestBase): def test_titleonly(self): title1_de = "title de" - n = TestModel.objects.create(title=title1_de) + n = TestModel(title=title1_de) self.failUnlessEqual(n.title, title1_de) # Because the original field "title" was specified in the constructor # it is directly passed into the instance's __dict__ and the descriptor @@ -352,7 +357,7 @@ class OtherFieldsTest(ModeltranslationTestBase): self.assertEqual('de', get_language()) self.assertEqual(7, inst.int) self.assertEqual(7, inst.int_de) - self.assertEqual(42, inst.int_en) # default value is honored + self.assertEqual(42, inst.int_en) # default value is honored inst.int += 2 inst.save() @@ -1121,3 +1126,161 @@ class TranslationAdminTest(ModeltranslationTestBase): ma_fieldsets = ma.inlines[0]( TestModel, self.site).get_fieldsets(request, self.test_obj) self.assertEqual(ma_fieldsets, fieldsets) + + +class TestManager(ModeltranslationTestBase): + def setUp(self): + # In this test case the default language is en, not de. + trans_real.activate('en') + + def test_filter_update(self): + """Test if filtering and updating is language-aware.""" + n = ManagerTestModel(title='') + n.title_en = 'en' + n.title_de = 'de' + n.save() + + m = ManagerTestModel(title='') + m.title_en = 'title en' + m.title_de = 'de' + m.save() + + self.assertEqual('en', get_language()) + + self.assertEqual(0, ManagerTestModel.objects.filter(title='de').count()) + self.assertEqual(1, ManagerTestModel.objects.filter(title='en').count()) + # Spanning works + self.assertEqual(2, ManagerTestModel.objects.filter(title__contains='en').count()) + + with override('de'): + self.assertEqual(2, ManagerTestModel.objects.filter(title='de').count()) + self.assertEqual(0, ManagerTestModel.objects.filter(title='en').count()) + # Spanning works + self.assertEqual(2, ManagerTestModel.objects.filter(title__endswith='e').count()) + + # Still possible to use explicit language version + self.assertEqual(1, ManagerTestModel.objects.filter(title_en='en').count()) + self.assertEqual(2, ManagerTestModel.objects.filter(title_en__contains='en').count()) + + ManagerTestModel.objects.update(title='new') + self.assertEqual(2, ManagerTestModel.objects.filter(title='new').count()) + n = ManagerTestModel.objects.get(pk=n.pk) + m = ManagerTestModel.objects.get(pk=m.pk) + self.assertEqual('en', n.title_en) + self.assertEqual('new', n.title_de) + self.assertEqual('title en', m.title_en) + self.assertEqual('new', m.title_de) + + def test_q(self): + """Test if Q queries are rewritten.""" + n = ManagerTestModel(title='') + n.title_en = 'en' + n.title_de = 'de' + n.save() + + self.assertEqual('en', get_language()) + self.assertEqual(0, ManagerTestModel.objects.filter(Q(title='de') | Q(pk=42)).count()) + self.assertEqual(1, ManagerTestModel.objects.filter(Q(title='en') | Q(pk=42)).count()) + + with override('de'): + self.assertEqual(1, ManagerTestModel.objects.filter(Q(title='de') | Q(pk=42)).count()) + self.assertEqual(0, ManagerTestModel.objects.filter(Q(title='en') | Q(pk=42)).count()) + + def test_f(self): + """Test if F queries are rewritten.""" + n = ManagerTestModel.objects.create(visits_en=1, visits_de=2) + + self.assertEqual('en', get_language()) + ManagerTestModel.objects.update(visits=F('visits') + 10) + n = ManagerTestModel.objects.all()[0] + self.assertEqual(n.visits_en, 11) + self.assertEqual(n.visits_de, 2) + + with override('de'): + ManagerTestModel.objects.update(visits=F('visits') + 20) + n = ManagerTestModel.objects.all()[0] + self.assertEqual(n.visits_en, 11) + self.assertEqual(n.visits_de, 22) + + def test_custom_manager(self): + """Test if user-defined manager is still working""" + n = CustomManagerTestModel(title='') + n.title_en = 'enigma' + n.title_de = 'foo' + n.save() + + m = CustomManagerTestModel(title='') + m.title_en = 'enigma' + m.title_de = 'bar' + m.save() + + # Custom method + self.assertEqual('bar', CustomManagerTestModel.objects.foo()) + + # Ensure that get_query_set is working - filter objects to those with 'a' in title + self.assertEqual('en', get_language()) + self.assertEqual(2, CustomManagerTestModel.objects.count()) + with override('de'): + self.assertEqual(1, CustomManagerTestModel.objects.count()) + + def test_creation(self): + """Test if field are rewritten in create.""" + self.assertEqual('en', get_language()) + n = ManagerTestModel.objects.create(title='foo') + self.assertEqual('foo', n.title_en) + self.assertEqual(None, n.title_de) + self.assertEqual('foo', n.title) + + # The same result + n = ManagerTestModel.objects.create(title_en='foo') + self.assertEqual('foo', n.title_en) + self.assertEqual(None, n.title_de) + self.assertEqual('foo', n.title) + + def test_creation_population(self): + """Test if language fields are populated with default value on creation.""" + n = ManagerTestModel.objects.create(title='foo', _populate=True) + self.assertEqual('foo', n.title_en) + self.assertEqual('foo', n.title_de) + self.assertEqual('foo', n.title) + + # You can specify some language... + n = ManagerTestModel.objects.create(title='foo', title_de='bar', _populate=True) + self.assertEqual('foo', n.title_en) + self.assertEqual('bar', n.title_de) + self.assertEqual('foo', n.title) + + # ... but remember that still original attribute points to current language + self.assertEqual('en', get_language()) + n = ManagerTestModel.objects.create(title='foo', title_en='bar', _populate=True) + self.assertEqual('bar', n.title_en) + self.assertEqual('foo', n.title_de) + self.assertEqual('bar', n.title) # points to en + with override('de'): + self.assertEqual('foo', n.title) # points to de + self.assertEqual('en', get_language()) + + # This feature (for backward-compatibility) require _populate keyword... + n = ManagerTestModel.objects.create(title='foo') + self.assertEqual('foo', n.title_en) + self.assertEqual(None, n.title_de) + self.assertEqual('foo', n.title) + + # ... or MODELTRANSLATION_AUTO_POPULATE setting + with override_settings(MODELTRANSLATION_AUTO_POPULATE=True): + reload(mt_settings) + self.assertEqual(True, mt_settings.AUTO_POPULATE) + n = ManagerTestModel.objects.create(title='foo') + self.assertEqual('foo', n.title_en) + self.assertEqual('foo', n.title_de) + self.assertEqual('foo', n.title) + + # _populate keyword has highest priority + n = ManagerTestModel.objects.create(title='foo', _populate=False) + self.assertEqual('foo', n.title_en) + self.assertEqual(None, n.title_de) + self.assertEqual('foo', n.title) + + # Restore previous state + reload(mt_settings) + self.assertEqual(False, mt_settings.AUTO_POPULATE) diff --git a/modeltranslation/tests/models.py b/modeltranslation/tests/models.py index 0ef0ca7..35624f8 100644 --- a/modeltranslation/tests/models.py +++ b/modeltranslation/tests/models.py @@ -69,3 +69,21 @@ class AbstractModelB(AbstractModelA): class DataModel(models.Model): data = models.TextField(blank=True, null=True) + + +class ManagerTestModel(models.Model): + title = models.CharField(ugettext_lazy('title'), max_length=255) + visits = models.IntegerField(ugettext_lazy('visits'), default=0) + + +class CustomManager(models.Manager): + def get_query_set(self): + return super(CustomManager, self).get_query_set().filter(title__contains='a') + + def foo(self): + return 'bar' + + +class CustomManagerTestModel(models.Model): + title = models.CharField(ugettext_lazy('title'), max_length=255) + objects = CustomManager() diff --git a/modeltranslation/tests/settings.py b/modeltranslation/tests/settings.py index e49b1bc..1e3af68 100644 --- a/modeltranslation/tests/settings.py +++ b/modeltranslation/tests/settings.py @@ -26,3 +26,5 @@ LANGUAGE_CODE = 'de' DEFAULT_LANGUAGE = 'de' USE_I18N = True + +MODELTRANSLATION_AUTO_POPULATE = False diff --git a/modeltranslation/tests/translation.py b/modeltranslation/tests/translation.py index c62e1c0..5ca9672 100644 --- a/modeltranslation/tests/translation.py +++ b/modeltranslation/tests/translation.py @@ -5,7 +5,8 @@ from modeltranslation.translator import translator, TranslationOptions from modeltranslation.tests.models import ( TestModel, FallbackModel, FallbackModel2, FileFieldsModel, OtherFieldsModel, AbstractModelA, AbstractModelB, - MultitableModelA, MultitableBModelA, MultitableModelC) + MultitableModelA, MultitableBModelA, MultitableModelC, + ManagerTestModel, CustomManagerTestModel) class TestTranslationOptions(TranslationOptions): @@ -59,3 +60,13 @@ translator.register(AbstractModelA, AbstractModelATranslationOptions) class AbstractModelBTranslationOptions(TranslationOptions): fields = ('titleb',) translator.register(AbstractModelB, AbstractModelBTranslationOptions) + + +class ManagerTestModelTranslationOptions(TranslationOptions): + fields = ('title', 'visits') +translator.register(ManagerTestModel, ManagerTestModelTranslationOptions) + + +class CustomManagerTestModelTranslationOptions(TranslationOptions): + fields = ('title',) +translator.register(CustomManagerTestModel, CustomManagerTestModelTranslationOptions) diff --git a/modeltranslation/tests/utils.py b/modeltranslation/tests/utils.py index b41d8aa..0834de8 100644 --- a/modeltranslation/tests/utils.py +++ b/modeltranslation/tests/utils.py @@ -1,5 +1,6 @@ """ -This is Django 1.4 override_settings decorator backported for compatibility with Django 1.3. +This are Django 1.4 override_settings decorator and override (language) context manager +backported for compatibility with Django 1.3. The only difference is that this version does not use settings_changes signal (because there is no such signal). @@ -8,6 +9,7 @@ from __future__ import with_statement # Python 2.5 compatibility from django.utils.functional import wraps from django.conf import settings, UserSettingsHolder +from django.utils.translation import get_language, activate, deactivate, deactivate_all class override_settings(object): @@ -58,3 +60,22 @@ class override_settings(object): def disable(self): settings._wrapped = self.wrapped + + +class override(object): + def __init__(self, language, deactivate=False): + self.language = language + self.deactivate = deactivate + self.old_language = get_language() + + def __enter__(self): + if self.language is not None: + activate(self.language) + else: + deactivate_all() + + def __exit__(self, exc_type, exc_value, traceback): + if self.deactivate: + deactivate() + else: + activate(self.old_language) diff --git a/modeltranslation/translator.py b/modeltranslation/translator.py index 8814fb5..021e005 100644 --- a/modeltranslation/translator.py +++ b/modeltranslation/translator.py @@ -1,9 +1,11 @@ # -*- coding: utf-8 -*- from django.conf import settings +from django.db.models import Manager from django.db.models.base import ModelBase from modeltranslation.fields import (TranslationFieldDescriptor, create_translation_field) +from modeltranslation.manager import MultilingualManager from modeltranslation.utils import build_localized_fieldname @@ -31,7 +33,7 @@ class TranslationOptions(object): def add_localized_fields(model): """ - Monkey patchs the original model class to provide additional fields for + Monkey patches the original model class to provide additional fields for every language. Only do that for fields which are defined in the translation options of the model. @@ -61,6 +63,26 @@ def add_localized_fields(model): return localized_fields +def add_manager(model): + """ + Monkey patches the original model to use MultilingualManager instead of + default manager (``objects``). + + If model has a custom manager, then merge it with MultilingualManager. + """ + if not hasattr(model, 'objects'): + return + current_manager = model.objects + if isinstance(current_manager, MultilingualManager): + return + if current_manager.__class__ is Manager: + current_manager.__class__ = MultilingualManager + else: + class NewMultilingualManager(current_manager.__class__, MultilingualManager): + pass + current_manager.__class__ = NewMultilingualManager + + #def translated_model_initialized(field_names, instance, **kwargs): #print "translated_model_initialized instance:", \ #instance, ", field:", field_names @@ -152,6 +174,10 @@ class Translator(object): for related_obj in model._meta.get_all_related_objects(): delete_cache_fields(related_obj.model) + # Set MultilingualManager + add_manager(model) + + # Substitute original field with descriptor model_fallback_values = getattr( translation_opts, 'fallback_values', None) for field_name in translation_opts.fields: