mirror of
https://github.com/Hopiu/django-modeltranslation.git
synced 2026-03-16 22:10:31 +00:00
feat: Add support for ManyToManyFields 🧑🤝🧑 (#668)
This commit is contained in:
parent
c8fd780523
commit
f69e3172bc
10 changed files with 829 additions and 22 deletions
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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: ""})
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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=[],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue