diff --git a/docs/modeltranslation/installation.rst b/docs/modeltranslation/installation.rst index 1aae289..6629781 100644 --- a/docs/modeltranslation/installation.rst +++ b/docs/modeltranslation/installation.rst @@ -249,12 +249,17 @@ Default: ``False`` .. versionadded:: 0.5 This setting controls if the :ref:`multilingual_manager` should automatically -populate language field values in its ``create`` method, so that these two -statements can be considered equivalent:: +populate language field values in its ``create`` and ``get_or_create`` method, and in model +constructors, so that these two blocks of statements can be considered equivalent:: - News.objects.create(title='-- no translation yet --', _populate=True) + News.objects.populate(True).create(title='-- no translation yet --') + with auto_populate(True): + q = News(title='-- no translation yet --') + + # same effect with MODELTRANSLATION_AUTO_POPULATE == True: News.objects.create(title='-- no translation yet --') + q = News(title='-- no translation yet --') ``MODELTRANSLATION_DEBUG`` diff --git a/docs/modeltranslation/usage.rst b/docs/modeltranslation/usage.rst index a29b153..9310fd3 100644 --- a/docs/modeltranslation/usage.rst +++ b/docs/modeltranslation/usage.rst @@ -131,11 +131,13 @@ It can be changed several times inside a query. So ``X.objects.rewrite(False)`` Auto-population *************** -In ``create()`` you can set special parameter ``_populate=True`` to populate all translation -(language) fields with values from translated (original) ones. It can be very convenient when working -with many languages. So:: +.. versionchanged:: 0.6 - x = News.objects.create(title='bar', _populate=True) +There is special manager method ``populate(mode)`` which can trigger ``create()`` or +``get_or_create()`` to populate all translation (language) fields with values from translated +(original) ones. It can be very convenient when working with many languages. So:: + + x = News.objects.populate(True).create(title='bar') is equivalent of:: @@ -144,14 +146,50 @@ is equivalent of:: Moreover, some fields can be explicitly assigned different values:: - x = News.objects.create(title='-- no translation yet --', title_de='enigma', _populate=True) + x = News.objects.populate(True).create(title='-- no translation yet --', title_de='enigma') -It will result in ``title_de == 'nic'`` and other ``title_?? == '-- no translation yet --'``. +It will result in ``title_de == 'enigma'`` and other ``title_?? == '-- no translation yet --'``. -There is a more convenient way than passing _populate all the time: +There is another way of altering the current population status, an ``auto_populate`` context manager:: + + from modeltranslation.utils import auto_populate + + with auto_populate(True): + x = News.objects.create(title='bar') + +Auto-population tooks place also in model constructor, what is extremely useful when loading +non-translated fixtures. Just remember to use the context manager:: + + with auto_populate(): # True can be ommited + call_command('loaddata', 'fixture.json') # Some fixture loading + + z = News(title='bar') + print z.title_en, z.title_de # prints 'bar bar' + +There is a more convenient way than calling ``populate`` manager method or entering +``auto_populate`` manager context all the time: :ref:`settings-modeltranslation_auto_populate` setting. -If ``_populate`` parameter is missing, ``create()`` will look at the setting to determine if -population should be used. +It controls the default population behaviour. + +There are 4 different population modes: + +``False`` + [set by default] + + Auto-population turned off + +``True`` or ``'all'`` + [default argument to population altering methods] + + Auto-population turned on, copying translated field value to all other languages + (unless a translation field value is provided) + +``'default'`` + Auto-population turned on, copying translated field value to default language field + (unless its value is provided) + +``'required'`` + Acts like ``'default'``, but copy value only if the original field is non-nullable .. _fallback: diff --git a/modeltranslation/manager.py b/modeltranslation/manager.py index 871e8e8..68339e9 100644 --- a/modeltranslation/manager.py +++ b/modeltranslation/manager.py @@ -5,13 +5,15 @@ django-linguo by Zach Mathew https://github.com/zmathew/django-linguo """ +from __future__ import with_statement # Python 2.5 compatibility from django.db import models from django.db.models.fields.related import RelatedField from django.db.models.sql.where import Constraint from django.utils.tree import Node from modeltranslation import settings -from modeltranslation.utils import build_localized_fieldname, get_language +from modeltranslation.utils import (build_localized_fieldname, get_language, + auto_populate) def get_translatable_fields_for_model(model): @@ -69,13 +71,13 @@ def get_fields_to_translatable_models(model): class MultilingualQuerySet(models.query.QuerySet): - _rewrite = True - def __init__(self, *args, **kwargs): super(MultilingualQuerySet, self).__init__(*args, **kwargs) self._post_init() def _post_init(self): + self._rewrite = True + self._populate = None 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 @@ -88,12 +90,20 @@ class MultilingualQuerySet(models.query.QuerySet): # This method was not present in django-linguo def _clone(self, *args, **kwargs): kwargs.setdefault('_rewrite', self._rewrite) + kwargs.setdefault('_populate', self._populate) return super(MultilingualQuerySet, self)._clone(*args, **kwargs) # This method was not present in django-linguo def rewrite(self, mode=True): return self._clone(_rewrite=mode) + # This method was not present in django-linguo + def populate(self, mode='all'): + """ + Overrides the translation fields population mode for this query set. + """ + return self._clone(_populate=mode) + def _rewrite_applied_operations(self): """ Rewrite fields in already applied filters/ordering. @@ -173,20 +183,29 @@ class MultilingualQuerySet(models.query.QuerySet): return super(MultilingualQuerySet, self).update(**kwargs) update.alters_data = True + # This method was not present in django-linguo + @property + def _populate_mode(self): + # Populate can be set using a global setting or a manager method. + if self._populate is None: + return settings.AUTO_POPULATE + return self._populate + # 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 translation_field in translatable_fields[key]: - kwargs.setdefault(translation_field.name, val) - # If not use populate feature, then normal rewriting will occur at model's __init__ - # That's why it is not performed here - no reason to rewrite twice. - return super(MultilingualQuerySet, self).create(**kwargs) + """ + Allows to override population mode with a ``populate`` method. + """ + with auto_populate(self._populate_mode): + return super(MultilingualQuerySet, self).create(**kwargs) + + # This method was not present in django-linguo + def get_or_create(self, **kwargs): + """ + Allows to override population mode with a ``populate`` method. + """ + with auto_populate(self._populate_mode): + return super(MultilingualQuerySet, self).get_or_create(**kwargs) class MultilingualManager(models.Manager): @@ -195,6 +214,9 @@ class MultilingualManager(models.Manager): def rewrite(self, *args, **kwargs): return self.get_query_set().rewrite(*args, **kwargs) + def populate(self, *args, **kwargs): + return self.get_query_set().populate(*args, **kwargs) + def get_query_set(self): qs = super(MultilingualManager, self).get_query_set() if qs.__class__ == models.query.QuerySet: diff --git a/modeltranslation/tests/__init__.py b/modeltranslation/tests/__init__.py index 2497afa..d9dc880 100644 --- a/modeltranslation/tests/__init__.py +++ b/modeltranslation/tests/__init__.py @@ -31,7 +31,8 @@ from modeltranslation.tests.translation import (FallbackModel2TranslationOptions FieldInheritanceCTranslationOptions, FieldInheritanceETranslationOptions) from modeltranslation.tests.test_settings import TEST_SETTINGS -from modeltranslation.utils import build_css_class, build_localized_fieldname +from modeltranslation.utils import (build_css_class, build_localized_fieldname, + auto_populate) try: from django.test.utils import override_settings @@ -1705,6 +1706,11 @@ class TestManager(ModeltranslationTestBase): # In this test case the default language is en, not de. trans_real.activate('en') + def tearDown(self): + # Settings may be loaded by translator, resulting in a different fallback. + trans_real.activate('de') + reload(mt_settings) + def test_filter_update(self): """Test if filtering and updating is language-aware.""" n = models.ManagerTestModel(title='') @@ -1864,20 +1870,20 @@ class TestManager(ModeltranslationTestBase): def test_creation_population(self): """Test if language fields are populated with default value on creation.""" - n = models.ManagerTestModel.objects.create(title='foo', _populate=True) + n = models.ManagerTestModel.objects.populate(True).create(title='foo') self.assertEqual('foo', n.title_en) self.assertEqual('foo', n.title_de) self.assertEqual('foo', n.title) # You can specify some language... - n = models.ManagerTestModel.objects.create(title='foo', title_de='bar', _populate=True) + n = models.ManagerTestModel.objects.populate(True).create(title='foo', title_de='bar') 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 = models.ManagerTestModel.objects.create(title='foo', title_en='bar', _populate=True) + n = models.ManagerTestModel.objects.populate(True).create(title='foo', title_en='bar') self.assertEqual('bar', n.title_en) self.assertEqual('foo', n.title_de) self.assertEqual('bar', n.title) # points to en @@ -1885,27 +1891,78 @@ class TestManager(ModeltranslationTestBase): self.assertEqual('foo', n.title) # points to de self.assertEqual('en', get_language()) - # This feature (for backward-compatibility) require _populate keyword... + # This feature (for backward-compatibility) require populate method... n = models.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) + with reload_override_settings(MODELTRANSLATION_AUTO_POPULATE=True): self.assertEqual(True, mt_settings.AUTO_POPULATE) n = models.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 = models.ManagerTestModel.objects.create(title='foo', _populate=False) + # populate method has highest priority + n = models.ManagerTestModel.objects.populate(False).create(title='foo') 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) + # Populate ``default`` fills just the default translation. + # TODO: Having more languages would make these tests more meaningful. + qs = models.ManagerTestModel.objects + m = qs.populate('default').create(title='foo', description='bar') + self.assertEqual('foo', m.title_de) + self.assertEqual('foo', m.title_en) + self.assertEqual('bar', m.description_de) + self.assertEqual('bar', m.description_en) + with override('de'): + m = qs.populate('default').create(title='foo', description='bar') + self.assertEqual('foo', m.title_de) + self.assertEqual(None, m.title_en) + self.assertEqual('bar', m.description_de) + self.assertEqual(None, m.description_en) + + # Populate ``required`` fills just non-nullable default translations. + qs = models.ManagerTestModel.objects + m = qs.populate('required').create(title='foo', description='bar') + self.assertEqual('foo', m.title_de) + self.assertEqual('foo', m.title_en) + self.assertEqual(None, m.description_de) + self.assertEqual('bar', m.description_en) + with override('de'): + m = qs.populate('required').create(title='foo', description='bar') + self.assertEqual('foo', m.title_de) + self.assertEqual(None, m.title_en) + self.assertEqual('bar', m.description_de) + self.assertEqual(None, m.description_en) + + def test_get_or_create_population(self): + """ + Populate may be used with ``get_or_create``. + """ + qs = models.ManagerTestModel.objects + m1, created1 = qs.populate(True).get_or_create(title='aaa') + m2, created2 = qs.populate(True).get_or_create(title='aaa') + self.assertTrue(created1) + self.assertFalse(created2) + self.assertEqual(m1, m2) + self.assertEqual('aaa', m1.title_en) + self.assertEqual('aaa', m1.title_de) + + def test_fixture_population(self): + """ + Test that a fixture with values only for the original fields + does not result in missing default translations for (original) + non-nullable fields. + """ + with auto_populate('required'): + call_command('loaddata', 'fixture.json', verbosity=0, commit=False) + m = models.TestModel.objects.get() + self.assertEqual(m.title_en, 'foo') + self.assertEqual(m.title_de, 'foo') + self.assertEqual(m.text_en, 'bar') + self.assertEqual(m.text_de, None) diff --git a/modeltranslation/tests/fixtures/fixture.json b/modeltranslation/tests/fixtures/fixture.json new file mode 100644 index 0000000..83462f3 --- /dev/null +++ b/modeltranslation/tests/fixtures/fixture.json @@ -0,0 +1,10 @@ +[ + { + "pk": 1, + "model": "tests.TestModel", + "fields": { + "title": "foo", + "text": "bar" + } + } +] diff --git a/modeltranslation/tests/models.py b/modeltranslation/tests/models.py index d963a6f..b3f73ad 100644 --- a/modeltranslation/tests/models.py +++ b/modeltranslation/tests/models.py @@ -195,6 +195,7 @@ class NameModel(models.Model): class ManagerTestModel(models.Model): title = models.CharField(ugettext_lazy('title'), max_length=255) visits = models.IntegerField(ugettext_lazy('visits'), default=0) + description = models.CharField(max_length=255, null=True) class Meta: ordering = ('-visits',) diff --git a/modeltranslation/tests/translation.py b/modeltranslation/tests/translation.py index 40a00fc..397ac29 100644 --- a/modeltranslation/tests/translation.py +++ b/modeltranslation/tests/translation.py @@ -109,7 +109,7 @@ translator.register(RichTextPage) ########## Manager testing class ManagerTestModelTranslationOptions(TranslationOptions): - fields = ('title', 'visits') + fields = ('title', 'visits', 'description') translator.register(ManagerTestModel, ManagerTestModelTranslationOptions) diff --git a/modeltranslation/translator.py b/modeltranslation/translator.py index 4f4f809..0dd810f 100644 --- a/modeltranslation/translator.py +++ b/modeltranslation/translator.py @@ -3,6 +3,7 @@ from django.conf import settings from django.db.models import Manager from django.db.models.base import ModelBase +from modeltranslation import settings as mt_settings from modeltranslation.fields import TranslationFieldDescriptor, create_translation_field from modeltranslation.manager import MultilingualManager, rewrite_lookup_key from modeltranslation.utils import build_localized_fieldname @@ -133,6 +134,7 @@ def patch_constructor(model): old_init = model.__init__ def new_init(self, *args, **kwargs): + populate_translation_fields(self.__class__, kwargs) for key, val in kwargs.items(): new_key = rewrite_lookup_key(model, key) # Old key is intentionally left in case old_init wants to play with it @@ -174,6 +176,56 @@ def delete_cache_fields(model): pass +def populate_translation_fields(sender, kwargs): + """ + When models are created or loaded from fixtures, replicates values + provided for translatable fields to some / all empty translation fields, + according to the current population mode. + + Population is performed only on keys (field names) present in kwargs. + Nothing is returned, but passed kwargs dictionary is altered. + + With ``mode`` set to: + -- ``all``: fills all translation fields, skipping just those for + which a translated value is also provided; + -- ``default``: fills only the default translation (unless it is + additionally provided); + -- ``required``: like ``default``, but only if the original field is + non-nullable; + + At least the ``required`` mode should be used when loading untranslated + fixtures to keep the database consistent (note that Django management + commands are normally forced to run with hardcoded ``en-us`` language + active). The ``default`` mode is useful if you need to ensure fallback + values are available, and ``all`` if you need to have all translations + defined (for example to make lookups / filtering without resorting to + query fallbacks). + """ + populate = mt_settings.AUTO_POPULATE + if not populate: + return + if populate is True: + # What was meant by ``True`` is now called ``all``. + populate = 'all' + + opts = translator.get_options_for_model(sender) + for key, val in kwargs.items(): + if key in opts.fields: + if populate == 'all': + # Set the value for every language. + for translation_field in opts.fields[key]: + kwargs.setdefault(translation_field.name, val) + elif populate == 'default': + default = build_localized_fieldname(key, mt_settings.DEFAULT_LANGUAGE) + kwargs.setdefault(default, val) + elif populate == 'required': + default = build_localized_fieldname(key, mt_settings.DEFAULT_LANGUAGE) + if not sender._meta.get_field(key).null: + kwargs.setdefault(default, val) + else: + raise AttributeError("Unknown population mode '%s'." % populate) + + class Translator(object): """ A Translator object encapsulates an instance of a translator. Models are @@ -248,9 +300,6 @@ class Translator(object): fallback_languages=model_fallback_languages) setattr(model, field_name, descriptor) - #signals.pre_init.connect(translated_model_initializing, sender=model, - #weak=False) - def unregister(self, model_or_iterable): """ Unregisters the given model(s). diff --git a/modeltranslation/utils.py b/modeltranslation/utils.py index c7756d1..d3049a4 100644 --- a/modeltranslation/utils.py +++ b/modeltranslation/utils.py @@ -1,4 +1,6 @@ # -*- coding: utf-8 -*- +from contextlib import contextmanager + from django.utils.encoding import force_unicode from django.utils.translation import get_language as _get_language from django.utils.functional import lazy @@ -94,3 +96,30 @@ def resolution_order(lang, override=None): fallback_def = override.get('default', settings.FALLBACK_LANGUAGES['default']) order = (lang,) + fallback_for_lang + fallback_def return tuple(unique(order)) + + +@contextmanager +def auto_populate(mode='all'): + """ + Overrides translation fields population mode (population mode decides which + unprovided translations will be filled during model construction / loading). + + Example: + + with auto_populate('all'): + s = Slugged.objects.create(title='foo') + s.title_en == 'foo' // True + s.title_de == 'foo' // True + + This method may be used to ensure consistency loading untranslated fixtures, + with non-default language active: + + with auto_populate('required'): + call_command('loaddata', 'fixture.json') + """ + current_population_mode = settings.AUTO_POPULATE + settings.AUTO_POPULATE = mode + try: + yield + finally: + settings.AUTO_POPULATE = current_population_mode