feat: Add support for ManyToManyFields 🧑‍🤝‍🧑 (#668)

This commit is contained in:
Gabriele Baldi 2023-01-27 14:05:18 +01:00 committed by GitHub
parent c8fd780523
commit f69e3172bc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 829 additions and 22 deletions

View file

@ -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/

View file

@ -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
@ -35,6 +37,7 @@ SUPPORTED_FIELDS = (
fields.files.ImageField,
fields.related.ForeignKey,
# Above implies also OneToOneField
fields.related.ManyToManyField,
)
NEW_RELATED_API = VERSION >= (1, 9)
@ -83,7 +86,7 @@ def field_factory(baseclass):
return TranslationFieldSpecific
class TranslationField(object):
class TranslationField:
"""
The translation field functions as a proxy to the original field which is
wrapped.
@ -156,9 +159,54 @@ class TranslationField(object):
# (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.
@ -172,11 +220,10 @@ class TranslationField(object):
)
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:
@ -189,7 +236,7 @@ class TranslationField(object):
)
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
@ -289,7 +336,7 @@ class TranslationField(object):
return cls(*args, **kwargs)
class TranslationFieldDescriptor(object):
class TranslationFieldDescriptor:
"""
A descriptor used for the original translated field.
"""
@ -367,13 +414,13 @@ class TranslationFieldDescriptor(object):
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
@ -400,7 +447,28 @@ class TranslatedRelationIdDescriptor(object):
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.
"""

View file

@ -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
@ -85,6 +85,17 @@ class Command(BaseCommand):
# 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: ""})

View file

@ -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.

View file

@ -937,6 +937,335 @@ class Migration(migrations.Migration):
),
],
),
migrations.CreateModel(
name='CustomThroughModel',
fields=[
(
'id',
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name='ID'
),
),
],
),
migrations.CreateModel(
name='ManyToManyFieldModel',
fields=[
(
'id',
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name='ID'
),
),
('title', models.CharField(max_length=255, verbose_name='title')),
('title_de', models.CharField(max_length=255, null=True, verbose_name='title')),
('title_en', models.CharField(max_length=255, null=True, verbose_name='title')),
('self_call_1', models.ManyToManyField(to='tests.manytomanyfieldmodel')),
('self_call_2', models.ManyToManyField(to='tests.manytomanyfieldmodel')),
(
'self_call_1_en',
models.ManyToManyField(null=True, to='tests.manytomanyfieldmodel'),
),
(
'self_call_1_de',
models.ManyToManyField(null=True, to='tests.manytomanyfieldmodel'),
),
(
'self_call_2_en',
models.ManyToManyField(null=True, to='tests.manytomanyfieldmodel'),
),
(
'self_call_2_de',
models.ManyToManyField(null=True, to='tests.manytomanyfieldmodel'),
),
],
),
migrations.CreateModel(
name='RegisteredThroughModel_de',
fields=[
(
'id',
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name='ID'
),
),
(
'rel_1',
models.ForeignKey(
db_tablespace='',
on_delete=django.db.models.deletion.CASCADE,
related_name='RegisteredThroughModel_de+',
to='tests.manytomanyfieldmodel',
),
),
(
'rel_2',
models.ForeignKey(
db_tablespace='',
on_delete=django.db.models.deletion.CASCADE,
related_name='RegisteredThroughModel_de+',
to='tests.testmodel',
),
),
('title', models.CharField(max_length=255, verbose_name='title')),
('title_de', models.CharField(max_length=255, null=True, verbose_name='title')),
('title_en', models.CharField(max_length=255, null=True, verbose_name='title')),
],
options={
'verbose_name': 'registered through model [de]',
'verbose_name_plural': 'registered through models [de]',
'db_table': 'tests_registeredthroughmodel_de',
'db_tablespace': '',
'auto_created': False,
},
),
migrations.CreateModel(
name='RegisteredThroughModel_en',
fields=[
(
'id',
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name='ID'
),
),
(
'rel_1',
models.ForeignKey(
db_tablespace='',
on_delete=django.db.models.deletion.CASCADE,
related_name='RegisteredThroughModel_en+',
to='tests.manytomanyfieldmodel',
),
),
(
'rel_2',
models.ForeignKey(
db_tablespace='',
on_delete=django.db.models.deletion.CASCADE,
related_name='RegisteredThroughModel_en+',
to='tests.testmodel',
),
),
('title', models.CharField(max_length=255, verbose_name='title')),
('title_de', models.CharField(max_length=255, null=True, verbose_name='title')),
('title_en', models.CharField(max_length=255, null=True, verbose_name='title')),
],
options={
'verbose_name': 'registered through model [en]',
'verbose_name_plural': 'registered through models [en]',
'db_table': 'tests_registeredthroughmodel_en',
'db_tablespace': '',
'auto_created': False,
},
),
migrations.CreateModel(
name='RegisteredThroughModel',
fields=[
(
'id',
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name='ID'
),
),
('title', models.CharField(max_length=255)),
('title_de', models.CharField(max_length=255, null=True)),
('title_en', models.CharField(max_length=255, null=True)),
(
'rel_1',
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to='tests.manytomanyfieldmodel'
),
),
(
'rel_2',
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to='tests.testmodel'
),
),
],
),
migrations.AddField(
model_name='manytomanyfieldmodel',
name='test',
field=models.ManyToManyField(related_name='m2m_test_ref', to='tests.testmodel'),
),
migrations.AddField(
model_name='manytomanyfieldmodel',
name='test_en',
field=models.ManyToManyField(
null=True, related_name='m2m_test_ref', to='tests.testmodel'
),
),
migrations.AddField(
model_name='manytomanyfieldmodel',
name='test_de',
field=models.ManyToManyField(
null=True, related_name='m2m_test_ref', to='tests.testmodel'
),
),
migrations.AddField(
model_name='manytomanyfieldmodel',
name='through_model',
field=models.ManyToManyField(
related_name='m2m_through_model_ref',
through='tests.CustomThroughModel',
to='tests.testmodel',
),
),
migrations.AddField(
model_name='manytomanyfieldmodel',
name='through_model_en',
field=models.ManyToManyField(
null=True,
related_name='manytomanyfieldmodel_through_model_en_set',
through='tests.CustomThroughModel',
to='tests.testmodel',
),
),
migrations.AddField(
model_name='manytomanyfieldmodel',
name='through_model_de',
field=models.ManyToManyField(
null=True,
related_name='manytomanyfieldmodel_through_model_de_set',
through='tests.CustomThroughModel',
to='tests.testmodel',
),
),
migrations.AddField(
model_name='manytomanyfieldmodel',
name='trans_through_model',
field=models.ManyToManyField(
related_name='m2m_trans_through_model_ref',
through='tests.RegisteredThroughModel',
to='tests.testmodel',
),
),
migrations.AddField(
model_name='manytomanyfieldmodel',
name='trans_through_model_en',
field=models.ManyToManyField(
null=True,
related_name='m2m_trans_through_model_ref',
through='tests.RegisteredThroughModel',
to='tests.testmodel',
),
),
migrations.AddField(
model_name='manytomanyfieldmodel',
name='trans_through_model_de',
field=models.ManyToManyField(
null=True,
related_name='m2m_trans_through_model_ref',
through='tests.RegisteredThroughModel',
to='tests.testmodel',
),
),
migrations.AddField(
model_name='manytomanyfieldmodel',
name='untrans',
field=models.ManyToManyField(related_name='m2m_untrans_ref', to='tests.nontranslated'),
),
migrations.AddField(
model_name='manytomanyfieldmodel',
name='untrans_en',
field=models.ManyToManyField(
null=True, related_name='m2m_untrans_ref', to='tests.nontranslated'
),
),
migrations.AddField(
model_name='manytomanyfieldmodel',
name='untrans_de',
field=models.ManyToManyField(
null=True, related_name='m2m_untrans_ref', to='tests.nontranslated'
),
),
migrations.CreateModel(
name='CustomThroughModel_de',
fields=[
(
'id',
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name='ID'
),
),
(
'rel_1',
models.ForeignKey(
db_tablespace='',
on_delete=django.db.models.deletion.CASCADE,
related_name='CustomThroughModel_de+',
to='tests.manytomanyfieldmodel',
),
),
(
'rel_2',
models.ForeignKey(
db_tablespace='',
on_delete=django.db.models.deletion.CASCADE,
related_name='CustomThroughModel_de+',
to='tests.testmodel',
),
),
],
options={
'verbose_name': 'custom through model [de]',
'verbose_name_plural': 'custom through models [de]',
'db_table': 'tests_customthroughmodel_de',
'db_tablespace': '',
'auto_created': False,
},
),
migrations.CreateModel(
name='CustomThroughModel_en',
fields=[
(
'id',
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name='ID'
),
),
(
'rel_1',
models.ForeignKey(
db_tablespace='',
on_delete=django.db.models.deletion.CASCADE,
related_name='CustomThroughModel_en+',
to='tests.manytomanyfieldmodel',
),
),
(
'rel_2',
models.ForeignKey(
db_tablespace='',
on_delete=django.db.models.deletion.CASCADE,
related_name='CustomThroughModel_en+',
to='tests.testmodel',
),
),
],
options={
'verbose_name': 'custom through model [en]',
'verbose_name_plural': 'custom through models [en]',
'db_table': 'tests_customthroughmodel_en',
'db_tablespace': '',
'auto_created': False,
},
),
migrations.AddField(
model_name='customthroughmodel',
name='rel_1',
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to='tests.manytomanyfieldmodel'
),
),
migrations.AddField(
model_name='customthroughmodel',
name='rel_2',
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to='tests.testmodel'
),
),
migrations.CreateModel(
name='ProxyTestModel',
fields=[],

View file

@ -56,7 +56,7 @@ class FileFieldsModel(models.Model):
image = models.ImageField(upload_to='modeltranslation_tests', null=True, blank=True)
# ######### Foreign Key / OneToOneField testing
# ######### Foreign Key / OneToOneField / ManytoManyField testing
class NonTranslated(models.Model):
@ -114,6 +114,43 @@ class OneToOneFieldModel(models.Model):
)
class ManyToManyFieldModel(models.Model):
title = models.CharField(gettext_lazy('title'), max_length=255)
test = models.ManyToManyField(
TestModel,
related_name="m2m_test_ref",
)
self_call_1 = models.ManyToManyField("self")
# test multiple self m2m
self_call_2 = models.ManyToManyField("self")
through_model = models.ManyToManyField(TestModel, through="CustomThroughModel")
trans_through_model = models.ManyToManyField(
TestModel, related_name="m2m_trans_through_model_ref", through="RegisteredThroughModel"
)
untrans = models.ManyToManyField(
NonTranslated,
related_name="m2m_untrans_ref",
)
class CustomThroughModel(models.Model):
rel_1 = models.ForeignKey(ManyToManyFieldModel, on_delete=models.CASCADE)
rel_2 = models.ForeignKey(TestModel, on_delete=models.CASCADE)
@property
def test_property(self):
return "%s_%s" % (self.__class__.__name__, self.rel_1_id)
def test_method(self):
return self.rel_1_id + 1
class RegisteredThroughModel(models.Model):
rel_1 = models.ForeignKey(ManyToManyFieldModel, on_delete=models.CASCADE)
rel_2 = models.ForeignKey(TestModel, on_delete=models.CASCADE)
title = models.CharField(max_length=255)
# ######### Custom fields testing

