Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Per-record default language #88

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions docs/pages/working-with-models.rst
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,65 @@ With the models above::
print(article.content) # 'VS-Europeese oceaanbewakingssatelliet gelanceerd'


Per-record default language
---------------------------

The value of an original field (a translatable field when used without a language suffix; for example `title`, but not
`title_nl`) is normally in the Django default language, which is the one specified by the setting `LANGUAGE_CODE`.
Translations to any other language will be stored in the model's implicitly generated JSON field. In some cases, it may
be desired to use per-record default languages instead of a single global default language. For example, for a model
for organizations from different parts of the world, each instance has a name that is in the respective local language
and it may be desired to use this local-language name by default instead of storing it in the JSON field and leaving the
original field empty for potentially many instances. Modeltrans supports this by using the argument
`default_language_field` when specifying a `TranslationField`::

class Organization(models.Model):
name = models.CharField(max_length=255)
language = models.CharField(max_length=2)
i18n = TranslationField(fields=("name",), default_language_field="language")

Now, no matter the `LANGUAGE_CODE` setting, for both of the following instances the `name` field will contain the local
name and the JSON field `i18n` will be empty::

amsterdam = Organization.objects.create(name="Gemeente Amsterdam", language="nl")
helsinki = Organization.objects.create(name="Helsingin kaupunki", language="fi")

In addition, the names are also available in `amsterdam.name_nl` and `helsinki.name_fi`.

The value of `default_language_field` can contain `__` to traverse foreign keys::

class Department(models.Model):
name = models.CharField(max_length=255)
organization = models.ForeignKey(Organization, on_delete=models.CASCADE)
i18n = TranslationField(fields=("name",), default_language_field="organization__language")

Care should be taken regarding fallback. When you access the virtual field `name_i18n`, the following steps are taken to
return a value:

1. If the instance has a name in the currently active Django language, this value will be used.
2. If the model has a `fallback_language_field` and a name exists in the language stored in this field, that value will
be used.
3. The languages in the fallback chain (as specified in the setting `MODELTRANS_FALLBACK`) will be tried and the first
found value will be returned.
4. If no name for any of the previously tried languages exists, the value of the original field `name` will be used.

Therefore, if you specified a `default_language_field`, you should keep in mind that the fallback chain will take effect
before the original field value is returned. When using `default_language_field`, sometimes the desired behavior is to
first try to get a value in the currently active language and, if this is impossible, fall back to the per-record
default language stored in the original field instead of falling back to whatever is the global default language. To
achieve this, you have two options:

- In addition to `default_language_field="<field>"`, also specify `fallback_language_field="<field>"`.
- Set `MODELTRANS_FALLBACK["default"]` to the empty tuple `()` to disable fallback to languages other than the original
one for all models. If you don't set `MODELTRANS_FALLBACK["default"]`, `(LANGUAGE_CODE,)` will be used, which means
that the global default language will have precedence over the per-record default language.

**Caveat:** Changing the default language for instances cannot be easily done at the moment. When you change the default
language, you must manually move the original field values to the JSON field and the other way around. Be aware that
changing the default language of an instance may affect instances from other models as well if their
`default_language_field` refers to the changed instance via foreign keys (using the `__` syntax).


Inheritance of models with translated fields.
---------------------------------------------

Expand Down
50 changes: 40 additions & 10 deletions modeltrans/fields.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from django.core.exceptions import ImproperlyConfigured
from django.db.models import F, fields
from django.db.models.expressions import Case, When
from django.db.models.functions import Cast, Coalesce
from django.utils.translation import gettext

Expand All @@ -22,10 +23,10 @@

SUPPORTED_FIELDS = (fields.CharField, fields.TextField)

DEFAULT_LANGUAGE = get_default_language()
GLOBAL_DEFAULT_LANGUAGE = get_default_language()


