Source code for modeltrans.manager

# -*- coding: utf-8 -*-

from django.core.exceptions import FieldDoesNotExist
from django.db.models import Count, Func, Manager, Q, QuerySet
from django.db.models.constants import LOOKUP_SEP
from django.db.models.expressions import CombinedExpression, F, OrderBy
from django.db.models.functions import Cast
from django.utils import six

from .conf import get_default_language
from .fields import TranslatedVirtualField


def transform_translatable_fields(model, fields):
    '''
    Transform the kwargs for a <Model>.objects.create() or <Model>()
    to allow passing translated field names.

    Arguments:
        fields (dict): kwargs to a model __init__ or Model.objects.create() method
            for which the field names need to be translated to values in the i18n field
    '''
    # If the current model does have the TranslationField, we must not apply
    # any transformation for it will result in a:
    # TypeError: 'i18n' is an invalid keyword argument for this function
    if not hasattr(model, 'i18n'):
        return fields

    ret = {
        'i18n': fields.pop('i18n', {})
    }

    # keep track of translated fields, and do not return an `i18n` key if no
    # translated fields are found.
    has_translated_fields = (len(ret['i18n'].items()) > 0)

    for field_name, value in fields.items():
        try:
            field = model._meta.get_field(field_name)
        except FieldDoesNotExist:
            ret[field_name] = value
            continue

        if isinstance(field, TranslatedVirtualField):
            has_translated_fields = True
            if field.get_language() == get_default_language():
                if field.original_name in fields:
                    raise ValueError(
                        'Attempted override of "{}" with "{}". '
                        'Only one of the two is allowed.'.format(field.original_name, field_name)
                    )
                ret[field.original_name] = value
            else:
                ret['i18n'][field.name] = value
        else:
            ret[field_name] = value

    if not has_translated_fields:
        return fields

    return ret


[docs]class MultilingualQuerySet(QuerySet): ''' Extends `~django.db.models.query.Queryset` and makes the translated versions of fields accessible through the normal queryset methods, analogous to the virtual fields added to a translated model: - `<field>` allow getting/setting the default language - ``<field>_<lang>`` (for example, `<field>_de`) allows getting/setting a specific language. Note that if `LANGUAGE_CODE == 'en'`, `<field>_en` is mapped to `<field>`. - `<field>_i18n` follows the currently active translation in Django, and falls back to the default language. When adding the `modeltrans.fields.TranslationField` to a model, MultilingualManager is automatically mixed in to the manager class of that model. ''' def _add_i18n_annotation(self, virtual_field=None, fallback=True, bare_lookup=None, annotation_name=None): ''' Private method to add an annotation to the query to extract the translated version of a field from the jsonb field to allow filtering and ordering. Arguments: field (TranslatedVirtualField): the virtual field to create an annotation for. annotation_name (str): name of the annotation, if None the default `<original_field>_<lang>_annotation` will be used fallback (bool): If `True`, `COALESCE` will be used to get the value of the original field if the requested translation is not in the `i18n` dict. Returns: the name of the annotation created. ''' expression = virtual_field.as_expression(fallback=fallback, bare_lookup=bare_lookup) if isinstance(expression, F): return expression.name if annotation_name is None: annotation_name = '{}_annotation'.format(virtual_field.name) self.query.add_annotation(expression, annotation_name) return annotation_name def _get_field(self, lookup): ''' Return the Django model field for a lookup plus the remainder of the lookup, which should be the lookup type. ''' model = self.model lookup_type = None # pk is not an actual field, but an alias for the implicit id field. if lookup == 'pk': key = None for field in model._meta.get_fields(): if getattr(field, 'primary_key', False): key = field return key, None field = None bits = lookup.split(LOOKUP_SEP) for i, bit in enumerate(bits): try: field = model._meta.get_field(bit) except FieldDoesNotExist: lookup_type = LOOKUP_SEP.join(bits[i:]) break if hasattr(field, 'remote_field'): rel = getattr(field, 'remote_field', None) model = getattr(rel, 'model', model) return field, lookup_type def _rewrite_filter_clause(self, lookup, value): ''' private method which rewrites a filter clause passed to filter()/exclude() etc., for example: for title_nl__like='va' _rewrite_filter_clause('title_nl__like', 'va') would be called. ''' value = self._rewrite_expression(value) field, lookup_type = self._get_field(lookup) if not isinstance(field, TranslatedVirtualField): return lookup, value if lookup_type is not None: bare_lookup = lookup[0:-(len(LOOKUP_SEP + lookup_type))] else: bare_lookup = lookup filter_field_name = self._add_i18n_annotation( virtual_field=field, bare_lookup=bare_lookup, fallback=field.language is None ) # re-add lookup type if lookup_type is not None: filter_field_name += LOOKUP_SEP + lookup_type return filter_field_name, value def _rewrite_expression(self, expr): ''' Rewrite expressions. https://docs.djangoproject.com/en/2.0/ref/models/expressions/ This current way of doing this is bound to lag behind any new things implemented in Django. It would be really nice to have a better/more generic way of doing this. ''' if isinstance(expr, F): field, _ = self._get_field(expr.name) if not isinstance(field, TranslatedVirtualField): return expr return field.as_expression(fallback=field.language is None, bare_lookup=expr.name) elif isinstance(expr, CombinedExpression): expr.lhs = self._rewrite_expression(expr.lhs) expr.rhs = self._rewrite_expression(expr.rhs) elif isinstance(expr, Count): expr.source_expressions[0] = self._rewrite_expression(expr.source_expressions[0]) elif isinstance(expr, Func): expr.source_expressions = list([self._rewrite_expression(e) for e in expr.source_expressions]) elif isinstance(expr, OrderBy): expr.expression = self._rewrite_expression(expr.expression) return expr def _rewrite_Q(self, q): if isinstance(q, Q): return Q._new_instance( list(self._rewrite_Q(child) for child in q.children), connector=q.connector, negated=q.negated ) if isinstance(q, (list, tuple)): return self._rewrite_filter_clause(*q) def _rewrite_ordering(self, field_names): new_field_names = [] for field_name in field_names: if not isinstance(field_name, six.string_types): new_field_names.append(self._rewrite_expression(field_name)) continue # remove descending prefix, not relevant for the annotation sort_order = '' if field_name[0] == '-': field_name = field_name[1:] sort_order = '-' if field_name == 'pk': new_field_names.append(sort_order + 'pk') continue field, lookup_type = self._get_field(field_name) if field is None or not isinstance(field, TranslatedVirtualField): # if the field is just a normal field or not a field # no rewriting needed new_field_names.append(sort_order + field_name) continue assert lookup_type is None, '{} is not a valid order_by lookup'.format(field_name) sort_field = field.as_expression(bare_lookup=field_name) if sort_order == '-': sort_field = sort_field.desc() new_field_names.append(sort_field) return new_field_names def annotate(self, *args, **kwargs): ''' Patch annotate to allow the use of translated field names in annotations. https://docs.djangoproject.com/en/stable/ref/models/querysets/#annotate ''' args = [self._rewrite_expression(a) for a in args] kwargs = {alias: self._rewrite_expression(expr) for alias, expr in kwargs.items()} return super(MultilingualQuerySet, self).annotate(*args, **kwargs) def create(self, **kwargs): ''' Patch the create method to allow adding the value for a translated field using `Model.objects.create(..., title_nl='...')`. https://docs.djangoproject.com/en/stable/ref/models/querysets/#create ''' return super(MultilingualQuerySet, self).create( **transform_translatable_fields(self.model, kwargs) ) def order_by(self, *field_names): ''' Annotate translated fields before sorting. Examples: - sort on `-title_nl` will add an annotation for `title_nl` - sort on `title_i18n` will add an annotation for the current language The field names pointing to translated fields in the `field_names` argument will be replaced by their annotated versions. https://docs.djangoproject.com/en/1.11/ref/models/querysets/#order_by ''' new_field_names = self._rewrite_ordering(field_names) return super(MultilingualQuerySet, self).order_by(*new_field_names) def _filter_or_exclude(self, negate, *args, **kwargs): ''' Annotate lookups for `filter()` and `exclude()`. Examples: - `title_nl__contains='foo'` will add an annotation for `title_nl` - `title_nl='bar'` will add an annotation for `title_nl` - `title_i18n='foo'` will add an annotation for a coalesce of the current active language, and all items of the fallback chain. - `Q(title_nl__contains='foo') will add an annotation for `title_nl` In all cases, the field part of the field lookup will be changed to use the annotated verion. ''' # handle Q expressions / args new_args = [] for arg in args: new_args.append(Q(self._rewrite_Q(arg))) # handle the kwargs new_kwargs = {} for field, value in kwargs.items(): new_kwargs.update(dict((self._rewrite_filter_clause(field, value), ))) return super(MultilingualQuerySet, self)._filter_or_exclude(negate, *new_args, **new_kwargs) def _values(self, *fields, **expressions): ''' Annotate lookups for `values()` and `values_list()` It must be possible to use: `Blogs.objects.all().values_list('title_i18n', 'title_nl', 'title_en')` But also spanning relations: `Blogs.objects.all().values_list('title_i18n', 'category__name__i18n')` ''' _fields = fields + tuple(expressions) for field_name in _fields: field, lookup_type = self._get_field(field_name) if not isinstance(field, TranslatedVirtualField): continue fallback = field.language is None if 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: self._add_i18n_annotation( virtual_field=field, fallback=fallback, bare_lookup=field_name, annotation_name=field_name ) return super(MultilingualQuerySet, self)._values(*fields, **expressions) def __reduce__(self): ''' Make sure a dynamic version of this class can be pickled ''' return multilingual_queryset_factory, (self.__class__.__bases__[0],), self.__getstate__()
def multilingual_queryset_factory(old_cls, instantiate=True): '''Return a MultilingualQuerySet, or mix MultilingualQuerySet in custom QuerySets.''' if old_cls == QuerySet: NewClass = MultilingualQuerySet else: class NewClass(old_cls, MultilingualQuerySet): pass NewClass.__name__ = 'Multilingual%s' % old_cls.__name__ return NewClass() if instantiate else NewClass
[docs]class MultilingualManager(Manager): ''' When adding the `modeltrans.fields.TranslationField` to a model, MultilingualManager is automatically mixed in to the manager class of that model. ''' use_for_related_fields = True def _patch_queryset(self, qs): qs.__class__ = multilingual_queryset_factory(qs.__class__, instantiate=False) return qs def get_queryset(self): ''' This method is repeated because some managers that don't use super() or alter queryset class may return queryset that is not subclass of MultilingualQuerySet. ''' qs = super(MultilingualManager, self).get_queryset() if isinstance(qs, MultilingualQuerySet): # Is already patched return qs return self._patch_queryset(qs)