View file

@ -41,7 +41,7 @@ from modeltranslation.utils import (
request = None
# How many models are registered for tests.
TEST_MODELS = 36
TEST_MODELS = 40
class reload_override_settings(override_settings):
@ -994,6 +994,284 @@ class ForeignKeyFieldsTest(ModeltranslationTestBase):
assert field.attname != build_localized_fieldname(field.name, 'id')
class ManyToManyFieldsTest(ModeltranslationTestBase):
@classmethod
def setUpClass(cls):
# 'model' attribute cannot be assigned to class in its definition,
# because ``models`` module will be reloaded and hence class would use old model classes.
super(ManyToManyFieldsTest, cls).setUpClass()
cls.model = models.ManyToManyFieldModel
def test_translated_models(self):
field_names = dir(self.model())
assert 'id' in field_names
for f in ('test', 'test_de', 'test_en', 'self_call_1', 'self_call_1_en', 'self_call_1_de'):
assert f in field_names
def test_db_column_names(self):
meta = self.model._meta
# Make sure the correct database columns always get used:
field = meta.get_field('test')
assert field.remote_field.through._meta.db_table == "tests_manytomanyfieldmodel_test"
field = meta.get_field('test_en')
assert field.remote_field.through._meta.db_table == "tests_manytomanyfieldmodel_test_en"
field = meta.get_field('test_de')
assert field.remote_field.through._meta.db_table == "tests_manytomanyfieldmodel_test_de"
field = meta.get_field('self_call_1')
assert field.remote_field.through._meta.db_table == "tests_manytomanyfieldmodel_self_call_1"
field = meta.get_field('self_call_1_en')
assert (
field.remote_field.through._meta.db_table == "tests_manytomanyfieldmodel_self_call_1_en"
)
field = meta.get_field('self_call_1_de')
assert (
field.remote_field.through._meta.db_table == "tests_manytomanyfieldmodel_self_call_1_de"
)
field = meta.get_field('through_model')
assert field.remote_field.through._meta.db_table == "tests_customthroughmodel"
field = meta.get_field('through_model_en')
assert field.remote_field.through._meta.db_table == "tests_customthroughmodel_en"
field = meta.get_field('through_model_de')
assert field.remote_field.through._meta.db_table == "tests_customthroughmodel_de"
def test_translated_models_instance(self):
models.TestModel.objects.bulk_create(
(
models.TestModel(title_en='m2m_test_%s_en' % i, title_de='m2m_test_%s_de' % i)
for i in range(10)
)
)
self.model.objects.bulk_create(
(
self.model(title_en='m2m_test_%s_en' % i, title_de='m2m_test_%s_de' % i)
for i in range(10)
)
)
models.NonTranslated.objects.bulk_create(
(models.NonTranslated(title='m2m_test_%s' % i) for i in range(10))
)
testmodel_qs = models.TestModel.objects.all()
testmodel_qs_1 = testmodel_qs.filter(title_en__in=['m2m_test_%s_en' % i for i in range(4)])
testmodel_qs_2 = testmodel_qs.filter(
title_en__in=['m2m_test_%s_en' % i for i in range(4, 10)]
)
untranslated_qs = models.NonTranslated.objects.all()
self_qs = self.model.objects.all()
self_qs_1 = self_qs.filter(title_en__in=['m2m_test_%s_en' % i for i in range(6)])
self_qs_2 = self_qs.filter(title_en__in=['m2m_test_%s_en' % i for i in range(6, 10)])
inst = self.model()
inst.save()
trans_real.activate("de")
inst.test.set(list(testmodel_qs_1.values_list("pk", flat=True)))
assert inst.test.through.objects.all().count() == testmodel_qs_1.count()
inst.through_model.set(testmodel_qs_2)
assert inst.through_model.through.objects.all().count() == testmodel_qs_2.count()
inst.self_call_2.set(self_qs_1)
assert inst.self_call_2.all().count() == self_qs_1.count()
trans_real.activate("en")
inst.trans_through_model.through.objects.bulk_create(
(
inst.trans_through_model.through(
title_en='m2m_test_%s_en' % (i + 1),
title_de='m2m_test_%s_de' % (i + 1),
rel_1_id=int(inst.pk),
rel_2_id=tst_model.pk,
)
for i, tst_model in enumerate(testmodel_qs[:2])
)
)
assert inst.trans_through_model.all().count() == 2
inst.untrans.set(untranslated_qs)
assert inst.untrans.through.objects.all().count() == untranslated_qs.count()
inst.self_call_1.set(self_qs_2)
assert (
inst.self_call_1.filter(pk__in=self_qs_2.values_list("pk", flat=True)).count()
== self_qs_2.count()
)
trans_real.activate("de")
assert inst.test.through.objects.all().count() == testmodel_qs_1.count()
assert inst.through_model.through.objects.all().count() == testmodel_qs_2.count()
assert inst.untrans.through.objects.count() == 0
assert inst.self_call_1.count() == 0
assert inst.trans_through_model == getattr(inst, "trans_through_model_de")
# Test prevent fallbacks:
trans_real.activate("en")
with default_fallback():
assert inst.untrans.through.objects.all().count() == untranslated_qs.count()
assert inst.trans_through_model == getattr(inst, "trans_through_model_en")
# Test through properties and methods inheriance:
trans_real.activate("de")
through_inst = inst.through_model.through.objects.first()
assert through_inst.test_property == "CustomThroughModel_de_%s" % inst.pk
assert through_inst.test_method() == inst.pk + 1
# Check filtering in direct way + lookup spanning
manager = self.model.objects
trans_real.activate("de")
assert manager.filter(test__in=testmodel_qs_1).distinct().count() == 1
assert manager.filter(test_en__in=testmodel_qs_1).distinct().count() == 0
assert manager.filter(test_de__in=testmodel_qs_1).distinct().count() == 1
assert (
manager.filter(through_model__title__in=testmodel_qs_2.values_list("title", flat=True))
.distinct()
.count()
== 1
)
assert (
manager.filter(
through_model_en__title__in=testmodel_qs_2.values_list("title", flat=True)
).count()
== 0
)
assert (
manager.filter(
through_model_de__title__in=testmodel_qs_2.values_list("title", flat=True)
)
.distinct()
.count()
== 1
)
assert manager.filter(self_call_2__in=self_qs_1).distinct().count() == 1
assert manager.filter(self_call_2_en__in=self_qs_1).count() == 0
assert manager.filter(self_call_2_de__in=self_qs_1).distinct().count() == 1
trans_real.activate("en")
assert manager.filter(trans_through_model__in=testmodel_qs_1).distinct().count() == 1
assert manager.filter(trans_through_model_de__in=testmodel_qs_1).count() == 0
assert manager.filter(trans_through_model_en__in=testmodel_qs_1).distinct().count() == 1
assert manager.filter(untrans__in=untranslated_qs).distinct().count() == 1
assert manager.filter(untrans_de__in=untranslated_qs).count() == 0
assert manager.filter(untrans_en__in=untranslated_qs).distinct().count() == 1
assert manager.filter(self_call_1__in=self_qs_2).distinct().count() == 1
assert manager.filter(self_call_1_de__in=self_qs_2).count() == 0
assert manager.filter(self_call_1_en__in=self_qs_2).distinct().count() == 1
def test_reverse_relations(self):
models.TestModel.objects.bulk_create(
(
models.TestModel(title_en='m2m_test_%s_en' % i, title_de='m2m_test_%s_de' % i)
for i in range(10)
)
)
self.model.objects.bulk_create(
(
self.model(title_en='m2m_test_%s_en' % i, title_de='m2m_test_%s_de' % i)
for i in range(10)
)
)
models.NonTranslated.objects.bulk_create(
(models.NonTranslated(title='m2m_test_%s' % i) for i in range(10))
)
inst_both = self.model(title_en="inst_both_en", title_de="inst_both_de")
inst_both.save()
inst_en = self.model(title_en="inst_en_en", title_de="inst_en_de")
inst_en.save()
inst_de = self.model(title_en="inst_de_en", title_de="inst_de_de")
inst_de.save()
testmodel_qs = models.TestModel.objects.all()
inst_both.test_en.set(testmodel_qs)
inst_both.test_de.set(testmodel_qs)
inst_en.test_en.set(testmodel_qs)
inst_de.test_de.set(testmodel_qs)
# Check that the reverse accessors are created on the model:
# Explicit related_name
testmodel_fields = get_field_names(models.TestModel)
testmodel_methods = dir(models.TestModel)
assert 'm2m_test_ref' in testmodel_fields
assert 'm2m_test_ref_de' in testmodel_fields
assert 'm2m_test_ref_en' in testmodel_fields
assert 'm2m_test_ref' in testmodel_methods
assert 'm2m_test_ref_de' in testmodel_methods
assert 'm2m_test_ref_en' in testmodel_methods
# Implicit related_name: manager descriptor name != query field name
assert 'customthroughmodel' in testmodel_fields
assert 'customthroughmodel_en' in testmodel_fields
assert 'customthroughmodel_de' in testmodel_fields
assert 'manytomanyfieldmodel_set' in testmodel_methods
assert 'manytomanyfieldmodel_en_set' in testmodel_methods
assert 'manytomanyfieldmodel_de_set' in testmodel_methods
test_inst = models.TestModel.objects.first()
# Check the German reverse accessor:
assert inst_both in test_inst.m2m_test_ref_de.all()
assert inst_de in test_inst.m2m_test_ref_de.all()
assert inst_en not in test_inst.m2m_test_ref_de.all()
# Check the English reverse accessor:
assert inst_both in test_inst.m2m_test_ref_en.all()
assert inst_en in test_inst.m2m_test_ref_en.all()
assert inst_de not in test_inst.m2m_test_ref_en.all()
# Check the default reverse accessor:
trans_real.activate("de")
assert inst_de in test_inst.m2m_test_ref.all()
assert inst_en not in test_inst.m2m_test_ref.all()
trans_real.activate("en")
assert inst_en in test_inst.m2m_test_ref.all()
assert inst_de not in test_inst.m2m_test_ref.all()
# Check implicit related_name reverse accessor:
inst_en.through_model.set(testmodel_qs)
assert inst_en in test_inst.manytomanyfieldmodel_set.all()
# Check filtering in reverse way + lookup spanning:
manager = models.TestModel.objects
trans_real.activate("de")
assert manager.filter(m2m_test_ref__in=[inst_both]).count() == 10
assert manager.filter(m2m_test_ref__in=[inst_de]).count() == 10
assert manager.filter(m2m_test_ref__id__in=[inst_de.pk]).count() == 10
assert manager.filter(m2m_test_ref__in=[inst_en]).count() == 0
assert manager.filter(m2m_test_ref_en__in=[inst_en]).count() == 10
assert manager.filter(manytomanyfieldmodel__in=[inst_en]).count() == 0
assert manager.filter(manytomanyfieldmodel_en__in=[inst_en]).count() == 10
assert manager.filter(m2m_test_ref__title='inst_de_de').distinct().count() == 10
assert manager.filter(m2m_test_ref__title='inst_de_en').distinct().count() == 0
assert manager.filter(m2m_test_ref__title_en='inst_de_en').distinct().count() == 10
assert manager.filter(m2m_test_ref_en__title='inst_en_de').distinct().count() == 10
trans_real.activate("en")
assert manager.filter(m2m_test_ref__in=[inst_both]).count() == 10
assert manager.filter(m2m_test_ref__in=[inst_en]).count() == 10
assert manager.filter(m2m_test_ref__id__in=[inst_en.pk]).count() == 10
assert manager.filter(m2m_test_ref__in=[inst_de]).count() == 0
assert manager.filter(m2m_test_ref_de__in=[inst_de]).count() == 10
assert manager.filter(manytomanyfieldmodel__in=[inst_en]).count() == 10
assert manager.filter(manytomanyfieldmodel__in=[inst_de]).count() == 0
assert manager.filter(manytomanyfieldmodel_de__in=[inst_de]).count() == 0
assert manager.filter(m2m_test_ref__title='inst_en_en').distinct().count() == 10
assert manager.filter(m2m_test_ref__title='inst_en_de').distinct().count() == 0
assert manager.filter(m2m_test_ref__title_de='inst_en_de').distinct().count() == 10
assert manager.filter(m2m_test_ref_de__title='inst_de_en').distinct().count() == 10
class OneToOneFieldsTest(ForeignKeyFieldsTest):
@classmethod
def setUpClass(cls):

View file

@ -69,7 +69,7 @@ class FileFieldsModelTranslationOptions(TranslationOptions):
)
# ######### Foreign Key / OneToOneField testing
# ######### Foreign Key / OneToOneField / ManytoManyField testing
@register(models.ForeignKeyModel)
@ -103,6 +103,24 @@ class ForeignKeyFilteredModelTranslationOptions(TranslationOptions):
fields = ('title',)
@register(models.ManyToManyFieldModel)
class ManyToManyFieldModelTranslationOptions(TranslationOptions):
fields = (
"title",
"test",
"self_call_1",
"self_call_2",
"through_model",
"trans_through_model",
"untrans",
)
@register(models.RegisteredThroughModel)
class RegisteredThroughModelTranslationOptions(TranslationOptions):
fields = ('title',)
# ######### Custom fields testing

