from django.core.exceptions import ImproperlyConfigured
from django.db.models import F, fields
from django.db.models.functions import Cast, Coalesce
from django.utils.translation import gettext
from .conf import get_default_language, get_fallback_chain, get_modeltrans_setting
from .utils import (
FallbackTransform,
build_localized_fieldname,
get_instance_field_value,
get_language,
)
try:
# django==3.1 moved JSONField into django.db.models
from django.db.models import JSONField
from django.db.models.fields.json import KeyTextTransform
except ImportError:
from django.contrib.postgres.fields import JSONField
from django.contrib.postgres.fields.jsonb import KeyTextTransform
SUPPORTED_FIELDS = (fields.CharField, fields.TextField)
DEFAULT_LANGUAGE = get_default_language()
def translated_field_factory(original_field, language=None, *args, **kwargs):
if not isinstance(original_field, SUPPORTED_FIELDS):
raise ImproperlyConfigured(
"{} is not supported by django-modeltrans.".format(original_field.__class__.__name__)
)
class Specific(TranslatedVirtualField, original_field.__class__):
pass
Specific.__name__ = "Translated{}".format(original_field.__class__.__name__)
return Specific(original_field, language, *args, **kwargs)
[docs]class TranslatedVirtualField:
"""
A field representing a single field translated to a specific language.
Arguments:
original_field: The original field to be translated
language: The language to translate to, 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):
# TODO: this feels like a big hack.
self.__dict__.update(original_field.__dict__)
self.original_field = original_field
self.language = language
self.blank = kwargs["blank"]
self.null = kwargs["null"]
self.concrete = False
self._help_text = kwargs.pop("help_text", None)
@property
def original_name(self):
return self.original_field.name
@property
def help_text(self):
if self._help_text is not None:
return self._help_text
if get_modeltrans_setting("MODELTRANS_ADD_FIELD_HELP_TEXT") and self.language is None:
return gettext("current language: {}").format(get_language())
def contribute_to_class(self, cls, name):
self.model = cls
self.attname = name
self.name = name
self.column = None
# Use a translated verbose name:
translated_field_name = gettext(self.original_field.verbose_name)
if self.language is not None:
translated_field_name += " ({})".format(self.language.upper())
self.verbose_name = translated_field_name
setattr(cls, name, self)
cls._meta.add_field(self, private=True)
def db_type(self, connection):
return None
def get_instance_fallback_chain(self, instance, language):
"""
Return the fallback chain for the instance.
Most of the time, it is just the configured fallback chain, but if the per-record-fallback feature
is used, the value of the field is added (if not None).
"""
default = get_fallback_chain(language)
i18n_field = instance._meta.get_field("i18n")
if i18n_field.fallback_language_field:
record_fallback_language = get_instance_field_value(
instance, i18n_field.fallback_language_field
)
if record_fallback_language:
return (record_fallback_language, *default)
return default
def __get__(self, instance, instance_type=None):
# This method is apparently called with instance=None from django.
# django-hstor raises AttributeError here, but that doesn't solve our problem.
if instance is None:
return
if "i18n" in instance.get_deferred_fields():
raise ValueError(
"Getting translated values on a model fetched with defer('i18n') is not supported."
)
language = self.get_language()
original_value = getattr(instance, self.original_name)
if language == DEFAULT_LANGUAGE and original_value:
return original_value
# Make sure we test for containment in a dict, not in None
if instance.i18n is None:
instance.i18n = {}
field_name = build_localized_fieldname(self.original_name, language)
# Just return the value if this is an explicit field (<name>_<lang>)
if self.language is not None:
return instance.i18n.get(field_name)
# 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 original_value:
return original_value
else:
continue
field_name = build_localized_fieldname(self.original_name, fallback_language)
if field_name in instance.i18n and instance.i18n[field_name]:
return instance.i18n.get(field_name)
# finally, return the original field if all else fails.
return getattr(instance, self.original_name)
def __set__(self, instance, value):
if instance.i18n is None:
instance.i18n = {}
language = self.get_language()
if language == DEFAULT_LANGUAGE:
setattr(instance, self.original_name, value)
else:
field_name = build_localized_fieldname(self.original_name, language)
# if value is None, remove field from `i18n`.
if value is None:
instance.i18n.pop(field_name, None)
else:
instance.i18n[field_name] = value
[docs] def get_field_name(self):
"""
Returns the field name for the current virtual field.
The field name is ``<original_field_name>_<language>`` in case of a specific
translation or ``<original_field_name>_i18n`` for the currently active language.
"""
if self.language is None:
lang = "i18n"
else:
lang = self.get_language()
return build_localized_fieldname(self.original_name, lang)
[docs] def get_language(self):
"""
Returns the language for this field.
In case of an explicit language (title_en), it returns "en", in case of
`title_i18n`, it returns the currently active Django language.
"""
return self.language if self.language is not None else get_language()
def output_field(self):
"""
The type of field used to Cast/Coalesce to.
Mainly because a max_length argument is required for CharField
until this PR is merged: https://github.com/django/django/pull/8758
"""
Field = self.original_field.__class__
if isinstance(self.original_field, fields.CharField):
return Field(max_length=self.original_field.max_length)
return Field()
def _localized_lookup(self, language, bare_lookup):
if language == 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
# they are in the lookup first.
i18n_lookup = bare_lookup.replace(self.name, "i18n")
# To support per-row fallback languages, an F-expression is passed as language parameter.
if isinstance(language, F):
# abuse build_localized_fieldname without language to get "<field>_"
field_prefix = build_localized_fieldname(self.original_name, "")
return FallbackTransform(field_prefix, language, i18n_lookup)
else:
return KeyTextTransform(
build_localized_fieldname(self.original_name, language), i18n_lookup
)
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:
return F(self._localized_lookup(language, bare_lookup))
if not fallback:
i18n_lookup = self._localized_lookup(language, bare_lookup)
return Cast(i18n_lookup, self.output_field())
fallback_chain = get_fallback_chain(language)
# First, add the current language to the list of lookups
lookups = [self._localized_lookup(language, bare_lookup)]
# Optionnally add the lookup for the per-row fallback language
i18n_field = self.model._meta.get_field("i18n")
if i18n_field.fallback_language_field:
lookups.append(
self._localized_lookup(F(i18n_field.fallback_language_field), bare_lookup)
)
# 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))
return Coalesce(*lookups, output_field=self.output_field())
[docs]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.
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.
virtual_fields (bool): If `False`, do not add virtual fields to access
translated values with.
Set to `True` during migration from django-modeltranslation to prevent
collisions with it's database fields while having the `i18n` field available.
fallback_language_field: If not None, this should be the name of the field containing a
language code to use as the first language in any fallback chain.
For example: if you have a model instance with 'nl' as language_code, and set
fallback_language_field='language_code', 'nl' will always be tried after the current
language before any other language.
"""
description = "Translation storage for a model"
def __init__(
self,
fields=None,
required_languages=None,
virtual_fields=True,
fallback_language_field=None,
*args,
**kwargs,
):
self.fields = fields or ()
self.required_languages = required_languages or ()
self.virtual_fields = virtual_fields
self.fallback_language_field = fallback_language_field
kwargs["editable"] = False
kwargs["null"] = True
super().__init__(*args, **kwargs)
def deconstruct(self):
name, path, args, kwargs = super().deconstruct()
del kwargs["editable"]
del kwargs["null"]
kwargs["fields"] = self.fields
kwargs["required_languages"] = self.required_languages
kwargs["virtual_fields"] = self.virtual_fields
return name, path, args, kwargs
def get_translated_fields(self):
"""Return a generator for all translated fields."""
for field in self.model._meta.get_fields():
if isinstance(field, TranslatedVirtualField):
yield field
def contribute_to_class(self, cls, name):
if name != "i18n":
raise ImproperlyConfigured('{} must have name "i18n"'.format(self.__class__.__name__))
super().contribute_to_class(cls, name)