From 0e76f1ec7df1287f03b89235f5460697f1a65f46 Mon Sep 17 00:00:00 2001 From: Jacek Tomaszewski Date: Sat, 12 Oct 2013 13:58:57 +0200 Subject: [PATCH] Add OneToOneField support. --- CHANGELOG.txt | 2 +- docs/modeltranslation/registration.rst | 2 +- modeltranslation/fields.py | 14 ++ modeltranslation/tests/__init__.py | 201 ++++++++++++++++++++++--- modeltranslation/tests/models.py | 10 +- modeltranslation/tests/translation.py | 9 +- modeltranslation/translator.py | 21 ++- 7 files changed, 235 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.txt b/CHANGELOG.txt index fbc872a..1980202 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -12,7 +12,7 @@ (resolves issue #171) ADDED: Only/defer methods to MultilingualManager. (resolves issue #166) - ADDED: Support for ForeignKey. + ADDED: Support for ForeignKey and OneToOneField. (thanks to Braden MacDonald and Jacek Tomaszewski, resolves issue #161) ADDED: An auto-population option to the loaddata command. diff --git a/docs/modeltranslation/registration.rst b/docs/modeltranslation/registration.rst index 1f51ffb..178107e 100644 --- a/docs/modeltranslation/registration.rst +++ b/docs/modeltranslation/registration.rst @@ -178,7 +178,7 @@ Model Field 0.4 0.5 0.7 ``TimeField`` |n| |y| |y| ``URLField`` |i| |i| |i| ``ForeignKey`` |n| |n| |y| -``OneToOneField`` |n| |n| |n| +``OneToOneField`` |n| |n| |y| ``ManyToManyField`` |n| |n| |n| =============================== === === === diff --git a/modeltranslation/fields.py b/modeltranslation/fields.py index 73bbbca..58f22fa 100644 --- a/modeltranslation/fields.py +++ b/modeltranslation/fields.py @@ -28,6 +28,7 @@ SUPPORTED_FIELDS = ( fields.files.FileField, fields.files.ImageField, fields.related.ForeignKey, + # Above implies also OneToOneField ) try: SUPPORTED_FIELDS += (fields.GenericIPAddressField,) # Django 1.4+ only @@ -303,3 +304,16 @@ class TranslatedRelationIdDescriptor(object): if val is not None: return val return None + + +class LanguageCacheSingleObjectDescriptor(object): + """ + A Mixin for RelatedObjectDescriptors which use current language in cache lookups. + """ + accessor = None # needs to be set on instance + + @property + def cache_name(self): + lang = get_language() + cache = build_localized_fieldname(self.accessor, lang) + return "_%s_cache" % cache diff --git a/modeltranslation/tests/__init__.py b/modeltranslation/tests/__init__.py index ea89380..10b0b4c 100644 --- a/modeltranslation/tests/__init__.py +++ b/modeltranslation/tests/__init__.py @@ -38,7 +38,7 @@ from modeltranslation.utils import (build_css_class, build_localized_fieldname, request = None # How many models are registered for tests. -TEST_MODELS = 26 +TEST_MODELS = 27 class reload_override_settings(override_settings): @@ -673,16 +673,22 @@ class FileFieldsTest(ModeltranslationTestBase): class ForeignKeyFieldsTest(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(ForeignKeyFieldsTest, cls).setUpClass() + cls.model = models.ForeignKeyModel def test_translated_models(self): - field_names = dir(models.ForeignKeyModel()) + field_names = dir(self.model()) self.assertTrue('id' in field_names) for f in ('test', 'test_de', 'test_en', 'optional', 'optional_en', 'optional_de'): self.assertTrue(f in field_names) self.assertTrue('%s_id' % f in field_names) def test_db_column_names(self): - meta = models.ForeignKeyModel._meta + meta = self.model._meta # Make sure the correct database columns always get used: attname, col = meta.get_field('test').get_attname_column() @@ -702,7 +708,7 @@ class ForeignKeyFieldsTest(ModeltranslationTestBase): test_inst1.save() test_inst2 = models.TestModel(title_en='title2_en', title_de='title2_de') test_inst2.save() - inst = models.ForeignKeyModel() + inst = self.model() trans_real.activate("de") inst.test = test_inst1 @@ -736,7 +742,7 @@ class ForeignKeyFieldsTest(ModeltranslationTestBase): # Check filtering in direct way + lookup spanning inst.test_en = test_inst2 inst.save() - manager = models.ForeignKeyModel.objects + manager = self.model.objects trans_real.activate("de") self.assertEqual(manager.filter(test=test_inst1).count(), 1) @@ -764,18 +770,18 @@ class ForeignKeyFieldsTest(ModeltranslationTestBase): test_inst.save() # Instantiate many 'ForeignKeyModel' instances: - fk_inst_both = models.ForeignKeyModel(title_en='f_title_en', title_de='f_title_de', - test_de=test_inst, test_en=test_inst) + fk_inst_both = self.model(title_en='f_title_en', title_de='f_title_de', + test_de=test_inst, test_en=test_inst) fk_inst_both.save() - fk_inst_de = models.ForeignKeyModel(title_en='f_title_en', title_de='f_title_de', - test_de_id=test_inst.pk) + fk_inst_de = self.model(title_en='f_title_en', title_de='f_title_de', + test_de_id=test_inst.pk) fk_inst_de.save() - fk_inst_en = models.ForeignKeyModel(title_en='f_title_en', title_de='f_title_de', - test_en=test_inst) + fk_inst_en = self.model(title_en='f_title_en', title_de='f_title_de', + test_en=test_inst) fk_inst_en.save() - fk_option_de = models.ForeignKeyModel.objects.create(optional_de=test_inst) - fk_option_en = models.ForeignKeyModel.objects.create(optional_en=test_inst) + fk_option_de = self.model.objects.create(optional_de=test_inst) + fk_option_en = self.model.objects.create(optional_en=test_inst) # Check that the reverse accessors are created on the model: # Explicit related_name @@ -867,13 +873,13 @@ class ForeignKeyFieldsTest(ModeltranslationTestBase): non_de = models.NonTranslated.objects.create(title='title_de') non_en = models.NonTranslated.objects.create(title='title_en') - fk_inst_both = models.ForeignKeyModel.objects.create( + fk_inst_both = self.model.objects.create( title_en='f_title_en', title_de='f_title_de', non_de=non_de, non_en=non_en) - fk_inst_de = models.ForeignKeyModel.objects.create(non_de=non_de) - fk_inst_en = models.ForeignKeyModel.objects.create(non_en=non_en) + fk_inst_de = self.model.objects.create(non_de=non_de) + fk_inst_en = self.model.objects.create(non_en=non_en) # Forward relation + spanning - manager = models.ForeignKeyModel.objects + manager = self.model.objects trans_real.activate("de") self.assertEqual(manager.filter(non=non_de).count(), 2) self.assertEqual(manager.filter(non=non_en).count(), 0) @@ -913,6 +919,167 @@ class ForeignKeyFieldsTest(ModeltranslationTestBase): return self.assertEqual(sorted(qs1, key=pk), sorted(qs2, key=pk)) +class OneToOneFieldsTest(ForeignKeyFieldsTest): + @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(OneToOneFieldsTest, cls).setUpClass() + cls.model = models.OneToOneFieldModel + + def test_uniqueness(self): + test_inst1 = models.TestModel(title_en='title1_en', title_de='title1_de') + test_inst1.save() + inst = self.model() + + trans_real.activate("de") + inst.test = test_inst1 + + trans_real.activate("en") + # That's ok, since test_en is different than test_de + inst.test = test_inst1 + inst.save() + + # But this violates uniqueness constraint + inst2 = self.model(test=test_inst1) + self.assertRaises(IntegrityError, inst2.save) + + def test_reverse_relations(self): + test_inst = models.TestModel(title_en='title_en', title_de='title_de') + test_inst.save() + + # Instantiate many 'OneToOneFieldModel' instances: + fk_inst_de = self.model(title_en='f_title_en', title_de='f_title_de', + test_de_id=test_inst.pk) + fk_inst_de.save() + fk_inst_en = self.model(title_en='f_title_en', title_de='f_title_de', + test_en=test_inst) + fk_inst_en.save() + + fk_option_de = self.model.objects.create(optional_de=test_inst) + fk_option_en = self.model.objects.create(optional_en=test_inst) + + # Check that the reverse accessors are created on the model: + # Explicit related_name + testmodel_fields = models.TestModel._meta.get_all_field_names() + testmodel_methods = dir(models.TestModel) + self.assertIn('test_o2o', testmodel_fields) + self.assertIn('test_o2o_de', testmodel_fields) + self.assertIn('test_o2o_en', testmodel_fields) + self.assertIn('test_o2o', testmodel_methods) + self.assertIn('test_o2o_de', testmodel_methods) + self.assertIn('test_o2o_en', testmodel_methods) + # Implicit related_name + self.assertIn('onetoonefieldmodel', testmodel_fields) + self.assertIn('onetoonefieldmodel_de', testmodel_fields) + self.assertIn('onetoonefieldmodel_en', testmodel_fields) + self.assertIn('onetoonefieldmodel', testmodel_methods) + self.assertIn('onetoonefieldmodel_de', testmodel_methods) + self.assertIn('onetoonefieldmodel_en', testmodel_methods) + + # Check the German reverse accessor: + self.assertEqual(fk_inst_de, test_inst.test_o2o_de) + + # Check the English reverse accessor: + self.assertEqual(fk_inst_en, test_inst.test_o2o_en) + + # Check the default reverse accessor: + trans_real.activate("de") + self.assertEqual(fk_inst_de, test_inst.test_o2o) + trans_real.activate("en") + self.assertEqual(fk_inst_en, test_inst.test_o2o) + + # Check implicit related_name reverse accessor: + self.assertEqual(fk_option_en, test_inst.onetoonefieldmodel) + + # Check filtering in reverse way + lookup spanning: + manager = models.TestModel.objects + trans_real.activate("de") + self.assertEqual(manager.filter(test_o2o=fk_inst_de).count(), 1) + self.assertEqual(manager.filter(test_o2o__id=fk_inst_de.pk).count(), 1) + self.assertEqual(manager.filter(test_o2o=fk_inst_en).count(), 0) + self.assertEqual(manager.filter(test_o2o_en=fk_inst_en).count(), 1) + self.assertEqual(manager.filter(onetoonefieldmodel=fk_option_de).count(), 1) + self.assertEqual(manager.filter(onetoonefieldmodel=fk_option_en).count(), 0) + self.assertEqual(manager.filter(onetoonefieldmodel_en=fk_option_en).count(), 1) + self.assertEqual(manager.filter(test_o2o__title='f_title_de').distinct().count(), 1) + self.assertEqual(manager.filter(test_o2o__title='f_title_en').distinct().count(), 0) + self.assertEqual(manager.filter(test_o2o__title_en='f_title_en').distinct().count(), 1) + trans_real.activate("en") + self.assertEqual(manager.filter(test_o2o=fk_inst_en).count(), 1) + self.assertEqual(manager.filter(test_o2o__id=fk_inst_en.pk).count(), 1) + self.assertEqual(manager.filter(test_o2o=fk_inst_de).count(), 0) + self.assertEqual(manager.filter(test_o2o_de=fk_inst_de).count(), 1) + self.assertEqual(manager.filter(onetoonefieldmodel=fk_option_en).count(), 1) + self.assertEqual(manager.filter(onetoonefieldmodel=fk_option_de).count(), 0) + self.assertEqual(manager.filter(onetoonefieldmodel_de=fk_option_de).count(), 1) + self.assertEqual(manager.filter(test_o2o__title='f_title_en').distinct().count(), 1) + self.assertEqual(manager.filter(test_o2o__title='f_title_de').distinct().count(), 0) + self.assertEqual(manager.filter(test_o2o__title_de='f_title_de').distinct().count(), 1) + + # Check assignment + trans_real.activate("de") + test_inst2 = models.TestModel(title_en='title_en', title_de='title_de') + test_inst2.save() + test_inst2.test_o2o = fk_inst_de + test_inst2.test_o2o_en = fk_inst_en + + self.assertEqual(fk_inst_de.test.pk, test_inst2.pk) + self.assertEqual(fk_inst_de.test_id, test_inst2.pk) + self.assertEqual(fk_inst_de.test_de, test_inst2) + self.assertEqual(test_inst2.test_o2o_de, test_inst2.test_o2o) + self.assertEqual(fk_inst_de, test_inst2.test_o2o) + trans_real.activate("en") + self.assertEqual(fk_inst_en.test.pk, test_inst2.pk) + self.assertEqual(fk_inst_en.test_id, test_inst2.pk) + self.assertEqual(fk_inst_en.test_en, test_inst2) + self.assertEqual(test_inst2.test_o2o_en, test_inst2.test_o2o) + self.assertEqual(fk_inst_en, test_inst2.test_o2o) + + def test_non_translated_relation(self): + non_de = models.NonTranslated.objects.create(title='title_de') + non_en = models.NonTranslated.objects.create(title='title_en') + + fk_inst_de = self.model.objects.create( + title_en='f_title_en', title_de='f_title_de', non_de=non_de) + fk_inst_en = self.model.objects.create( + title_en='f_title_en2', title_de='f_title_de2', non_en=non_en) + + # Forward relation + spanning + manager = self.model.objects + trans_real.activate("de") + self.assertEqual(manager.filter(non=non_de).count(), 1) + self.assertEqual(manager.filter(non=non_en).count(), 0) + self.assertEqual(manager.filter(non_en=non_en).count(), 1) + self.assertEqual(manager.filter(non__title='title_de').count(), 1) + self.assertEqual(manager.filter(non__title='title_en').count(), 0) + self.assertEqual(manager.filter(non_en__title='title_en').count(), 1) + trans_real.activate("en") + self.assertEqual(manager.filter(non=non_en).count(), 1) + self.assertEqual(manager.filter(non=non_de).count(), 0) + self.assertEqual(manager.filter(non_de=non_de).count(), 1) + self.assertEqual(manager.filter(non__title='title_en').count(), 1) + self.assertEqual(manager.filter(non__title='title_de').count(), 0) + self.assertEqual(manager.filter(non_de__title='title_de').count(), 1) + + # Reverse relation + spanning + manager = models.NonTranslated.objects + trans_real.activate("de") + self.assertEqual(manager.filter(test_o2o=fk_inst_de).count(), 1) + self.assertEqual(manager.filter(test_o2o=fk_inst_en).count(), 0) + self.assertEqual(manager.filter(test_o2o_en=fk_inst_en).count(), 1) + self.assertEqual(manager.filter(test_o2o__title='f_title_de').count(), 1) + self.assertEqual(manager.filter(test_o2o__title='f_title_en').count(), 0) + self.assertEqual(manager.filter(test_o2o__title_en='f_title_en').count(), 1) + trans_real.activate("en") + self.assertEqual(manager.filter(test_o2o=fk_inst_en).count(), 1) + self.assertEqual(manager.filter(test_o2o=fk_inst_de).count(), 0) + self.assertEqual(manager.filter(test_o2o_de=fk_inst_de).count(), 1) + self.assertEqual(manager.filter(test_o2o__title='f_title_en2').count(), 1) + self.assertEqual(manager.filter(test_o2o__title='f_title_de2').count(), 0) + self.assertEqual(manager.filter(test_o2o__title_de='f_title_de2').count(), 1) + + class OtherFieldsTest(ModeltranslationTestBase): def test_translated_models(self): inst = models.OtherFieldsModel.objects.create() diff --git a/modeltranslation/tests/models.py b/modeltranslation/tests/models.py index f659edd..daa6c89 100644 --- a/modeltranslation/tests/models.py +++ b/modeltranslation/tests/models.py @@ -55,7 +55,7 @@ class FileFieldsModel(models.Model): image = models.ImageField(upload_to='modeltranslation_tests', null=True, blank=True) -########## Foreign Key fields testing +########## Foreign Key / OneToOneField testing class NonTranslated(models.Model): title = models.CharField(ugettext_lazy('title'), max_length=255) @@ -69,6 +69,14 @@ class ForeignKeyModel(models.Model): non = models.ForeignKey(NonTranslated, blank=True, null=True, related_name="test_fks") +class OneToOneFieldModel(models.Model): + title = models.CharField(ugettext_lazy('title'), max_length=255) + test = models.OneToOneField(TestModel, null=True, related_name="test_o2o") + optional = models.OneToOneField(TestModel, blank=True, null=True) + # No hidden option for OneToOne + non = models.OneToOneField(NonTranslated, blank=True, null=True, related_name="test_o2o") + + ########## Custom fields testing class OtherFieldsModel(models.Model): diff --git a/modeltranslation/tests/translation.py b/modeltranslation/tests/translation.py index fc113eb..41a1c52 100644 --- a/modeltranslation/tests/translation.py +++ b/modeltranslation/tests/translation.py @@ -7,7 +7,7 @@ from modeltranslation.tests.models import ( DescriptorModel, AbstractModelA, AbstractModelB, Slugged, MetaData, Displayable, Page, RichText, RichTextPage, MultitableModelA, MultitableModelB, MultitableModelC, ManagerTestModel, CustomManagerTestModel, CustomManager2TestModel, GroupFieldsetsModel, NameModel, - ThirdPartyRegisteredModel, ProxyTestModel, UniqueNullableModel) + ThirdPartyRegisteredModel, ProxyTestModel, UniqueNullableModel, OneToOneFieldModel) class TestTranslationOptions(TranslationOptions): @@ -49,13 +49,18 @@ class FileFieldsModelTranslationOptions(TranslationOptions): translator.register(FileFieldsModel, FileFieldsModelTranslationOptions) -########## Foreign Key fields testing +########## Foreign Key / OneToOneField testing class ForeignKeyModelTranslationOptions(TranslationOptions): fields = ('title', 'test', 'optional', 'hidden', 'non',) translator.register(ForeignKeyModel, ForeignKeyModelTranslationOptions) +class OneToOneFieldModelTranslationOptions(TranslationOptions): + fields = ('title', 'test', 'optional', 'non',) +translator.register(OneToOneFieldModel, OneToOneFieldModelTranslationOptions) + + ########## Custom fields testing class OtherFieldsModelTranslationOptions(TranslationOptions): diff --git a/modeltranslation/translator.py b/modeltranslation/translator.py index ced8d21..b5150ee 100644 --- a/modeltranslation/translator.py +++ b/modeltranslation/translator.py @@ -1,14 +1,15 @@ # -*- coding: utf-8 -*- from django.conf import settings from django.utils.six import with_metaclass -from django.db.models import Manager, ForeignKey +from django.db.models import Manager, ForeignKey, OneToOneField from django.db.models.base import ModelBase from django.db.models.signals import post_init from django.dispatch import receiver from modeltranslation import settings as mt_settings from modeltranslation.fields import (NONE, create_translation_field, TranslationFieldDescriptor, - TranslatedRelationIdDescriptor) + TranslatedRelationIdDescriptor, + LanguageCacheSingleObjectDescriptor) from modeltranslation.manager import MultilingualManager, rewrite_lookup_key from modeltranslation.utils import build_localized_fieldname @@ -274,6 +275,17 @@ def populate_translation_fields(sender, kwargs): raise AttributeError("Unknown population mode '%s'." % populate) +def patch_related_object_descriptor_caching(ro_descriptor): + """ + Patch SingleRelatedObjectDescriptor or ReverseSingleRelatedObjectDescriptor to use + language-aware caching. + """ + class NewSingleObjectDescriptor(LanguageCacheSingleObjectDescriptor, ro_descriptor.__class__): + pass + ro_descriptor.accessor = ro_descriptor.related.get_accessor_name() + ro_descriptor.__class__ = NewSingleObjectDescriptor + + class Translator(object): """ A Translator object encapsulates an instance of a translator. Models are @@ -372,6 +384,11 @@ class Translator(object): other_opts.related_fields.append(field.related_query_name()) add_manager(field.rel.to) # Add manager in case of non-registered model + if isinstance(field, OneToOneField): + # Fix translated_field caching for SingleRelatedObjectDescriptor + sro_descriptor = getattr(field.rel.to, field.related.get_accessor_name()) + patch_related_object_descriptor_caching(sro_descriptor) + def unregister(self, model_or_iterable): """ Unregisters the given model(s).