modeltrans.manager — django-modeltrans 0.0.4 documentation

# Source code for modeltrans.manager

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

    from django.core.exceptions import FieldDoesNotExist
    from django.db import models
    from django.db.models import F, TextField
    from django.db.models.constants import LOOKUP_SEP
    from django.db.models.functions import Cast

    from . import settings
    from .fields import TranslatedVirtualField, TranslationField
    from .utils import split_translated_fieldname

    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
        '''

        ret = {
            'i18n': fields.get('i18n', {})
        }
        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, TranslationField):
                continue

            if isinstance(field, TranslatedVirtualField):
                if field.get_language() == settings.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

        return ret

    class MultilingualQuerySet(models.query.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 `DEFAULT_LANGUAGE == '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, field, annotation_name=None, fallback=True):
            '''
            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 (by 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.
            '''
            annotation = field.sql_lookup(fallback)
            if isinstance(annotation, str):
                return annotation

            if field.model is not self.model and annotation_name is not None:
                # strip the language to make sure Django properly joins the tables.
                # ie: when the lookup is `category__name_nl`, we add an annotation
                # for placeholder=Cast('category__name').
                # This has the side-effect that Django properly joins the tables,
                # but in case of values(), it is not added to the final query.
                lookup_with_original_field = annotation_name[:annotation_name.rfind(field.name)] + field.original_name
                self.query.add_annotation(
                    Cast(lookup_with_original_field, TextField()), 'related_annotation_helper'
                )

            if annotation_name is None:
                annotation_name = '{}_annotation'.format(field.name)

            self.query.add_annotation(annotation, annotation_name)
            return annotation_name

        def create(self, **kwargs):
            '''
            Patch the create method to allow adding the value for a translated field
            using `Model.objects.create(..., title_nl='...')`.
            '''
            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.
            '''
            new_field_names = []

            for field_name in field_names:
                if '_' not in field_name:
                    new_field_names.append(field_name)
                    continue

                # remove descending prefix, not relevant for the annotation
                sort_order = ''
                if field_name[0] == '-':
                    field_name = field_name[1:]
                    sort_order = '-'

                field = self.model._meta.get_field(field_name)
                if not isinstance(field, TranslatedVirtualField):
                    new_field_names.append(sort_order + field_name)
                    continue

                sort_field_name = self.add_i18n_annotation(field, fallback=True)

                new_field_names.append(sort_order + sort_field_name)

            return super(MultilingualQuerySet, self).order_by(*new_field_names)

        def rewrite_expression(self, lookup, value):
            requested_field_name = lookup
            query_type = ''

            value = self.rewrite_F(value)

            # strip the query type
            # TODO: this is going wrong if a related lookup is used.
            # ie category__name.
            # probably fixable by looking at the
            if '__' in lookup:
                lookup = lookup[0:lookup.rfind('__')]
                query_type = requested_field_name[len(lookup):]

            # special case for pk, because it is not a field.
            if lookup == 'pk':
                return requested_field_name, value

            try:
                field = self.model._meta.get_field(lookup)
            except FieldDoesNotExist:
                return requested_field_name, value

            if not isinstance(field, TranslatedVirtualField):
                return requested_field_name, value

            fallback = field.language is None
            filter_field_name = self.add_i18n_annotation(field, fallback=fallback)

            # re-add query type
            filter_field_name += query_type

            return filter_field_name, value

        def rewrite_F(self, f):
            if not isinstance(f, F):
                return f
            field = self._get_field(f.name)
            rewritten = self.add_i18n_annotation(field, fallback=False)

            return F(rewritten)

        def rewrite_Q(self, q):
            if isinstance(q, models.Q):
                return models.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_expression(*q)

        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 `title_<language>`
                  where `<language>` is the current active language.
                - `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.
            '''
            # TODO: handle F expressions in the righthand (value) side of filters

            # handle Q expressions
            new_args = []
            for arg in args:
                new_args.append(self.rewrite_Q(arg))

            # handle the kwargs
            new_kwargs = {}
            for field, value in kwargs.items():
                new_kwargs.update(dict((self.rewrite_expression(field, value), )))

            return super(MultilingualQuerySet, self)._filter_or_exclude(negate, *new_args, **new_kwargs)

        def _get_field(self, lookup):
            model = self.model

            field = None
            for part in lookup.split(LOOKUP_SEP):
                try:
                    field = model._meta.get_field(part)
                except FieldDoesNotExist:
                    break

                if hasattr(field, 'remote_field'):
                    rel = getattr(field, 'remote_field', None)
                    model = getattr(rel, 'model', model)

            return field

        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')`
            '''
            _fields = fields + tuple(expressions)

            for field_name in _fields:
                field = self._get_field(field_name)
                if not isinstance(field, TranslatedVirtualField):
                    continue

                fallback = field.language is None

                if field.get_language() == settings.DEFAULT_LANGUAGE:
                    # TODO: see if we can just do this with add_i18n_annotation()
                    self.query.add_annotation(Cast(field.original_name, field.output_field()), field_name)
                else:
                    self.add_i18n_annotation(
                        field,
                        annotation_name=field_name,
                        fallback=fallback
                    )

            return super(MultilingualQuerySet, self)._values(*fields, **expressions)

    def multilingual_queryset_factory(old_cls, instantiate=True):
        '''Return a MultilingualQuerySet, or mix MultilingualQuerySet in custom QuerySets.'''
        if old_cls == models.query.QuerySet:
            NewClass = MultilingualQuerySet
        else:
            class NewClass(old_cls, MultilingualQuerySet):
                pass
            NewClass.__name__ = 'Multilingual%s' % old_cls.__name__
        return NewClass() if instantiate else NewClass

    class MultilingualManager(models.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)