View file

@ -1,9 +1,10 @@
# -*- coding: utf-8 -*-
from functools import partial
from typing import Callable, Iterable
import django
from django.core.exceptions import ImproperlyConfigured
from django.db.models import Manager, ForeignKey, OneToOneField, options
from django.db.models import Manager, ForeignKey, ManyToManyField, OneToOneField, options
from django.db.models.base import ModelBase
from django.db.models.signals import post_init
from django.utils.functional import cached_property
@ -14,6 +15,7 @@ from modeltranslation.fields import (
create_translation_field,
TranslationFieldDescriptor,
TranslatedRelationIdDescriptor,
TranslatedManyToManyDescriptor,
LanguageCacheSingleObjectDescriptor,
)
from modeltranslation.manager import (
@ -443,6 +445,8 @@ class Translator(object):
def __init__(self):
# All seen models (model class -> ``TranslationOptions`` instance).
self._registry = {}
# List of funcs to execute after all imports are done.
self._lazy_operations: Iterable[Callable] = []
def register(self, model_or_iterable, opts_class=None, **options):
"""
@ -543,11 +547,16 @@ class Translator(object):
fallback_undefined=field_fallback_undefined,
)
setattr(model, field_name, descriptor)
if isinstance(field, ForeignKey):
if isinstance(field, (ForeignKey, ManyToManyField)):
# We need to use a special descriptor so that
# _id fields on translated ForeignKeys work
# as expected.
desc = TranslatedRelationIdDescriptor(field_name, model_fallback_languages)
desc_class = (
TranslatedManyToManyDescriptor
if isinstance(field, ManyToManyField)
else TranslatedRelationIdDescriptor
)
desc = desc_class(field_name, model_fallback_languages)
setattr(model, field.get_attname(), desc)
# Set related field names on other model
@ -644,6 +653,13 @@ class Translator(object):
)
return opts
def execute_lazy_operations(self) -> None:
while self._lazy_operations:
self._lazy_operations.pop(0)(translator=self)
def lazy_operation(self, func: Callable, *args, **kwargs) -> None:
self._lazy_operations.append(partial(func, *args, **kwargs))
# This global object represents the singleton translator object
translator = Translator()

View file

@ -1,10 +1,10 @@
from contextlib import contextmanager
from django.db import models
from django.utils.encoding import force_str
from django.utils.translation import get_language as _get_language
from django.utils.translation import get_language_info
from django.utils.functional import lazy
from modeltranslation import settings
from modeltranslation.thread_context import (
set_auto_populate,
@ -183,3 +183,45 @@ def parse_field(setting, field_name, default):
return setting.get(field_name, default)
else:
return setting
def build_localized_intermediary_model(intermediary_model: models.Model, lang: str) -> models.Model:
from modeltranslation.translator import translator
meta = type(
"Meta",
(),
{
"db_table": build_localized_fieldname(intermediary_model._meta.db_table, lang),
"auto_created": intermediary_model._meta.auto_created,
"app_label": intermediary_model._meta.app_label,
"db_tablespace": intermediary_model._meta.db_tablespace,
"unique_together": intermediary_model._meta.unique_together,
"verbose_name": build_localized_verbose_name(
intermediary_model._meta.verbose_name, lang
),
"verbose_name_plural": build_localized_verbose_name(
intermediary_model._meta.verbose_name_plural, lang
),
"apps": intermediary_model._meta.apps,
},
)
klass = type(
build_localized_fieldname(intermediary_model.__name__, lang),
(models.Model,),
{
**{k: v for k, v in dict(intermediary_model.__dict__).items() if k != "_meta"},
**{f.name: f.clone() for f in intermediary_model._meta.fields},
"Meta": meta,
},
)
def lazy_register_model(old_model, new_model, translator):
cls_opts = translator._get_options_for_model(old_model)
if cls_opts.registered and new_model not in translator._registry:
name = "%sTranslationOptions" % new_model.__name__
translator.register(new_model, type(name, (cls_opts.__class__,), {}))
translator.lazy_operation(lazy_register_model, intermediary_model, klass)
return klass