From f69e3172bc6254a4ddd8def7500632d0046b30eb Mon Sep 17 00:00:00 2001 From: Gabriele Baldi Date: Fri, 27 Jan 2023 14:05:18 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Add=20support=20for=20ManyToManyFields?= =?UTF-8?q?=20=F0=9F=A7=91=E2=80=8D=F0=9F=A4=9D=E2=80=8D=F0=9F=A7=91=20(#6?= =?UTF-8?q?68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/modeltranslation/caveats.rst | 5 + modeltranslation/fields.py | 96 ++++- .../commands/update_translation_fields.py | 13 +- modeltranslation/models.py | 3 + .../tests/migrations/0001_initial.py | 329 ++++++++++++++++++ modeltranslation/tests/models.py | 39 ++- modeltranslation/tests/tests.py | 280 ++++++++++++++- modeltranslation/tests/translation.py | 20 +- modeltranslation/translator.py | 22 +- modeltranslation/utils.py | 44 ++- 10 files changed, 829 insertions(+), 22 deletions(-) diff --git a/docs/modeltranslation/caveats.rst b/docs/modeltranslation/caveats.rst index 75aebbc..fb8a787 100644 --- a/docs/modeltranslation/caveats.rst +++ b/docs/modeltranslation/caveats.rst @@ -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/ diff --git a/modeltranslation/fields.py b/modeltranslation/fields.py index 15b09d6..1544325 100644 --- a/modeltranslation/fields.py +++ b/modeltranslation/fields.py @@ -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 - + 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. """ diff --git a/modeltranslation/management/commands/update_translation_fields.py b/modeltranslation/management/commands/update_translation_fields.py index 5576f94..668d9f8 100644 --- a/modeltranslation/management/commands/update_translation_fields.py +++ b/modeltranslation/management/commands/update_translation_fields.py @@ -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: ""}) diff --git a/modeltranslation/models.py b/modeltranslation/models.py index f0c6bd8..30d1a7e 100644 --- a/modeltranslation/models.py +++ b/modeltranslation/models.py @@ -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. diff --git a/modeltranslation/tests/migrations/0001_initial.py b/modeltranslation/tests/migrations/0001_initial.py index 9817162..d2c9139 100644 --- a/modeltranslation/tests/migrations/0001_initial.py +++ b/modeltranslation/tests/migrations/0001_initial.py @@ -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=[], diff --git a/modeltranslation/tests/models.py b/modeltranslation/tests/models.py index 38554cb..69db9ed 100644 --- a/modeltranslation/tests/models.py +++ b/modeltranslation/tests/models.py @@ -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 diff --git a/modeltranslation/tests/tests.py b/modeltranslation/tests/tests.py index 4a29f23..9bc06d7 100644 --- a/modeltranslation/tests/tests.py +++ b/modeltranslation/tests/tests.py @@ -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): diff --git a/modeltranslation/tests/translation.py b/modeltranslation/tests/translation.py index 474562b..75c0d38 100644 --- a/modeltranslation/tests/translation.py +++ b/modeltranslation/tests/translation.py @@ -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 diff --git a/modeltranslation/translator.py b/modeltranslation/translator.py index e1d7c6a..acc93c7 100644 --- a/modeltranslation/translator.py +++ b/modeltranslation/translator.py @@ -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() diff --git a/modeltranslation/utils.py b/modeltranslation/utils.py index fb5af92..69ff285 100644 --- a/modeltranslation/utils.py +++ b/modeltranslation/utils.py @@ -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