[IMP] core: support delay translation

Jsonb data structure of model_terms translated fields' columns
'{
	"en_US": "<div>Apple</div>"
	"fr_FR": "<div>Pomme</div><div>Banane</div>"
	"_fr_FR": "<div>Pomme</div>"
}'::jsonb

"column" IS NULL OR "column"->>'en_US' IS NOT NULL

"column"->>'lang'
1. stores last confirmed value
2. logically fallbacks to
    COALESCE(
        "column"->>'lang',
        "column"->>'en_US'
    )

"column"->>'_lang'
1. stores translations and the last written html/xml structure
2. shares the same html/xml structure with other "column"->>'_lang'
3. logically fallbacks to
    COALESCE(
        "column"->>'_lang',
        "column"->>'lang',
        "column"->>'_en_US',
        "column"->>'en_US'
    )

Context:
1. `delay_translations`(new) only write values to _langs while keeping
translations for langs when the written value has at least one translatable term
2. `check_translations`(new) read _langs values to create translation mapping
for the translation dialog
3. `edit_translations` read values whose translatable terms are wrapped by
`<span></span>` with term information for the TRANSLATE mode of website

Potential issue
since the record value of a model terms translated field is logical content
dependent (check_translations, edit_translations), all computed field computed
from any model terms translated field should also be marked.
@api.depends_context('lang', 'edit_translations', 'check_translations')

Data flow for column value, cache value and record value
(make sure the window is wide enough to see the graph)

                             +- - - - - - - - - - - - - - - - - - - - - - - - - - - -+
                             ' Record(str):                                          '
                             '                                                       '
                             ' +---------------------------------------------------+ '               +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~+
                             ' | "<div><span data-oe-model='model'                 | '               {            read fr_FR             {
                             ' | data-oe-id='id' ...>French</span></div>"          | ' <------------ } context.get('edit_translations')  } <+
                             ' +---------------------------------------------------+ '               +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~+  |
                             ' +---------------------------------------------------+ '               +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~+  |
                             ' | "<div>French</div>"                               | '               {            read fr_FR             {  |
                             ' |                                                   | ' <------------ } context.get('check_translations') } <+
                             ' +---------------------------------------------------+ '               +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~+  |
