diff --git a/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js b/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js index 3c7c72c1ce0..3655d1162c9 100644 --- a/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js +++ b/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js @@ -2544,13 +2544,19 @@ export class Wysiwyg extends Component { // iframe or not. // If the element is given, then search within not from the document. const $ = element ? getJqueryFromDocument(element) : getJqueryFromDocument(this.$editable[0].ownerDocument); - const $allBlocks = $((this.options || {}).savableSelector).filter('.o_dirty'); + const $allBlocks = $((this.options || {}).savableSelector).filter( + this.options.enableTranslation + ? '.o_dirty, .o_delay_translation' + : '.o_dirty'); const $dirty = $('.o_dirty'); $dirty .removeAttr('contentEditable') .removeClass('o_dirty oe_carlos_danger o_is_inline_editable'); + const $delay_translation = $('.o_delay_translation'); + $delay_translation.removeClass('o_delay_translation'); + $('.o_editable') .removeClass('o_editable o_is_inline_editable o_editable_date_field_linked o_editable_date_field_format_changed'); @@ -2678,7 +2684,10 @@ export class Wysiwyg extends Component { escapedHtml, !$el.data('oe-expression') && $el.data('oe-xpath') || null ], { - context, + context: { + ...context, + delay_translations: true, + }, }); return result; } diff --git a/addons/website/models/ir_module_module.py b/addons/website/models/ir_module_module.py index d713dbfdc9b..8990ba48e7a 100644 --- a/addons/website/models/ir_module_module.py +++ b/addons/website/models/ir_module_module.py @@ -199,21 +199,26 @@ class IrModuleModule(models.Model): if dst_mname != new_rec._name: continue old_field = old_rec._fields[src_fname] - old_translations = { - lang: value - for lang, value in old_field._get_stored_translations(old_rec).items() - if lang in valid_langs - } - if not old_translations: + old_stored_translations = old_field._get_stored_translations(old_rec) + if not old_stored_translations: continue - if not callable(old_field.translate): - if old_rec[src_fname] == new_rec[dst_fname]: - new_rec.update_field_translations(dst_fname, old_translations) + if old_field.translate is True: + if old_rec[src_fname] != new_rec[dst_fname]: + continue + new_rec.update_field_translations(dst_fname, { + k: v for k, v in old_stored_translations.items() if k in valid_langs and k != cur_lang + }) else: - old_translation_lang = old_translations.get(cur_lang) or old_translations.get('en_US') + old_translations = { + k: old_stored_translations.get(f'_{k}', v) + for k, v in old_stored_translations.items() + if k in valid_langs + } # {from_lang_term: {lang: to_lang_term} - translation_dictionary = old_field.get_translation_dictionary(old_translation_lang, { - lang: value for lang, value in old_translations.items() if lang != cur_lang}) + translation_dictionary = old_field.get_translation_dictionary( + old_translations.pop(cur_lang, old_translations['en_US']), + old_translations + ) # {lang: {old_term: new_term} translations = defaultdict(dict) for from_lang_term, to_lang_terms in translation_dictionary.items(): diff --git a/addons/website/static/src/components/wysiwyg_adapter/wysiwyg_adapter.js b/addons/website/static/src/components/wysiwyg_adapter/wysiwyg_adapter.js index 9ff8eb497de..8acce9a7c3a 100644 --- a/addons/website/static/src/components/wysiwyg_adapter/wysiwyg_adapter.js +++ b/addons/website/static/src/components/wysiwyg_adapter/wysiwyg_adapter.js @@ -995,7 +995,7 @@ export class WysiwygAdapterComponent extends Wysiwyg { }); }; } - if (this._isDirty()) { + if (this._isDirty() || this.options.enableTranslation) { return this.save().then(callback, event.data.onFailure); } else { return callback(); diff --git a/odoo/addons/base/models/ir_ui_view.py b/odoo/addons/base/models/ir_ui_view.py index 5dc29cf4d84..9ee34896b3b 100644 --- a/odoo/addons/base/models/ir_ui_view.py +++ b/odoo/addons/base/models/ir_ui_view.py @@ -214,7 +214,7 @@ actual arch. model_id = fields.Many2one("ir.model", string="Model of the view", compute='_compute_model_id', inverse='_inverse_compute_model_id') @api.depends('arch_db', 'arch_fs', 'arch_updated') - @api.depends_context('read_arch_from_file', 'lang', 'edit_translations') + @api.depends_context('read_arch_from_file', 'lang', 'edit_translations', 'check_translations') def _compute_arch(self): def resolve_external_ids(arch_fs, view_xml_id): def replacer(m): diff --git a/odoo/addons/base/tests/test_expression.py b/odoo/addons/base/tests/test_expression.py index 3c6536d3c09..3f4c2571143 100644 --- a/odoo/addons/base/tests/test_expression.py +++ b/odoo/addons/base/tests/test_expression.py @@ -1280,8 +1280,8 @@ class TestQueries(TransactionCase): with self.assertQueries([''' SELECT "res_partner_title"."id" FROM "res_partner_title" - WHERE (COALESCE("res_partner_title"."name"->>%s, "res_partner_title"."name"->>'en_US') like %s) - ORDER BY COALESCE("res_partner_title"."name"->>%s, "res_partner_title"."name"->>'en_US') + WHERE (COALESCE("res_partner_title"."name"->>%s, "res_partner_title"."name"->>%s) like %s) + ORDER BY COALESCE("res_partner_title"."name"->>%s, "res_partner_title"."name"->>%s) ''']): Model.search([('name', 'like', 'foo')]) @@ -1325,10 +1325,10 @@ class TestQueries(TransactionCase): self.assertEqual(Model._rec_names_search, ['name', 'model']) with self.assertQueries([''' - SELECT "ir_model"."id", "ir_model"."name"->>'en_US' + SELECT "ir_model"."id", "ir_model"."name"->>%s FROM "ir_model" WHERE ( - ("ir_model"."name"->>'en_US' ILIKE %s) + ("ir_model"."name"->>%s ILIKE %s) OR ("ir_model"."model"::text ILIKE %s) ) ORDER BY "ir_model"."model" @@ -1337,10 +1337,10 @@ class TestQueries(TransactionCase): Model.name_search('foo') with self.assertQueries([''' - SELECT "ir_model"."id", "ir_model"."name"->>'en_US' + SELECT "ir_model"."id", "ir_model"."name"->>%s FROM "ir_model" WHERE ( - ("ir_model"."name" is NULL OR "ir_model"."name"->>'en_US' not ilike %s) + ("ir_model"."name" is NULL OR "ir_model"."name"->>%s not ilike %s) AND (("ir_model"."model"::text NOT ILIKE %s) OR "ir_model"."model" IS NULL) ) ORDER BY "ir_model"."model" diff --git a/odoo/addons/base/tests/test_translate.py b/odoo/addons/base/tests/test_translate.py index 855e7b9103c..59fe7b8efbc 100644 --- a/odoo/addons/base/tests/test_translate.py +++ b/odoo/addons/base/tests/test_translate.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +from hashlib import sha256 from unittest.mock import patch import logging import time @@ -1158,6 +1159,117 @@ class TestXMLTranslation(TransactionCase): self.assertEqual(view.with_context(lang='en_US').arch_db, '
Bread and cheese
Fork3
') self.assertEqual(view.with_context(lang='es_ES').arch_db, '
Bread and cheese
Tenedor3
') + def test_delay_translations(self): + archf = '
%s
%s
' + terms_en = ('Knife', 'Fork', 'Spoon') + terms_fr = ('Couteau', 'Fourchette', 'Cuiller') + view0 = self.create_view(archf, terms_en, fr_FR=terms_fr) + + archf2 = '

%s

%s
' + terms_en2 = ('new Knife', 'Fork', 'Spoon') + # write en_US with delay_translations + view0.with_context(lang='en_US', delay_translations=True).arch_db = archf2 % terms_en2 + view0.invalidate_recordset() + + self.assertEqual( + view0.with_context(lang='en_US').arch_db, + archf2 % terms_en2, + 'en_US value should be the latest one since it is updated directly' + ) + self.assertEqual(view0.with_context(lang='en_US', check_translations=True).arch_db, archf2 % terms_en2) + + self.assertEqual( + view0.with_context(lang='fr_FR').arch_db, + archf % terms_fr, + "fr_FR value should keep the same since its translations hasn't been confirmed" + ) + self.assertEqual( + view0.with_context(lang='fr_FR', edit_translations=True).arch_db, + '
' + '

' + '' + f'{terms_fr[1]}' + '' + '

' + '
' + '' + f'{terms_fr[2]}' + '' + '
' + '
' + ) + self.assertEqual( + view0.with_context(lang='fr_FR', check_translations=True).arch_db, + archf2 % (terms_en2[0], terms_fr[1], terms_fr[2]) + ) + + self.assertEqual( + view0.with_context(lang='nl_NL').arch_db, + archf2 % terms_en2, + "nl_NL value should fallback to en_US value" + ) + self.assertEqual( + view0.with_context(lang='nl_NL', check_translations=True).arch_db, + archf2 % terms_en2 + ) + + # update and confirm translations + view0.update_field_translations('arch_db', {'fr_FR': {}}) + self.assertEqual( + view0.with_context(lang='fr_FR').arch_db, + archf2 % (terms_en2[0], terms_fr[1], terms_fr[2]) + ) + self.assertEqual( + view0.with_context(lang='fr_FR', check_translations=True).arch_db, + archf2 % (terms_en2[0], terms_fr[1], terms_fr[2]) + ) + + def test_delay_translations_no_term(self): + archf = '
%s
%s
' + terms_en = ('Knife', 'Fork', 'Spoon') + terms_fr = ('Couteau', 'Fourchette', 'Cuiller') + view0 = self.create_view(archf, terms_en, fr_FR=terms_fr) + + archf2 = '
' + # delay_translations only works when the written value has at least one translatable term + view0.with_context(lang='en_US', delay_translations=True).arch_db = archf2 + for lang in ('en_US', 'fr_FR', 'nl_NL'): + self.assertEqual( + view0.with_context(lang=lang).arch_db, + archf2, + f'arch_db for {lang} should be {archf2}' + ) + self.assertEqual( + view0.with_context(lang=lang, check_translations=True).arch_db, + archf2, + f'arch_db for {lang} should be {archf2} when check_translations' + ) + class TestHTMLTranslation(TransactionCase): def test_write_non_existing(self): @@ -1172,6 +1284,28 @@ class TestHTMLTranslation(TransactionCase): # same behavior is expected for translated fields company.flush_recordset() + def test_delay_translations_no_term(self): + self.env['res.lang']._activate_lang('fr_FR') + self.env['res.lang']._activate_lang('nl_NL') + Company = self.env['res.company'] + company0 = Company.create({'name': 'company_1', 'report_footer': '

Knife

'}) + company0.update_field_translations('report_footer', {'fr_FR': {'Knife': 'Couteau'}}) + + for html in ('

', '', False): + # delay_translations only works when the written value has at least one translatable term + company0.with_context(lang='en_US', delay_translations=True).report_footer = html + for lang in ('en_US', 'fr_FR', 'nl_NL'): + self.assertEqual( + company0.with_context(lang=lang).report_footer, + html, + f'report_footer for {lang} should be {html}' + ) + self.assertEqual( + company0.with_context(lang=lang, check_translations=True).report_footer, + html, + f'report_footer for {lang} should be {html} when check_translations' + ) + @tagged('post_install', '-at_install') class TestLanguageInstallPerformance(TransactionCase): diff --git a/odoo/addons/test_new_api/tests/test_indexed_translation.py b/odoo/addons/test_new_api/tests/test_indexed_translation.py index 15143562399..0b8f0efef4a 100644 --- a/odoo/addons/test_new_api/tests/test_indexed_translation.py +++ b/odoo/addons/test_new_api/tests/test_indexed_translation.py @@ -46,19 +46,19 @@ class TestIndexedTranslation(odoo.tests.TransactionCase): SELECT "test_new_api_indexed_translation"."id" FROM "test_new_api_indexed_translation" WHERE (jsonb_path_query_array("test_new_api_indexed_translation"."name", '$.*')::text ILIKE %s - AND "test_new_api_indexed_translation"."name"->>'en_US' ILIKE %s) + AND "test_new_api_indexed_translation"."name"->>%s ILIKE %s) ORDER BY "test_new_api_indexed_translation"."id" """, """ SELECT "test_new_api_indexed_translation"."id" FROM "test_new_api_indexed_translation" WHERE (jsonb_path_query_array("test_new_api_indexed_translation"."name", '$.*')::text ILIKE %s - AND COALESCE("test_new_api_indexed_translation"."name"->>%s, "test_new_api_indexed_translation"."name"->>'en_US') ILIKE %s) + AND COALESCE("test_new_api_indexed_translation"."name"->>%s, "test_new_api_indexed_translation"."name"->>%s) ILIKE %s) ORDER BY "test_new_api_indexed_translation"."id" """, """ SELECT "test_new_api_indexed_translation"."id" FROM "test_new_api_indexed_translation" WHERE ("test_new_api_indexed_translation"."name" IS NULL - OR COALESCE("test_new_api_indexed_translation"."name"->>%s, "test_new_api_indexed_translation"."name"->>'en_US') ILIKE %s) + OR COALESCE("test_new_api_indexed_translation"."name"->>%s, "test_new_api_indexed_translation"."name"->>%s) ILIKE %s) ORDER BY "test_new_api_indexed_translation"."id" """]): record_en.search([('name', 'ilike', 'foo')]) diff --git a/odoo/addons/test_new_api/tests/test_new_fields.py b/odoo/addons/test_new_api/tests/test_new_fields.py index 805d94dd21f..be233a3cbe2 100644 --- a/odoo/addons/test_new_api/tests/test_new_fields.py +++ b/odoo/addons/test_new_api/tests/test_new_fields.py @@ -2805,9 +2805,9 @@ class TestFields(TransactionCaseWithUserDemo): with self.assertQueries([""" SELECT "test_new_api_prefetch"."id", - "test_new_api_prefetch"."name"->>'en_US', - "test_new_api_prefetch"."description"->>'en_US', - "test_new_api_prefetch"."html_description"->>'en_US', + "test_new_api_prefetch"."name"->>%s, + "test_new_api_prefetch"."description"->>%s, + "test_new_api_prefetch"."html_description"->>%s, "test_new_api_prefetch"."create_uid", "test_new_api_prefetch"."create_date", "test_new_api_prefetch"."write_uid", diff --git a/odoo/addons/test_new_api/tests/test_properties.py b/odoo/addons/test_new_api/tests/test_properties.py index c9127ff9a42..c3afce57d5f 100644 --- a/odoo/addons/test_new_api/tests/test_properties.py +++ b/odoo/addons/test_new_api/tests/test_properties.py @@ -278,7 +278,7 @@ class PropertiesCase(TestPropertiesMixin): "test_new_api_message"."author", "test_new_api_message"."name", "test_new_api_message"."important", - "test_new_api_message"."label"->>'en_US', + "test_new_api_message"."label"->>%s, "test_new_api_message"."priority", "test_new_api_message"."active", "test_new_api_message"."create_uid", diff --git a/odoo/addons/test_new_api/tests/test_related_translation.py b/odoo/addons/test_new_api/tests/test_related_translation.py index f47843324bc..f5e8fb6e986 100644 --- a/odoo/addons/test_new_api/tests/test_related_translation.py +++ b/odoo/addons/test_new_api/tests/test_related_translation.py @@ -139,6 +139,31 @@ class TestRelatedTranslation(odoo.tests.TransactionCase): translation_importer.save(overwrite=True) self.assertEqual(self.test2.with_context(lang='fr_FR').name, 'Nouveau couteau') + def test_write_from_ori_term(self): + self.test1.with_context(lang='fr_FR').html = '

Nouveau couteau

Fourchette

Cuiller

' + self.assertEqual(self.test1.with_context(lang='en_US').html, '

Nouveau couteau

Fork

Spoon

') + self.assertEqual(self.test1.with_context(lang='fr_FR').html, '

Nouveau couteau

Fourchette

Cuiller

') + self.assertEqual(self.test2.with_context(lang='en_US').html, '

Nouveau couteau

Fork

Spoon

') + self.assertEqual(self.test2.with_context(lang='fr_FR').html, '

Nouveau couteau

Fourchette

Cuiller

') + self.assertEqual(self.test3.with_context(lang='en_US').html, '

Nouveau couteau

Fork

Spoon

') + self.assertEqual(self.test3.with_context(lang='fr_FR').html, '

Nouveau couteau

Fourchette

Cuiller

') + + def test_delay_write_from_ori_term(self): + self.test1.with_context(lang='fr_FR', delay_translations=True).html = '

Nouveau couteau

Fourchette

Cuiller

' + self.assertEqual(self.test1.with_context(lang='en_US').html, '

Knife

Fork

Spoon

') + self.assertEqual(self.test1.with_context(lang='fr_FR').html, '

Nouveau couteau

Fourchette

Cuiller

') + self.assertEqual(self.test2.with_context(lang='en_US').html, '

Knife

Fork

Spoon

') + self.assertEqual(self.test2.with_context(lang='fr_FR').html, '

Nouveau couteau

Fourchette

Cuiller

') + self.assertEqual(self.test3.with_context(lang='en_US').html, '

Knife

Fork

Spoon

') + self.assertEqual(self.test3.with_context(lang='fr_FR').html, '

Nouveau couteau

Fourchette

Cuiller

') + + self.assertEqual(self.test1.with_context(lang='en_US', check_translations=True).html, '

Nouveau couteau

Fork

Spoon

') + self.assertEqual(self.test1.with_context(lang='fr_FR', check_translations=True).html, '

Nouveau couteau

Fourchette

Cuiller

') + self.assertEqual(self.test2.with_context(lang='en_US', check_translations=True).html, '

Nouveau couteau

Fork

Spoon

') + self.assertEqual(self.test2.with_context(lang='fr_FR', check_translations=True).html, '

Nouveau couteau

Fourchette

Cuiller

') + self.assertEqual(self.test3.with_context(lang='en_US', check_translations=True).html, '

Nouveau couteau

Fork

Spoon

') + self.assertEqual(self.test3.with_context(lang='fr_FR', check_translations=True).html, '

Nouveau couteau

Fourchette

Cuiller

') + def test_translate_from_ori_term(self): self.assertEqual(self.test1.with_context(lang='en_US').html, '

Knife

Fork

Spoon

') self.assertEqual(self.test1.with_context(lang='fr_FR').html, '

Couteau

Fourchette

Cuiller

') @@ -158,6 +183,31 @@ class TestRelatedTranslation(odoo.tests.TransactionCase): self.assertEqual(self.test3.with_context(lang='en_US').html, '

Knife

Fork

Spoon

') self.assertEqual(self.test3.with_context(lang='fr_FR').html, '

Nouveau couteau

Fourchette

Cuiller

') + def test_write_from_related_term(self): + self.test3.with_context(lang='fr_FR').html = '

Nouveau couteau

Fourchette

Cuiller

' + self.assertEqual(self.test1.with_context(lang='en_US').html, '

Nouveau couteau

Fork

Spoon

') + self.assertEqual(self.test1.with_context(lang='fr_FR').html, '

Nouveau couteau

Fourchette

Cuiller

') + self.assertEqual(self.test2.with_context(lang='en_US').html, '

Nouveau couteau

Fork

Spoon

') + self.assertEqual(self.test2.with_context(lang='fr_FR').html, '

Nouveau couteau

Fourchette

Cuiller

') + self.assertEqual(self.test3.with_context(lang='en_US').html, '

Nouveau couteau

Fork

Spoon

') + self.assertEqual(self.test3.with_context(lang='fr_FR').html, '

Nouveau couteau

Fourchette

Cuiller

') + + def test_delay_write_from_related_term(self): + self.test3.with_context(lang='fr_FR', delay_translations=True).html = '

Nouveau couteau

Fourchette

Cuiller

' + self.assertEqual(self.test1.with_context(lang='en_US').html, '

Knife

Fork

Spoon

') + self.assertEqual(self.test1.with_context(lang='fr_FR').html, '

Nouveau couteau

Fourchette

Cuiller

') + self.assertEqual(self.test2.with_context(lang='en_US').html, '

Knife

Fork

Spoon

') + self.assertEqual(self.test2.with_context(lang='fr_FR').html, '

Nouveau couteau

Fourchette

Cuiller

') + self.assertEqual(self.test3.with_context(lang='en_US').html, '

Knife

Fork

Spoon

') + self.assertEqual(self.test3.with_context(lang='fr_FR').html, '

Nouveau couteau

Fourchette

Cuiller

') + + self.assertEqual(self.test1.with_context(lang='en_US', check_translations=True).html, '

Nouveau couteau

Fork

Spoon

') + self.assertEqual(self.test1.with_context(lang='fr_FR', check_translations=True).html, '

Nouveau couteau

Fourchette

Cuiller

') + self.assertEqual(self.test2.with_context(lang='en_US', check_translations=True).html, '

Nouveau couteau

Fork

Spoon

') + self.assertEqual(self.test2.with_context(lang='fr_FR', check_translations=True).html, '

Nouveau couteau

Fourchette

Cuiller

') + self.assertEqual(self.test3.with_context(lang='en_US', check_translations=True).html, '

Nouveau couteau

Fork

Spoon

') + self.assertEqual(self.test3.with_context(lang='fr_FR', check_translations=True).html, '

Nouveau couteau

Fourchette

Cuiller

') + def test_translate_from_related_term(self): self.assertEqual(self.test1.with_context(lang='en_US').html, '

Knife

Fork

Spoon

') self.assertEqual(self.test1.with_context(lang='fr_FR').html, '

Couteau

Fourchette

Cuiller

') diff --git a/odoo/api.py b/odoo/api.py index 4c9214a3f2f..2b243605e47 100644 --- a/odoo/api.py +++ b/odoo/api.py @@ -944,7 +944,7 @@ class Cache(object): cache_value = field_cache.get(record.id, EMPTY_DICT) if cache_value is None: return True - lang = record.env.lang or 'en_US' + lang = field._lang(record.env) return lang in cache_value return record.id in field_cache @@ -965,7 +965,7 @@ class Cache(object): field_cache = self._get_field_cache(record, field) cache_value = field_cache[record._ids[0]] if field.translate and cache_value is not None: - lang = record.env.lang or 'en_US' + lang = field._lang(record.env) return cache_value[lang] return cache_value except KeyError: @@ -986,6 +986,7 @@ class Cache(object): """ field_cache = self._set_field_cache(record, field) if field.translate and value is not None: + # only for model translated fields lang = record.env.lang or 'en_US' cache_value = field_cache.get(record._ids[0]) or {} cache_value[lang] = value @@ -1019,6 +1020,7 @@ class Cache(object): dirty must raise an exception """ if field.translate: + # only for model translated fields lang = records.env.lang or 'en_US' field_cache = self._get_field_cache(records, field) cache_values = [] @@ -1065,14 +1067,20 @@ class Cache(object): if field.translate: if records.env.context.get('prefetch_langs'): langs = {lang for lang, _ in records.env['res.lang'].get_installed()} | {'en_US'} + _langs = {f'_{l}' for l in langs} if field._lang(records.env).startswith('_') else set() for id_, val in zip(records._ids, values): if val is None: field_cache.setdefault(id_, None) else: - val_all_en = dict.fromkeys(langs, val['en_US']) - field_cache[id_] = {**val_all_en, **val} + if _langs: # fallback missing _lang to lang if exists + val.update({f'_{k}': v for k, v in val.items() if k in langs and f'_{k}' not in val}) + field_cache[id_] = { + **dict.fromkeys(langs, val['en_US']), # fallback missing lang to en_US + **dict.fromkeys(_langs, val.get('_en_US')), # fallback missing _lang to _en_US + **val + } else: - lang = records.env.lang or 'en_US' + lang = field._lang(records.env) for id_, val in zip(records._ids, values): if val is None: field_cache.setdefault(id_, None) @@ -1106,7 +1114,7 @@ class Cache(object): """ Return the cached values of ``field`` for ``records`` until a value is not found. """ field_cache = self._get_field_cache(records, field) if field.translate: - lang = records.env.lang or 'en_US' + lang = field._lang(records.env) def get_value(id_): cache_value = field_cache[id_] @@ -1160,7 +1168,7 @@ class Cache(object): """ Return the ids of ``records`` that have no value for ``field``. """ field_cache = self._get_field_cache(records, field) if field.translate: - lang = records.env.lang or 'en_US' + lang = field._lang(records.env) for record_id in records._ids: cache_value = field_cache.get(record_id, False) if cache_value is False or not (cache_value is None or lang in cache_value): diff --git a/odoo/fields.py b/odoo/fields.py index 213099f0610..65feb9c6240 100644 --- a/odoo/fields.py +++ b/odoo/fields.py @@ -1715,20 +1715,23 @@ class _String(Field): if value is None: return False if callable(self.translate) and record.env.context.get('edit_translations'): - terms = self.get_trans_terms(value) + if not (terms := self.get_trans_terms(value)): + return value base_lang = record._get_base_lang() if base_lang != (record.env.lang or 'en_US'): - base_value = record.with_context(edit_translations=None, lang=base_lang)[self.name] + base_value = record.with_context(edit_translations=None, check_translations=True, lang=base_lang)[self.name] base_terms = self.get_trans_terms(base_value) term_to_state = {term: "translated" if base_term != term else "to_translate" for term, base_term in zip(terms, base_terms)} else: term_to_state = defaultdict(lambda: 'translated') + lang = record.env.lang or 'en_US' + delay_translation = value != record.with_context(edit_translations=None, check_translations=None, lang=lang)[self.name] + # use a wrapper to let the frontend js code identify each term and its metadata in the 'edit_translations' context + def translate_func(term): + return f'''{term}''' # pylint: disable=not-callable - value = self.translate( - lambda term: f'''{term}''', - value - ) + value = self.translate(translate_func, value) return value def convert_to_write(self, value, record): @@ -1773,6 +1776,23 @@ class _String(Field): res = cr.fetchone() return res[0] if res else None + def get_translation_fallback_langs(self, env): + lang = self._lang(env) + if lang == '_en_US': + return '_en_US', 'en_US' + if lang == 'en_US': + return ('en_US',) + if lang.startswith('_'): + return lang, lang[1:], '_en_US', 'en_US' + return lang, 'en_US' + + def _lang(self, env): + context = env.context + lang = env.lang or 'en_US' + if callable(self.translate) and (context.get('edit_translations') or context.get('check_translations')): + lang = '_' + lang + return lang + def write(self, records, value): if not self.translate or value is False or value is None: super().write(records, value) @@ -1793,6 +1813,7 @@ class _String(Field): # not dirty fields if not dirty: + lang = self._lang(records.env) cache.update_raw(records, self, [{lang: cache_value} for _id in records._ids], dirty=False) return @@ -1812,6 +1833,7 @@ class _String(Field): # pylint: disable=not-callable cache_value = self.translate(lambda t: None, cache_value) new_terms = set(self.get_trans_terms(cache_value)) + delay_translations = records.env.context.get('delay_translations') for record in records: # shortcut when no term needs to be translated if not new_terms: @@ -1819,11 +1841,16 @@ class _String(Field): continue # _get_stored_translations can be refactored and prefetches translations for multi records, # but it is really rare to write the same non-False/None/no-term value to multi records - old_translations = self._get_stored_translations(record) - if not old_translations: + stored_translations = self._get_stored_translations(record) + if not stored_translations: new_translations_list.append({'en_US': cache_value, lang: cache_value}) continue - from_lang_value = old_translations.get(lang, old_translations['en_US']) + old_translations = { + k: stored_translations.get(f'_{k}', v) + for k, v in stored_translations.items() + if not k.startswith('_') + } + from_lang_value = old_translations.pop(lang, old_translations['en_US']) translation_dictionary = self.get_translation_dictionary(from_lang_value, old_translations) text2terms = defaultdict(list) for term in new_terms: @@ -1844,10 +1871,18 @@ class _String(Field): l: self.translate(lambda term: translation_dictionary.get(term, {l: None})[l], cache_value) for l in old_translations.keys() } - new_translations[lang] = cache_value + if delay_translations: + new_store_translations = stored_translations + new_store_translations.update({f'_{k}': v for k, v in new_translations.items()}) + new_store_translations.pop(f'_{lang}', None) + else: + new_store_translations = new_translations + new_store_translations[lang] = cache_value + if not records.env['res.lang']._lang_get_id('en_US'): - new_translations['en_US'] = cache_value - new_translations_list.append(new_translations) + new_store_translations['en_US'] = cache_value + new_store_translations.pop('_en_US', None) + new_translations_list.append(new_store_translations) # Maybe we can use Cache.update(records.with_context(cache_update_raw=True), self, new_translations_list, dirty=True) cache.update_raw(records, self, new_translations_list, dirty=True) diff --git a/odoo/models.py b/odoo/models.py index 74ee1bc41db..ee09a200d95 100644 --- a/odoo/models.py +++ b/odoo/models.py @@ -2819,10 +2819,11 @@ class BaseModel(metaclass=MetaModel): elif field.translate and not self.env.context.get('prefetch_langs'): sql_field = SQL.identifier(alias, fname) - lang = self.env.lang or 'en_US' - if lang == 'en_US': - return SQL("%s->>'en_US'", sql_field) - return SQL("COALESCE(%s->>%s, %s->>'en_US')", sql_field, lang, sql_field) + langs = field.get_translation_fallback_langs(self.env) + sql_field_langs = [SQL("%s->>%s", sql_field, lang) for lang in langs] + if len(sql_field_langs) == 1: + return sql_field_langs[0] + return SQL("COALESCE(%s)", SQL(", ").join(sql_field_langs)) elif field.type == 'properties' and property_name: return self._field_properties_to_sql(alias, fname, property_name, query) @@ -3618,13 +3619,16 @@ class BaseModel(metaclass=MetaModel): # assert record_fr.with_context(lang='fr_FR') == '
English 1
French 2
' # assert record_nl.with_context(lang='nl_NL') == '
English 3
English 2
' - old_translations = field._get_stored_translations(self) - if not old_translations: + stored_translations = field._get_stored_translations(self) + if not stored_translations: return False - new_translations = old_translations - old_value_en = old_translations.get('en_US') + old_translations = { + k: stored_translations.get(f'_{k}', v) + for k, v in stored_translations.items() + if not k.startswith('_') + } for lang, translation in translations.items(): - old_value = new_translations.get(lang, old_value_en) + old_value = old_translations.get(lang) or old_translations.get('en_US') if digest: old_terms = field.get_trans_terms(old_value) old_terms_digested2value = {digest(old_term): old_term for old_term in old_terms} @@ -3633,8 +3637,9 @@ class BaseModel(metaclass=MetaModel): for key, value in translation.items() if key in old_terms_digested2value } - new_translations[lang] = field.translate(translation.get, old_value) - self.env.cache.update_raw(self, field, [new_translations], dirty=True) + stored_translations[lang] = field.translate(translation.get, old_value) + stored_translations.pop(f'_{lang}', None) + self.env.cache.update_raw(self, field, [stored_translations], dirty=True) # the following write is incharge of # 1. mark field as modified @@ -3657,16 +3662,17 @@ class BaseModel(metaclass=MetaModel): field = self._fields[field_name] # We don't forbid reading inactive/non-existing languages, langs = set(langs or [l[0] for l in self.env['res.lang'].get_installed()]) - val_en = self.with_context(lang='en_US')[field_name] + self_lang = self.with_context(check_translations=True, prefetch_langs=True) + val_en = self_lang.with_context(lang='en_US')[field_name] if not callable(field.translate): translations = [{ 'lang': lang, 'source': val_en, - 'value': self.with_context(lang=lang)[field_name] + 'value': self_lang.with_context(lang=lang)[field_name] } for lang in langs] else: translation_dictionary = field.get_translation_dictionary( - val_en, {lang: self.with_context(lang=lang)[field_name] for lang in langs} + val_en, {lang: self_lang.with_context(lang=lang)[field_name] for lang in langs} ) translations = [{ 'lang': lang, @@ -5474,25 +5480,27 @@ class BaseModel(metaclass=MetaModel): elif field.translate and field.store and name not in excluded and old[name]: # for translatable fields we copy their translations - old_translations = field._get_stored_translations(old) - if not old_translations: + old_stored_translations = field._get_stored_translations(old) + if not old_stored_translations: continue lang = self.env.lang or 'en_US' - old_value_lang = old_translations.pop(lang, old_translations['en_US']) - old_translations = { - lang: value - for lang, value in old_translations.items() - if lang in valid_langs - } - if not old_translations: - continue - if not callable(field.translate): - new.update_field_translations(name, old_translations) + if field.translate is True: + new.update_field_translations(name, { + k: v for k, v in old_stored_translations.items() if k in valid_langs and k != lang + }) else: + old_translations = { + k: old_stored_translations.get(f'_{k}', v) + for k, v in old_stored_translations.items() + if k in valid_langs + } + # {from_lang_term: {lang: to_lang_term} + translation_dictionary = field.get_translation_dictionary( + old_translations.pop(lang, old_translations['en_US']), + old_translations + ) # {lang: {old_term: new_term}} translations = defaultdict(dict) - # {from_lang_term: {lang: to_lang_term} - translation_dictionary = field.get_translation_dictionary(old_value_lang, old_translations) for from_lang_term, to_lang_terms in translation_dictionary.items(): for lang, to_lang_term in to_lang_terms.items(): translations[lang][from_lang_term] = to_lang_term diff --git a/odoo/osv/expression.py b/odoo/osv/expression.py index 91cdec48a92..dc887ec550e 100644 --- a/odoo/osv/expression.py +++ b/odoo/osv/expression.py @@ -1444,11 +1444,12 @@ class expression(object): check_null = len(params) < len(right) if params: params = [field.convert_to_column(p, model, validate=False).adapted['en_US'] for p in params] - lang = model.env.lang or 'en_US' - if lang == 'en_US': - sql_left = SQL("%s->>'en_US'", sql_field) + langs = field.get_translation_fallback_langs(model.env) + sql_left_langs = [SQL("%s->>%s", sql_field, lang) for lang in langs] + if len(sql_left_langs) == 1: + sql_left = sql_left_langs[0] else: - sql_left = SQL("COALESCE(%s->>%s, %s->>'en_US')", sql_field, lang, sql_field) + sql_left = SQL('COALESCE(%s)', SQL(', ').join(sql_left_langs)) sql = SQL("%s %s %s", sql_left, sql_operator, tuple(params)) else: # The case for (left, 'in', []) or (left, 'not in', []). diff --git a/odoo/tools/translate.py b/odoo/tools/translate.py index 7e050d6e9d1..c62a26f7a4f 100644 --- a/odoo/tools/translate.py +++ b/odoo/tools/translate.py @@ -1017,7 +1017,7 @@ class TranslationReader: if not records: return - for record in records: + for record in records.with_context(check_translations=True): module = imd_per_id[record.id].module xml_name = "%s.%s" % (module, imd_per_id[record.id].name) for field_name, field in record._fields.items(): @@ -1406,16 +1406,20 @@ class TranslationImporter: for id_, xmlid, values, noupdate in cr.fetchall(): if not values: continue - value_en = values.get('en_US') - if not value_en: + _value_en = values.get('_en_US', values['en_US']) + if not _value_en: continue # {src: {lang: value}} record_dictionary = field_dictionary[xmlid] langs = {lang for translations in record_dictionary.values() for lang in translations.keys()} translation_dictionary = field.get_translation_dictionary( - value_en, - {k: v for k, v in values.items() if k in langs} + _value_en, + { + k: values.get(f'_{k}', v) + for k, v in values.items() + if k in langs + } ) if force_overwrite or (not noupdate and overwrite): @@ -1429,7 +1433,9 @@ class TranslationImporter: translation_dictionary[term_en] = translations for lang in langs: - values[lang] = field.translate(lambda term: translation_dictionary.get(term, {}).get(lang), value_en) + # translate and confirm model_terms translations + values[lang] = field.translate(lambda term: translation_dictionary.get(term, {}).get(lang), _value_en) + values.pop(f'_{lang}', None) params.extend((id_, Json(values))) if params: env.cr.execute(f"""