Skip to content

Commit

Permalink
feat: Add support for ManyToManyFields πŸ§‘β€πŸ€β€πŸ§‘ (#668)
Browse files Browse the repository at this point in the history
  • Loading branch information
bnznamco authored Jan 27, 2023
1 parent c8fd780 commit f69e317
Show file tree
Hide file tree
Showing 10 changed files with 829 additions and 22 deletions.
5 changes: 5 additions & 0 deletions docs/modeltranslation/caveats.rst
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,9 @@ Using in combination with ``django-rest-framework``
-------------------------------------------------
When creating a new viewset , make sure to override ``get_queryset`` method, using ``queryset`` as a property won't work because it is being evaluated once, before any language was set.

Translating ``ManyToManyField`` fields
-------------------------------------------------
Translated ``ManyToManyField`` fields do not support fallbacks. This is because the field descriptor returns a ``Manager`` when accessed. If falbacks were enabled we could find ourselves using the manager of a different language than the current one without realizing it. This can lead to using the ``.set()`` method on the wrong language.
Due to this behavior the fallbacks on M2M fields have been disabled.

.. _documentation: https://django-audit-log.readthedocs.io/
96 changes: 82 additions & 14 deletions modeltranslation/fields.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
from django import VERSION
from django import forms
import copy
from typing import Iterable

from django import VERSION, forms
from django.core.exceptions import ImproperlyConfigured
from django.db.models import fields

from modeltranslation import settings as mt_settings
from modeltranslation.thread_context import fallbacks_enabled
from modeltranslation.utils import (
get_language,
build_localized_fieldname,
build_localized_intermediary_model,
build_localized_verbose_name,
get_language,
resolution_order,
)
from modeltranslation.widgets import ClearableWidgetWrapper


SUPPORTED_FIELDS = (
fields.CharField,
# Above implies also CommaSeparatedIntegerField, EmailField, FilePathField, SlugField
Expand All @@ -35,6 +37,7 @@
fields.files.ImageField,
fields.related.ForeignKey,
# Above implies also OneToOneField
fields.related.ManyToManyField,
)

NEW_RELATED_API = VERSION >= (1, 9)
Expand Down Expand Up @@ -83,7 +86,7 @@ class TranslationFieldSpecific(TranslationField, baseclass):
return TranslationFieldSpecific


class TranslationField(object):
class TranslationField:
"""
The translation field functions as a proxy to the original field which is
wrapped.
Expand Down Expand Up @@ -156,9 +159,54 @@ def __init__(self, translated_field, language, empty_value, *args, **kwargs):
# (will show up e.g. in the admin).
self.verbose_name = build_localized_verbose_name(translated_field.verbose_name, language)

# M2M support - <rewrite related_name> <patch intermediary model>
if isinstance(self.translated_field, fields.related.ManyToManyField) and hasattr(
self.remote_field, "through"
):

# Since fields cannot share the same remote_field object:
self.remote_field = copy.copy(self.remote_field)

# To support multiple relations to self, must provide a non null language scoped related_name
if self.remote_field.symmetrical and (
self.remote_field.model == "self"
or self.remote_field.model == self.model._meta.object_name
or self.remote_field.model == self.model
):
self.remote_field.related_name = "%s_rel_+" % self.name
elif self.remote_field.is_hidden():
# Even if the backwards relation is disabled, django internally uses it, need to use a language scoped related_name
self.remote_field.related_name = "_%s_%s_+" % (
self.model.__name__.lower(),
self.name,
)
else:
# Default case with standard related_name must also include language scope
if self.remote_field.related_name is None:
# For implicit related_name use different query field name
loc_related_query_name = build_localized_fieldname(
self.related_query_name(), self.language
)
self.related_query_name = lambda: loc_related_query_name
self.remote_field.related_name = "%s_set" % (
build_localized_fieldname(self.model.__name__.lower(), language),
)
else:
self.remote_field.related_name = build_localized_fieldname(
self.remote_field.get_accessor_name(), language
)

# Patch intermediary model with language scope to create correct db table
self.remote_field.through = build_localized_intermediary_model(
self.remote_field.through, language
)
self.remote_field.field = self

if hasattr(self.remote_field.model._meta, '_related_objects_cache'):
del self.remote_field.model._meta._related_objects_cache

# ForeignKey support - rewrite related_name
if not NEW_RELATED_API and self.rel and self.related and not self.rel.is_hidden():
import copy
elif not NEW_RELATED_API and self.rel and self.related and not self.rel.is_hidden():

current = self.related.get_accessor_name()
self.rel = copy.copy(self.rel) # Since fields cannot share the same rel object.
Expand All @@ -172,11 +220,10 @@ def __init__(self, translated_field, language, empty_value, *args, **kwargs):
)
self.related_query_name = lambda: loc_related_query_name
self.rel.related_name = build_localized_fieldname(current, self.language)
self.rel.field = self # Django 1.6
self.rel.field = self
if hasattr(self.rel.to._meta, '_related_objects_cache'):
del self.rel.to._meta._related_objects_cache
elif NEW_RELATED_API and self.remote_field and not self.remote_field.is_hidden():
import copy

current = self.remote_field.get_accessor_name()
# Since fields cannot share the same rel object:
Expand All @@ -189,7 +236,7 @@ def __init__(self, translated_field, language, empty_value, *args, **kwargs):
)
self.related_query_name = lambda: loc_related_query_name
self.remote_field.related_name = build_localized_fieldname(current, self.language)
self.remote_field.field = self # Django 1.6
self.remote_field.field = self
if hasattr(self.remote_field.model._meta, '_related_objects_cache'):
del self.remote_field.model._meta._related_objects_cache

Expand Down Expand Up @@ -289,7 +336,7 @@ def clone(self):
return cls(*args, **kwargs)


class TranslationFieldDescriptor(object):
class TranslationFieldDescriptor:
"""
A descriptor used for the original translated field.
"""
Expand Down Expand Up @@ -367,13 +414,13 @@ def __get__(self, instance, owner):
return default


class TranslatedRelationIdDescriptor(object):
class TranslatedRelationIdDescriptor:
"""
A descriptor used for the original '_id' attribute of a translated
ForeignKey field.
"""

def __init__(self, field_name, fallback_languages):
def __init__(self, field_name: str, fallback_languages: Iterable[str]):
self.field_name = field_name # The name of the original field (excluding '_id')
self.fallback_languages = fallback_languages

Expand All @@ -400,7 +447,28 @@ def __get__(self, instance, owner):
return None


class LanguageCacheSingleObjectDescriptor(object):
class TranslatedManyToManyDescriptor:
"""
A descriptor used to return correct related manager without language fallbacks.
"""

def __init__(self, field_name, fallback_languages):
self.field_name = field_name # The name of the original field
self.fallback_languages = fallback_languages

def __get__(self, instance, owner):
# TODO: do we really need to handle fallbacks with m2m relations?
loc_field_name = build_localized_fieldname(self.field_name, get_language())
loc_attname = (instance or owner)._meta.get_field(loc_field_name).get_attname()
return getattr((instance or owner), loc_attname)

def __set__(self, instance, value):
loc_field_name = build_localized_fieldname(self.field_name, get_language())
loc_attname = instance._meta.get_field(loc_field_name).get_attname()
setattr(instance, loc_attname, value)


class LanguageCacheSingleObjectDescriptor:
"""
A Mixin for RelatedObjectDescriptors which use current language in cache lookups.
"""
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
from django.db.models import F, Q
from django.db.models import F, Q, ManyToManyField
from django.core.management.base import BaseCommand, CommandError

from modeltranslation.settings import AVAILABLE_LANGUAGES, DEFAULT_LANGUAGE
Expand Down Expand Up @@ -85,6 +85,17 @@ def handle(self, *args, **options):
# We'll only update fields which do not have an existing value
q = Q(**{def_lang_fieldname: None})
field = model._meta.get_field(field_name)
if isinstance(field, ManyToManyField):
trans_field = getattr(model, def_lang_fieldname)
if not trans_field.through.objects.exists():
field_names = [f.name for f in trans_field.through._meta.fields]
trans_field.through.objects.bulk_create(
trans_field.through(
**{f: v for f, v in dict(inst.__dict__) if f in field_names}
)
for inst in getattr(model, field_name).through.objects.all()
)
continue
if field.empty_strings_allowed:
q |= Q(**{def_lang_fieldname: ""})

Expand Down
3 changes: 3 additions & 0 deletions modeltranslation/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ def autodiscover():
for module in TRANSLATION_FILES:
import_module(module)

# This executes 'after imports' scheduled operations
translator.execute_lazy_operations()

# In debug mode, print a list of registered models and pid to stdout.
# Note: Differing model order is fine, we don't rely on a particular
# order, as far as base classes are registered before subclasses.
Expand Down
Loading

0 comments on commit f69e317

Please sign in to comment.