+~~~~~~~~~~~~+               ' +---------------------------------------------------+ '                                                      |
{ read fr_FR { ------------> ' | "<a>French</a>"                                   | '                                                      |
+~~~~~~~~~~~~+               ' +---------------------------------------------------+ '                                                      |
  ^                          '                                                       '                                                      |
  |                          +- - - - - - - - - - - - - - - - - - - - - - - - - - - -+                                                      |
  |                                                                                                                                         |
  |                                                                                                                                         |
  |                                                                                                                                         |
  |                          +- - - - - - - - - - - - - - - - - - - - - - - - - - - -+                                                      |
  |                          ' Cache(dict):                                          '                                                      |
  |                          '                                                       '                                                      |
  |                          ' +---------------------------------------------------+ '                                                      |
  |                          ' |                                                   | ' -----------------------------------------------------+
  |                          ' | "_fr_FR": "<div>French</div>"                     | '
  |                          ' |                                                   | ' <----------------------------------------------------+
  |                          ' +---------------------------------------------------+ '                                                      |
  |                          ' +---------------------------------------------------+ '               +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~+
  +------------------------- ' |                                                   | '               {              fetch fr_FR               {
                             ' | "fr_FR": "<a>French</a>"                          | '               }    context.get('edit_translations')    }
  +------------------------> ' |                                                   | '               {  or context.get('check_translations')  {
  |                          ' +---------------------------------------------------+ '               +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~+
+~~~~~~~~~~~~~~~~~+          '                                                       '                                                      ^
{   fetch fr_FR   {          +- - - - - - - - - - - - - - - - - - - - - - - - - - - -+                              COALESCE(               |
+~~~~~~~~~~~~~~~~~+                                                                                                     "c"->>'_fr_FR',     |
  ^                                                                                                                     "c"->>'fr_FR',      |
  | COALESCE(                                                                                                           "c"->>'_en_US',     |
  |     "c"->>'fr_FR',       +- - - - - - - - - - - - - - - - - - - - - - - - - - - -+                                  "c"->>'en_US'       |
  |     "c"->>'en_US'        ' Database(jsonb):                                      '                              )                       |
  | )                        '                                                       '                                                      |
  |                          ' +---------------------------------------------------+ '                                                      |
  |                          ' | "_fr_FR": "<div>French</div>"                     | ' -----------------------------------------------------+
  |                          ' +---------------------------------------------------+ '                                                      |
  |                          ' +---------------------------------------------------+ '                                                      |
  +------------------------- ' | "fr_FR": "<a>French</a>"                          | ' -----------------------------------------------------+
  |                          ' +---------------------------------------------------+ '                                                      |
  |                          ' +---------------------------------------------------+ '                                                      |
  |                          ' | "_en_US": "<div>English</div>"                    | ' -----------------------------------------------------+
  |                          ' +---------------------------------------------------+ '                                                      |
  |                          ' +---------------------------------------------------+ '                                                      |
  +------------------------- ' | "en_US": "<div>English1</div><div>English2</div>" | ' -----------------------------------------------------+
                             ' +---------------------------------------------------+ '
                             '                                                       '
                             +- - - - - - - - - - - - - - - - - - - - - - - - - - - -+

Task: 3043340
Part-of: odoo/odoo#139819
This commit is contained in:
Chong Wang (cwg)
2023-10-27 11:35:03 +00:00
parent 43c6b5e7a8
commit 2d08f97c07
15 changed files with 342 additions and 86 deletions
@@ -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;
}
+17 -12
View File
@@ -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():
@@ -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();
+1 -1
View File
@@ -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):
+6 -6
View File
@@ -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"
+134
View File
@@ -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, '<form string="X">Bread and cheese<div>Fork3</div></form>')
self.assertEqual(view.with_context(lang='es_ES').arch_db, '<form string="X">Bread and cheese<div>Tenedor3</div></form>')
def test_delay_translations(self):
archf = '<form string="%s"><div>%s</div><div>%s</div></form>'
terms_en = ('Knife', 'Fork', 'Spoon')
terms_fr = ('Couteau', 'Fourchette', 'Cuiller')
view0 = self.create_view(archf, terms_en, fr_FR=terms_fr)
archf2 = '<form string="%s"><p>%s</p><div>%s</div></form>'
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,
'<form string="'
'&lt;span '
'class=&quot;o_delay_translation&quot; '
'data-oe-model=&quot;ir.ui.view&quot; '
f'data-oe-id=&quot;{view0.id}&quot; '
'data-oe-field=&quot;arch_db&quot; '
'data-oe-translation-state=&quot;to_translate&quot; '
f'data-oe-translation-initial-sha=&quot;{sha256(terms_en2[0].encode()).hexdigest()}&quot;'
'&gt;'
f'{terms_en2[0]}'
'&lt;/span&gt;"'
'>'
'<p>'
'<span '
'class="o_delay_translation" '
'data-oe-model="ir.ui.view" '
f'data-oe-id="{view0.id}" '
'data-oe-field="arch_db" '
'data-oe-translation-state="translated" '
f'data-oe-translation-initial-sha="{sha256(terms_fr[1].encode()).hexdigest()}"'
'>'
f'{terms_fr[1]}'
'</span>'
'</p>'
'<div>'
'<span '
'class="o_delay_translation" '
'data-oe-model="ir.ui.view" '
f'data-oe-id="{view0.id}" '
'data-oe-field="arch_db" '
'data-oe-translation-state="translated" '
f'data-oe-translation-initial-sha="{sha256(terms_fr[2].encode()).hexdigest()}"'
'>'
f'{terms_fr[2]}'
'</span>'
'</div>'
'</form>'
)
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 = '<form string="%s"><div>%s</div><div>%s</div></form>'
terms_en = ('Knife', 'Fork', 'Spoon')
terms_fr = ('Couteau', 'Fourchette', 'Cuiller')
view0 = self.create_view(archf, terms_en, fr_FR=terms_fr)
archf2 = '<form/>'
# 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': '<h1>Knife</h1>'})
company0.update_field_translations('report_footer', {'fr_FR': {'Knife': 'Couteau'}})
for html in ('<h1></h1>', '', 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):
@@ -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')])
@@ -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",
@@ -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",
@@ -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 = '<p>Nouveau couteau</p><p>Fourchette</p><p>Cuiller</p>'
self.assertEqual(self.test1.with_context(lang='en_US').html, '<p>Nouveau couteau</p><p>Fork</p><p>Spoon</p>')
self.assertEqual(self.test1.with_context(lang='fr_FR').html, '<p>Nouveau couteau</p><p>Fourchette</p><p>Cuiller</p>')
self.assertEqual(self.test2.with_context(lang='en_US').html, '<p>Nouveau couteau</p><p>Fork</p><p>Spoon</p>')
self.assertEqual(self.test2.with_context(lang='fr_FR').html, '<p>Nouveau couteau</p><p>Fourchette</p><p>Cuiller</p>')
self.assertEqual(self.test3.with_context(lang='en_US').html, '<p>Nouveau couteau</p><p>Fork</p><p>Spoon</p>')
self.assertEqual(self.test3.with_context(lang='fr_FR').html, '<p>Nouveau couteau</p><p>Fourchette</p><p>Cuiller</p>')
def test_delay_write_from_ori_term(self):
self.test1.with_context(lang='fr_FR', delay_translations=True).html = '<p>Nouveau couteau</p><p>Fourchette</p><p>Cuiller</p>'
self.assertEqual(self.test1.with_context(lang='en_US').html, '<p>Knife</p><p>Fork</p><p>Spoon</p>')
self.assertEqual(self.test1.with_context(lang='fr_FR').html, '<p>Nouveau couteau</p><p>Fourchette</p><p>Cuiller</p>')
self.assertEqual(self.test2.with_context(lang='en_US').html, '<p>Knife</p><p>Fork</p><p>Spoon</p>')
self.assertEqual(self.test2.with_context(lang='fr_FR').html, '<p>Nouveau couteau</p><p>Fourchette</p><p>Cuiller</p>')
self.assertEqual(self.test3.with_context(lang='en_US').html, '<p>Knife</p><p>Fork</p><p>Spoon</p>')
self.assertEqual(self.test3.with_context(lang='fr_FR').html, '<p>Nouveau couteau</p><p>Fourchette</p><p>Cuiller</p>')
self.assertEqual(self.test1.with_context(lang='en_US', check_translations=True).html, '<p>Nouveau couteau</p><p>Fork</p><p>Spoon</p>')
self.assertEqual(self.test1.with_context(lang='fr_FR', check_translations=True).html, '<p>Nouveau couteau</p><p>Fourchette</p><p>Cuiller</p>')
self.assertEqual(self.test2.with_context(lang='en_US', check_translations=True).html, '<p>Nouveau couteau</p><p>Fork</p><p>Spoon</p>')
self.assertEqual(self.test2.with_context(lang='fr_FR', check_translations=True).html, '<p>Nouveau couteau</p><p>Fourchette</p><p>Cuiller</p>')
self.assertEqual(self.test3.with_context(lang='en_US', check_translations=True).html, '<p>Nouveau couteau</p><p>Fork</p><p>Spoon</p>')
self.assertEqual(self.test3.with_context(lang='fr_FR', check_translations=True).html, '<p>Nouveau couteau</p><p>Fourchette</p><p>Cuiller</p>')
def test_translate_from_ori_term(self):
self.assertEqual(self.test1.with_context(lang='en_US').html, '<p>Knife</p><p>Fork</p><p>Spoon</p>')
self.assertEqual(self.test1.with_context(lang='fr_FR').html, '<p>Couteau</p><p>Fourchette</p><p>Cuiller</p>')
@@ -158,6 +183,31 @@ class TestRelatedTranslation(odoo.tests.TransactionCase):
self.assertEqual(self.test3.with_context(lang='en_US').html, '<p>Knife</p><p>Fork</p><p>Spoon</p>')
self.assertEqual(self.test3.with_context(lang='fr_FR').html, '<p>Nouveau couteau</p><p>Fourchette</p><p>Cuiller</p>')
def test_write_from_related_term(self):
self.test3.with_context(lang='fr_FR').html = '<p>Nouveau couteau</p><p>Fourchette</p><p>Cuiller</p>'
self.assertEqual(self.test1.with_context(lang='en_US').html, '<p>Nouveau couteau</p><p>Fork</p><p>Spoon</p>')
self.assertEqual(self.test1.with_context(lang='fr_FR').html, '<p>Nouveau couteau</p><p>Fourchette</p><p>Cuiller</p>')
self.assertEqual(self.test2.with_context(lang='en_US').html, '<p>Nouveau couteau</p><p>Fork</p><p>Spoon</p>')
self.assertEqual(self.test2.with_context(lang='fr_FR').html, '<p>Nouveau couteau</p><p>Fourchette</p><p>Cuiller</p>')
self.assertEqual(self.test3.with_context(lang='en_US').html, '<p>Nouveau couteau</p><p>Fork</p><p>Spoon</p>')
self.assertEqual(self.test3.with_context(lang='fr_FR').html, '<p>Nouveau couteau</p><p>Fourchette</p><p>Cuiller</p>')
def test_delay_write_from_related_term(self):
self.test3.with_context(lang='fr_FR', delay_translations=True).html = '<p>Nouveau couteau</p><p>Fourchette</p><p>Cuiller</p>'
self.assertEqual(self.test1.with_context(lang='en_US').html, '<p>Knife</p><p>Fork</p><p>Spoon</p>')
self.assertEqual(self.test1.with_context(lang='fr_FR').html, '<p>Nouveau couteau</p><p>Fourchette</p><p>Cuiller</p>')
self.assertEqual(self.test2.with_context(lang='en_US').html, '<p>Knife</p><p>Fork</p><p>Spoon</p>')
self.assertEqual(self.test2.with_context(lang='fr_FR').html, '<p>Nouveau couteau</p><p>Fourchette</p><p>Cuiller</p>')
self.assertEqual(self.test3.with_context(lang='en_US').html, '<p>Knife</p><p>Fork</p><p>Spoon</p>')
self.assertEqual(self.test3.with_context(lang='fr_FR').html, '<p>Nouveau couteau</p><p>Fourchette</p><p>Cuiller</p>')
self.assertEqual(self.test1.with_context(lang='en_US', check_translations=True).html, '<p>Nouveau couteau</p><p>Fork</p><p>Spoon</p>')
self.assertEqual(self.test1.with_context(lang='fr_FR', check_translations=True).html, '<p>Nouveau couteau</p><p>Fourchette</p><p>Cuiller</p>')
self.assertEqual(self.test2.with_context(lang='en_US', check_translations=True).html, '<p>Nouveau couteau</p><p>Fork</p><p>Spoon</p>')
self.assertEqual(self.test2.with_context(lang='fr_FR', check_translations=True).html, '<p>Nouveau couteau</p><p>Fourchette</p><p>Cuiller</p>')
self.assertEqual(self.test3.with_context(lang='en_US', check_translations=True).html, '<p>Nouveau couteau</p><p>Fork</p><p>Spoon</p>')
self.assertEqual(self.test3.with_context(lang='fr_FR', check_translations=True).html, '<p>Nouveau couteau</p><p>Fourchette</p><p>Cuiller</p>')
def test_translate_from_related_term(self):
self.assertEqual(self.test1.with_context(lang='en_US').html, '<p>Knife</p><p>Fork</p><p>Spoon</p>')
self.assertEqual(self.test1.with_context(lang='fr_FR').html, '<p>Couteau</p><p>Fourchette</p><p>Cuiller</p>')
+15 -7
View File
@@ -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):
+47 -12
View File
@@ -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'''<span {'class="o_delay_translation" ' if delay_translation else ''}data-oe-model="{record._name}" data-oe-id="{record.id}" data-oe-field="{self.name}" data-oe-translation-state="{term_to_state[term]}" data-oe-translation-initial-sha="{sha256(term.encode()).hexdigest()}">{term}</span>'''
# pylint: disable=not-callable
value = self.translate(
lambda term: f'''<span data-oe-model="{record._name}" data-oe-id="{record.id}" data-oe-field="{self.name}" data-oe-translation-state="{term_to_state[term]}" data-oe-translation-initial-sha="{sha256(term.encode()).hexdigest()}">{term}</span>''',
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)
+36 -28
View File
@@ -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') == '<div>English 1</div><div>French 2<div/>'
# assert record_nl.with_context(lang='nl_NL') == '<div>English 3</div><div>English 2<div/>'
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
+5 -4
View File
@@ -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', []).
+12 -6
View File
@@ -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"""