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, '
') self.assertEqual(view.with_context(lang='es_ES').arch_db, '') + def test_delay_translations(self): + archf = '' + terms_en = ('Knife', 'Fork', 'Spoon') + terms_fr = ('Couteau', 'Fourchette', 'Cuiller') + view0 = self.create_view(archf, terms_en, fr_FR=terms_fr) + + archf2 = '' + 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, + '' + ) + 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 = '' + 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': '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') == '