def translated_field_factory(original_field, language=None, *args, **kwargs):
def translated_field_factory(original_field, language=None, default_language_field=None, *args, **kwargs):
if not isinstance(original_field, SUPPORTED_FIELDS):
raise ImproperlyConfigured(
"{} is not supported by django-modeltrans.".format(original_field.__class__.__name__)
Expand All @@ -36,7 +37,7 @@ class Specific(TranslatedVirtualField, original_field.__class__):

Specific.__name__ = "Translated{}".format(original_field.__class__.__name__)

return Specific(original_field, language, *args, **kwargs)
return Specific(original_field, language, *args, default_language_field=default_language_field, **kwargs)


class TranslatedVirtualField:
Expand All @@ -45,18 +46,21 @@ class TranslatedVirtualField:

Arguments:
original_field: The original field to be translated
language: The language to translate to, or `None` to track the current active Django language.
language: The language to translate to, or `None` to use the default language (see `default_language_field`)
default_language_field: Name of the field that contains the default language for this field, or `None` to track
the current active Django language
"""

# Implementation inspired by HStoreVirtualMixin from:
# https://github.com/djangonauts/django-hstore/blob/master/django_hstore/virtual.py

def __init__(self, original_field, language=None, *args, **kwargs):
def __init__(self, original_field, language=None, *args, default_language_field=None, **kwargs):
# TODO: this feels like a big hack.
self.__dict__.update(original_field.__dict__)

self.original_field = original_field
self.language = language
self.default_language_field = default_language_field

self.blank = kwargs["blank"]
self.null = kwargs["null"]
Expand Down Expand Up @@ -128,7 +132,8 @@ def __get__(self, instance, instance_type=None):

language = self.get_language()
original_value = getattr(instance, self.original_name)
if language == DEFAULT_LANGUAGE and original_value:
default_language = self.get_default_language(instance)
if language == default_language and original_value:
return original_value

# Make sure we test for containment in a dict, not in None
Expand All @@ -144,7 +149,7 @@ def __get__(self, instance, instance_type=None):
# This is the _i18n version of the field, and the current language is not available,
# so we walk the fallback chain:
for fallback_language in (language,) + self.get_instance_fallback_chain(instance, language):
if fallback_language == DEFAULT_LANGUAGE:
if fallback_language == default_language:
if original_value:
return original_value
else:
Expand All @@ -163,7 +168,8 @@ def __set__(self, instance, value):

language = self.get_language()

if language == DEFAULT_LANGUAGE:
default_language = self.get_default_language(instance)
if language == default_language:
setattr(instance, self.original_name, value)
else:
field_name = build_localized_fieldname(self.original_name, language)
Expand Down Expand Up @@ -211,7 +217,7 @@ def output_field(self):
return Field()

def _localized_lookup(self, language, bare_lookup):
if language == DEFAULT_LANGUAGE:
if not self.default_language_field and language == GLOBAL_DEFAULT_LANGUAGE:
return bare_lookup.replace(self.name, self.original_name)

# When accessing a table directly, the i18_lookup will be just "i18n", while following relations
Expand All @@ -223,6 +229,15 @@ def _localized_lookup(self, language, bare_lookup):
# abuse build_localized_fieldname without language to get "<field>_"
field_prefix = build_localized_fieldname(self.original_name, "")
return FallbackTransform(field_prefix, language, i18n_lookup)
elif self.default_language_field:
default_value_field = bare_lookup.replace(self.name, self.original_name)
return Case(
When(**{self.default_language_field: language}, then=default_value_field),
default=KeyTextTransform(
build_localized_fieldname(self.original_name, language), i18n_lookup
),
output_field=self.output_field(),
)
else:
return KeyTextTransform(
build_localized_fieldname(self.original_name, language), i18n_lookup
Expand All @@ -233,7 +248,7 @@ def as_expression(self, bare_lookup, fallback=True):
Compose an expression to get the value for this virtual field in a query.
"""
language = self.get_language()
if language == DEFAULT_LANGUAGE:
if not self.default_language_field and language == GLOBAL_DEFAULT_LANGUAGE:
return F(self._localized_lookup(language, bare_lookup))

if not fallback:
Expand All @@ -254,15 +269,28 @@ def as_expression(self, bare_lookup, fallback=True):
# and now, add the list of fallback languages to the lookup list
for fallback_language in fallback_chain:
lookups.append(self._localized_lookup(fallback_language, bare_lookup))

# Add the original field as a fallback (might not be in the fallback chain)
lookups.append(bare_lookup.replace(self.name, self.original_name))

return Coalesce(*lookups, output_field=self.output_field())

def get_default_language(self, instance):
if not self.default_language_field:
return GLOBAL_DEFAULT_LANGUAGE
return get_instance_field_value(instance, self.default_language_field)


class TranslationField(JSONField):
"""
This model field is used to store the translations in the translated model.

Arguments:
fields (iterable): List of model field names to make translatable.
default_language_field (field name):
Field of the model containing the language stored in the original
model fields; use Django main language by default. May contain `__`
as a field name separator to follow foreign keys.
required_languages (iterable or dict): List of languages required for the model.
If a dict is supplied, the keys must be translated field names with the value
containing a list of required languages for that specific field.
Expand All @@ -282,13 +310,15 @@ class TranslationField(JSONField):
def __init__(
self,
fields=None,
default_language_field=None,
required_languages=None,
virtual_fields=True,
fallback_language_field=None,
*args,
**kwargs,
):
self.fields = fields or ()
self.default_language_field = default_language_field
self.required_languages = required_languages or ()
self.virtual_fields = virtual_fields
self.fallback_language_field = fallback_language_field
Expand Down
11 changes: 9 additions & 2 deletions modeltrans/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,15 @@ def __new__(mcs, name, bases, attrs):
if model_class:
i18n_field = get_i18n_field(model_class)
if i18n_field:
has_default_language_field = bool(i18n_field.default_language_field)

for original_field_name in i18n_field.fields: # for all translated fields
# for all possible system languages
for language in languages:
# Ignore the default language suffix (and use the original field) only if the field has no
# specified default_language_field because, if it does, we don't know the default language
field_name = build_localized_fieldname(
original_field_name, language, ignore_default=True
original_field_name, language, ignore_default=not has_default_language_field
)

# add i18n field if an explicitly chosen field
Expand Down Expand Up @@ -225,9 +229,12 @@ def get_included_fields(self):
"""

fields = {}
has_default_language_field = bool(self.model_i18n_field.default_language_field)
for original_field in self.i18n_fields:
# Ignore the default language suffix (and use the original field) only if the field has no
# specified default_language_field because, if it does, we don't know the default language
fields[original_field] = [
build_localized_fieldname(original_field, language_code, ignore_default=True)
build_localized_fieldname(original_field, language_code, ignore_default=not has_default_language_field)
for language_code in self.language_codes
]
fields["__all__"] = list(itertools.chain.from_iterable(fields.values()))
Expand Down
14 changes: 12 additions & 2 deletions modeltrans/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from .conf import get_default_language
from .fields import TranslatedVirtualField
from .utils import get_instance_field_value


def transform_translatable_fields(model, fields):
Expand Down Expand Up @@ -39,7 +40,16 @@ def transform_translatable_fields(model, fields):

if isinstance(field, TranslatedVirtualField):
has_translated_fields = True
if field.get_language() == get_default_language():
if field.default_language_field:
first_part, *path = field.default_language_field.split(LOOKUP_SEP, maxsplit=1)
if path:
assert len(path) == 1
default_language = get_instance_field_value(fields[first_part], path[0])
else:
default_language = fields[first_part]
else:
default_language = get_default_language()
if field.get_language() == default_language:
if field.original_name in fields:
raise ValueError(
'Attempted override of "{}" with "{}". '
Expand Down Expand Up @@ -317,7 +327,7 @@ def _values(self, *fields, **expressions):

fallback = field.language is None

if field.get_language() == get_default_language():
if not field.default_language_field and field.get_language() == get_default_language():
original_field = field_name.replace(field.name, field.original_field.name)
self.query.add_annotation(Cast(original_field, field.output_field()), field_name)
else:
Expand Down
39 changes: 24 additions & 15 deletions modeltrans/translator.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,10 @@ def translate_model(Model):
validate(Model)

add_manager(Model)
default_language_field = get_i18n_field_param(Model, i18n_field, "default_language_field")
fields_to_translate = get_i18n_field_param(Model, i18n_field, "fields")
required_languages = get_i18n_field_param(Model, i18n_field, "required_languages")
add_virtual_fields(Model, fields_to_translate, required_languages)
add_virtual_fields(Model, default_language_field, fields_to_translate, required_languages)
patch_constructor(Model)

translate_meta_ordering(Model)
Expand Down Expand Up @@ -136,7 +137,7 @@ def raise_if_field_exists(Model, field_name):
)


def add_virtual_fields(Model, fields, required_languages):
def add_virtual_fields(Model, default_language_field, fields, required_languages):
"""
Adds newly created translation fields to the given translation options.
"""
Expand All @@ -154,34 +155,42 @@ def add_virtual_fields(Model, fields, required_languages):
# first, add a `<original_field_name>_i18n` virtual field to get the currently
# active translation for a field
field = translated_field_factory(
original_field=original_field, blank=True, null=True, editable=False # disable in admin
original_field=original_field, blank=True, null=True, editable=False, # disable in admin
default_language_field=default_language_field,
)

raise_if_field_exists(Model, field.get_field_name())
field.contribute_to_class(Model, field.get_field_name())

# add a virtual field pointing to the original field with name
# <original_field_name>_<LANGUAGE_CODE>
field = translated_field_factory(
original_field=original_field,
language=get_default_language(),
blank=True,
null=True,
editable=False,
)
raise_if_field_exists(Model, field.get_field_name())
field.contribute_to_class(Model, field.get_field_name())
if default_language_field:
# create field for global default language later on
add_field_for_global_default_language = True
else:
# create field for global default language now with different arguments than fields for other languages
add_field_for_global_default_language = False
# add a virtual field pointing to the original field with name
# <original_field_name>_<LANGUAGE_CODE>
field = translated_field_factory(
original_field=original_field,
language=get_default_language(),
blank=True,
null=True,
editable=False,
)
raise_if_field_exists(Model, field.get_field_name())
field.contribute_to_class(Model, field.get_field_name())

# now, for each language, add a virtual field to get the tranlation for
# that specific langauge
# <original_field_name>_<language>
for language in get_available_languages(include_default=False):
for language in get_available_languages(include_default=add_field_for_global_default_language):
blank_allowed = language not in field_required_languages
field = translated_field_factory(
original_field=original_field,
language=language,
blank=blank_allowed,
null=blank_allowed,
default_language_field=default_language_field,
)
raise_if_field_exists(Model, field.get_field_name())
field.contribute_to_class(Model, field.get_field_name())
Expand Down
6 changes: 4 additions & 2 deletions modeltrans/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,14 @@ def split_translated_fieldname(field_name):
return (field_name[0:_pos], field_name[_pos + 1 :])


def build_localized_fieldname(field_name, lang, ignore_default=False):
def build_localized_fieldname(field_name, lang, ignore_default=False, default_language=None):
if default_language is None:
default_language = get_default_language()
if lang == "id":
# The 2-letter Indonesian language code is problematic with the
# current naming scheme as Django foreign keys also add "id" suffix.
lang = "ind"
if ignore_default and lang == get_default_language():
if ignore_default and lang == default_language:
return field_name
return "{}_{}".format(field_name, lang.replace("-", "_"))

Expand Down
Loading