diff --git a/addons/web/static/src/views/fields/translation_dialog.js b/addons/web/static/src/views/fields/translation_dialog.js index c59cbf7c49a..f648ab8ce07 100644 --- a/addons/web/static/src/views/fields/translation_dialog.js +++ b/addons/web/static/src/views/fields/translation_dialog.js @@ -74,10 +74,10 @@ export class TranslationDialog extends Component { if (!translations[term.lang]) { translations[term.lang] = {}; } - const source = term.value ? term.value : term.source; - translations[term.lang][source] = updatedTermValue; + const oldTermValue = term.value ? term.value : term.source; + translations[term.lang][oldTermValue] = updatedTermValue || term.source; } else { - translations[term.lang] = updatedTermValue; + translations[term.lang] = updatedTermValue || false; } } }); diff --git a/addons/web/static/tests/views/fields/char_field_tests.js b/addons/web/static/tests/views/fields/char_field_tests.js index cf38dee8011..1bd5ac20875 100644 --- a/addons/web/static/tests/views/fields/char_field_tests.js +++ b/addons/web/static/tests/views/fields/char_field_tests.js @@ -319,9 +319,9 @@ QUnit.module("Fields", (hooks) => { if (call_get_field_translations === 1) { return Promise.resolve([ [ - { lang: "en_US", source: "yop", value: "english value" }, - { lang: "fr_BE", source: "yop", value: "valeur français" }, - { lang: "es_ES", source: "yop", value: "" }, + { lang: "en_US", source: "english value", value: "english value" }, + { lang: "fr_BE", source: "english value", value: "valeur français" }, + { lang: "es_ES", source: "english value", value: "english value" }, ], { translation_type: "char", translation_show_source: false }, ]); @@ -330,8 +330,8 @@ QUnit.module("Fields", (hooks) => { if (route === "/web/dataset/call_kw/partner/update_field_translations") { assert.deepEqual( args[2], - { en_US: "english value", es_ES: "" }, - "the new translation value should be written" + { en_US: "english value", es_ES: false }, + "the new translation value should be written and the value false voids the translation" ); return Promise.resolve(null); } @@ -401,8 +401,8 @@ QUnit.module("Fields", (hooks) => { ); assert.strictEqual( translations[2].value, - "", - "Spanish translation should be an empty string" + "english value", + "Spanish translation should fallback to the English translation" ); }); diff --git a/odoo/addons/base/tests/test_translate.py b/odoo/addons/base/tests/test_translate.py index 05a4723bb90..23872e94920 100644 --- a/odoo/addons/base/tests/test_translate.py +++ b/odoo/addons/base/tests/test_translate.py @@ -8,7 +8,7 @@ from psycopg2 import IntegrityError from psycopg2.extras import Json import io -from odoo.exceptions import AccessError, ValidationError +from odoo.exceptions import UserError from odoo.tools import sql from odoo.tools.translate import quote, unquote, xml_translate, html_translate, TranslationImporter from odoo.tests.common import TransactionCase, BaseCase, new_test_user, tagged @@ -687,6 +687,43 @@ class TestTranslationWrite(TransactionCase): self.assertEqual(group.with_context(lang='fr_FR').comment, 'French comment 2') self.assertEqual(group.with_context(lang='en_US').comment, 'French comment') + def test_update_field_translations(self): + self.env['res.lang']._activate_lang('fr_FR') + categoryEN = self.category.with_context(lang='en_US') + categoryFR = self.category.with_context(lang='fr_FR') + + self.category.update_field_translations('name', {'en_US': 'English Name', 'fr_FR': 'French Name'}) + self.assertEqual(categoryEN.name, 'English Name') + self.assertEqual(categoryFR.name, 'French Name') + + # void fr_FR translation and fallback to en_US + self.category.update_field_translations('name', {'fr_FR': False}) + self.assertEqual(categoryEN.name, 'English Name') + self.assertEqual(categoryFR.name, 'English Name') + + categoryEN.name = 'English Name 2' + self.assertEqual(categoryEN.name, 'English Name 2') + self.assertEqual(categoryFR.name, 'English Name 2') + + # cannot void en_US + self.category.update_field_translations('name', {'en_US': 'English Name', 'fr_FR': 'French Name'}) + self.category.update_field_translations('name', {'en_US': False}) + self.assertEqual(categoryEN.name, 'English Name') + self.assertEqual(categoryFR.name, 'French Name') + + # empty str is a valid translation + self.category.update_field_translations('name', {'fr_FR': ''}) + self.assertEqual(categoryEN.name, 'English Name') + self.assertEqual(categoryFR.name, '') + + self.category.update_field_translations('name', {'en_US': '', 'fr_FR': 'French Name'}) + self.assertEqual(categoryEN.name, '') + self.assertEqual(categoryFR.name, 'French Name') + + # raise error when the translations are in the form for model_terms translated fields + with self.assertRaises(UserError): + self.category.update_field_translations('name', {'fr_FR': {'English Name': 'French Name'}}) + def test_field_selection(self): """ Test translations of field selections. """ self.env['res.lang']._activate_lang('fr_FR') diff --git a/odoo/models.py b/odoo/models.py index 36f54d1338f..bfcb35e8378 100644 --- a/odoo/models.py +++ b/odoo/models.py @@ -3001,10 +3001,13 @@ class BaseModel(metaclass=MetaModel): The main difference comes from the extra function ``digest``, which may be used to make identifiers for old terms. - :param dict translations: if the field has ``translate=True``, it should be a dictionary - like ``{lang: new_value}``; if ``translate`` is a callable, it should be like - ``{lang: {old_term: new_term}}``, or ``{lang: {digest(old_term): new_term}}`` when - ``digest`` is a callable + :param dict translations: + if the field has ``translate=True``, it should be a dictionary like ``{lang: new_value}`` + new_value: str: the new translation for lang + new_value: False: void the current translation for lang and fallback to current en_US value + if ``translate`` is a callable, it should be like + ``{lang: {old_term: new_term}}``, or ``{lang: {digest(old_term): new_term}}`` when ``digest`` is callable + new_value: str: the new translation of old_term for lang :param digest: an optional digest function for the old_term """ self.ensure_one() @@ -3018,25 +3021,36 @@ class BaseModel(metaclass=MetaModel): # a non-related non-stored computed field cannot be translated, even if it has inverse function return False + # Strictly speaking, a translated related/computed field cannot be stored + # because the compute function only support one language + # `not field.store` is a redundant logic. + # But some developers store translated related fields. + # In these cases, only all translations of the first stored translation field will be updated + # For other stored related translated field, the translation for the flush language will be updated + if field.related and not field.store: + related_path, field_name = field.related.rsplit(".", 1) + return self.mapped(related_path)._update_field_translations(field_name, translations, digest) + self.check_access_rights('write') + self.check_field_access_rights('write', [field_name]) + self.check_access_rule('write') + if field.translate is True: - for lang, translation in translations.items(): - if translation is not None: - self.with_context(lang=lang)[field_name] = translation + # falsy values (except emtpy str) are used to void the corresponding translation + if any(translation and not isinstance(translation, str) for translation in translations.values()): + raise UserError(_("Translations for model translated fields only accept falsy values and str")) + value_en = translations.get('en_US', True) + if not value_en and value_en != '': + translations.pop('en_US') + translations = { + lang: translation if isinstance(translation, str) else None + for lang, translation in translations.items() + } + self.invalidate_recordset([field_name]) + self._cr.execute(f''' + UPDATE {self._table} SET {field_name} = jsonb_strip_nulls({field_name} || %s) WHERE id = %s + ''', (Json(translations), self.id)) + self.modified([field_name]) else: - # Strictly speaking, a translated related/computed field cannot be stored - # because the compute function only support one language - # `not field.store` is a redundant logic. - # But some developers store translated related fields. - # In these cases, only all translations of the first stored translation field will be updated - # For other stored related translated field, the translation for the flush language will be updated - if field.related and not field.store: - related_path, field_name = field.related.rsplit(".", 1) - return self.mapped(related_path)._update_field_translations(field_name, translations, digest) - - self.check_access_rights('write') - self.check_field_access_rights('write', [field_name]) - self.check_access_rule('write') - # Note: # update terms in 'en_US' will not change its value other translated values # record_en = Model_en.create({'html': '
English 1
English 2
'