" % encode(value)
- root = etree.fromstring(div, etree.HTMLParser(encoding='utf-8'))
- # root is html > body > div
- # serialize div as XML and discard surrounding tags
- value = etree.tostring(root[0][0], encoding='utf-8')[5:-6]
- elif field.translate == html_translate:
- # wrap value inside a div and parse it as HTML
- div = "
%s
" % encode(value)
- root = etree.fromstring(div, etree.HTMLParser(encoding='utf-8'))
- # root is html > body > div
- # serialize div as HTML and discard surrounding tags
- value = etree.tostring(root[0][0], encoding='utf-8', method='html')[5:-6]
- return self.write({'value': value})
diff --git a/addons/web_editor/models/models.py b/addons/web_editor/models/models.py
index 059e35f5651..effd7ffda3a 100644
--- a/addons/web_editor/models/models.py
+++ b/addons/web_editor/models/models.py
@@ -1,5 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
+from hashlib import sha256
+
from odoo import api, models
@@ -12,3 +14,10 @@ class Base(models.AbstractModel):
keys.append('sanitize')
keys.append('sanitize_tags')
return keys
+
+
+class BaseModel(models.AbstractModel):
+ _inherit = 'base'
+
+ def update_field_translations_sha(self, fname, translations):
+ return self._update_field_translations(fname, translations, lambda old_term: sha256(old_term.encode()).hexdigest())
diff --git a/addons/web_editor/static/src/js/editor/snippets.editor.js b/addons/web_editor/static/src/js/editor/snippets.editor.js
index 97aa04f1424..d78b0b5ff0b 100644
--- a/addons/web_editor/static/src/js/editor/snippets.editor.js
+++ b/addons/web_editor/static/src/js/editor/snippets.editor.js
@@ -3818,8 +3818,8 @@ var SnippetsMenu = Widget.extend({
}
if (!range ||
!$currentSelectionTarget.parents('#wrapwrap, .iframe-editor-wrapper .o_editable').length ||
- $(selection.anchorNode).parent('[data-oe-model]:not([data-oe-type="html"]):not([data-oe-field="arch"]):not([data-oe-translation-id])').length ||
- $(selection.focusNode).parent('[data-oe-model]:not([data-oe-type="html"]):not([data-oe-field="arch"]):not([data-oe-translation-id])').length ||
+ $(selection.anchorNode).parent('[data-oe-model]:not([data-oe-type="html"]):not([data-oe-field="arch"]):not([data-oe-translation-initial-sha])').length ||
+ $(selection.focusNode).parent('[data-oe-model]:not([data-oe-type="html"]):not([data-oe-field="arch"]):not([data-oe-translation-initial-sha])').length ||
(e && $(e.target).closest('.fa, img').length ||
this.options.wysiwyg.lastMediaClicked && $(this.options.wysiwyg.lastMediaClicked).is('.fa, img')) ||
(this.options.wysiwyg.lastElement && !this.options.wysiwyg.lastElement.isContentEditable)
diff --git a/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js b/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js
index b3feab5dd75..74dfd68141a 100644
--- a/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js
+++ b/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js
@@ -1016,7 +1016,7 @@ const Wysiwyg = Widget.extend({
observerData.observer.observe(observerData.field, observerOptions);
}
} else {
- const odooFieldSelector = '[data-oe-model], [data-oe-translation-id]';
+ const odooFieldSelector = '[data-oe-model], [data-oe-translation-initial-sha]';
const $odooFields = this.$editable.find(odooFieldSelector);
this.odooFieldObservers = [];
@@ -1032,8 +1032,8 @@ const Wysiwyg = Widget.extend({
.filter('[data-oe-field="' + $node.data('oe-field') + '"]');
}
- if ($node.data('oe-translation-id')) {
- $nodes = $nodes.filter('[data-oe-translation-id="' + $node.data('oe-translation-id') + '"]');
+ if ($node.data('oe-translation-initial-sha')) {
+ $nodes = $nodes.filter('[data-oe-translation-initial-sha="' + $node.data('oe-translation-initial-sha') + '"]');
}
if ($node.data('oe-type')) {
$nodes = $nodes.filter('[data-oe-type="' + $node.data('oe-type') + '"]');
@@ -1922,13 +1922,21 @@ const Wysiwyg = Widget.extend({
* @override
*/
_saveTranslationElement: function ($el, context, withLang = true) {
- if ($el.data('oe-translation-id')) {
+ if ($el.data('oe-translation-initial-sha')) {
+ const $els = $el;
+ const translations = {};
+ translations[context.lang] = Object.assign({}, ...$els.toArray().map(
+ (x) => ({
+ [$(x).data('oe-translation-initial-sha')]: this._getEscapedElement($(x)).html()
+ })
+ ));
return this._rpc({
- model: 'ir.translation',
- method: 'save_html',
+ model: $els.data('oe-model'),
+ method: 'update_field_translations_sha',
args: [
- [+$el.data('oe-translation-id')],
- this._getEscapedElement($el).html()
+ [+$els.data('oe-id')],
+ $els.data('oe-field'),
+ translations,
],
context: context,
});
@@ -2151,10 +2159,13 @@ const Wysiwyg = Widget.extend({
$('.o_editable')
.removeClass('o_editable o_is_inline_editable o_editable_date_field_linked o_editable_date_field_format_changed');
- const defs = _.map($allBlocks, (el) => {
- const $el = $(el);
+ const defs = _.map(_.groupBy($allBlocks.toArray(),
+ function($obj) {
+ return $obj.dataset['oe-model'] + $obj.dataset['oe-field'] + $obj.dataset['oe-id']
+ }), (els) => {
+ const $els = $(els);
- $el.find('[class]').filter(function () {
+ $els.find('[class]').filter(function () {
if (!this.getAttribute('class').match(/\S/)) {
this.removeAttribute('class');
}
@@ -2166,16 +2177,16 @@ const Wysiwyg = Widget.extend({
if (this.options.enableTranslation) {
saveElement = '_saveTranslationElement';
}
- return this[saveElement]($el, context || this.options.context)
+ return this[saveElement]($els, context || this.options.context)
.then(function () {
- $el.removeClass('o_dirty');
+ $els.removeClass('o_dirty');
}).guardedCatch(function (response) {
// because ckeditor regenerates all the dom, we can't just
// setup the popover here as everything will be destroyed by
// the DOM regeneration. Add markings instead, and returns a
// new rejection with all relevant info
var id = _.uniqueId('carlos_danger_');
- $el.addClass('o_dirty o_editable oe_carlos_danger ' + id);
+ $els.addClass('o_dirty o_editable oe_carlos_danger ' + id);
$('.o_editable.' + id)
.removeClass(id)
.popover({
diff --git a/addons/web_editor/static/tests/field_html_tests.js b/addons/web_editor/static/tests/field_html_tests.js
index b41db58e906..4afbc222408 100644
--- a/addons/web_editor/static/tests/field_html_tests.js
+++ b/addons/web_editor/static/tests/field_html_tests.js
@@ -130,19 +130,6 @@ QUnit.module('web_editor', {}, function () {
body_arch: "
yep
",
}],
},
- "ir.translation": {
- fields: {
- lang_code: {type: "char"},
- value: {type: "char"},
- res_id: {type: "integer"}
- },
- records: [{
- id: 99,
- res_id: 12,
- value: '',
- lang_code: 'en_US'
- }]
- },
});
testUtils.mock.patch(ajax, {
@@ -880,15 +867,18 @@ QUnit.module('web_editor', {}, function () {
'',
res_id: 1,
mockRPC: function (route, args) {
- if (route === '/web/dataset/call_button' && args.method === 'translate_fields') {
- assert.deepEqual(args.args, ['note.note', 1, 'body'], "should call 'call_button' route");
- return Promise.resolve({
- domain: [],
- context: {search_default_name: 'partnes,foo'},
- });
+ if (route === "/web/dataset/call_kw/note.note/get_field_translations") {
+ assert.deepEqual(args.args, [[1],"body"], "should translate the body field of the record");
+ return Promise.resolve([
+ [{lang: "en_US", source: "first paragraph", value: "first paragraph"},
+ {lang: "en_US", source: "second paragraph", value: "second paragraph"},
+ {lang: "fr_BE", source: "first paragraph", value: "premier paragraphe"},
+ {lang: "fr_BE", source: "second paragraph", value: "deuxième paragraphe"}],
+ {translation_type: "text", translation_show_source: true},
+ ]);
}
if (route === "/web/dataset/call_kw/res.lang/get_installed") {
- return Promise.resolve([["en_US"], ["fr_BE"]]);
+ return Promise.resolve([["en_US", "English"], ["fr_BE", "French (Belgium)"]]);
}
return this._super.apply(this, arguments);
},
diff --git a/addons/website/models/__init__.py b/addons/website/models/__init__.py
index adb5c079f54..40ee6ae2a3b 100644
--- a/addons/website/models/__init__.py
+++ b/addons/website/models/__init__.py
@@ -18,7 +18,6 @@ from . import website_menu
from . import website_page
from . import website_rewrite
from . import ir_rule
-from . import ir_translation
from . import ir_ui_view
from . import res_company
from . import res_partner
diff --git a/addons/website/models/ir_module_module.py b/addons/website/models/ir_module_module.py
index d68d32fb9c5..69988752211 100644
--- a/addons/website/models/ir_module_module.py
+++ b/addons/website/models/ir_module_module.py
@@ -3,7 +3,7 @@
import logging
import os
-from collections import OrderedDict
+from collections import defaultdict, OrderedDict
from odoo import api, fields, models
from odoo.addons.base.models.ir_model import MODULE_UNINSTALL_FLAG
@@ -188,14 +188,31 @@ class IrModuleModule(models.Model):
def _post_copy(self, old_rec, new_rec):
self.ensure_one()
translated_fields = self._theme_translated_fields.get(old_rec._name, [])
+ cur_lang = self.env.lang or 'en_US'
+ old_rec.flush_recordset()
for (src_field, dst_field) in translated_fields:
- self._cr.execute("""INSERT INTO ir_translation (lang, src, name, res_id, state, value, type, module)
- SELECT t.lang, t.src, %s, %s, t.state, t.value, t.type, t.module
- FROM ir_translation t
- WHERE name = %s
- AND res_id = %s
- ON CONFLICT DO NOTHING""",
- (dst_field, new_rec.id, src_field, old_rec.id))
+ __, src_fname = src_field.split(',')
+ dst_mname, dst_fname = dst_field.split(',')
+ if dst_mname != new_rec._name:
+ continue
+ old_field = old_rec._fields[src_fname]
+ old_translations = old_field._get_stored_translations(old_rec)
+ if not old_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)
+ else:
+ old_translation_lang = old_translations.get(cur_lang) or old_translations.get('en_US')
+ # {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})
+ # {lang: {old_term: new_term}
+ translations = defaultdict(dict)
+ 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
+ new_rec.with_context(install_filename='dummy').update_field_translations(dst_fname, translations)
def _theme_load(self, website):
"""
@@ -456,3 +473,70 @@ class IrModuleModule(models.Model):
cow_view = View.browse(view_replay[0])
View._load_records_write_on_cow(cow_view, view_replay[1], view_replay[2])
self.pool.website_views_to_adapt.clear()
+
+ @api.model
+ def _load_module_terms(self, modules, langs, overwrite=False):
+ """ Add missing website specific translation """
+ res = super()._load_module_terms(modules, langs, overwrite=overwrite)
+
+ if not langs or langs == ['en_US'] or not modules:
+ return res
+
+ # Add specific view translations
+
+ # use the translation dic of the generic to translate the specific
+ self.env.cr.flush()
+ cache = self.env.cache
+ View = self.env['ir.ui.view']
+ field = self.env['ir.ui.view']._fields['arch_db']
+ # assume there are not too many records
+ self.env.cr.execute(""" SELECT generic.arch_db, specific.arch_db, specific.id
+ FROM ir_ui_view generic
+ INNER JOIN ir_ui_view specific
+ ON generic.key = specific.key
+ WHERE generic.website_id IS NULL AND generic.type = 'qweb'
+ AND specific.website_id IS NOT NULL
+ """)
+ for generic_arch_db, specific_arch_db, specific_id in self.env.cr.fetchall():
+ if not generic_arch_db:
+ continue
+ langs_update = (langs & generic_arch_db.keys()) - {'en_US'}
+ generic_arch_db_en = generic_arch_db.pop('en_US')
+ specific_arch_db_en = specific_arch_db.pop('en_US')
+ generic_arch_db = {k: generic_arch_db[k] for k in langs_update}
+ specific_arch_db = {k: specific_arch_db.get(k, specific_arch_db_en) for k in langs_update}
+ generic_translation_dictionary = field.get_translation_dictionary(generic_arch_db_en, generic_arch_db)
+ specific_translation_dictionary = field.get_translation_dictionary(specific_arch_db_en, specific_arch_db)
+ # update specific_translation_dictionary
+ for term_en, specific_term_langs in specific_translation_dictionary.items():
+ if term_en not in generic_translation_dictionary:
+ continue
+ for lang, generic_term_lang in generic_translation_dictionary[term_en].items():
+ if overwrite or term_en == specific_term_langs[lang]:
+ specific_term_langs[lang] = generic_term_lang
+ for lang in langs_update:
+ specific_arch_db[lang] = field.translate(
+ lambda term: specific_translation_dictionary.get(term, {lang: None})[lang], specific_arch_db_en)
+ specific_arch_db['en_US'] = specific_arch_db_en
+ cache.update_raw(View.browse(specific_id), field, [specific_arch_db], dirty=True)
+
+ default_menu = self.env.ref('website.main_menu', raise_if_not_found=False)
+ if not default_menu:
+ return res
+
+ o_menu_name = [f"'{lang}', o_menu.name->>'{lang}'" for lang in langs if lang != 'en_US']
+ o_menu_name = 'jsonb_build_object(' + ', '.join(o_menu_name) + ')'
+ self.env.cr.execute(f"""
+ UPDATE website_menu menu
+ SET name = {'menu.name || ' + o_menu_name if overwrite else o_menu_name + ' || menu.name'}
+ FROM website_menu o_menu
+ INNER JOIN website_menu s_menu
+ ON o_menu.name->>'en_US' = s_menu.name->>'en_US' AND o_menu.url = s_menu.url
+ INNER JOIN website_menu root_menu
+ ON s_menu.parent_id = root_menu.id AND root_menu.parent_id IS NULL
+ WHERE o_menu.website_id IS NULL AND o_menu.parent_id = %s
+ AND s_menu.website_id IS NOT NULL
+ AND menu.id = s_menu.id
+ """, (default_menu.id,))
+
+ return res
diff --git a/addons/website/models/ir_translation.py b/addons/website/models/ir_translation.py
deleted file mode 100644
index a1703c55ab5..00000000000
--- a/addons/website/models/ir_translation.py
+++ /dev/null
@@ -1,64 +0,0 @@
-# -*- coding: utf-8 -*-
-# Part of Odoo. See LICENSE file for full copyright and licensing details.
-
-from odoo import models
-
-class IrTranslation(models.Model):
- _inherit = "ir.translation"
-
- def _load_module_terms(self, modules, langs, overwrite=False):
- """ Add missing website specific translation """
- res = super()._load_module_terms(modules, langs, overwrite=overwrite)
-
- if not langs or not modules:
- return res
-
- if overwrite:
- conflict_clause = """
- ON CONFLICT {}
- DO UPDATE SET (name, lang, res_id, src, type, value, module, state, comments) =
- (EXCLUDED.name, EXCLUDED.lang, EXCLUDED.res_id, EXCLUDED.src, EXCLUDED.type,
- EXCLUDED.value, EXCLUDED.module, EXCLUDED.state, EXCLUDED.comments)
- WHERE EXCLUDED.value IS NOT NULL AND EXCLUDED.value != ''
- """
- else:
- conflict_clause = " ON CONFLICT DO NOTHING"
-
- # Add specific view translations
- self.env.cr.execute("""
- INSERT INTO ir_translation(name, lang, res_id, src, type, value, module, state, comments)
- SELECT DISTINCT ON (specific.id, t.lang, md5(src)) t.name, t.lang, specific.id, t.src, t.type, t.value, t.module, t.state, t.comments
- FROM ir_translation t
- INNER JOIN ir_ui_view generic
- ON t.type = 'model_terms' AND t.name = 'ir.ui.view,arch_db' AND t.res_id = generic.id
- INNER JOIN ir_ui_view specific
- ON generic.key = specific.key
- WHERE t.lang IN %s and t.module IN %s
- AND generic.website_id IS NULL AND generic.type = 'qweb'
- AND specific.website_id IS NOT NULL""" + conflict_clause.format(
- "(type, name, lang, res_id, md5(src))"
- ), (tuple(langs), tuple(modules)))
-
- default_menu = self.env.ref('website.main_menu', raise_if_not_found=False)
- if not default_menu:
- return res
-
- # Add specific menu translations
- self.env.cr.execute("""
- INSERT INTO ir_translation(name, lang, res_id, src, type, value, module, state, comments)
- SELECT DISTINCT ON (s_menu.id, t.lang) t.name, t.lang, s_menu.id, t.src, t.type, t.value, t.module, t.state, t.comments
- FROM ir_translation t
- INNER JOIN website_menu o_menu
- ON t.type = 'model' AND t.name = 'website.menu,name' AND t.res_id = o_menu.id
- INNER JOIN website_menu s_menu
- ON o_menu.name = s_menu.name AND o_menu.url = s_menu.url
- INNER JOIN website_menu root_menu
- ON s_menu.parent_id = root_menu.id AND root_menu.parent_id IS NULL
- WHERE t.lang IN %s and t.module IN %s
- AND o_menu.website_id IS NULL AND o_menu.parent_id = %s
- AND s_menu.website_id IS NOT NULL
- """ + conflict_clause.format(
- "(type, lang, name, res_id) WHERE type = 'model'"
- ), (tuple(langs), tuple(modules), default_menu.id))
-
- return res
diff --git a/addons/website/models/website.py b/addons/website/models/website.py
index 3114f59fe12..61ef236875a 100644
--- a/addons/website/models/website.py
+++ b/addons/website/models/website.py
@@ -1379,12 +1379,13 @@ class Website(models.Model):
def _get_cached(self, field):
return self._get_cached_values()[field]
- def _get_html_fields(self):
- html_fields = [('ir_ui_view', 'arch_db')]
+ def _get_html_fields_attributes(self):
+ html_fields = [('ir_ui_view', 'arch_db', True)]
cr = self.env.cr
cr.execute(r"""
SELECT f.model,
- f.name
+ f.name,
+ f.translate
FROM ir_model_fields f
JOIN ir_model m
ON m.id = f.model_id
@@ -1394,10 +1395,10 @@ class Website(models.Model):
AND f.model NOT LIKE 'ir.actions%'
AND f.model != 'mail.message'
""")
- for model, name in cr.fetchall():
+ for model, name, translate in cr.fetchall():
table = self.env[model]._table
if tools.table_exists(cr, table) and tools.column_exists(cr, table, name):
- html_fields.append((table, name))
+ html_fields.append((table, name, translate))
return html_fields
def _get_snippets_assets(self):
@@ -1428,7 +1429,7 @@ class Website(models.Model):
""")
return self.env.cr.fetchall()
- def _is_snippet_used(self, snippet_module, snippet_id, asset_version, asset_type, html_fields):
+ def _is_snippet_used(self, snippet_module, snippet_id, asset_version, asset_type, html_fields_attributes):
snippet_occurences = []
# Check snippet template definition to avoid disabling its related assets.
# This special case is needed because snippet template definitions do not
@@ -1440,11 +1441,12 @@ class Website(models.Model):
# As well as every snippet dropped in html fields
self.env.cr.execute(sql.SQL(" UNION ").join(
- sql.SQL("SELECT regexp_matches({}, {}, 'g') FROM {}").format(
+ sql.SQL("SELECT regexp_matches({}{}, {}, 'g') FROM {}").format(
sql.Identifier(column),
+ sql.SQL("->>'en_US'" if translate else ''),
sql.Placeholder('snippet_regex'),
sql.Identifier(table)
- ) for table, column in html_fields
+ ) for table, column, translate in html_fields_attributes
), {'snippet_regex': f'<([^>]*data-snippet="{snippet_id}"[^>]*)>'})
results = self.env.cr.fetchall()
for r in results:
@@ -1461,10 +1463,10 @@ class Website(models.Model):
def _disable_unused_snippets_assets(self):
snippets_assets = self._get_snippets_assets()
- html_fields = self._get_html_fields()
+ html_fields_attributes = self._get_html_fields_attributes()
for snippet_module, snippet_id, asset_version, asset_type, _asset_id in snippets_assets:
- is_snippet_used = self._is_snippet_used(snippet_module, snippet_id, asset_version, asset_type, html_fields)
+ is_snippet_used = self._is_snippet_used(snippet_module, snippet_id, asset_version, asset_type, html_fields_attributes)
# The regex catches XXX.scss, XXX.js and XXX_variables.scss
assets_regex = f'{snippet_id}/{asset_version}.+{asset_type}'
@@ -1652,6 +1654,7 @@ class Website(models.Model):
"""
match_pattern = r'[\w-]{%s,}' % min(4, len(search) - 3)
similarity_threshold = 0.3
+ lang = self.env.lang or 'en_US'
for search_detail in search_details:
model_name, fields = search_detail['model'], search_detail['search_fields']
model = self.env[model_name]
@@ -1668,6 +1671,11 @@ class Website(models.Model):
field=unaccent(sql.SQL("{table}.{field}").format(
table=sql.Identifier((self.env['ir.ui.view'] if field == 'arch_db' or (field == 'name' and 'arch_db' in fields) else model)._table),
field=sql.Identifier(field)
+ )) if not (self.env['ir.ui.view'] if field == 'arch_db' or (field == 'name' and 'arch_db' in fields) else model)._fields[field].translate else
+ unaccent(sql.SQL("COALESCE({table}.{field}->>{lang}, {table}.{field}->>'en_US')").format(
+ table=sql.Identifier((self.env['ir.ui.view'] if field == 'arch_db' or (field == 'name' and 'arch_db' in fields) else model)._table),
+ field=sql.Identifier(field),
+ lang=sql.Literal(lang)
))
) for field in fields]
best_similarity = sql.SQL('GREATEST({similarities})').format(
@@ -1698,55 +1706,6 @@ class Website(models.Model):
)
self.env.cr.execute(query, {'search': search})
ids = {row[0] for row in self.env.cr.fetchall() if row[1] and row[1] >= similarity_threshold}
- if self.env.lang:
- # Specific handling for website.page that inherits its arch_db and name fields
- # TODO make more generic
- if 'arch_db' in fields:
- # Look for partial translations
- similarity = sql.SQL("word_similarity({search}, {field})").format(
- search=unaccent(sql.Placeholder('search')),
- field=unaccent(sql.SQL('t.value'))
- )
- names = ['%s,%s' % (self.env['ir.ui.view']._name, field) for field in fields]
- query = sql.SQL("""
- SELECT {table}.id, {similarity} AS _similarity
- FROM {table}
- LEFT JOIN ir_ui_view v ON {table}.view_id = v.id
- LEFT JOIN ir_translation t ON v.id = t.res_id
- WHERE t.lang = {lang}
- AND t.name = ANY({names})
- AND t.type = 'model_terms'
- AND t.value IS NOT NULL
- ORDER BY _similarity desc
- LIMIT 1000
- """).format(
- table=sql.Identifier(model._table),
- similarity=similarity,
- lang=sql.Placeholder('lang'),
- names=sql.Placeholder('names'),
- )
- else:
- similarity = sql.SQL("word_similarity({search}, {field})").format(
- search=unaccent(sql.Placeholder('search')),
- field=unaccent(sql.SQL('value'))
- )
- names = ['%s,%s' % (model._name, field) for field in fields]
- query = sql.SQL("""
- SELECT res_id, {similarity} AS _similarity
- FROM ir_translation
- WHERE lang = {lang}
- AND name = ANY({names})
- AND type = 'model'
- AND value IS NOT NULL
- ORDER BY _similarity desc
- LIMIT 1000
- """).format(
- similarity=similarity,
- lang=sql.Placeholder('lang'),
- names=sql.Placeholder('names'),
- )
- self.env.cr.execute(query, {'lang': self.env.lang, 'names': names, 'search': search})
- ids.update(row[0] for row in self.env.cr.fetchall() if row[1] and row[1] >= similarity_threshold)
domain.append([('id', 'in', list(ids))])
domain = AND(domain)
records = model.search_read(domain, fields, limit=limit)
diff --git a/addons/website/models/website_page.py b/addons/website/models/website_page.py
index 2a0945456d4..eeb9139dc9d 100644
--- a/addons/website/models/website_page.py
+++ b/addons/website/models/website_page.py
@@ -231,28 +231,18 @@ class Page(models.Model):
# Perform search in translations
# TODO Remove when domains will support xml_translate fields
query = sql.SQL("""
- SELECT {table}.{id}
+ SELECT {table}.id
FROM {table}
- LEFT JOIN ir_ui_view v ON {table}.{view_id} = v.{id}
- LEFT JOIN ir_translation t ON v.{id} = t.{res_id}
- WHERE t.lang = {lang}
- AND t.name = ANY({names})
- AND t.type = 'model_terms'
- AND t.value ilike {search}
+ LEFT JOIN ir_ui_view v ON {table}.view_id = v.id
+ WHERE v.name ILIKE {search}
+ OR jsonb_path_query_array(v.arch_db,'$.*')::text ILIKE {search}
LIMIT {limit}
""").format(
table=sql.Identifier(self._table),
- id=sql.Identifier('id'),
- view_id=sql.Identifier('view_id'),
- res_id=sql.Identifier('res_id'),
- lang=sql.Placeholder('lang'),
- names=sql.Placeholder('names'),
search=sql.Placeholder('search'),
limit=sql.Placeholder('limit'),
)
self.env.cr.execute(query, {
- 'lang': self.env.lang,
- 'names': ['ir.ui.view,arch_db', 'ir.ui.view,name'],
'search': '%%%s%%' % escape_psql(search),
'limit': limit,
})
diff --git a/addons/website/static/src/components/translator/translator.js b/addons/website/static/src/components/translator/translator.js
index 828a66f67d6..63d5b0002ca 100644
--- a/addons/website/static/src/components/translator/translator.js
+++ b/addons/website/static/src/components/translator/translator.js
@@ -53,13 +53,13 @@ export class TranslatorInfoDialog extends Component {
TranslatorInfoDialog.components = { WebsiteDialog };
TranslatorInfoDialog.template = 'website.TranslatorInfoDialog';
-const savableSelector = '[data-oe-translation-id], ' +
+const savableSelector = '[data-oe-translation-initial-sha], ' +
'[data-oe-model][data-oe-id][data-oe-field], ' +
- '[placeholder*="data-oe-translation-id="], ' +
- '[title*="data-oe-translation-id="], ' +
- '[value*="data-oe-translation-id="], ' +
- 'textarea:contains(data-oe-translation-id), ' +
- '[alt*="data-oe-translation-id="]';
+ '[placeholder*="data-oe-translation-initial-sha="], ' +
+ '[title*="data-oe-translation-initial-sha="], ' +
+ '[value*="data-oe-translation-initial-sha="], ' +
+ 'textarea:contains(data-oe-translation-initial-sha), ' +
+ '[alt*="data-oe-translation-initial-sha="]';
export class WebsiteTranslator extends WebsiteEditorComponent {
setup() {
@@ -110,11 +110,8 @@ export class WebsiteTranslator extends WebsiteEditorComponent {
}
getTranslationObject(nodeEl) {
- let { oeTranslationId: id } = nodeEl.dataset;
- if (!id) {
- const { oeModel, oeId, oeField } = nodeEl.dataset;
- id = [oeModel, oeId, oeField].join(',');
- }
+ const { oeModel, oeId, oeField, oeTranslationInitialMd5 } = nodeEl.dataset;
+ const id = [oeModel, oeId, oeField, oeTranslationInitialMd5].join(',');
let translation = this.translations.filter(t => t.id === id)[0];
if (!translation) {
translation = { id };
@@ -128,10 +125,10 @@ export class WebsiteTranslator extends WebsiteEditorComponent {
const self = this;
var attrs = ['placeholder', 'title', 'alt', 'value'];
const $editable = this.getEditableArea();
- const translationRegex = /]*data-oe-translation-id="([0-9]+)"[^>]*>(.*)<\/span>/;
+ const translationRegex = /]*data-oe-translation-initial-sha="([^"]+)"[^>]*>(.*)<\/span>/;
let $edited = $();
_.each(attrs, function (attr) {
- const attrEdit = $editable.filter('[' + attr + '*="data-oe-translation-id="]').filter(':empty, input, select, textarea, img');
+ const attrEdit = $editable.filter('[' + attr + '*="data-oe-translation-initial-sha="]').filter(':empty, input, select, textarea, img');
attrEdit.each(function () {
var $node = $(this);
var translation = $node.data('translation') || {};
@@ -147,7 +144,7 @@ export class WebsiteTranslator extends WebsiteEditorComponent {
});
$edited = $edited.add(attrEdit);
});
- const textEdit = $editable.filter('textarea:contains(data-oe-translation-id)');
+ const textEdit = $editable.filter('textarea:contains(data-oe-translation-initial-sha)');
textEdit.each(function () {
var $node = $(this);
var translation = $node.data('translation') || {};
diff --git a/addons/website/static/tests/tours/rte.js b/addons/website/static/tests/tours/rte.js
index c516e790078..a7f6fff87e2 100644
--- a/addons/website/static/tests/tours/rte.js
+++ b/addons/website/static/tests/tours/rte.js
@@ -89,7 +89,7 @@ wTourUtils.registerWebsitePreviewTour('rte_translator', {
trigger: '.modal-footer .btn-secondary',
}, {
content: "check if translation is activate",
- trigger: 'iframe [data-oe-translation-id]',
+ trigger: 'iframe [data-oe-translation-initial-sha]',
}, {
content: "translate text",
extra_trigger: '#oe_snippets.o_loaded',
diff --git a/addons/website/tests/test_configurator.py b/addons/website/tests/test_configurator.py
index 641c7406b0d..5cf817e609a 100644
--- a/addons/website/tests/test_configurator.py
+++ b/addons/website/tests/test_configurator.py
@@ -56,11 +56,10 @@ class TestConfiguratorCommon(odoo.tests.HttpCase):
class TestConfiguratorTranslation(TestConfiguratorCommon):
def test_01_configurator_translation(self):
- with mute_logger('odoo.addons.base.models.ir_translation'):
- self.env["base.language.install"].create({
- 'overwrite': True,
- 'lang_ids': [(6, 0, [self.env.ref('base.lang_fr').id])],
- }).lang_install()
+ self.env["base.language.install"].create({
+ 'overwrite': True,
+ 'lang_ids': [(6, 0, [self.env.ref('base.lang_fr').id])],
+ }).lang_install()
feature = self.env['website.configurator.feature'].search([('name', '=', 'Privacy Policy')])
feature.with_context(lang='fr_FR').write({'name': 'Politique de confidentialité'})
self.env.ref('base.user_admin').write({'lang': self.env.ref('base.lang_fr').code})
diff --git a/addons/website/tests/test_controllers.py b/addons/website/tests/test_controllers.py
index 2b9acdf929a..ae7a843c291 100644
--- a/addons/website/tests/test_controllers.py
+++ b/addons/website/tests/test_controllers.py
@@ -20,6 +20,7 @@ class TestControllers(tests.HttpCase):
base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
suggested_links_url = base_url + '/website/get_suggested_links'
+ old_pages = Page
for i in range(0, 10):
new_page = Page.create({
'name': 'Generic',
@@ -32,11 +33,14 @@ class TestControllers(tests.HttpCase):
'is_published': True,
})
if i % 2 == 0:
- # mark as old
- new_page._write({'write_date': '2020-01-01'})
+ old_pages += new_page
else:
last_5_url_edited.append(new_page.url)
+ self.opener.post(url=suggested_links_url, json={'params': {'needle': '/'}})
+ # mark as old
+ old_pages._write({'write_date': '2020-01-01'})
+
res = self.opener.post(url=suggested_links_url, json={'params': {'needle': '/'}})
resp = json.loads(res.content)
assert 'result' in resp
diff --git a/addons/website/tests/test_disable_unused_snippets_assets.py b/addons/website/tests/test_disable_unused_snippets_assets.py
index 4be813cb03d..87a9d0f2f1f 100644
--- a/addons/website/tests/test_disable_unused_snippets_assets.py
+++ b/addons/website/tests/test_disable_unused_snippets_assets.py
@@ -63,9 +63,11 @@ class TestDisableSnippetsAssets(TransactionCase):
self.homepage.write({
'arch_db': HOMEPAGE_UP_TO_DATE,
})
+ self.homepage.flush_recordset()
self.mega_menu.write({
'mega_menu_content': MEGA_MENU_OUTDATED,
})
+ self.mega_menu.flush_recordset()
self.Website._disable_unused_snippets_assets()
s_website_form_000_scss = self._get_snippet_asset('s_website_form', '000', 'scss')
diff --git a/addons/website/tests/test_menu.py b/addons/website/tests/test_menu.py
index eb0e721292b..b391304be45 100644
--- a/addons/website/tests/test_menu.py
+++ b/addons/website/tests/test_menu.py
@@ -69,7 +69,7 @@ class TestMenu(common.TransactionCase):
self.assertEqual(total_menus + 4, Menu.search_count([]), "New website's bootstraping should have duplicate default menu tree (Top/Home/Contactus/Sub Default Menu)")
def test_04_specific_menu_translation(self):
- Translation = self.env['ir.translation']
+ IrModuleModule = self.env['ir.module.module']
Menu = self.env['website.menu']
existing_menus = Menu.search([])
@@ -85,9 +85,6 @@ class TestMenu(common.TransactionCase):
# create fr_FR translation for template menu
self.env.ref('base.lang_fr').active = True
template_menu.with_context(lang='fr_FR').name = 'Menu en français'
- Translation.search([
- ('name', '=', 'website.menu,name'), ('res_id', '=', template_menu.id),
- ]).module = 'website'
self.assertEqual(specific1.name, 'Menu in english',
'Translating template menu does not translate specific menu')
@@ -95,7 +92,7 @@ class TestMenu(common.TransactionCase):
specific1.name = 'Menu in french'
# loading translation add missing specific translation
- Translation._load_module_terms(['website'], ['fr_FR'])
+ IrModuleModule._load_module_terms(['website'], ['fr_FR'])
Menu.invalidate_model(['name'])
self.assertEqual(specific1.name, 'Menu in french',
'Load translation without overwriting keep existing translation')
@@ -103,7 +100,7 @@ class TestMenu(common.TransactionCase):
'Load translation add missing translation from template menu')
# loading translation with overwrite sync all translations from menu template
- Translation._load_module_terms(['website'], ['fr_FR'], overwrite=True)
+ IrModuleModule._load_module_terms(['website'], ['fr_FR'], overwrite=True)
Menu.invalidate_model(['name'])
self.assertEqual(specific1.name, 'Menu en français',
'Load translation with overwriting update existing menu from template')
diff --git a/addons/website/tests/test_ui.py b/addons/website/tests/test_ui.py
index 6b999761459..3e2d7c918e1 100644
--- a/addons/website/tests/test_ui.py
+++ b/addons/website/tests/test_ui.py
@@ -91,6 +91,7 @@ class TestUiHtmlEditor(odoo.tests.HttpCase):
'''
generic_page.arch = oe_structure_layout
+ oe_structure_layout = generic_page.arch
self.start_tour(self.env['website'].get_client_action_url('/generic'), 'html_editor_multiple_templates', login='admin')
self.assertEqual(View.search_count([('key', '=', 'test.generic_view')]), 2, "homepage view should have been COW'd")
self.assertTrue(generic_page.arch == oe_structure_layout, "Generic homepage view should be untouched")
diff --git a/addons/website/tests/test_views.py b/addons/website/tests/test_views.py
index 1dcdeb807f2..a039f42b219 100644
--- a/addons/website/tests/test_views.py
+++ b/addons/website/tests/test_views.py
@@ -995,29 +995,25 @@ class TestCowViewSaving(TestViewSavingCommon):
View.pool._init = original_pool_init
def test_specific_view_translation(self):
- Translation = self.env['ir.translation']
-
- Translation.insert_missing(self.base_view._fields['arch_db'], self.base_view)
- translation = Translation.search([
- ('res_id', '=', self.base_view.id), ('name', '=', 'ir.ui.view,arch_db')
- ])
- translation.value = 'hello'
- translation.module = 'website'
-
+ self.env['res.lang']._activate_lang('fr_BE')
+ self.base_view.with_context(lang='en_US').arch_db = '
',
"loading module translation copy translation from base to specific view")
def test_soc_complete_flow(self):
diff --git a/addons/website_blog/tests/test_performance.py b/addons/website_blog/tests/test_performance.py
index 164656e44c2..b05c8b8a4f1 100644
--- a/addons/website_blog/tests/test_performance.py
+++ b/addons/website_blog/tests/test_performance.py
@@ -14,7 +14,7 @@ class TestBlogPerformance(UtilPerf):
def test_10_perf_sql_blog_standard_data(self):
self.assertEqual(self._get_url_hot_query('/blog'), 11)
- self.assertEqual(self._get_url_hot_query('/blog', cache=False), 27)
+ self.assertEqual(self._get_url_hot_query('/blog', cache=False), 24)
def test_20_perf_sql_blog_bigger_data_scaling(self):
BlogPost = self.env['blog.post']
@@ -27,9 +27,9 @@ class TestBlogPerformance(UtilPerf):
blog_post.tag_ids += blog_tags
blog_tags = blog_tags[:-1]
self.assertEqual(self._get_url_hot_query('/blog'), 11)
- self.assertEqual(self._get_url_hot_query('/blog', cache=False), 34)
- self.assertEqual(self._get_url_hot_query(blog_post[0].website_url), 19)
- self.assertEqual(self._get_url_hot_query(blog_post[0].website_url, cache=False), 22)
+ self.assertEqual(self._get_url_hot_query('/blog', cache=False), 31)
+ self.assertEqual(self._get_url_hot_query(blog_post[0].website_url), 16)
+ self.assertEqual(self._get_url_hot_query(blog_post[0].website_url, cache=False), 18)
def test_30_perf_sql_blog_bigger_data_scaling(self):
BlogPost = self.env['blog.post']
diff --git a/odoo/addons/base/__manifest__.py b/odoo/addons/base/__manifest__.py
index 4357a31fd24..24df0e7f803 100644
--- a/odoo/addons/base/__manifest__.py
+++ b/odoo/addons/base/__manifest__.py
@@ -39,7 +39,6 @@ The kernel of Odoo, needed for all installation.
'views/ir_attachment_views.xml',
'views/ir_rule_views.xml',
'views/ir_sequence_views.xml',
- 'views/ir_translation_views.xml',
'views/ir_ui_menu_views.xml',
'views/ir_ui_view_views.xml',
'views/ir_default_views.xml',
@@ -59,7 +58,6 @@ The kernel of Odoo, needed for all installation.
'wizard/base_module_upgrade_views.xml',
'wizard/base_module_uninstall_views.xml',
'wizard/base_export_language_views.xml',
- 'wizard/base_update_translations_views.xml',
'wizard/base_partner_merge_views.xml',
'data/ir_actions_data.xml',
'data/ir_demo_failure_data.xml',
diff --git a/odoo/addons/base/data/base_data.sql b/odoo/addons/base/data/base_data.sql
index 6cd12e6cd25..419e3891093 100644
--- a/odoo/addons/base/data/base_data.sql
+++ b/odoo/addons/base/data/base_data.sql
@@ -27,7 +27,7 @@ CREATE TABLE res_users (
CREATE TABLE res_groups (
id serial NOT NULL,
- name varchar NOT NULL,
+ name jsonb NOT NULL,
primary key(id)
);
@@ -38,7 +38,7 @@ CREATE TABLE ir_module_category (
write_date timestamp without time zone,
write_uid integer, -- references res_users on delete set null,
parent_id integer REFERENCES ir_module_category ON DELETE SET NULL,
- name character varying NOT NULL,
+ name jsonb NOT NULL,
primary key(id)
);
@@ -49,15 +49,15 @@ CREATE TABLE ir_module_module (
write_date timestamp without time zone,
write_uid integer, -- references res_users on delete set null,
website character varying,
- summary character varying,
+ summary jsonb,
name character varying NOT NULL,
author character varying,
icon varchar,
state character varying(16),
latest_version character varying,
- shortdesc character varying,
+ shortdesc jsonb,
category_id integer REFERENCES ir_module_category ON DELETE SET NULL,
- description text,
+ description jsonb,
application boolean default False,
demo boolean default False,
web boolean DEFAULT FALSE,
@@ -135,6 +135,6 @@ insert into res_users (id, login, password, active, partner_id, company_id, crea
insert into ir_model_data (name, module, model, noupdate, res_id) VALUES ('user_root', 'base', 'res.users', true, 1);
select setval('res_users_id_seq', 1);
-insert into res_groups (id, name) VALUES (1, 'Employee');
+insert into res_groups (id, name) VALUES (1, '{"en_US": "Employee"}');
insert into ir_model_data (name, module, model, noupdate, res_id) VALUES ('group_user', 'base', 'res.groups', true, 1);
select setval('res_groups_id_seq', 1);
diff --git a/odoo/addons/base/i18n/base.pot b/odoo/addons/base/i18n/base.pot
index 2b80fe9cb81..5b6c68804bf 100644
--- a/odoo/addons/base/i18n/base.pot
+++ b/odoo/addons/base/i18n/base.pot
@@ -29684,3 +29684,27 @@ msgstr ""
#: model:res.country,name:base.ax
msgid "Åland Islands"
msgstr ""
+
+#. module: base
+#: code:addons/base/models/ir_fields.py:0
+#, python-format
+msgid "yes"
+msgstr ""
+
+#. module: base
+#: code:addons/base/models/ir_fields.py:0
+#, python-format
+msgid "no"
+msgstr ""
+
+#. module: base
+#: code:addons/base/models/ir_fields.py:0
+#, python-format
+msgid "true"
+msgstr ""
+
+#. module: base
+#: code:addons/base/models/ir_fields.py:0
+#, python-format
+msgid "false"
+msgstr ""
diff --git a/odoo/addons/base/models/__init__.py b/odoo/addons/base/models/__init__.py
index 5ad7f201450..86fe94b5ba0 100644
--- a/odoo/addons/base/models/__init__.py
+++ b/odoo/addons/base/models/__init__.py
@@ -14,7 +14,6 @@ from . import ir_binary
from . import ir_cron
from . import ir_filters
from . import ir_default
-from . import ir_translation
from . import ir_exports
from . import ir_rule
from . import ir_config_parameter
diff --git a/odoo/addons/base/models/ir_actions.py b/odoo/addons/base/models/ir_actions.py
index 9a90d60f737..817daa0071c 100644
--- a/odoo/addons/base/models/ir_actions.py
+++ b/odoo/addons/base/models/ir_actions.py
@@ -25,7 +25,7 @@ class IrActions(models.Model):
_table = 'ir_actions'
_order = 'name'
- name = fields.Char(required=True)
+ name = fields.Char(string='Action Name', required=True, translate=True)
type = fields.Char(string='Action Type', required=True)
xml_id = fields.Char(compute='_compute_xml_id', string="External ID")
help = fields.Html(string='Action Description',
@@ -240,7 +240,6 @@ class IrActionsActWindow(models.Model):
fvg = self.env[act.res_model].get_view(act.search_view_id.id, 'search')
act.search_view = str(fvg)
- name = fields.Char(string='Action Name', translate=True)
type = fields.Char(default="ir.actions.act_window")
view_id = fields.Many2one('ir.ui.view', string='View Ref.', ondelete='set null')
domain = fields.Char(string='Domain Value',
@@ -372,7 +371,6 @@ class IrActionsActUrl(models.Model):
_inherit = 'ir.actions.actions'
_order = 'name'
- name = fields.Char(string='Action Name', translate=True)
type = fields.Char(default='ir.actions.act_url')
url = fields.Text(string='Action URL', required=True)
target = fields.Selection([('new', 'New Window'), ('self', 'This Window')],
@@ -421,7 +419,6 @@ class IrActionsServer(models.Model):
# - Command: x2Many commands namespace
# To return an action, assign: action = {...}\n\n\n\n"""
- name = fields.Char(string='Action Name', translate=True)
type = fields.Char(default='ir.actions.server')
usage = fields.Selection([
('ir_actions_server', 'Server Action'),
@@ -842,7 +839,6 @@ class IrActionsActClient(models.Model):
_table = 'ir_act_client'
_order = 'name'
- name = fields.Char(string='Action Name', translate=True)
type = fields.Char(default='ir.actions.client')
tag = fields.Char(string='Client action tag', required=True,
diff --git a/odoo/addons/base/models/ir_actions_report.py b/odoo/addons/base/models/ir_actions_report.py
index 141c274dbda..e805dce13dd 100644
--- a/odoo/addons/base/models/ir_actions_report.py
+++ b/odoo/addons/base/models/ir_actions_report.py
@@ -91,7 +91,6 @@ class IrActionsReport(models.Model):
_table = 'ir_act_report_xml'
_order = 'name'
- name = fields.Char(translate=True)
type = fields.Char(default='ir.actions.report')
binding_type = fields.Selection(default='report')
model = fields.Char(required=True, string='Model Name')
diff --git a/odoo/addons/base/models/ir_cron.py b/odoo/addons/base/models/ir_cron.py
index 960327bcad5..5d514a3f76d 100644
--- a/odoo/addons/base/models/ir_cron.py
+++ b/odoo/addons/base/models/ir_cron.py
@@ -179,7 +179,7 @@ class ir_cron(models.Model):
def _get_all_ready_jobs(cls, cr):
""" Return a list of all jobs that are ready to be executed """
cr.execute("""
- SELECT *
+ SELECT *, cron_name->>'en_US' as cron_name
FROM ir_cron
WHERE active = true
AND numbercall != 0
@@ -241,7 +241,7 @@ class ir_cron(models.Model):
# Learn more: https://www.postgresql.org/docs/current/explicit-locking.html#LOCKING-ROWS
query = """
- SELECT *
+ SELECT *, cron_name->>'en_US' as cron_name
FROM ir_cron
WHERE active = true
AND numbercall != 0
diff --git a/odoo/addons/base/models/ir_fields.py b/odoo/addons/base/models/ir_fields.py
index 362ec117252..619d66ab4e1 100644
--- a/odoo/addons/base/models/ir_fields.py
+++ b/odoo/addons/base/models/ir_fields.py
@@ -8,7 +8,8 @@ import psycopg2
import pytz
from odoo import api, Command, fields, models, _
-from odoo.tools import ustr
+from odoo.tools import ustr, OrderedSet
+from odoo.tools.translate import code_translations, _lt
REFERENCING_FIELDS = {None, 'id', '.id'}
def only_ref_fields(record):
@@ -16,6 +17,13 @@ def only_ref_fields(record):
def exclude_ref_fields(record):
return {k: v for k, v in record.items() if k not in REFERENCING_FIELDS}
+# these lazy translations promise translations for ['yes', 'no', 'true', 'false']
+BOOLEAN_TRANSLATIONS = (
+ _lt('yes'),
+ _lt('no'),
+ _lt('true'),
+ _lt('false')
+)
class ImportWarning(Warning):
""" Used to send warnings upwards the stack during the import process """
@@ -190,8 +198,8 @@ class IrFieldsConverter(models.AbstractModel):
# potentially broken casefolding? What about locales?
trues = set(word.lower() for word in itertools.chain(
[u'1', u"true", u"yes"], # don't use potentially translated values
- self._get_translations(['code'], u"true"),
- self._get_translations(['code'], u"yes"),
+ self._get_boolean_translations(u"true"),
+ self._get_boolean_translations(u"yes"),
))
if value.lower() in trues:
return True, []
@@ -199,8 +207,8 @@ class IrFieldsConverter(models.AbstractModel):
# potentially broken casefolding? What about locales?
falses = set(word.lower() for word in itertools.chain(
[u'', u"0", u"false", u"no"],
- self._get_translations(['code'], u"false"),
- self._get_translations(['code'], u"no"),
+ self._get_boolean_translations(u"false"),
+ self._get_boolean_translations(u"no"),
))
if value.lower() in falses:
return False, []
@@ -296,17 +304,46 @@ class IrFieldsConverter(models.AbstractModel):
return fields.Datetime.to_string(dt.astimezone(pytz.UTC)), []
@api.model
- def _get_translations(self, types, src):
- types = tuple(types)
+ def _get_boolean_translations(self, src):
# Cache translations so they don't have to be reloaded from scratch on
# every row of the file
tnx_cache = self._cr.cache.setdefault(self._name, {})
- if tnx_cache.setdefault(types, {}) and src in tnx_cache[types]:
- return tnx_cache[types][src]
+ if src in tnx_cache:
+ return tnx_cache[src]
- Translations = self.env['ir.translation']
- tnx = Translations.search([('type', 'in', types), ('src', '=', src)])
- result = tnx_cache[types][src] = [t.value for t in tnx if t.value is not False]
+ values = OrderedSet()
+ for lang, __ in self.env['res.lang'].get_installed():
+ translations = code_translations.get_python_translations('base', lang)
+ if src in translations:
+ values.add(translations[src])
+
+ result = tnx_cache[src] = list(values)
+ return result
+
+ @api.model
+ def _get_selection_translations(self, field, src):
+ if not src:
+ return []
+ # Cache translations so they don't have to be reloaded from scratch on
+ # every row of the file
+ tnx_cache = self._cr.cache.setdefault(self._name, {})
+ if src in tnx_cache:
+ return tnx_cache[src]
+
+ values = OrderedSet()
+ self.env['ir.model.fields.selection'].flush_model()
+ query = """
+ SELECT s.name
+ FROM ir_model_fields_selection s
+ JOIN ir_model_fields f ON s.field_id = f.id
+ WHERE f.model = %s AND f.name = %s AND s.name->>'en_US' = %s
+ """
+ self.env.cr.execute(query, [field.model_name, field.name, src])
+ for (name,) in self.env.cr.fetchall():
+ name.pop('en_US')
+ values.update(name.values())
+
+ result = tnx_cache[src] = list(values)
return result
@api.model
@@ -317,7 +354,14 @@ class IrFieldsConverter(models.AbstractModel):
for item, label in selection:
label = ustr(label)
- labels = [label] + self._get_translations(('selection', 'model', 'code'), label)
+ if callable(field.selection):
+ labels = [label]
+ for item2, label2 in field._description_selection(self.env):
+ if item2 == item:
+ labels.append(label2)
+ break
+ else:
+ labels = [label] + self._get_selection_translations(field, label)
# case insensitive comparaison of string to allow to set the value even if the given 'value' param is not
# exactly (case sensitive) the same as one of the selection item.
if value.lower() == str(item).lower() or any(value.lower() == label.lower() for label in labels):
diff --git a/odoo/addons/base/models/ir_filters.py b/odoo/addons/base/models/ir_filters.py
index 3a3938b3caa..0acab9baf64 100644
--- a/odoo/addons/base/models/ir_filters.py
+++ b/odoo/addons/base/models/ir_filters.py
@@ -11,7 +11,7 @@ class IrFilters(models.Model):
_description = 'Filters'
_order = 'model_id, name, id desc'
- name = fields.Char(string='Filter Name', translate=True, required=True)
+ name = fields.Char(string='Filter Name', required=True)
user_id = fields.Many2one('res.users', string='User', ondelete='cascade',
help="The user this filter is private to. When left empty the filter is public "
"and available to all users.")
@@ -28,7 +28,11 @@ class IrFilters(models.Model):
@api.model
def _list_all_models(self):
- self._cr.execute("SELECT model, name FROM ir_model ORDER BY name")
+ lang = self.env.lang or 'en_US'
+ self._cr.execute(
+ "SELECT model, COALESCE(name->>%s, name->>'en_US') FROM ir_model ORDER BY 2",
+ [lang],
+ )
return self._cr.fetchall()
def copy(self, default=None):
diff --git a/odoo/addons/base/models/ir_http.py b/odoo/addons/base/models/ir_http.py
index da8c869823b..d1ce7eb26d7 100644
--- a/odoo/addons/base/models/ir_http.py
+++ b/odoo/addons/base/models/ir_http.py
@@ -4,6 +4,7 @@
#----------------------------------------------------------
import base64
import hashlib
+import json
import logging
import mimetypes
import os
@@ -22,6 +23,7 @@ from odoo.exceptions import AccessDenied, AccessError, MissingError
from odoo.http import request, Response, ROUTING_KEYS, Stream
from odoo.service import security
from odoo.tools import consteq, submap
+from odoo.tools.translate import code_translations
from odoo.modules.module import get_resource_path, get_module_path
_logger = logging.getLogger(__name__)
@@ -198,3 +200,45 @@ class IrHttp(models.AbstractModel):
@api.autovacuum
def _gc_sessions(self):
http.root.session_store.vacuum()
+
+ @api.model
+ def get_translations_for_webclient(self, modules, lang):
+ if not modules:
+ modules = self.pool._init_modules
+ if not lang:
+ lang = self._context.get("lang")
+ langs = self.env['res.lang']._lang_get(lang)
+ lang_params = None
+ if langs:
+ lang_params = {
+ "name": langs.name,
+ "direction": langs.direction,
+ "date_format": langs.date_format,
+ "time_format": langs.time_format,
+ "grouping": langs.grouping,
+ "decimal_point": langs.decimal_point,
+ "thousands_sep": langs.thousands_sep,
+ "week_start": langs.week_start,
+ }
+ lang_params['week_start'] = int(lang_params['week_start'])
+ lang_params['code'] = lang
+
+ # Regional languages (ll_CC) must inherit/override their parent lang (ll), but this is
+ # done server-side when the language is loaded, so we only need to load the user's lang.
+ translations_per_module = {}
+ for module in modules:
+ translations_per_module[module] = code_translations.get_web_translations(module, lang)
+
+ return translations_per_module, lang_params
+
+ @api.model
+ @tools.ormcache('frozenset(modules)', 'lang')
+ def get_web_translations_hash(self, modules, lang):
+ translations, lang_params = self.get_translations_for_webclient(modules, lang)
+ translation_cache = {
+ 'lang_parameters': lang_params,
+ 'modules': translations,
+ 'lang': lang,
+ 'multi_lang': len(self.env['res.lang'].sudo().get_installed()) > 1,
+ }
+ return hashlib.sha1(json.dumps(translation_cache, sort_keys=True).encode()).hexdigest()
diff --git a/odoo/addons/base/models/ir_model.py b/odoo/addons/base/models/ir_model.py
index 213c08223b5..8e84e64a605 100644
--- a/odoo/addons/base/models/ir_model.py
+++ b/odoo/addons/base/models/ir_model.py
@@ -11,6 +11,7 @@ from collections.abc import Mapping
from operator import itemgetter
from psycopg2 import sql
+from psycopg2.extras import Json
from odoo import api, fields, models, tools, _, _lt, Command
from odoo.exceptions import AccessError, UserError, ValidationError
@@ -101,29 +102,55 @@ def query_update(cr, table, values, selectors):
return [row[0] for row in cr.fetchall()]
-def upsert(cr, table, cols, rows, conflict):
+def select_en(model, fnames, where, params):
+ """ Select the given columns from the given model's table, with the given WHERE clause.
+ Translated fields are returned in 'en_US'.
+ """
+ table = quote(model._table)
+ cols = ", ".join(
+ f"{quote(fname)}->>'en_US'" if model._fields[fname].translate else quote(fname)
+ for fname in fnames
+ )
+ query = f"SELECT {cols} FROM {table} WHERE {where}"
+ model.env.cr.execute(query, params)
+ return model.env.cr.fetchall()
+
+
+def upsert_en(model, fnames, rows, conflict):
""" Insert or update the table with the given rows.
- :param cr: database cursor
- :param table: table name
- :param cols: list of column names
+ :param model: recordset of the model to query
+ :param fnames: list of column names
:param rows: list of tuples, where each tuple value corresponds to a column name
:param conflict: list of column names to put into the ON CONFLICT clause
:return: the ids of the inserted or updated rows
"""
- query = """
- INSERT INTO {table} ({cols}) VALUES {rows}
- ON CONFLICT ({conflict}) DO UPDATE SET ({cols}) = ({excluded})
+ table = quote(model._table)
+ cols = ", ".join(quote(fname) for fname in fnames)
+ values = ", ".join("%s" for row in rows)
+ conf = ", ".join(conflict)
+ excluded = ", ".join(f"EXCLUDED.{quote(fname)}" for fname in fnames)
+ query = f"""
+ INSERT INTO {table} ({cols}) VALUES {values}
+ ON CONFLICT ({conf}) DO UPDATE SET ({cols}) = ({excluded})
RETURNING id
- """.format(
- table=quote(table),
- cols=", ".join(quote(col) for col in cols),
- rows=", ".join("%s" for row in rows),
- conflict=", ".join(conflict),
- excluded=", ".join("EXCLUDED." + quote(col) for col in cols),
- )
- cr.execute(query, rows)
- return [row[0] for row in cr.fetchall()]
+ """
+
+ # for translated fields, we can actually erase the json value, as
+ # translations will be reloaded after this
+ def identity(val):
+ return val
+
+ def jsonify(val):
+ return Json({'en_US': val}) if val is not None else val
+
+ wrappers = [(jsonify if model._fields[fname].translate else identity) for fname in fnames]
+ params = [
+ tuple(func(val) for func, val in zip(wrappers, row))
+ for row in rows
+ ]
+ model.env.cr.execute(query, params)
+ return [row[0] for row in model.env.cr.fetchall()]
#
@@ -260,11 +287,6 @@ class IrModel(models.Model):
self._cr.execute(sql.SQL('DROP VIEW {}').format(sql.Identifier(table)))
elif kind == 'r':
self._cr.execute(sql.SQL('DROP TABLE {} CASCADE').format(sql.Identifier(table)))
- # discard all translations for this model
- self._cr.execute("""
- DELETE FROM ir_translation
- WHERE type IN ('model', 'model_terms') AND name LIKE %s
- """, [model.model + ',%'])
else:
_logger.runbot('The model %s could not be dropped because it did not exist in the registry.', model.model)
return True
@@ -357,21 +379,16 @@ class IrModel(models.Model):
cols = list(unique(['model'] + list(rows[0])))
expected = [tuple(row[col] for col in cols) for row in rows]
- cr = self.env.cr
- query = "SELECT {}, id FROM ir_model WHERE model IN %s".format(
- ", ".join(quote(col) for col in cols)
- )
- cr.execute(query, [tuple(model_names)])
model_ids = {}
existing = {}
- for row in cr.fetchall():
- model_ids[row[0]] = row[-1]
- existing[row[0]] = row[:-1]
+ for row in select_en(self, ['id'] + cols, "model IN %s", [tuple(model_names)]):
+ model_ids[row[1]] = row[0]
+ existing[row[1]] = row[1:]
# create or update rows
rows = [row for row in expected if existing.get(row[0]) != row]
if rows:
- ids = upsert(self.env.cr, self._table, cols, rows, ['model'])
+ ids = upsert_en(self, cols, rows, ['model'])
for row, id_ in zip(rows, ids):
model_ids[row[0]] = id_
self.pool.post_init(mark_modified, self.browse(ids), cols[1:])
@@ -417,7 +434,8 @@ class IrModel(models.Model):
Parent._inherit_children.discard(name)
# add manual models
cr = self.env.cr
- cr.execute('SELECT * FROM ir_model WHERE state=%s', ['manual'])
+ # we cannot use self._fields to determine translated fields, as it has not been set up yet
+ cr.execute("SELECT *, name->>'en_US' AS name FROM ir_model WHERE state = 'manual'")
for model_data in cr.dictfetchall():
model_class = self._instanciate(model_data)
Model = model_class._build_model(self.pool, cr)
@@ -725,12 +743,6 @@ class IrModelFields(models.Model):
tables_to_drop.add(rel_name)
if field.state == 'manual' and is_model:
model._pop_field(field.name)
- if field.translate:
- # discard all translations for this field
- self._cr.execute("""
- DELETE FROM ir_translation
- WHERE type IN ('model', 'model_terms') AND name=%s
- """, ['%s,%s' % (field.model, field.name)])
if tables_to_drop:
# drop the relation tables that are not used by other fields
@@ -911,9 +923,12 @@ class IrModelFields(models.Model):
# names of the models to patch
patched_models = set()
+ # write callable(self._fields[fname].translate) means changing content
+ translate_only = self.env.lang not in (None, 'en_US') and all(self._fields[fname].translate is True for fname in vals)
+
if vals and self:
for item in self:
- if item.state != 'manual':
+ if item.state != 'manual' and not translate_only:
raise UserError(_('Properties of base fields cannot be altered in this manner! '
'Please modify them through Python code, '
'preferably through a custom addon!'))
@@ -1041,20 +1056,16 @@ class IrModelFields(models.Model):
cols = list(unique(['model', 'name'] + list(rows[0])))
expected = [tuple(row[col] for col in cols) for row in rows]
- query = "SELECT {}, id FROM ir_model_fields WHERE model IN %s".format(
- ", ".join(quote(col) for col in cols),
- )
- cr.execute(query, [tuple(model_names)])
field_ids = {}
existing = {}
- for row in cr.fetchall():
- field_ids[row[:2]] = row[-1]
- existing[row[:2]] = row[:-1]
+ for row in select_en(self, ['id'] + cols, "model IN %s", [tuple(model_names)]):
+ field_ids[row[1:3]] = row[0]
+ existing[row[1:3]] = row[1:]
# create or update rows
rows = [row for row in expected if existing.get(row[:2]) != row]
if rows:
- ids = upsert(cr, self._table, cols, rows, ['model', 'name'])
+ ids = upsert_en(self, cols, rows, ['model', 'name'])
for row, id_ in zip(rows, ids):
field_ids[row[:2]] = id_
self.pool.post_init(mark_modified, self.browse(ids), cols[2:])
@@ -1086,7 +1097,12 @@ class IrModelFields(models.Model):
@tools.ormcache()
def _all_manual_field_data(self):
cr = self._cr
- cr.execute("SELECT * FROM ir_model_fields WHERE state='manual'")
+ # we cannot use self._fields to determine translated fields, as it has not been set up yet
+ cr.execute("""
+ SELECT *, field_description->>'en_US' AS field_description, help->>'en_US' AS help
+ FROM ir_model_fields
+ WHERE state = 'manual'
+ """)
result = defaultdict(dict)
for row in cr.dictfetchall():
result[row['model']][row['name']] = row
@@ -1168,6 +1184,42 @@ class IrModelFields(models.Model):
except Exception:
_logger.exception("Failed to load field %s.%s: skipped", model._name, field_data['name'])
+ @api.model
+ @tools.ormcache_context('model_name', keys=('lang',))
+ def get_field_string(self, model_name):
+ """ Return the translation of fields strings in the context's language.
+ Note that the result contains the available translations only.
+
+ :param model_name: the name of a model
+ :return: the model's fields' strings as a dictionary `{field_name: field_string}`
+ """
+ fields = self.sudo().search([('model', '=', model_name)])
+ return {field.name: field.field_description for field in fields}
+
+ @api.model
+ @tools.ormcache_context('model_name', keys=('lang',))
+ def get_field_help(self, model_name):
+ """ Return the translation of fields help in the context's language.
+ Note that the result contains the available translations only.
+
+ :param model_name: the name of a model
+ :return: the model's fields' help as a dictionary `{field_name: field_help}`
+ """
+ fields = self.sudo().search([('model', '=', model_name)])
+ return {field.name: field.help for field in fields}
+
+ @api.model
+ @tools.ormcache_context('model_name', 'field_name', keys=('lang',))
+ def get_field_selection(self, model_name, field_name):
+ """ Return the translation of a field's selection in the context's language.
+ Note that the result contains the available translations only.
+
+ :param model_name: the name of the field's model
+ :param field_name: the name of the field
+ :return: the fields' selection as a list
+ """
+ field = self._get(model_name, field_name)
+ return [(sel.value, sel.name) for sel in field.selection_ids]
class IrModelSelection(models.Model):
_name = 'ir.model.fields.selection'
@@ -1192,8 +1244,9 @@ class IrModelSelection(models.Model):
return self._get_selection_data(field_id)
def _get_selection_data(self, field_id):
+ # return selection as expected on registry (no translations)
self._cr.execute("""
- SELECT value, name
+ SELECT value, name->>'en_US'
FROM ir_model_fields_selection
WHERE field_id=%s
ORDER BY sequence, id
@@ -1223,7 +1276,7 @@ class IrModelSelection(models.Model):
cr = self.env.cr
query = """
- SELECT s.field_id, s.value, s.name, s.sequence
+ SELECT s.field_id, s.value, s.name->>'en_US', s.sequence
FROM ir_model_fields_selection s, ir_model_fields f
WHERE s.field_id = f.id AND f.model IN %s
"""
@@ -1234,7 +1287,7 @@ class IrModelSelection(models.Model):
cols = ['field_id', 'value', 'name', 'sequence']
rows = [key + val for key, val in expected.items() if existing.get(key) != val]
if rows:
- ids = upsert(cr, self._table, cols, rows, ['field_id', 'value'])
+ ids = upsert_en(self, cols, rows, ['field_id', 'value'])
self.pool.post_init(mark_modified, self.browse(ids), cols[2:])
# update their XML ids
@@ -1285,8 +1338,10 @@ class IrModelSelection(models.Model):
cur_row['value'], model_name, field_name)
rows_to_remove.append(cur_row['id'])
elif cur_row is None:
+ new_row['name'] = Json({'en_US': new_row['name']})
rows_to_insert.append(dict(new_row, field_id=field_id))
elif any(new_row[key] != cur_row[key] for key in new_row):
+ new_row['name'] = Json({'en_US': new_row['name']})
rows_to_update.append(dict(new_row, id=cur_row['id']))
if rows_to_insert:
@@ -1308,7 +1363,7 @@ class IrModelSelection(models.Model):
a dict {field_name: {value: row_values}}.
"""
query = """
- SELECT s.*
+ SELECT s.*, s.name->>'en_US' AS name
FROM ir_model_fields_selection s
JOIN ir_model_fields f ON s.field_id=f.id
WHERE f.model=%s and f.name=%s
@@ -1556,7 +1611,7 @@ class IrModelConstraint(models.Model):
return
assert type in ('f', 'u')
cr = self._cr
- query = """ SELECT c.id, type, definition, message
+ query = """ SELECT c.id, type, definition, message->>'en_US' as message
FROM ir_model_constraint c, ir_module_module m
WHERE c.module=m.id AND c.name=%s AND m.name=%s """
cr.execute(query, (conname, module))
@@ -1572,8 +1627,7 @@ class IrModelConstraint(models.Model):
(SELECT id FROM ir_model WHERE model=%s),
%s, %s, %s)
RETURNING id"""
- cr.execute(query,
- (conname, self.env.uid, self.env.uid, module, model._name, type, definition, message))
+ cr.execute(query, (conname, self.env.uid, self.env.uid, module, model._name, type, definition, Json({'en_US': message})))
return self.browse(cr.fetchone()[0])
cons_id = cons.pop('id')
@@ -1582,7 +1636,7 @@ class IrModelConstraint(models.Model):
SET write_date=now() AT TIME ZONE 'UTC',
write_uid=%s, type=%s, definition=%s, message=%s
WHERE id=%s"""
- cr.execute(query, (self.env.uid, type, definition, message, cons_id))
+ cr.execute(query, (self.env.uid, type, definition, Json({'en_US': message}), cons_id))
return self.browse(cons_id)
def _reflect_constraints(self, model_names):
@@ -1735,17 +1789,18 @@ class IrModelAccess(models.Model):
:rtype: list
"""
assert access_mode in ('read', 'write', 'create', 'unlink'), 'Invalid access mode'
- self._cr.execute("""
- SELECT c.name, g.name
+ lang = self.env.lang or 'en_US'
+ self._cr.execute(f"""
+ SELECT COALESCE(c.name->>%s, c.name->>'en_US'), COALESCE(g.name->>%s, g.name->>'en_US')
FROM ir_model_access a
JOIN ir_model m ON (a.model_id = m.id)
JOIN res_groups g ON (a.group_id = g.id)
LEFT JOIN ir_module_category c ON (c.id = g.category_id)
- WHERE m.model = %%s
+ WHERE m.model = %s
AND a.active = TRUE
- AND a.perm_%s = TRUE
+ AND a.perm_{access_mode} = TRUE
ORDER BY c.name, g.name NULLS LAST
- """ % access_mode, [model_name])
+ """, [lang, lang, model_name])
return [('%s/%s' % x) if x[0] else x[1] for x in self._cr.fetchall()]
# The context parameter is useful when the method translates error messages.
diff --git a/odoo/addons/base/models/ir_module.py b/odoo/addons/base/models/ir_module.py
index cbcc8352486..a8ce7c0947a 100644
--- a/odoo/addons/base/models/ir_module.py
+++ b/odoo/addons/base/models/ir_module.py
@@ -32,6 +32,7 @@ from odoo.osv import expression
from odoo.tools.parse_version import parse_version
from odoo.tools.misc import topological_sort
from odoo.http import request
+from odoo.modules import get_module_path, get_module_resource
_logger = logging.getLogger(__name__)
@@ -947,7 +948,7 @@ class Module(models.Model):
for mod in update_mods
}
mod_names = topological_sort(mod_dict)
- self.env['ir.translation']._load_module_terms(mod_names, filter_lang, overwrite)
+ self.env['ir.module.module']._load_module_terms(mod_names, filter_lang, overwrite)
def _check(self):
for module in self:
@@ -1024,6 +1025,50 @@ class Module(models.Model):
return super(Module, self).search_panel_select_range(field_name, **kwargs)
+ @api.model
+ def _load_module_terms(self, modules, langs, overwrite=False):
+ """ Load PO files of the given modules for the given languages. """
+ # load i18n files
+ for module_name in modules:
+ modpath = get_module_path(module_name)
+ if not modpath:
+ continue
+ for lang in langs:
+ lang_code = tools.get_iso_codes(lang)
+ lang_overwrite = overwrite
+ base_lang_code = None
+ if '_' in lang_code:
+ base_lang_code = lang_code.split('_')[0]
+
+ # Step 1: for sub-languages, load base language first (e.g. es_CL.po is loaded over es.po)
+ if base_lang_code:
+ base_trans_file = get_module_resource(module_name, 'i18n', base_lang_code + '.po')
+ if base_trans_file:
+ _logger.info('module %s: loading base translation file %s for language %s', module_name, base_lang_code, lang)
+ tools.trans_load(self._cr, base_trans_file, lang, verbose=False, overwrite=lang_overwrite)
+ lang_overwrite = True # make sure the requested translation will override the base terms later
+
+ # i18n_extra folder is for additional translations handle manually (eg: for l10n_be)
+ base_trans_extra_file = get_module_resource(module_name, 'i18n_extra', base_lang_code + '.po')
+ if base_trans_extra_file:
+ _logger.info('module %s: loading extra base translation file %s for language %s', module_name, base_lang_code, lang)
+ tools.trans_load(self._cr, base_trans_extra_file, lang, verbose=False, overwrite=lang_overwrite)
+ lang_overwrite = True # make sure the requested translation will override the base terms later
+
+ # Step 2: then load the main translation file, possibly overriding the terms coming from the base language
+ trans_file = get_module_resource(module_name, 'i18n', lang_code + '.po')
+ if trans_file:
+ _logger.info('module %s: loading translation file (%s) for language %s', module_name, lang_code, lang)
+ tools.trans_load(self._cr, trans_file, lang, verbose=False, overwrite=lang_overwrite)
+ elif lang_code != 'en_US':
+ _logger.info('module %s: no translation for language %s', module_name, lang_code)
+
+ trans_extra_file = get_module_resource(module_name, 'i18n_extra', lang_code + '.po')
+ if trans_extra_file:
+ _logger.info('module %s: loading extra translation file (%s) for language %s', module_name, lang_code, lang)
+ tools.trans_load(self._cr, trans_extra_file, lang, verbose=False, overwrite=lang_overwrite)
+ return True
+
DEP_STATES = STATES + [('unknown', 'Unknown')]
diff --git a/odoo/addons/base/models/ir_translation.py b/odoo/addons/base/models/ir_translation.py
deleted file mode 100644
index cb74e377eae..00000000000
--- a/odoo/addons/base/models/ir_translation.py
+++ /dev/null
@@ -1,927 +0,0 @@
-# -*- coding: utf-8 -*-
-# Part of Odoo. See LICENSE file for full copyright and licensing details.
-import hashlib
-import itertools
-import json
-import logging
-import operator
-from collections import defaultdict
-from difflib import get_close_matches
-
-from odoo import api, fields, models, tools, SUPERUSER_ID, _
-from odoo.exceptions import AccessError, UserError, ValidationError
-from odoo.modules import get_module_path, get_module_resource
-
-_logger = logging.getLogger(__name__)
-
-TRANSLATION_TYPE = [
- ('model', 'Model Field'),
- ('model_terms', 'Structured Model Field'),
- ('code', 'Code'),
-]
-
-
-class IrTranslationImport(object):
- """ Temporary cursor for optimizing mass insert into model 'ir.translation'.
-
- Open it (attached to a sql cursor), feed it with translation data and
- finish() it in order to insert multiple translations in a batch.
- """
- _table = 'tmp_ir_translation_import'
-
- def __init__(self, cr, overwrite=False):
- """ Store some values, and also create a temporary SQL table to accept
- the data.
-
- :param model: the model to insert the data into (as a recordset)
- """
- self._cr = cr
- self._model_table = "ir_translation"
- self._overwrite = overwrite
- self._debug = False
- self._rows = []
-
- # Note that Postgres will NOT inherit the constraints or indexes
- # of ir_translation, so this copy will be much faster.
- query = """ CREATE TEMP TABLE %s (
- imd_model VARCHAR(64),
- imd_name VARCHAR(128),
- noupdate BOOLEAN
- ) INHERITS (%s) """ % (self._table, self._model_table)
- self._cr.execute(query)
-
- def push(self, trans_dict):
- """ Feed a translation, as a dictionary, into the cursor """
- params = dict(trans_dict, state="translated")
-
- self._rows.append((params['name'], params['lang'], params['res_id'],
- params['src'], params['type'], params['imd_model'],
- params['module'], params['imd_name'], params['value'],
- params['state'], params['comments']))
-
- def finish(self):
- """ Transfer the data from the temp table to ir.translation """
- cr = self._cr
-
- # Step 0: insert rows in batch
- query = """ INSERT INTO %s (name, lang, res_id, src, type, imd_model,
- module, imd_name, value, state, comments)
- VALUES """ % self._table
- for rows in cr.split_for_in_conditions(self._rows):
- cr.execute(query + ", ".join(["%s"] * len(rows)), rows)
-
- _logger.debug("ir.translation.cursor: We have %d entries to process", len(self._rows))
-
- # Step 1: resolve ir.model.data references to res_ids
- cr.execute(""" UPDATE %s AS ti
- SET res_id = imd.res_id,
- noupdate = imd.noupdate
- FROM ir_model_data AS imd
- WHERE ti.res_id IS NULL
- AND ti.module IS NOT NULL AND ti.imd_name IS NOT NULL
- AND ti.module = imd.module AND ti.imd_name = imd.name
- AND ti.imd_model = imd.model; """ % self._table)
-
- if self._debug:
- cr.execute(""" SELECT module, imd_name, imd_model FROM %s
- WHERE res_id IS NULL AND module IS NOT NULL """ % self._table)
- for row in cr.fetchall():
- _logger.info("ir.translation.cursor: missing res_id for %s.%s <%s> ", *row)
-
- # Records w/o res_id must _not_ be inserted into our db, because they are
- # referencing non-existent data.
- cr.execute("DELETE FROM %s WHERE res_id IS NULL AND module IS NOT NULL" % self._table)
-
- count = 0
- # Step 2: insert new or upsert non-noupdate translations
- if self._overwrite:
- cr.execute(""" INSERT INTO %s(name, lang, res_id, src, type, value, module, state, comments)
- SELECT name, lang, res_id, src, type, value, module, state, comments
- FROM %s
- WHERE type = 'code'
- AND noupdate IS NOT TRUE
- ON CONFLICT (type, lang, md5(src)) WHERE type = 'code'
- DO UPDATE SET (name, lang, res_id, src, type, value, module, state, comments) = (EXCLUDED.name, EXCLUDED.lang, EXCLUDED.res_id, EXCLUDED.src, EXCLUDED.type, EXCLUDED.value, EXCLUDED.module, EXCLUDED.state,
- CASE WHEN %s.comments = 'openerp-web' THEN 'openerp-web' ELSE EXCLUDED.comments END
- )
- WHERE EXCLUDED.value IS NOT NULL AND EXCLUDED.value != '';
- """ % (self._model_table, self._table, self._model_table))
- count += cr.rowcount
- cr.execute(""" INSERT INTO %s(name, lang, res_id, src, type, value, module, state, comments)
- SELECT name, lang, res_id, src, type, value, module, state, comments
- FROM %s
- WHERE type = 'model'
- AND noupdate IS NOT TRUE
- ON CONFLICT (type, lang, name, res_id) WHERE type = 'model'
- DO UPDATE SET (name, lang, res_id, src, type, value, module, state, comments) = (EXCLUDED.name, EXCLUDED.lang, EXCLUDED.res_id, EXCLUDED.src, EXCLUDED.type, EXCLUDED.value, EXCLUDED.module, EXCLUDED.state, EXCLUDED.comments)
- WHERE EXCLUDED.value IS NOT NULL AND EXCLUDED.value != '';
- """ % (self._model_table, self._table))
- count += cr.rowcount
-
- cr.execute(""" INSERT INTO %s(name, lang, res_id, src, type, value, module, state, comments)
- SELECT name, lang, res_id, src, type, value, module, state, comments
- FROM %s
- WHERE type = 'model_terms'
- AND noupdate IS NOT TRUE
- ON CONFLICT (type, name, lang, res_id, md5(src))
- DO UPDATE SET (name, lang, res_id, src, type, value, module, state, comments) = (EXCLUDED.name, EXCLUDED.lang, EXCLUDED.res_id, EXCLUDED.src, EXCLUDED.type, EXCLUDED.value, EXCLUDED.module, EXCLUDED.state, EXCLUDED.comments)
- WHERE EXCLUDED.value IS NOT NULL AND EXCLUDED.value != '';
- """ % (self._model_table, self._table))
- count += cr.rowcount
- cr.execute(""" INSERT INTO %s(name, lang, res_id, src, type, value, module, state, comments)
- SELECT name, lang, res_id, src, type, value, module, state, comments
- FROM %s
- WHERE %%s OR noupdate is true
- ON CONFLICT DO NOTHING;
- """ % (self._model_table, self._table), [not self._overwrite])
- count += cr.rowcount
-
- if self._debug:
- cr.execute("SELECT COUNT(*) FROM ONLY %s" % self._model_table)
- total = cr.fetchone()[0]
- _logger.debug("ir.translation.cursor: %d entries now in ir.translation, %d common entries with tmp", total, count)
-
- # Step 3: cleanup
- cr.execute("DROP TABLE %s" % self._table)
- self._rows.clear()
- return True
-
-
-class IrTranslation(models.Model):
- _name = "ir.translation"
- _description = 'Translation'
- _log_access = False
-
- name = fields.Char(string='Translated field', required=True)
- res_id = fields.Integer(string='Record ID', index=True)
- lang = fields.Selection(selection='_get_languages', string='Language', validate=False)
- type = fields.Selection(TRANSLATION_TYPE, string='Type', index=True)
- src = fields.Text(string='Internal Source') # stored in database, kept for backward compatibility
- value = fields.Text(string='Translation Value')
- module = fields.Char(index=True, help="Module this term belongs to")
-
- state = fields.Selection([('to_translate', 'To Translate'),
- ('inprogress', 'Translation in Progress'),
- ('translated', 'Translated')],
- string="Status", default='to_translate',
- help="Automatically set to let administators find new terms that might need to be translated")
-
- # aka gettext extracted-comments - we use them to flag openerp-web translation
- # cfr: http://www.gnu.org/savannah-checkouts/gnu/gettext/manual/html_node/PO-Files.html
- comments = fields.Text(string='Translation comments', index=True)
-
- _sql_constraints = [
- ('lang_fkey_res_lang', 'FOREIGN KEY(lang) REFERENCES res_lang(code)',
- 'Language code of translation item must be among known languages'),
- ]
-
- @api.model
- def _get_languages(self):
- return self.env['res.lang'].get_installed()
-
- def _auto_init(self):
- res = super(IrTranslation, self)._auto_init()
- # Add separate md5 index on src (no size limit on values, and good performance).
- tools.create_index(self._cr, 'ir_translation_src_md5', self._table, ['md5(src)'])
- # Cover 'model_terms' type
- tools.create_unique_index(self._cr, 'ir_translation_unique', self._table,
- ['type', 'name', 'lang', 'res_id', 'md5(src)'])
- if not tools.index_exists(self._cr, 'ir_translation_code_unique'):
- self._cr.execute("CREATE UNIQUE INDEX ir_translation_code_unique ON ir_translation (type, lang, md5(src)) WHERE type = 'code'")
- if not tools.index_exists(self._cr, 'ir_translation_model_unique'):
- self._cr.execute("CREATE UNIQUE INDEX ir_translation_model_unique ON ir_translation (type, lang, name, res_id) WHERE type = 'model'")
-
- return res
-
- @api.model
- def _get_ids(self, name, tt, lang, ids):
- """ Return the translations of records.
-
- :param name: a string defined as ","
- :param tt: the type of translation (should always be "model")
- :param lang: the language code
- :param ids: the ids of the given records
- """
- translations = dict.fromkeys(ids, False)
- if ids:
- self._cr.execute("""SELECT res_id, value FROM ir_translation
- WHERE lang=%s AND type=%s AND name=%s AND res_id IN %s""",
- (lang, tt, name, tuple(ids)))
- for res_id, value in self._cr.fetchall():
- translations[res_id] = value
- return translations
-
- CACHED_MODELS = {'ir.model.fields', 'ir.ui.view'}
-
- def _modified_model(self, model_name):
- """ Invalidate the ormcache if necessary, depending on ``model_name``.
- This should be called when modifying translations of type 'model'.
- """
- if model_name in self.CACHED_MODELS:
- self.clear_caches()
-
- def _modified(self):
- """ Invalidate the ormcache if necessary, depending on the translations ``self``. """
- # DLE P63: test_views.py
- for trans in self:
- if trans.type == 'model_terms' and trans.res_id:
- model, field = trans.name.split(',')
- if model in self.env:
- model = self.env[model]
- if field in model._fields:
- field = model._fields[field]
- record = model.browse(trans.res_id)
- record.modified([field.name])
- for trans in self:
- if (trans.type != 'model' or
- (trans.name.split(',')[0] in self.CACHED_MODELS) or
- (trans.comments and 'openerp-web' in trans.comments)): # clear get_web_trans_hash
- self.clear_caches()
- break
-
- @api.model
- def _set_ids(self, name, tt, lang, ids, value, src=None):
- """ Update the translations of records.
-
- :param name: a string defined as ","
- :param tt: the type of translation (should always be "model")
- :param lang: the language code
- :param ids: the ids of the given records
- :param value: the value of the translation
- :param src: the source of the translation
- """
- self._modified_model(name.split(',')[0])
- self.flush_model()
-
- # update existing translations
- self._cr.execute("""UPDATE ir_translation
- SET value=%s, src=%s, state=%s
- WHERE lang=%s AND type=%s AND name=%s AND res_id IN %s
- RETURNING res_id""",
- (value, src, 'translated', lang, tt, name, tuple(ids)))
- existing_ids = [row[0] for row in self._cr.fetchall()]
- self.invalidate_model(['value', 'src', 'state'])
-
- # create missing translations
- self.sudo().create([{
- 'lang': lang,
- 'type': tt,
- 'name': name,
- 'res_id': res_id,
- 'value': value,
- 'src': src,
- 'state': 'translated',
- }
- for res_id in set(ids) - set(existing_ids)
- ])
- return len(ids)
-
- def _set_source(self, name, ids, src):
- """ Update the translation source of records.
-
- :param name: a string defined as ","
- :param ids: the ids of the given records
- :param src: the source of the translation
- """
- self.flush_model()
- self._cr.execute("""UPDATE ir_translation
- SET src=%s
- WHERE type=%s AND name=%s AND res_id IN %s
- RETURNING id""",
- (src, 'model', name, tuple(ids)))
- existing_ids = [row[0] for row in self._cr.fetchall()]
- # invalidate src for updated translations
- self.browse(existing_ids).invalidate_recordset(['src'])
-
- @api.model
- def _get_source_query(self, name, types, lang, source, res_id):
- self.flush_model()
- if source:
- # Note: the extra test on md5(src) is a hint for postgres to use the
- # index ir_translation_src_md5
- query = """SELECT value FROM ir_translation
- WHERE lang=%s AND type in %s AND src=%s AND md5(src)=md5(%s)"""
- source = tools.ustr(source)
- params = (lang or '', types, source, source)
- if res_id:
- query += " AND res_id in %s"
- params += (res_id,)
- if name:
- query += " AND name=%s"
- params += (tools.ustr(name),)
- else:
- query = """ SELECT value FROM ir_translation
- WHERE lang=%s AND type in %s AND name=%s """
- params = (lang or '', types, tools.ustr(name))
-
- return (query, params)
-
- @tools.ormcache('name', 'types', 'lang', 'source', 'res_id')
- def __get_source(self, name, types, lang, source, res_id):
- # res_id is a tuple or None, otherwise ormcache cannot cache it!
- query, params = self._get_source_query(name, types, lang, source, res_id)
- self._cr.execute(query, params)
- res = self._cr.fetchone()
- trad = res and res[0] or u''
- if source and not trad:
- return tools.ustr(source)
- return trad
-
- @api.model
- def _get_source(self, name, types, lang, source=None, res_id=None):
- """ Return the translation for the given combination of ``name``,
- ``type``, ``language`` and ``source``. All values passed to this method
- should be unicode (not byte strings), especially ``source``.
-
- :param name: identification of the term to translate, such as field name (optional if source is passed)
- :param types: single string defining type of term to translate (see ``type`` field on ir.translation), or sequence of allowed types (strings)
- :param lang: language code of the desired translation
- :param source: optional source term to translate (should be unicode)
- :param res_id: optional resource id or a list of ids to translate (if used, ``source`` should be set)
- :rtype: unicode
- :return: the request translation, or an empty unicode string if no translation was
- found and `source` was not passed
- """
- # FIXME: should assert that `source` is unicode and fix all callers to
- # always pass unicode so we can remove the string encoding/decoding.
- if not lang:
- return tools.ustr(source or '')
- if isinstance(types, str):
- types = (types,)
- if res_id:
- if isinstance(res_id, int):
- res_id = (res_id,)
- else:
- res_id = tuple(res_id)
- return self.__get_source(name, types, lang, source, res_id)
-
- @api.model
- def _get_terms_query(self, field, records):
- """ Utility function that makes the query for field terms. """
- self.flush_model()
- query = """ SELECT * FROM ir_translation
- WHERE lang=%s AND type=%s AND name=%s AND res_id IN %s """
- name = "%s,%s" % (field.model_name, field.name)
- params = (records.env.lang, 'model_terms', name, tuple(records.ids))
- return query, params
-
- @api.model
- def _get_terms_mapping(self, field, records):
- """ Return a function mapping a ir_translation row (dict) to a value.
- This method is called before querying the database for translations.
- """
- return lambda data: data['value']
-
- @api.model
- def _get_terms_translations(self, field, records):
- """ Return the terms and translations of a given `field` on `records`.
-
- :return: {record_id: {source: value}}
- """
- result = {rid: {} for rid in records.ids}
- if records:
- map_trans = self._get_terms_mapping(field, records)
- query, params = self._get_terms_query(field, records)
- self._cr.execute(query, params)
- for data in self._cr.dictfetchall():
- result[data['res_id']][data['src']] = map_trans(data)
- return result
-
- @api.model
- def _sync_terms_translations(self, field, records):
- """ Synchronize the translations to the terms to translate, after the
- English value of a field is modified. The algorithm tries to match
- existing translations to the terms to translate, provided the distance
- between modified strings is not too large. It allows to not retranslate
- data where a typo has been fixed in the English value.
- """
- if not callable(field.translate):
- return
-
- Translation = self.env['ir.translation']
- outdated = Translation
- discarded = Translation
-
- for record in records:
- # get field value and terms to translate
- value = record[field.name]
- terms = set(field.get_trans_terms(value))
- translations = Translation.search([
- ('type', '=', 'model_terms'),
- ('name', '=', "%s,%s" % (field.model_name, field.name)),
- ('res_id', '=', record.id),
- ])
-
- if not terms:
- # discard all translations for that field
- discarded += translations
- continue
-
- # remap existing translations on terms when possible; each term
- # should be translated at most once per language
- done = set() # {(src, lang), ...}
- translations_to_match = []
-
- for translation in translations:
- if not translation.value:
- discarded += translation
- # consider it done to avoid being matched against another term
- done.add((translation.src, translation.lang))
- elif translation.src in terms:
- done.add((translation.src, translation.lang))
- else:
- translations_to_match.append(translation)
-
- if translations_to_match:
- text2term = {field.get_text_content(term): term for term in terms}
- for translation in translations_to_match:
- # match the terms without formatting elements
- src_text = field.get_text_content(translation.src)
- matches = get_close_matches(src_text, text2term, 1, 0.9)
- src = text2term[matches[0]] if matches else None
- if not src:
- outdated += translation
- elif (src, translation.lang) in done:
- discarded += translation
- else:
- vals = {'src': src, 'state': translation.state}
- if translation.lang == records.env.lang:
- vals['value'] = src
- translation.write(vals)
- done.add((src, translation.lang))
-
- # process outdated and discarded translations
- outdated.write({'state': 'to_translate'})
-
- if discarded:
- # delete in SQL to avoid invalidating the whole cache
- discarded._modified()
- discarded.modified(self._fields)
- discarded.flush_recordset()
- discarded.invalidate_recordset()
- self.env.cr.execute("DELETE FROM ir_translation WHERE id IN %s", [discarded._ids])
-
- @api.model
- @tools.ormcache_context('model_name', keys=('lang',))
- def get_field_string(self, model_name):
- """ Return the translation of fields strings in the context's language.
- Note that the result contains the available translations only.
-
- :param model_name: the name of a model
- :return: the model's fields' strings as a dictionary `{field_name: field_string}`
- """
- fields = self.env['ir.model.fields'].sudo().search([('model', '=', model_name)])
- return {field.name: field.field_description for field in fields}
-
- @api.model
- @tools.ormcache_context('model_name', keys=('lang',))
- def get_field_help(self, model_name):
- """ Return the translation of fields help in the context's language.
- Note that the result contains the available translations only.
-
- :param model_name: the name of a model
- :return: the model's fields' help as a dictionary `{field_name: field_help}`
- """
- fields = self.env['ir.model.fields'].sudo().search([('model', '=', model_name)])
- return {field.name: field.help for field in fields}
-
- @api.model
- @tools.ormcache_context('model_name', 'field_name', keys=('lang',))
- def get_field_selection(self, model_name, field_name):
- """ Return the translation of a field's selection in the context's language.
- Note that the result contains the available translations only.
-
- :param model_name: the name of the field's model
- :param field_name: the name of the field
- :return: the fields' selection as a list
- """
- field = self.env['ir.model.fields']._get(model_name, field_name)
- return [(sel.value, sel.name) for sel in field.selection_ids]
-
- def check(self, mode):
- """ Check access rights of operation ``mode`` on ``self`` for the
- current user. Raise an AccessError in case conditions are not met.
- """
- if self.env.is_superuser():
- return
-
- # collect translated field records (model_ids) and other translations
- trans_ids = []
- model_ids = defaultdict(set)
- model_fields = defaultdict(set)
- for trans in self:
- if trans.type in ('model', 'model_terms'):
- mname, fname = trans.name.split(',')
- model_ids[mname].add(trans.res_id)
- model_fields[mname].add(fname)
- else:
- trans_ids.append(trans.id)
-
- # check for regular access rights on other translations
- if trans_ids:
- records = self.browse(trans_ids)
- records.check_access_rights(mode)
- records.check_access_rule(mode)
-
- # check for read/write access on translated field records
- fmode = 'read' if mode == 'read' else 'write'
- for mname, ids in model_ids.items():
- records = self.env[mname].browse(ids).exists()
- records.check_access_rights(fmode)
- records.check_field_access_rights(fmode, model_fields[mname])
- if mode == 'create' and set(records._ids) != ids:
- raise ValidationError(_("Creating translation on non existing records"))
- if not records:
- continue
- records.check_access_rule(fmode)
-
- @api.constrains('type', 'name', 'value')
- def _check_value(self):
- for trans in self.with_context(lang=None):
- if trans.type == 'model' and trans.value:
- mname, fname = trans.name.split(',')
- record = trans.env[mname].browse(trans.res_id)
- field = record._fields[fname]
- if callable(field.translate):
- src = trans.src
- val = trans.value.strip()
- # check whether applying (src -> val) then (val -> src)
- # gives the original value back
- value0 = field.translate(lambda term: None, record[fname])
- value1 = field.translate({src: val}.get, value0)
- # don't check the reverse if no translation happened
- if value0 == value1:
- continue
- value2 = field.translate({val: src}.get, value1)
- if value2 != value0:
- raise ValidationError(_("Translation is not valid:\n%s", val))
-
- @api.model_create_multi
- def create(self, vals_list):
- records = super(IrTranslation, self.sudo()).create(vals_list).with_env(self.env)
- records.check('create')
- records._modified()
- # DLE P62: `test_translate.py`, `test_sync`
- self.env.flush_all()
- return records
-
- def write(self, vals):
- if vals.get('value'):
- vals.setdefault('state', 'translated')
- elif vals.get('src') or not vals.get('value', True):
- vals.setdefault('state', 'to_translate')
- self.check('write')
- result = super(IrTranslation, self.sudo()).write(vals)
- self.check('write')
- self._modified()
- # DLE P62: `test_translate.py`, `test_sync`
- # when calling `flush` with a field list, if there is no value for one of these fields,
- # the flush to database is not done.
- # this causes issues when changing the src/value of a translation, as when we read, we ask the flush,
- # but its not really the field which is in the towrite values, but its translation
- self.env.flush_all()
- return result
-
- def unlink(self):
- self.check('unlink')
- self._modified()
- return super(IrTranslation, self.sudo()).unlink()
-
- @api.model
- def _search(self, args, offset=0, limit=None, order=None, count=False, access_rights_uid=None):
- # DLE P67, `test_new_fields.py`, `test_80_copy`
- # When assigning a translation to a field
- # e.g. email.with_context(lang='fr_FR').label = "bonjour"
- # and then search on translations for this translation, must flush as the translation has not yet been written in database
- if any(field.translate for field in self.env.cache.get_dirty_fields()):
- self.env.flush_all()
- return super(IrTranslation, self)._search(args, offset=offset, limit=limit, order=order, count=count, access_rights_uid=access_rights_uid)
-
- @api.model
- def insert_missing(self, field, records):
- """ Insert missing translations for `field` on `records`. """
- self.flush_model()
- records = records.with_context(lang=None)
- external_ids = records.get_external_id() # if no xml_id, empty string
- if callable(field.translate):
- # insert missing translations for each term in src
- query = """ INSERT INTO ir_translation (lang, type, name, res_id, src, value, module, state)
- SELECT l.code, 'model_terms', %(name)s, %(res_id)s, %(src)s, '', %(module)s, 'to_translate'
- FROM res_lang l
- WHERE l.active AND NOT EXISTS (
- SELECT 1 FROM ir_translation
- WHERE lang=l.code AND type='model' AND name=%(name)s AND res_id=%(res_id)s AND src=%(src)s
- )
- ON CONFLICT DO NOTHING;
- """
- for record in records:
- module = external_ids[record.id].split('.')[0]
- src = record[field.name] or None
- for term in set(field.get_trans_terms(src)):
- self._cr.execute(query, {
- 'name': "%s,%s" % (field.model_name, field.name),
- 'res_id': record.id,
- 'src': term,
- 'module': module
- })
- else:
- # insert missing translations for src
- query = """ INSERT INTO ir_translation (lang, type, name, res_id, src, value, module, state)
- SELECT l.code, 'model', %(name)s, %(res_id)s, %(src)s, '', %(module)s, 'to_translate'
- FROM res_lang l
- WHERE l.active AND NOT EXISTS (
- SELECT 1 FROM ir_translation
- WHERE lang=l.code AND type='model' AND name=%(name)s AND res_id=%(res_id)s
- );
-
- DELETE FROM ir_translation dup
- WHERE type='model' AND name=%(name)s AND res_id=%(res_id)s
- AND dup.id NOT IN (SELECT MAX(t.id)
- FROM ir_translation t
- WHERE t.lang=dup.lang AND type='model' AND name=%(name)s AND res_id=%(res_id)s
- );
-
- UPDATE ir_translation SET src=%(src)s
- WHERE type='model' AND name=%(name)s AND res_id=%(res_id)s;
- """
- for record in records:
- module = external_ids[record.id].split('.')[0]
- self._cr.execute(query, {
- 'name': "%s,%s" % (field.model_name, field.name),
- 'res_id': record.id,
- 'src': record[field.name] or None,
- 'module': module
- })
- self._modified_model(field.model_name)
-
- @api.model
- def _upsert_translations(self, vals_list):
- """ Insert or update translations of type 'model' or 'model_terms'.
-
- This method is used for creations of translations where the given
- ``vals_list`` is trusted to be the right values and potential
- conflicts should be updated to the new given value.
- Mandatory values: name, lang, res_id, src, type
- The other keys are ignored during update if not present
- """
- self.flush_model()
- rows_by_type = defaultdict(list)
- for vals in vals_list:
- rows_by_type[vals['type']].append((
- vals['name'], vals['lang'], vals['res_id'], vals['src'] or '', vals['type'],
- vals.get('module'), vals['value'] or '', vals.get('state'), vals.get('comments'),
- ))
-
- if rows_by_type['model']:
- query = """
- INSERT INTO ir_translation (name, lang, res_id, src, type,
- module, value, state, comments)
- VALUES {}
- ON CONFLICT (type, lang, name, res_id) WHERE type='model'
- DO UPDATE SET (name, lang, res_id, src, type, value, module, state, comments) =
- (EXCLUDED.name, EXCLUDED.lang, EXCLUDED.res_id, EXCLUDED.src, EXCLUDED.type,
- EXCLUDED.value,
- COALESCE(EXCLUDED.module, ir_translation.module),
- COALESCE(EXCLUDED.state, ir_translation.state),
- COALESCE(EXCLUDED.comments, ir_translation.comments))
- WHERE EXCLUDED.value IS NOT NULL AND EXCLUDED.value != '';
- """.format(", ".join(["%s"] * len(rows_by_type['model'])))
- self.env.cr.execute(query, rows_by_type['model'])
-
- if rows_by_type['model_terms']:
- query = """
- INSERT INTO ir_translation (name, lang, res_id, src, type,
- module, value, state, comments)
- VALUES {}
- ON CONFLICT (type, name, lang, res_id, md5(src))
- DO UPDATE SET (name, lang, res_id, src, type, value, module, state, comments) =
- (EXCLUDED.name, EXCLUDED.lang, EXCLUDED.res_id, EXCLUDED.src, EXCLUDED.type,
- EXCLUDED.value, EXCLUDED.module, EXCLUDED.state, EXCLUDED.comments)
- WHERE EXCLUDED.value IS NOT NULL AND EXCLUDED.value != '';
- """.format(", ".join(["%s"] * len(rows_by_type['model_terms'])))
- self.env.cr.execute(query, rows_by_type['model_terms'])
-
- def _update_translations(self, vals_list):
- """ Update translations of type 'model' or 'model_terms'.
-
- This method is used for update of translations where the given
- ``vals_list`` is trusted to be the right values
- No new translation will be created
- """
- self.flush_model()
- grouped_rows = {}
- for vals in vals_list:
- key = (vals['lang'], vals['type'], vals['name'])
- grouped_rows.setdefault(key, [vals['value'], vals['src'], vals['state'], []])
- grouped_rows[key][3].append(vals['res_id'])
-
- for where, values in grouped_rows.items():
- self._cr.execute(
- """ UPDATE ir_translation
- SET value=%s,
- src=%s,
- state=%s
- WHERE lang=%s AND type=%s AND name=%s AND res_id in %s
- """,
- (values[0], values[1], values[2], where[0], where[1], where[2], tuple(values[3]))
- )
- self.invalidate_model(['value', 'src', 'state'])
-
- @api.model
- def translate_fields(self, model, id, field=None):
- """ Open a view for translating the field(s) of the record (model, id). """
- main_lang = 'en_US'
- if not self.env['res.lang'].search_count([('code', '!=', main_lang)]):
- raise UserError(_("Translation features are unavailable until you install an extra translation."))
-
- # determine domain for selecting translations
- record = self.env[model].with_context(lang=main_lang).browse(id)
- domain = ['&', ('res_id', '=', id), ('name', '=like', model + ',%')]
-
- def make_domain(fld, rec):
- name = "%s,%s" % (fld.model_name, fld.name)
- return ['&', ('res_id', '=', rec.id), ('name', '=', name)]
-
- # insert missing translations, and extend domain for related fields
- for name, fld in record._fields.items():
- if not fld.translate:
- continue
-
- rec = record
- if fld.related:
- try:
- # traverse related fields up to their data source
- while fld.related:
- rec, fld = fld.traverse_related(rec)
- if rec:
- domain = ['|'] + domain + make_domain(fld, rec)
- except AccessError:
- continue
-
- assert fld.translate and rec._name == fld.model_name
- self.insert_missing(fld, rec)
-
- action = {
- 'name': _('Translate'),
- 'res_model': 'ir.translation',
- 'type': 'ir.actions.act_window',
- 'view_mode': 'tree',
- 'view_id': self.env.ref('base.view_translation_dialog_tree').id,
- 'target': 'current',
- 'domain': domain,
- 'context': {},
- }
- if field:
- fld = record._fields[field]
- if not fld.related:
- action['context'] = {
- 'search_default_name': "%s,%s" % (fld.model_name, fld.name),
- }
- else:
- rec = record
- try:
- while fld.related:
- rec, fld = fld.traverse_related(rec)
- if rec:
- action['context'] = {'search_default_name': "%s,%s" % (fld.model_name, fld.name),}
- except AccessError:
- pass
-
- action['target'] = 'new'
- action['context']['translation_type'] = 'text' if fld.type in ['text', 'html'] else 'char'
- action['context']['translation_show_src'] = False
- if callable(fld.translate):
- action['view_id'] = self.env.ref('base.view_translation_lang_src_value_tree').id,
- action['context']['translation_show_src'] = True
- else:
- action['view_id'] = self.env.ref('base.view_translation_lang_value_tree').id,
-
- return action
-
- def _get_import_cursor(self, overwrite):
- """ Return a cursor-like object for fast inserting translations """
- return IrTranslationImport(self._cr, overwrite)
-
- def _load_module_terms(self, modules, langs, overwrite=False):
- """ Load PO files of the given modules for the given languages. """
- # load i18n files
- for module_name in modules:
- modpath = get_module_path(module_name)
- if not modpath:
- continue
- for lang in langs:
- lang_code = tools.get_iso_codes(lang)
- lang_overwrite = overwrite
- base_lang_code = None
- if '_' in lang_code:
- base_lang_code = lang_code.split('_')[0]
-
- # Step 1: for sub-languages, load base language first (e.g. es_CL.po is loaded over es.po)
- if base_lang_code:
- base_trans_file = get_module_resource(module_name, 'i18n', base_lang_code + '.po')
- if base_trans_file:
- _logger.info('module %s: loading base translation file %s for language %s', module_name, base_lang_code, lang)
- tools.trans_load(self._cr, base_trans_file, lang, verbose=False, overwrite=lang_overwrite)
- lang_overwrite = True # make sure the requested translation will override the base terms later
-
- # i18n_extra folder is for additional translations handle manually (eg: for l10n_be)
- base_trans_extra_file = get_module_resource(module_name, 'i18n_extra', base_lang_code + '.po')
- if base_trans_extra_file:
- _logger.info('module %s: loading extra base translation file %s for language %s', module_name, base_lang_code, lang)
- tools.trans_load(self._cr, base_trans_extra_file, lang, verbose=False, overwrite=lang_overwrite)
- lang_overwrite = True # make sure the requested translation will override the base terms later
-
- # Step 2: then load the main translation file, possibly overriding the terms coming from the base language
- trans_file = get_module_resource(module_name, 'i18n', lang_code + '.po')
- if trans_file:
- _logger.info('module %s: loading translation file (%s) for language %s', module_name, lang_code, lang)
- tools.trans_load(self._cr, trans_file, lang, verbose=False, overwrite=lang_overwrite)
- elif lang_code != 'en_US':
- _logger.info('module %s: no translation for language %s', module_name, lang_code)
-
- trans_extra_file = get_module_resource(module_name, 'i18n_extra', lang_code + '.po')
- if trans_extra_file:
- _logger.info('module %s: loading extra translation file (%s) for language %s', module_name, lang_code, lang)
- tools.trans_load(self._cr, trans_extra_file, lang, verbose=False, overwrite=lang_overwrite)
- return True
-
- @api.model
- def get_technical_translations(self, model_name):
- """ Find the translations for the fields of `model_name`
-
- Find the technical translations for the fields of the model, including
- string, tooltip and available selections.
-
- :return: action definition to open the list of available translations
- """
- fields = self.env['ir.model.fields'].search([('model', '=', model_name)])
- selection_ids = tools.flatten([field.selection_ids.ids for field in fields if field.ttype == 'selection'])
- view = self.env.ref("base.view_translation_tree", False) or self.env['ir.ui.view']
- return {
- 'name': _("Technical Translations"),
- 'view_mode': 'tree',
- 'views': [(view.id, "list")],
- 'res_model': 'ir.translation',
- 'type': 'ir.actions.act_window',
- 'domain': [
- '&',
- ('type', '=', 'model'),
- '|',
- '&', ('res_id', 'in', fields.ids),
- ('name', 'like', 'ir.model.fields,'),
- '&', ('res_id', 'in', selection_ids),
- ('name', 'like', 'ir.model.fields.selection,')
- ],
- }
-
- @api.model
- def get_translations_for_webclient(self, mods, lang):
- if not mods:
- mods = [x['name'] for x in self.env['ir.module.module'].sudo().search_read(
- [('state', '=', 'installed')], ['name'])]
- if not lang:
- lang = self._context.get("lang")
- langs = self.env['res.lang']._lang_get(lang)
- lang_params = None
- if langs:
- lang_params = {
- "name": langs.name,
- "direction": langs.direction,
- "date_format": langs.date_format,
- "time_format": langs.time_format,
- "grouping": langs.grouping,
- "decimal_point": langs.decimal_point,
- "thousands_sep": langs.thousands_sep,
- "week_start": langs.week_start,
- }
- lang_params['week_start'] = int(lang_params['week_start'])
- lang_params['code'] = lang
-
- # Regional languages (ll_CC) must inherit/override their parent lang (ll), but this is
- # done server-side when the language is loaded, so we only need to load the user's lang.
- translations_per_module = {}
- messages = self.env['ir.translation'].sudo().search_read([
- ('module', 'in', mods), ('lang', '=', lang),
- ('comments', 'like', 'openerp-web'), ('value', '!=', False),
- ('value', '!=', '')],
- ['module', 'src', 'value', 'lang'], order='module')
- for mod, msg_group in itertools.groupby(messages, key=operator.itemgetter('module')):
- translations_per_module.setdefault(mod, {'messages': []})
- translations_per_module[mod]['messages'].extend({
- 'id': m['src'],
- 'string': m['value']}
- for m in msg_group)
-
- return translations_per_module, lang_params
-
- @api.model
- @tools.ormcache('frozenset(mods)', 'lang')
- def get_web_translations_hash(self, mods, lang):
- translations, lang_params = self.get_translations_for_webclient(mods, lang)
- translation_cache = {
- 'lang_parameters': lang_params,
- 'modules': translations,
- 'lang': lang,
- 'multi_lang': len(self.env['res.lang'].sudo().get_installed()) > 1,
- }
- return hashlib.sha1(json.dumps(translation_cache, sort_keys=True).encode()).hexdigest()
diff --git a/odoo/addons/base/models/ir_ui_view.py b/odoo/addons/base/models/ir_ui_view.py
index 63786b76fcf..22ae309fac8 100644
--- a/odoo/addons/base/models/ir_ui_view.py
+++ b/odoo/addons/base/models/ir_ui_view.py
@@ -320,6 +320,9 @@ actual arch.
data['arch_fs'] = '/'.join(path_info[0:2])
data['arch_updated'] = False
view.write(data)
+ # the xml_translate will clean the arch_db when write (e.g. ('
') -> (''))
+ # view.arch should be reassigned here
+ view.arch = view.arch_db
# the field 'arch' depends on the context and has been implicitly
# modified in all languages; the invalidation below ensures that the
# field does not keep an old value in another environment
@@ -566,6 +569,12 @@ actual arch.
self.inherit_children_ids.unlink()
return super(View, self).unlink()
+ def _update_field_translations(self, fname, translations, digest=None):
+ res = super()._update_field_translations(fname, translations, digest)
+ if fname == 'arch_db' and 'install_filename' not in self._context:
+ self.write({'arch_updated': True})
+ return res
+
@api.returns('self', lambda value: value.id)
def copy(self, default=None):
self.ensure_one()
@@ -2028,10 +2037,6 @@ actual arch.
# Misc
#------------------------------------------------------
- def open_translations(self):
- """ Open a view for editing the translations of field 'arch_db'. """
- return self.env['ir.translation'].translate_fields('ir.ui.view', self.id, 'arch_db')
-
@api.model
def _validate_custom_views(self, model):
"""Validate architecture of custom views (= without xml id) for a given model.
diff --git a/odoo/addons/base/models/res_lang.py b/odoo/addons/base/models/res_lang.py
index 3fa70be9e50..16e6850e3ef 100644
--- a/odoo/addons/base/models/res_lang.py
+++ b/odoo/addons/base/models/res_lang.py
@@ -305,8 +305,6 @@ class Lang(models.Model):
raise UserError(_("You cannot delete the language which is Active!\nPlease de-activate the language first."))
def unlink(self):
- for language in self:
- self.env['ir.translation'].search([('lang', '=', language.code)]).unlink()
self.clear_caches()
return super(Lang, self).unlink()
diff --git a/odoo/addons/base/security/ir.model.access.csv b/odoo/addons/base/security/ir.model.access.csv
index 1ef2990e269..83006e29d53 100644
--- a/odoo/addons/base/security/ir.model.access.csv
+++ b/odoo/addons/base/security/ir.model.access.csv
@@ -28,8 +28,6 @@
"access_ir_sequence_group_system","ir_sequence group_system","model_ir_sequence","group_system",1,1,1,1
"access_ir_sequence_date_range_group_user","ir_sequence_date_range group_user","model_ir_sequence_date_range","group_user",1,0,0,0
"access_ir_sequence_date_range_group_system","ir_sequence_date_range group_system","model_ir_sequence_date_range","group_system",1,1,1,1
-"access_ir_translation_all","ir_translation all","model_ir_translation","group_user",1,1,1,1
-"access_ir_translation_group_system","ir_translation group_system","model_ir_translation","group_system",1,1,1,1
"access_ir_ui_menu_group_user","ir_ui_menu group_user","model_ir_ui_menu",base.group_user,1,0,0,0
"access_ir_ui_menu_group_system","ir_ui_menu group_system","model_ir_ui_menu","group_system",1,1,1,1
"access_ir_ui_view_group_user","ir_ui_view group_user","model_ir_ui_view",,0,0,0,0
@@ -121,7 +119,6 @@
"access_base_module_upgrade","access.base.module.upgrade","model_base_module_upgrade","base.group_system",1,1,1,0
"access_base_module_uninstall","access.base.module.uninstall","model_base_module_uninstall","base.group_system",1,1,1,0
"access_base_language_export","access.base.language.export","model_base_language_export","base.group_user",1,1,1,0
-"access_base_update_translations","access.base.update.translations","model_base_update_translations","base.group_system",1,1,1,0
"access_base_partner_merge_line","access.base.partner.merge.line","model_base_partner_merge_line","base.group_partner_manager",1,1,1,1
"access_base_partner_merge_automatic_wizard","access.base.partner.merge.automatic.wizard","model_base_partner_merge_automatic_wizard","base.group_partner_manager",1,1,1,0
"access_ir_profile","ir_profile","model_ir_profile","group_system",1,1,1,1
diff --git a/odoo/addons/base/tests/test_api.py b/odoo/addons/base/tests/test_api.py
index 4db957b9399..df162c976cb 100644
--- a/odoo/addons/base/tests/test_api.py
+++ b/odoo/addons/base/tests/test_api.py
@@ -214,6 +214,17 @@ class TestAPI(SavepointCaseWithUserDemo):
with self.assertRaises(AccessError):
demo_partner.company_id.name
+ @mute_logger('odoo.models')
+ def test_55_environment_lang(self):
+ """ Check the record env.lang behavior """
+ partner = self.partner_demo
+ self.env['res.lang']._activate_lang('fr_FR')
+ self.assertEqual(partner.with_context(lang=None).env.lang, None, 'None lang context should have None env.lang')
+ self.assertEqual(partner.with_context(lang='en_US').env.lang, 'en_US', 'en_US active lang context should have en_US env.lang')
+ self.assertEqual(partner.with_context(lang='fr_FR').env.lang, 'fr_FR', 'fr_FR active lang context should have fr_FR env.lang')
+ self.assertEqual(partner.with_context(lang='nl_NL').env.lang, None, 'Inactive lang context lang should have None env.lang')
+ self.assertEqual(partner.with_context(lang='Dummy').env.lang, None, 'Ilegal lang context should have None env.lang')
+
@mute_logger('odoo.models')
def test_60_cache(self):
""" Check the record cache behavior """
diff --git a/odoo/addons/base/tests/test_expression.py b/odoo/addons/base/tests/test_expression.py
index 26fe1116140..abb3c68311a 100644
--- a/odoo/addons/base/tests/test_expression.py
+++ b/odoo/addons/base/tests/test_expression.py
@@ -1171,14 +1171,8 @@ class TestQueries(TransactionCase):
with self.assertQueries(['''
SELECT "res_partner_title".id
FROM "res_partner_title"
- LEFT JOIN "ir_translation" AS "res_partner_title__name" ON
- ("res_partner_title"."id" = "res_partner_title__name"."res_id"
- AND "res_partner_title__name"."type" = 'model'
- AND "res_partner_title__name"."name" = %s
- AND "res_partner_title__name"."lang" = %s
- AND "res_partner_title__name"."value" != %s)
- WHERE COALESCE("res_partner_title__name"."value", "res_partner_title"."name") LIKE %s
- ORDER BY COALESCE("res_partner_title__name"."value", "res_partner_title"."name")
+ WHERE jsonb_path_query_array("res_partner_title"."name", '$.*')::text like %s
+ ORDER BY COALESCE("res_partner_title"."name"->>'fr_FR', "res_partner_title"."name"->>'en_US')
''']):
Model.search([('name', 'like', 'foo')])
@@ -1226,7 +1220,7 @@ class TestQueries(TransactionCase):
with self.assertQueries(['''
SELECT "ir_model".id
FROM "ir_model"
- WHERE ("ir_model"."name" ILIKE %s OR ("ir_model"."model"::text ILIKE %s))
+ WHERE (jsonb_path_query_array("ir_model"."name",'$.*')::text ILIKE %s OR ("ir_model"."model"::text ILIKE %s))
ORDER BY "ir_model"."model"
LIMIT 100
''']):
@@ -1237,7 +1231,7 @@ class TestQueries(TransactionCase):
SELECT "ir_model".id
FROM "ir_model"
WHERE (
- "ir_model"."name" NOT ILIKE %s
+ jsonb_path_query_array("ir_model"."name",'$.*')::text 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 96151c05580..0a9fdc4bee8 100644
--- a/odoo/addons/base/tests/test_translate.py
+++ b/odoo/addons/base/tests/test_translate.py
@@ -5,9 +5,11 @@ import logging
import time
from psycopg2 import IntegrityError
+from psycopg2.extras import Json
+import io
from odoo.exceptions import AccessError, ValidationError
-from odoo.tools import mute_logger
+from odoo.tools import trans_load_data
from odoo.tools.translate import quote, unquote, xml_translate, html_translate
from odoo.tests.common import TransactionCase, BaseCase, new_test_user, tagged
@@ -264,7 +266,7 @@ class TestLanguageInstall(TransactionCase):
def _load_module_terms(self, modules, langs, overwrite=False):
loaded.append((modules, langs, overwrite))
- with patch('odoo.addons.base.models.ir_translation.IrTranslation._load_module_terms', _load_module_terms):
+ with patch('odoo.addons.base.models.ir_module.Module._load_module_terms', _load_module_terms):
wizard.lang_install()
# _load_module_terms is called once with lang='fr_FR' and overwrite=True
@@ -280,15 +282,17 @@ class TestTranslation(TransactionCase):
cls.env['res.lang']._activate_lang('fr_FR')
cls.env.ref('base.module_base')._update_translations(['fr_FR'])
cls.customers = cls.env['res.partner.category'].create({'name': 'Customers'})
- cls.env['ir.translation'].create({
- 'type': 'model',
- 'name': 'res.partner.category,name',
- 'module': 'base',
- 'lang': 'fr_FR',
- 'res_id': cls.customers.id,
- 'value': 'Clients',
- 'state': 'translated',
- })
+
+ cls.customers_xml_id = cls.customers.export_data(['id']).get('datas')[0][0]
+ po_string = '''
+ #. module: __export__
+ #: model:res.partner.category,name:%s
+ msgid "Customers"
+ msgstr "Clients"
+ ''' % cls.customers_xml_id
+ with io.BytesIO(bytes(po_string, encoding='utf-8')) as f:
+ f.name = 'dummy'
+ trans_load_data(cls.env.cr, f, 'po', 'fr_FR', verbose=True, overwrite=True)
def test_101_create_translated_record(self):
category = self.customers.with_context({})
@@ -301,18 +305,11 @@ class TestTranslation(TransactionCase):
category = self.customers.with_context({'lang': 'fr_FR'}).copy()
category_no = category.with_context({})
- self.assertEqual(category_no.name, 'Customers', "Duplication did not set untranslated value")
+ self.assertEqual(category_no.name, 'Customers', "Duplication should copy all translations")
category_fr = category.with_context({'lang': 'fr_FR'})
self.assertEqual(category_fr.name, 'Clients', "Did not found translation for initial value")
- translation_fr = self.env['ir.translation'].search([
- ('name', '=', 'res.partner.category,name'),
- ('res_id', '=', category.id),
- ('lang', '=', 'fr_FR'),
- ])
- self.assertEqual(translation_fr.src, 'Customers', "Did not set English version as source")
-
def test_103_duplicate_record_fr(self):
category = self.customers.with_context({'lang': 'fr_FR'}).copy({'name': 'Clients (copie)'})
@@ -322,13 +319,6 @@ class TestTranslation(TransactionCase):
category_fr = category.with_context({'lang': 'fr_FR'})
self.assertEqual(category_fr.name, 'Clients (copie)', "Did not used default value for translated value")
- translation_fr = self.env['ir.translation'].search([
- ('name', '=', 'res.partner.category,name'),
- ('res_id', '=', category.id),
- ('lang', '=', 'fr_FR'),
- ])
- self.assertEqual(translation_fr.src, 'Clients (copie)', "Did not set new name as source")
-
def test_104_orderby_translated_field(self):
""" Test search ordered by a translated field. """
# create a category with a French translation
@@ -340,75 +330,6 @@ class TestTranslation(TransactionCase):
self.assertEqual(categories.ids, [padawans.id, self.customers.id],
"Search ordered by translated name should return Padawans (Apprentis) before Customers (Clients)")
- def test_105_duplicated_translation(self):
- """ Test synchronizing translations with duplicated source """
- # create a category with a French translation
- padawans = self.env['res.partner.category'].create({'name': 'Padawan'})
- self.env['ir.translation'].create({
- 'type': 'model',
- 'name': 'res.partner.category,name',
- 'module':'base',
- 'lang': 'fr_FR',
- 'res_id': padawans.id,
- 'value': 'Apprenti',
- 'state': 'translated',
- })
- # change name and insert a duplicate manually
- padawans.write({'name': 'Padawans'})
- with self.assertRaises(IntegrityError), mute_logger('odoo.sql_db'):
- with self.env.cr.savepoint():
- self.env['ir.translation'].create({
- 'type': 'model',
- 'name': 'res.partner.category,name',
- 'module':'base',
- 'lang': 'fr_FR',
- 'res_id': padawans.id,
- 'value': 'Apprentis',
- 'state': 'translated',
- })
- self.env['ir.translation'].translate_fields('res.partner.category', padawans.id, 'name')
- translations = self.env['ir.translation'].search([
- ('res_id', '=', padawans.id), ('name', '=', 'res.partner.category,name'), ('lang', '=', 'fr_FR'),
- ])
- self.assertEqual(len(translations), 1, "Translations were not duplicated after `translate_fields` call")
- self.assertEqual(translations.value, "Apprenti", "The first translation must stay")
-
- def test_106_en_us_translation(self):
- """ Test synchronizing translations with duplicated source """
- # create a category with a French translation
- cheese = self.env['res.partner.category'].create({'name': 'Cheese'})
-
- self.env['ir.translation'].translate_fields('res.partner.category', cheese.id, 'name')
-
- translations = self.env['ir.translation'].search([('name', '=', 'res.partner.category,name'), ('res_id', '=', cheese.id)], order='lang')
- self.assertEqual(len(translations), 2)
- self.assertRecordValues(translations,
- [{'lang': 'en_US', 'src': 'Cheese', 'value': ''},
- {'lang': 'fr_FR', 'src': 'Cheese', 'value': ''}])
-
- # Translate in both language
- translations[0].value = 'The Cheese'
- translations[1].value = 'Fromage'
-
- # lang=None bypass translation system
- self.assertEqual(cheese.with_context(lang=None).name, 'Cheese')
- self.assertEqual(cheese.with_context(lang='fr_FR').name, 'Fromage')
- self.assertEqual(cheese.with_context(lang='en_US').name, 'The Cheese')
- self.env.flush_all()
-
- # set a new master value
- cheese.with_context(lang='en_US').write({'name': 'Delicious Cheese'})
-
- # every src must be updated
- self.assertEqual(cheese.with_context(lang=None).name, 'Delicious Cheese')
- self.assertRecordValues(translations,
- [{'lang': 'en_US', 'src': 'Delicious Cheese', 'value': 'Delicious Cheese'},
- {'lang': 'fr_FR', 'src': 'Delicious Cheese', 'value': 'Fromage'}])
-
- self.assertEqual(cheese.with_context(lang=None).name, 'Delicious Cheese')
- self.assertEqual(cheese.with_context(lang='fr_FR').name, 'Fromage')
- self.assertEqual(cheese.with_context(lang='en_US').name, 'Delicious Cheese')
-
def test_107_duplicate_record_en(self):
category = self.customers.with_context({'lang': 'en_US'}).copy()
@@ -418,52 +339,93 @@ class TestTranslation(TransactionCase):
category_fr = category.with_context({'lang': 'fr_FR'})
self.assertEqual(category_fr.name, 'Clients', "Did not found translation for initial value")
- translation_fr = self.env['ir.translation'].search([
- ('name', '=', 'res.partner.category,name'),
- ('res_id', '=', category.id),
- ('lang', '=', 'fr_FR'),
- ])
- self.assertEqual(translation_fr.src, 'Customers', "Did not set English version as source")
+ def test_108_search_en(self):
+ CategoryEn = self.env['res.partner.category'].with_context(lang='en_US')
+ category_equal = CategoryEn.search([('name', '=', 'Customers')])
+ self.assertEqual(category_equal.id, self.customers.id, "Search with '=' doesn't work for English")
+ category_ilike = CategoryEn.search([('name', 'ilike', 'stoMer')])
+ self.assertIn(self.customers, category_ilike, "Search with 'ilike' doesn't work for English")
+ category_eq_ilike = CategoryEn.search([('name', '=ilike', 'CustoMers')])
+ self.assertIn(self.customers, category_eq_ilike, "Search with '=ilike' doesn't work for English")
+ category_in = CategoryEn.search([('name', 'in', ['Customers'])])
+ self.assertIn(self.customers, category_in, "Search with 'in' doesn't work for English")
+
+ def test_109_search_fr(self):
+ CategoryFr = self.env['res.partner.category'].with_context(lang='fr_FR')
+ category_equal = CategoryFr.search([('name', '=', 'Clients')])
+ self.assertEqual(category_equal.id, self.customers.id, "Search with '=' doesn't work for non English")
+ category_ilike = CategoryFr.search([('name', 'ilike', 'lIen')])
+ self.assertIn(self.customers, category_ilike, "Search with 'ilike' doesn't work for non English")
+ category_eq_ilike = CategoryFr.search([('name', '=ilike', 'clieNts')])
+ self.assertIn(self.customers, category_eq_ilike, "Search with '=ilike' doesn't work for non English")
+ category_in = CategoryFr.search([('name', 'in', ['Clients'])])
+ self.assertIn(self.customers, category_in, "Search with 'in' doesn't work for non English")
+
+ def test_110_search_es(self):
+ self.env['res.lang']._activate_lang('es_ES')
+ langs = self.env['res.lang'].get_installed()
+ self.assertEqual([('en_US', 'English (US)'), ('fr_FR', 'French / Français'), ('es_ES', 'Spanish / Español')],
+ langs, "Test did not start with the expected languages")
+ CategoryEs = self.env['res.partner.category'].with_context(lang='es_ES')
+ category_equal = CategoryEs.search([('name', '=', 'Customers')])
+ self.assertEqual(category_equal.id, self.customers.id, "Search with '=' should use the English name if the current language translation is not available")
+ category_ilike = CategoryEs.search([('name', 'ilike', 'usTom')])
+ self.assertIn(self.customers, category_ilike, "Search with 'ilike' should use the English name if the current language translation is not available")
+ category_eq_ilike = CategoryEs.search([('name', '=ilike', 'CustoMers')])
+ self.assertIn(self.customers, category_eq_ilike, "Search with '=ilike' should use the English name if the current language translation is not available")
+ category_in = CategoryEs.search([('name', 'in', ['Customers'])])
+ self.assertIn(self.customers, category_in, "Search with 'in' should use the English name if the current language translation is not available")
+
+ # TODO Currently, the unique constraint doesn't work for translatable field
+ # def test_111_unique_en(self):
+ # Country = self.env['res.country']
+ # country_1 = Country.create({'name': 'Odoo'})
+ # country_1.with_context(lang='fr_FR').name = 'Odoo_Fr'
+ # country_1.flush_recordset()
+ #
+ # country_2 = Country.create({'name': 'Odoo2'})
+ # with self.assertRaises(IntegrityError), mute_logger('odoo.sql_db'):
+ # country_2.name = 'Odoo'
+ # country_2.flush_recordset()
+ #
+ # with self.assertRaises(IntegrityError), mute_logger('odoo.sql_db'):
+ # country_3 = Country.create({'name': 'Odoo'})
class TestTranslationWrite(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.category = cls.env['res.partner.category'].create({'name': 'Reblochon'})
+ cls.category_xml_id = cls.category.export_data(['id']).get('datas')[0][0]
+
+ def test_00(self):
+ self.env['res.lang']._activate_lang('fr_FR')
- def test_01_en(self):
langs = self.env['res.lang'].get_installed()
- self.assertEqual([('en_US', 'English (US)')], langs, "Test did not started with expected languages")
+ self.assertEqual([('en_US', 'English (US)'), ('fr_FR', 'French / Français')], langs,
+ "Test did not started with expected languages")
- self.category.with_context(lang='en_US').write({'name': 'English Name'})
- name = self.category.with_context(lang=None).read(['name'])
- self.assertEqual(name[0]['name'], "English Name", "Reference field not updated")
- translations = self.env['ir.translation'].search([
- ('name', '=', 'res.partner.category,name'),
- ('res_id', '=', self.category.id),
- ('lang', '=', 'en_US'),
- ])
- self.assertEqual(len(translations), 0, "No English translation should be created when writing in English")
+ category = self.env['res.partner.category'].with_context(lang='en_US').create({'name': 'English'})
+ self.assertEqual(category.with_context(lang='en_US').name, 'English')
+ self.assertEqual(category.with_context(lang='fr_FR').name, 'English')
- def test_02_en_translated(self):
- langs = self.env['res.lang'].get_installed()
- self.assertEqual([('en_US', 'English (US)')], langs, "Test did not started with expected languages")
- translation = self.env['ir.translation'].create({
- 'type': 'model',
- 'name': 'res.partner.category,name',
- 'lang': 'en_US',
- 'res_id': self.category.id,
- 'src': 'Reblochon',
- 'value': 'Translated Name',
- 'state': 'translated',
- })
+ category.with_context(lang='en_US').name = 'English 2'
+ self.assertEqual(category.with_context(lang='fr_FR').name, 'English 2')
- self.category.with_context(lang='en_US').write({'name': 'English Name'})
- translation_value = translation.read(['value'])
- self.assertEqual(translation_value[0]['value'], "English Name", "Existing translation was not updated")
+ category2 = self.env['res.partner.category'].with_context(lang='fr_FR').create({'name': 'French'})
+ self.assertEqual(category2.with_context(lang='en_US').name, 'French')
+ self.assertEqual(category2.with_context(lang='fr_FR').name, 'French')
- source_name = self.category.with_context(lang=None).read(['name'])
- self.assertEqual(source_name[0]['name'], "English Name", "Reference field not updated")
+ category2.with_context(lang='en_US').name = 'English'
+ self.assertEqual(category2.with_context(lang='fr_FR').name, 'French')
+
+ category3 = self.env['res.partner.category'].with_context(lang='en_US').create({'name': 'English'})
+ self.assertEqual(category3.with_context(lang='en_US').name, 'English')
+ self.assertEqual(category3.with_context(lang='fr_FR').name, 'English')
+
+ category3.with_context(lang='fr_FR').name = 'French 2'
+ category3.with_context(lang='en_US').name = 'English 2'
+ self.assertEqual(category3.with_context(lang='fr_FR').name, 'French 2')
def test_03_fr_single(self):
self.env['res.lang']._activate_lang('fr_FR')
@@ -474,14 +436,16 @@ class TestTranslationWrite(TransactionCase):
self.assertEqual([('fr_FR', 'French / Français')], langs, "Test did not started with expected languages")
self.category.with_context(lang='fr_FR').write({'name': 'French Name'})
- source_name = self.category.with_context(lang=None).read(['name'])
- self.assertEqual(source_name[0]['name'], "French Name", "Reference field not updated")
- translations = self.env['ir.translation'].search([
- ('name', '=', 'res.partner.category,name'),
- ('res_id', '=', self.category.id),
- ('lang', '=', 'fr_FR'),
- ])
- self.assertEqual(len(translations), 0, "No French translation should be created when writing in French")
+
+ fr_name = self.category.with_context(lang='fr_FR').read(['name'])
+ self.assertEqual(fr_name[0]['name'], "French Name", "Reference field not updated")
+
+ # read from the cache
+ self.assertEqual(self.category.with_context(lang='fr_FR').name, "French Name")
+
+ # read from database
+ self.category.invalidate_recordset()
+ self.assertEqual(self.category.with_context(lang='fr_FR').name, "French Name")
def test_04_fr_multi(self):
self.env['res.lang']._activate_lang('fr_FR')
@@ -490,58 +454,31 @@ class TestTranslationWrite(TransactionCase):
self.assertEqual([('en_US', 'English (US)'), ('fr_FR', 'French / Français')], langs,
"Test did not started with expected languages")
- category_en = self.category.with_context(lang='en_US')
- category_fr = self.category.with_context(lang='fr_FR')
+ po_string = '''
+ #. module: __export__
+ #: model:res.partner.category,name:%s
+ msgid "Reblochon"
+ msgstr "Translated Name"
+ ''' % self.category_xml_id
+ with io.BytesIO(bytes(po_string, encoding='utf-8')) as f:
+ f.name = 'dummy'
+ trans_load_data(self.env.cr, f, 'po', 'en_US', verbose=True, overwrite=True)
- # no translation at first
- self.assertEqual(category_fr.name, 'Reblochon')
- self.assertFalse(self.env['ir.translation'].search([
- ('name', '=', 'res.partner.category,name'),
- ('res_id', '=', self.category.id),
- ], order='lang'))
+ self.category.with_context(lang='fr_FR').write({'name': 'French Name'})
+ self.category.with_context(lang='en_US').write({'name': 'English Name'})
- # change source
- self.category.write({'name': 'Blorb'})
- self.assertEqual(category_fr.name, 'Blorb')
- self.assertFalse(self.env['ir.translation'].search([
- ('name', '=', 'res.partner.category,name'),
- ('res_id', '=', self.category.id),
- ], order='lang'))
+ # read from the cache first
+ self.assertEqual(self.category.with_context(lang=None).name, "English Name")
+ self.assertEqual(self.category.with_context(lang='fr_FR').name, "French Name")
+ self.assertEqual(self.category.with_context(lang='en_US').name, "English Name")
- # change source
- category_en.write({'name': 'Cheese'})
- self.assertEqual(category_fr.name, 'Cheese')
- translations = self.env['ir.translation'].search([
- ('name', '=', 'res.partner.category,name'),
- ('res_id', '=', self.category.id),
- ], order='lang')
- self.assertRecordValues(translations, [
- {'src': 'Cheese', 'value': 'Cheese', 'lang': 'en_US'},
- ])
+ # force save to database and clear the cache: force a clean state
+ self.category.invalidate_recordset()
- # add a translation
- category_fr.write({'name': 'French Name'})
- self.assertEqual(category_en.name, 'Cheese')
- translations = self.env['ir.translation'].search([
- ('name', '=', 'res.partner.category,name'),
- ('res_id', '=', self.category.id),
- ], order='lang')
- self.assertRecordValues(translations, [
- {'src': 'Cheese', 'value': 'Cheese', 'lang': 'en_US'},
- {'src': 'Cheese', 'value': 'French Name', 'lang': 'fr_FR'},
- ])
-
- # change source
- category_en.write({'name': 'English Name'})
- self.assertEqual(category_fr.name, 'French Name')
- translations = self.env['ir.translation'].search([
- ('name', '=', 'res.partner.category,name'),
- ('res_id', '=', self.category.id),
- ], order='lang')
- self.assertRecordValues(translations, [
- {'src': 'English Name', 'value': 'English Name', 'lang': 'en_US'},
- {'src': 'English Name', 'value': 'French Name', 'lang': 'fr_FR'}
- ])
+ # read from database
+ self.assertEqual(self.category.with_context(lang=None).name, "English Name")
+ self.assertEqual(self.category.with_context(lang='fr_FR').name, "French Name")
+ self.assertEqual(self.category.with_context(lang='en_US').name, "English Name")
def test_04_fr_multi_no_en(self):
self.env['res.lang']._activate_lang('fr_FR')
@@ -557,17 +494,18 @@ class TestTranslationWrite(TransactionCase):
self.category.with_context(lang='es_ES').write({'name': 'Spanish Name'})
self.category.with_context(lang=None).write({'name': 'None Name'})
- translations = self.env['ir.translation'].search([
- ('name', '=', 'res.partner.category,name'),
- ('res_id', '=', self.category.id),
- ], order='lang')
- self.assertRecordValues(translations, [
- {'src': 'None Name', 'value': 'Spanish Name', 'lang': 'es_ES'},
- {'src': 'None Name', 'value': 'French Name', 'lang': 'fr_FR'},
- ])
+ # read from the cache first
+ self.assertEqual(self.category.with_context(lang='fr_FR').name, "French Name")
+ self.assertEqual(self.category.with_context(lang='es_ES').name, "Spanish Name")
+ self.assertEqual(self.category.with_context(lang=None).name, "None Name")
- def test_05_remove_multi_empty_string(self):
- self._test_05_remove_multi("")
+ # force save to database and clear the cache: force a clean state
+ self.category.invalidate_recordset()
+
+ # read from database
+ self.assertEqual(self.category.with_context(lang='fr_FR').name, "French Name")
+ self.assertEqual(self.category.with_context(lang='es_ES').name, "Spanish Name")
+ self.assertEqual(self.category.with_context(lang=None).name, "None Name")
def test_05_remove_multi_false(self):
self._test_05_remove_multi(False)
@@ -584,142 +522,100 @@ class TestTranslationWrite(TransactionCase):
belgium.with_context(lang='en_US').write({'vat_label': 'VAT'})
belgium.with_context(lang='fr_FR').write({'vat_label': 'TVA'})
- translations = self.env['ir.translation'].search([
- ('name', '=', 'res.country,vat_label'),
- ('res_id', '=', belgium.id),
- ])
- self.assertEqual(len(translations), 2, "Translations are not created")
-
# remove the value
belgium.with_context(lang='fr_FR').write({'vat_label': empty_value})
# should recover the initial value from db
- self.assertFalse(
+ self.assertEqual(
+ empty_value,
belgium.with_context(lang='fr_FR').vat_label,
- "Value was not reset"
+ "Value should be the empty_value"
)
- self.assertFalse(
+ self.assertEqual(
+ empty_value,
belgium.with_context(lang='en_US').vat_label,
- "Value was not reset in other languages"
+ "Value should be the empty_value"
)
- self.assertFalse(
+ self.assertEqual(
+ empty_value,
belgium.with_context(lang=None).vat_label,
- "Value was not reset on the field model"
+ "Value should be the empty_value"
)
- translations = self.env['ir.translation'].search([
- ('name', '=', 'res.country,vat_label'),
- ('res_id', '=', belgium.id),
- ])
- self.assertEqual(len(translations), 0, "Translations were not removed")
-
- # simulate remove the English translation in the interface
- belgium.with_context(lang='fr_FR').write({'vat_label': 'TVA'})
belgium.with_context(lang='en_US').write({'vat_label': 'VAT'})
- self.env['ir.translation'].translate_fields('res.country', belgium.id, 'vat_label')
- en_translation = self.env['ir.translation'].search([
- ('name', '=', 'res.country,vat_label'),
- ('res_id', '=', belgium.id),
- ('lang', '=', 'en_US'),
- ])
- en_translation.write({'value': ''})
+ belgium.with_context(lang='fr_FR').write({'vat_label': 'TVA'})
- # should recover the initial value from db
+ # remove the value
+ belgium.with_context(lang='en_US').write({'vat_label': empty_value})
self.assertEqual(
- "TVA", belgium.with_context(lang='fr_FR').vat_label,
- "French translation was not kept"
+ empty_value,
+ belgium.with_context(lang='fr_FR').vat_label,
+ "Value should be the empty_value"
)
self.assertEqual(
- "VAT", belgium.with_context(lang='en_US').vat_label,
- "Did not fallback to source when reset"
+ empty_value,
+ belgium.with_context(lang='en_US').vat_label,
+ "Value should be the empty_value"
+ )
+ self.assertEqual(
+ empty_value,
+ belgium.with_context(lang=None).vat_label,
+ "Value should be the empty_value"
)
- def test_orphan(self):
- """ What happens with orphan translations. """
+ def test_write_empty_and_value(self):
self.env['res.lang']._activate_lang('fr_FR')
+ self.env['res.lang']._activate_lang('nl_NL')
- # create a user with access rights on partner categories
- user = new_test_user(self.env, 'deleter')
- group = self.env.ref('base.group_partner_manager')
- user.groups_id = [(4, group.id)]
+ langs = self.env['res.lang'].get_installed()
+ self.assertEqual([('nl_NL', 'Dutch / Nederlands'), ('en_US', 'English (US)'), ('fr_FR', 'French / Français')], langs,
+ "Test did not started with expected languages")
- # this access rule triggers a MissingError
- self.env['ir.rule'].create({
- 'model_id': self.env['ir.model']._get_id('res.partner.category'),
- 'groups': [(4, group.id)],
- 'domain_force': "[('name', 'ilike', 'e')]",
- })
+ belgium = self.env.ref('base.be')
+ # vat_label is translatable and not required
+ belgium.with_context(lang='en_US').write({'vat_label': 'VAT_US'})
+ belgium.with_context(lang='fr_FR').write({'vat_label': 'VAT_FR'})
+ belgium.with_context(lang='nl_NL').write({'vat_label': 'VAT_NL'})
- # create a translation, and delete the record from the database
- translation = self.env['ir.translation'].create({
- 'type': 'model',
- 'name': 'res.partner.category,name',
- 'lang': 'fr_FR',
- 'res_id': self.category.id,
- 'src': 'Reblochon',
- 'value': 'Parfum Exquis',
- 'state': 'translated',
- })
- self.env.flush_all()
- self.env.invalidate_all()
- self.cr.execute("DELETE FROM res_partner_category WHERE id=%s", [self.category.id])
+ belgium.invalidate_recordset()
- # deleting the translation should be possible, provided the user has
- # access rights on the translation's model
- user0 = new_test_user(self.env, 'cannot modify category')
- with self.assertRaises(AccessError):
- translation.with_user(user0).unlink()
+ belgium.with_context(lang='en_US').write({'vat_label': False})
+ belgium.with_context(lang='fr_FR').write({'vat_label': 'TVA_FR2'})
+ self.assertEqual(belgium.with_context(lang='en_US').vat_label, 'TVA_FR2')
+ self.assertEqual(belgium.with_context(lang='nl_NL').vat_label, 'TVA_FR2')
- translation.with_user(user).unlink()
+ belgium.with_context(lang='fr_FR').write({'vat_label': 'TVA_FR3'})
+ belgium.with_context(lang='en_US').write({'vat_label': ''})
+ self.assertEqual(belgium.with_context(lang='en_US').vat_label, '')
+ self.assertEqual(belgium.with_context(lang='nl_NL').vat_label, '')
- # however, creating orphan translations should not be possible
- with self.assertRaises(ValidationError):
- translation.with_user(user).create({
- 'type': 'model',
- 'name': 'res.partner.category,name',
- 'lang': 'fr_FR',
- 'res_id': self.category.id,
- 'src': 'Reblochon',
- 'value': 'Parfum Exquis',
- 'state': 'translated',
- })
+ def test_cresate_emtpy_false(self):
+ self._test_create_empty(False)
- def test_write(self):
- """ What happens with orphan translations. """
+ # feature removed
+ # def test_cresate_emtpy_empty_string(self):
+ # self._test_create_empty('')
+
+ def _test_create_empty(self, empty_value):
self.env['res.lang']._activate_lang('fr_FR')
+ langs = self.env['res.lang'].get_installed()
+ self.assertEqual([('en_US', 'English (US)'), ('fr_FR', 'French / Français')], langs,
+ "Test did not started with expected languages")
- # create a user with access rights on partner categories
- user = new_test_user(self.env, 'updater')
- group = self.env.ref('base.group_system')
- user.groups_id = [(4, group.id)]
- action = user.env["ir.actions.act_window"].create({
- "name": "Dummy Action",
- "res_model": "res.users",
- "help": "
Cheese
",
- })
+ group = self.env['res.groups'].create({'name': 'test_group', 'comment': empty_value})
+ self.assertEqual(group.with_context(lang='en_US').comment, empty_value)
+ self.assertEqual(group.with_context(lang='fr_FR').comment, empty_value)
- # create a translation, and delete the record from the database
- translation = user.env['ir.translation'].create({
- 'type': 'model_terms',
- 'name': 'ir.actions.act_window,help',
- 'lang': 'fr_FR',
- 'res_id': action.id,
- 'src': 'Cheese',
- 'value': 'Fromage',
- 'state': 'translated',
- })
- self.env.flush_all()
- self.env.invalidate_all()
+ group.with_context(lang='fr_FR').comment = 'French comment'
+ self.assertEqual(group.with_context(lang='fr_FR').comment, 'French comment')
+ self.assertEqual(group.with_context(lang='en_US').comment, 'French comment')
- # deleting the translation should be possible, provided the user has
- # access rights on the translation's model
- user0 = new_test_user(self.env, 'cannot modify an action')
- with self.assertRaises(AccessError):
- translation.with_user(user0).unlink()
-
- translation.with_user(user).unlink()
+ group.with_context(lang='fr_FR').comment = 'French comment 2'
+ self.assertEqual(group.with_context(lang='fr_FR').comment, 'French comment 2')
+ self.assertEqual(group.with_context(lang='en_US').comment, 'French comment')
def test_field_selection(self):
""" Test translations of field selections. """
+ self.env['res.lang']._activate_lang('fr_FR')
field = self.env['ir.model']._fields['state']
self.assertEqual([key for key, _ in field.selection], ['manual', 'base'])
@@ -742,14 +638,17 @@ class TestTranslationWrite(TransactionCase):
# add translation for the string of field ir.model.name
ir_model_field = self.env['ir.model.fields']._get('ir.model', 'name')
LABEL = "Description du Modèle"
- self.env['ir.translation'].create({
- 'type': 'model',
- 'name': 'ir.model.fields,field_description',
- 'lang': 'fr_FR',
- 'res_id': ir_model_field.id,
- 'src': 'Name',
- 'value': LABEL,
- })
+
+ ir_model_field_xml_id = ir_model_field.export_data(['id']).get('datas')[0][0]
+ po_string = '''
+ #. module: __export__
+ #: model:ir.model.fields,field_description:%s
+ msgid "Model Description"
+ msgstr "%s"
+ ''' % (ir_model_field_xml_id, LABEL)
+ with io.BytesIO(bytes(po_string, encoding='utf-8')) as f:
+ f.name = 'dummy'
+ trans_load_data(self.env.cr, f, 'po', 'fr_FR', verbose=True, overwrite=True)
# check that fields_get() returns the expected label
model = self.env['ir.model'].with_context(lang='fr_FR')
@@ -775,23 +674,15 @@ class TestXMLTranslation(TransactionCase):
'model': 'res.partner',
'arch': archf % terms,
})
- # DLE P70: `_sync_terms_translations`, which delete translations for which there is no value, is called sooner than before
- # because it's called in `_write`, which is called by `flush`, which is called by the `search`.
- # `arch_db` is in `_write` instead of `create` because `arch_db` is the inverse of `arch`.
- # We need to flush `arch_db` before creating the translations otherwise the translation for which there is no value will be deleted,
- # while the `test_sync_update` specifically needs empty translations
- self.env.flush_all()
+ view.invalidate_recordset()
+
+ val = {'en_US': archf % terms}
for lang, trans_terms in kwargs.items():
- for src, val in zip(terms, trans_terms):
- self.env['ir.translation'].create({
- 'type': 'model_terms',
- 'name': 'ir.ui.view,arch_db',
- 'lang': lang,
- 'res_id': view.id,
- 'src': src,
- 'value': val,
- 'state': 'translated',
- })
+ val[lang] = archf % trans_terms
+ query = """UPDATE ir_ui_view
+ SET arch_db = %s
+ WHERE id = %s"""
+ self.env.cr.execute(query, (Json(val), view.id))
return view
def test_copy(self):
@@ -801,7 +692,7 @@ class TestXMLTranslation(TransactionCase):
terms_fr = ('Couteau', 'Fourchette', 'Cuiller')
view0 = self.create_view(archf, terms_en, fr_FR=terms_fr)
- env_en = self.env(context={})
+ env_en = self.env(context={'lang': 'en_US'})
env_fr = self.env(context={'lang': 'fr_FR'})
# check translated field
@@ -865,17 +756,16 @@ class TestXMLTranslation(TransactionCase):
view.with_env(env_fr).write({'arch_db': archf % new_terms_fr})
# check whether translations have been synchronized
- self.assertEqual(view.with_env(env_nolang).arch_db, archf % new_terms_fr)
self.assertEqual(view.with_env(env_en).arch_db, archf % terms_en)
self.assertEqual(view.with_env(env_fr).arch_db, archf % new_terms_fr)
self.assertEqual(view.with_env(env_nl).arch_db, archf % terms_nl)
def test_sync_xml(self):
""" Check translations of 'arch' after xml tags changes in source terms. """
- archf = ''
- terms_en = ('Bread and cheese',)
- terms_fr = ('Pain et fromage',)
- terms_nl = ('Brood and kaas',)
+ archf = ''
+ terms_en = ('Bread and cheese', 'Fork')
+ terms_fr = ('Pain et fromage', 'Fourchette')
+ terms_nl = ('Brood and kaas', 'Vork')
view = self.create_view(archf, terms_en, en_US=terms_en, fr_FR=terms_fr, nl_NL=terms_nl)
env_nolang = self.env(context={})
@@ -883,57 +773,30 @@ class TestXMLTranslation(TransactionCase):
env_fr = self.env(context={'lang': 'fr_FR'})
env_nl = self.env(context={'lang': 'nl_NL'})
- self.assertEqual(view.with_env(env_nolang).arch, archf % terms_en)
- self.assertEqual(view.with_env(env_en).arch, archf % terms_en)
- self.assertEqual(view.with_env(env_fr).arch, archf % terms_fr)
- self.assertEqual(view.with_env(env_nl).arch, archf % terms_nl)
+ self.assertEqual(view.with_env(env_nolang).arch_db, archf % terms_en)
+ self.assertEqual(view.with_env(env_en).arch_db, archf % terms_en)
+ self.assertEqual(view.with_env(env_fr).arch_db, archf % terms_fr)
+ self.assertEqual(view.with_env(env_nl).arch_db, archf % terms_nl)
# modify source term in view (add css style)
- terms_en = ('Bread and cheese',)
- view.with_env(env_en).write({'arch': archf % terms_en})
+ terms_en = ('Bread and cheese', 'Fork')
+ view.with_env(env_en).write({'arch_db': archf % terms_en})
# check whether translations have been kept
- self.assertEqual(view.with_env(env_nolang).arch, archf % terms_en)
- self.assertEqual(view.with_env(env_en).arch, archf % terms_en)
- self.assertEqual(view.with_env(env_fr).arch, archf % terms_fr)
- self.assertEqual(view.with_env(env_nl).arch, archf % terms_nl)
+ self.assertEqual(view.with_env(env_nolang).arch_db, archf % terms_en)
+ self.assertEqual(view.with_env(env_en).arch_db, archf % terms_en)
+ self.assertEqual(view.with_env(env_fr).arch_db, archf % terms_fr)
+ self.assertEqual(view.with_env(env_nl).arch_db, archf % terms_nl)
# modify source term in view (actual text change)
- terms_en = ('Bread and butter',)
- view.with_env(env_en).write({'arch': archf % terms_en})
+ terms_en = ('Bread and butter', 'Fork')
+ view.with_env(env_en).write({'arch_db': archf % terms_en})
# check whether translations have been reset
- self.assertEqual(view.with_env(env_nolang).arch, archf % terms_en)
- self.assertEqual(view.with_env(env_en).arch, archf % terms_en)
- self.assertEqual(view.with_env(env_fr).arch, archf % terms_en)
- self.assertEqual(view.with_env(env_nl).arch, archf % terms_en)
-
- def test_sync_update(self):
- """ Check translations after major changes in source terms. """
- archf = ''
- terms_src = ('Subtotal', 'Subtotal:')
- terms_en = ('', 'Sub total:')
- view = self.create_view(archf, terms_src, en_US=terms_en)
-
- translations = self.env['ir.translation'].search([
- ('type', '=', 'model_terms'),
- ('name', '=', "ir.ui.view,arch_db"),
- ('res_id', '=', view.id),
- ])
- self.assertEqual(len(translations), 2)
-
- # modifying the arch should sync existing translations without errors
- new_arch = archf % ('Subtotal', 'Subtotal : ')
- view.write({"arch_db": new_arch})
-
- translations = self.env['ir.translation'].search([
- ('type', '=', 'model_terms'),
- ('name', '=', "ir.ui.view,arch_db"),
- ('res_id', '=', view.id),
- ])
- # 'Subtotal' being src==value, it will be discared
- # 'Subtotal:' will be discarded as it match 'Subtotal' instead of 'Subtotal : '
- self.assertEqual(len(translations), 0)
+ self.assertEqual(view.with_env(env_nolang).arch_db, archf % terms_en)
+ self.assertEqual(view.with_env(env_en).arch_db, archf % terms_en)
+ self.assertEqual(view.with_env(env_fr).arch_db, archf % (terms_en[0], terms_fr[1]))
+ self.assertEqual(view.with_env(env_nl).arch_db, archf % (terms_en[0], terms_nl[1]))
def test_cache_consistency(self):
view = self.env["ir.ui.view"].create({
@@ -950,6 +813,35 @@ class TestXMLTranslation(TransactionCase):
self.assertIn("", view.arch_db)
self.assertIn("", view_fr.arch_db)
+ # TODO 1. add method to translate html/xml field. 2. add tests new translation method
+ def test_update_field_translations(self):
+ archf = ''
+ terms_en = ('Bread and cheese', 'Fork')
+ terms_fr = ('Pain et fromage', 'Fourchette')
+ terms_nl = ('Brood and kaas', 'Vork')
+ view = self.create_view(archf, terms_en, fr_FR=terms_fr, nl_NL=terms_nl)
+
+ # cache arch_db
+ view.arch_db
+ view.with_context(lang='en_US').arch_db
+ view.with_context(lang='fr_FR').arch_db
+ view_nl = view.with_context(lang='nl_NL').arch_db
+
+ view.update_field_translations('arch_db', {
+ 'en_US': {'Fork': 'Fork2'},
+ 'fr_FR': {'Fourchette': 'Fourchette2'}
+ })
+
+ self.assertEqual(view.arch_db, '')
+ self.assertEqual(view.with_context(lang='en_US').arch_db, '')
+ self.assertEqual(view.with_context(lang='fr_FR').arch_db, '')
+ self.assertEqual(view.with_context(lang='nl_NL').arch_db, view_nl)
+
+ view.invalidate_recordset()
+ self.assertEqual(view.arch_db, '')
+ self.assertEqual(view.with_context(lang='en_US').arch_db, '')
+ self.assertEqual(view.with_context(lang='fr_FR').arch_db, '')
+ self.assertEqual(view.with_context(lang='nl_NL').arch_db, view_nl)
@tagged('post_install', '-at_install')
class TestLanguageInstallPerformance(TransactionCase):
diff --git a/odoo/addons/base/tests/test_views.py b/odoo/addons/base/tests/test_views.py
index bd59399e898..5e07bd81754 100644
--- a/odoo/addons/base/tests/test_views.py
+++ b/odoo/addons/base/tests/test_views.py
@@ -9,6 +9,7 @@ from functools import partial
from lxml import etree
from lxml.builder import E
from psycopg2 import IntegrityError
+from psycopg2.extras import Json
from odoo.exceptions import AccessError, ValidationError
from odoo.tests import common
@@ -295,26 +296,12 @@ class TestViewInheritance(ViewCase):
self.env['res.lang']._activate_lang('fr_FR')
v = self.makeView("T", arch='')
- self.env['ir.translation']._upsert_translations([{
- 'type': 'model_terms',
- 'name': 'ir.ui.view,arch_db',
- 'lang': 'fr_FR',
- 'res_id': v.id,
- 'src': 'Foo',
- 'value': 'Fou',
- }, {
- 'type': 'model_terms',
- 'name': 'ir.ui.view,arch_db',
- 'lang': 'fr_FR',
- 'res_id': v.id,
- 'src': 'Bar',
- 'value': 'Barre',
- }])
+ v.update_field_translations('arch_db', {'fr_FR': {'Foo': 'Fou', 'Bar': 'Barre'}})
self.assertEqual(v.arch, '')
# modify v to discard translations; this should not invalidate 'arch'!
- v.arch = ''
- self.assertEqual(v.arch, '')
+ v.arch = ''
+ self.assertEqual(v.arch, '')
def test_get_combined_arch_query_count(self):
# If the query count increases, you probably made the view combination
@@ -744,14 +731,7 @@ class TestNoModel(ViewCase):
'inherit_id': False,
'type': 'qweb',
})
- self.env['ir.translation'].create({
- 'type': 'model_terms',
- 'name': 'ir.ui.view,arch_db',
- 'res_id': view.id,
- 'lang': 'fr_FR',
- 'src': TEXT_EN,
- 'value': TEXT_FR,
- })
+ view.update_field_translations('arch_db', {'fr_FR': {TEXT_EN: TEXT_FR}})
view = view.with_context(lang='fr_FR')
self.assertEqual(view.arch, ARCH % TEXT_FR)
@@ -1516,6 +1496,9 @@ class TestViews(ViewCase):
kw.pop('id', None)
kw.setdefault('mode', 'extension' if kw.get('inherit_id') else 'primary')
kw.setdefault('active', True)
+ if 'arch_db' in kw:
+ arch_db = kw['arch_db']
+ kw['arch_db'] = Json({'en_US': arch_db}) if self.env.lang == 'en_US' else Json({'en_US': arch_db, self.env.lang: arch_db})
keys = sorted(kw)
fields = ','.join('"%s"' % (k.replace('"', r'\"'),) for k in keys)
@@ -3216,7 +3199,7 @@ class TestViewTranslations(common.TransactionCase):
super().setUpClass()
cls.env['res.lang']._activate_lang('fr_FR')
cls.env['res.lang']._activate_lang('nl_NL')
- cls.env['ir.translation']._load_module_terms(['base'], ['fr_FR', 'nl_NL'])
+ cls.env['ir.module.module']._load_module_terms(['base'], ['fr_FR', 'nl_NL'])
def create_view(self, archf, terms, **kwargs):
view = self.env['ir.ui.view'].create({
@@ -3230,19 +3213,12 @@ class TestViewTranslations(common.TransactionCase):
# We need to flush `arch_db` before creating the translations otherwise the translation for which there is no value will be deleted,
# while the `test_sync_update` specifically needs empty translations
self.env.flush_all()
- self.env['ir.translation'].create([
- {
- 'type': 'model_terms',
- 'name': 'ir.ui.view,arch_db',
- 'lang': lang,
- 'res_id': view.id,
- 'src': src,
- 'value': val,
- 'state': 'translated',
- }
- for lang, trans_terms in kwargs.items()
- for src, val in zip(terms, trans_terms)
- ])
+ val = {'en_US': archf % terms}
+ for lang, trans_terms in kwargs.items():
+ val[lang] = archf % trans_terms
+ query = "UPDATE ir_ui_view SET arch_db = %s WHERE id = %s"
+ self.env.cr.execute(query, [Json(val), view.id])
+ self.env.invalidate_all()
return view
def test_sync(self):
@@ -3279,7 +3255,7 @@ class TestViewTranslations(common.TransactionCase):
view.with_env(env_fr).write({'arch': archf % new_terms_fr})
# check whether translations have been synchronized
- self.assertEqual(view.with_env(env_nolang).arch, archf % new_terms_fr)
+ self.assertEqual(view.with_env(env_nolang).arch, archf % terms_en)
self.assertEqual(view.with_env(env_en).arch, archf % terms_en)
self.assertEqual(view.with_env(env_fr).arch, archf % new_terms_fr)
self.assertEqual(view.with_env(env_nl).arch, archf % terms_nl)
@@ -3329,27 +3305,11 @@ class TestViewTranslations(common.TransactionCase):
terms_en = ('', 'Sub total:')
view = self.create_view(archf, terms_src, en_US=terms_en)
- translations = self.env['ir.translation'].search([
- ('type', '=', 'model_terms'),
- ('name', '=', "ir.ui.view,arch_db"),
- ('res_id', '=', view.id),
- ])
- self.assertEqual(len(translations), 2)
-
# modifying the arch should sync existing translations without errors
new_arch = archf % ('Subtotal', 'Subtotal : ')
view.write({"arch": new_arch})
self.assertEqual(view.arch, new_arch)
- translations = self.env['ir.translation'].search([
- ('type', '=', 'model_terms'),
- ('name', '=', "ir.ui.view,arch_db"),
- ('res_id', '=', view.id),
- ])
- # 'Subtotal' being src==value, it will be discared
- # 'Subtotal:' will be discarded as it match 'Subtotal' instead of 'Subtotal : '
- self.assertEqual(len(translations), 0)
-
def test_cache_consistency(self):
view = self.env["ir.ui.view"].create({
"name": "test_translate_xml_cache_invalidation",
diff --git a/odoo/addons/base/views/ir_translation_views.xml b/odoo/addons/base/views/ir_translation_views.xml
deleted file mode 100644
index 657b1247313..00000000000
--- a/odoo/addons/base/views/ir_translation_views.xml
+++ /dev/null
@@ -1,112 +0,0 @@
-
-
-
-
- ir.translation
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ir.translation
-
-
-
-
-
- ir.translation
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ir.translation
-
-
-
-
-
-
-
-
-
-
-
-
- ir.translation
-
-
-
-
-
-
-
-
-
- ir.translation
-
-
-
-
-
-
-
-
-
-
- Translated Terms
- ir.translation
-
-
-
-
-
-
diff --git a/odoo/addons/base/views/ir_ui_view_views.xml b/odoo/addons/base/views/ir_ui_view_views.xml
index aa0a2003e7e..c989e819c07 100644
--- a/odoo/addons/base/views/ir_ui_view_views.xml
+++ b/odoo/addons/base/views/ir_ui_view_views.xml
@@ -28,8 +28,7 @@
-
+
diff --git a/odoo/addons/base/wizard/__init__.py b/odoo/addons/base/wizard/__init__.py
index 2eedfd6bba5..dba34e4bbe8 100644
--- a/odoo/addons/base/wizard/__init__.py
+++ b/odoo/addons/base/wizard/__init__.py
@@ -7,5 +7,4 @@ from . import base_import_language
from . import base_module_upgrade
from . import base_module_uninstall
from . import base_export_language
-from . import base_update_translations
from . import base_partner_merge
diff --git a/odoo/addons/base/wizard/base_language_install.py b/odoo/addons/base/wizard/base_language_install.py
index 67f9e4924a2..0cd328c4550 100644
--- a/odoo/addons/base/wizard/base_language_install.py
+++ b/odoo/addons/base/wizard/base_language_install.py
@@ -38,7 +38,6 @@ class BaseLanguageInstall(models.TransientModel):
mods = self.env['ir.module.module'].search([('state', '=', 'installed')])
self.lang_ids.active = True
mods._update_translations(self.lang_ids.mapped('code'), self.overwrite)
- self.env.cr.execute('ANALYZE ir_translation')
if len(self.lang_ids) == 1:
return {
diff --git a/odoo/addons/base/wizard/base_update_translations.py b/odoo/addons/base/wizard/base_update_translations.py
deleted file mode 100644
index 40a99f4f133..00000000000
--- a/odoo/addons/base/wizard/base_update_translations.py
+++ /dev/null
@@ -1,38 +0,0 @@
-# -*- coding: utf-8 -*-
-# Part of Odoo. See LICENSE file for full copyright and licensing details.
-
-import tarfile
-import tempfile
-
-from odoo import api, fields, models, tools, _
-from odoo.exceptions import UserError
-
-
-class BaseUpdateTranslations(models.TransientModel):
- _name = 'base.update.translations'
- _description = 'Update Translations'
-
- @api.model
- def _get_languages(self):
- return self.env['res.lang'].get_installed()
-
-
- lang = fields.Selection(_get_languages, 'Language', required=True)
-
- @api.model
- def _get_lang_name(self, lang_code):
- lang = self.env['res.lang']._lang_get(lang_code)
- if not lang:
- raise UserError(_('No language with code "%s" exists', lang_code))
- return lang.name
-
- def act_update(self):
- with tempfile.NamedTemporaryFile() as buf:
- tools.trans_export(self.lang, ['all'], buf, 'tgz', self._cr)
- buf.seek(0)
- tar = tarfile.open(fileobj=buf)
- for file_info in tar:
- module_file = tar.extractfile(file_info)
- tools.trans_load_data(self._cr, module_file, 'po', self.lang, create_empty_translation=True)
- tar.close()
- return {'type': 'ir.actions.act_window_close'}
diff --git a/odoo/addons/base/wizard/base_update_translations_views.xml b/odoo/addons/base/wizard/base_update_translations_views.xml
deleted file mode 100644
index 6af126b648a..00000000000
--- a/odoo/addons/base/wizard/base_update_translations_views.xml
+++ /dev/null
@@ -1,29 +0,0 @@
-
-
-
-
- Generate Missing Terms
- base.update.translations
-
-
-
-
-
- Generate Missing Terms
- ir.actions.act_window
- base.update.translations
- form
- new
-
-
-
-
-
diff --git a/odoo/addons/test_impex/models.py b/odoo/addons/test_impex/models.py
index 9a2fdf40392..eaff023da29 100644
--- a/odoo/addons/test_impex/models.py
+++ b/odoo/addons/test_impex/models.py
@@ -1,11 +1,13 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
-from odoo import api, fields, models
+from odoo import api, fields, models, _
-
-def selection_fn(model):
- return [(str(key), val) for key, val in enumerate(["Corge", "Grault", "Wheee", "Moog"])]
+def selection_fn(self):
+ return [
+ (str(key), val)
+ for key, val in enumerate([_("Corge"), _("Grault"), _("Wheee"), _("Moog")])
+ ]
def compute_fn(records):
for record in records:
diff --git a/odoo/addons/test_impex/tests/test_load.py b/odoo/addons/test_impex/tests/test_load.py
index a11bd44a88b..b9d365baf61 100644
--- a/odoo/addons/test_impex/tests/test_load.py
+++ b/odoo/addons/test_impex/tests/test_load.py
@@ -9,6 +9,7 @@ from odoo import fields
from odoo.addons.base.tests.common import SavepointCaseWithUserDemo
from odoo.tests import common
from odoo.tools.misc import mute_logger
+from odoo.tools.translate import code_translations
def message(msg, type='error', from_=0, to_=0, record=0, field='value', **kwargs):
return dict(kwargs,
@@ -77,19 +78,6 @@ class ImporterCase(common.TransactionCase):
})
return '__test__.' + name
- def add_translations(self, name, type, code, *tnx):
- self.env['res.lang']._activate_lang(code)
- Translations = self.env['ir.translation']
- for source, value in tnx:
- Translations.create({
- 'name': name,
- 'lang': code,
- 'type': type,
- 'src': source,
- 'value': value,
- 'state': 'translated',
- })
-
class test_ids_stuff(ImporterCase):
model_name = 'export.integer'
@@ -153,18 +141,14 @@ class test_boolean_field(ImporterCase):
], values(records))
def test_falses(self):
- for lang, source, value in [('fr_FR', 'no', u'non'),
- ('de_DE', 'no', u'nein'),
- ('ru_RU', 'no', u'нет'),
- ('nl_BE', 'false', u'vals'),
- ('lt_LT', 'false', u'klaidingas')]:
- self.add_translations('test_import.py', 'code', lang, (source, value))
+ for lang in ['fr_FR', 'de_DE', 'ru_RU', 'nl_BE', 'lt_LT']:
+ self.env['res.lang']._activate_lang(lang)
falses = [[u'0'], [u'no'], [u'false'], [u'FALSE'], [u''],
- [u'non'], # no, fr
- [u'nein'], # no, de
- [u'нет'], # no, ru
- [u'vals'], # false, nl
- [u'klaidingas'], # false, lt,
+ [u'faux'], # false, fr
+ [u'falsch'], # false, de
+ [u'ложь'], # no, ru
+ [u'onwaar'], # false, nl
+ [u'ne'], # false, lt,
]
result = self.import_(['value'], falses)
@@ -448,11 +432,11 @@ class test_text(ImporterCase):
class test_selection(ImporterCase):
model_name = 'export.selection'
- translations_fr = [
- ("Foo", "tete"),
- ("Bar", "titi"),
- ("Qux", "toto"),
- ]
+ translations_fr = {
+ "Foo": "tete",
+ "Bar": "titi",
+ "Qux": "toto",
+ }
def test_imported(self):
result = self.import_(['value'], [
@@ -466,8 +450,11 @@ class test_selection(ImporterCase):
self.assertEqual(['3', '2', '1', '2'], values(self.read()))
def test_imported_translated(self):
- self.add_translations(
- 'ir.model.fields.selection,name', 'model', 'fr_FR', *self.translations_fr)
+ self.env['res.lang']._activate_lang('fr_FR')
+ # add translations for selection
+ selections = self.env['ir.model.fields.selection'].search([('name', 'in', list(self.translations_fr))])
+ for selection in selections:
+ selection.with_context(lang='fr_FR').name = self.translations_fr[selection.name]
result = self.import_(['value'], [
['toto'],
@@ -526,12 +513,12 @@ class test_selection_with_default(ImporterCase):
class test_selection_function(ImporterCase):
model_name = 'export.selection.function'
- translations_fr = [
- ("Corge", "toto"),
- ("Grault", "titi"),
- ("Wheee", "tete"),
- ("Moog", "tutu"),
- ]
+ translations_fr = {
+ "Corge": "toto",
+ "Grault": "titi",
+ "Wheee": "tete",
+ "Moog": "tutu",
+ }
def test_imported(self):
""" import uses fields_get, so translates import label (may or may not
@@ -550,8 +537,10 @@ class test_selection_function(ImporterCase):
def test_translated(self):
""" Expects output of selection function returns translated labels
"""
- self.add_translations(
- 'ir.model.fields.selection,name', 'model', 'fr_FR', *self.translations_fr)
+ self.env['res.lang']._activate_lang('fr_FR')
+ # hack into code translations
+ code_translations.python_translations[('test_impex', 'fr_FR')] = self.translations_fr
+ self.addCleanup(code_translations.python_translations.pop, ('test_impex', 'fr_FR'))
result = self.import_(['value'], [
['titi'],
diff --git a/odoo/addons/test_new_api/models/test_new_api.py b/odoo/addons/test_new_api/models/test_new_api.py
index 2458df5bc20..ffed8a9caa0 100644
--- a/odoo/addons/test_new_api/models/test_new_api.py
+++ b/odoo/addons/test_new_api/models/test_new_api.py
@@ -5,6 +5,7 @@ import logging
from odoo import models, fields, api, _, Command
from odoo.exceptions import AccessError, ValidationError
+from odoo.tools.translate import html_translate
class Category(models.Model):
@@ -1649,10 +1650,10 @@ class Prefetch(models.Model):
_description = 'A model to check the prefetching of fields (translated and group)'
name = fields.Char('Name', translate=True)
- description = fields.Char('Description', translate=True, prefetch=True)
- html_description = fields.Html('Styled description', translate=True, prefetch=True)
- rare_description = fields.Char('Rare Description', translate=True)
- rare_html_description = fields.Html('Rare Styled description', translate=True)
+ description = fields.Char('Description', translate=True)
+ html_description = fields.Html('Styled description', translate=True)
+ rare_description = fields.Char('Rare Description', translate=True, prefetch=False)
+ rare_html_description = fields.Html('Rare Styled description', translate=True, prefetch=False)
harry = fields.Integer('Harry Potter', prefetch='Harry Potter')
hermione = fields.Char('Hermione Granger', prefetch='Harry Potter')
ron = fields.Float('Ron Weasley', prefetch='Harry Potter')
@@ -1697,3 +1698,29 @@ class ModifiedLine(models.Model):
def _compute_total_price_quantity(self):
for rec in self:
rec.total_price_quantity = rec.total_price * rec.quantity
+
+
+class RelatedTranslation(models.Model):
+ _name = 'test_new_api.related_translation_1'
+ _description = 'A model to test translation for related fields'
+
+ name = fields.Char('Name', translate=True)
+ html = fields.Html('HTML', translate=html_translate)
+
+
+class RelatedTranslation2(models.Model):
+ _name = 'test_new_api.related_translation_2'
+ _description = 'A model to test translation for related fields'
+
+ parent_id = fields.Many2one('test_new_api.related_translation_1', string='Parent Model')
+ name = fields.Char('Name Related', related='parent_id.name', readonly=False)
+ html = fields.Html('HTML Related', related='parent_id.html', readonly=False)
+
+
+class RelatedTranslation3(models.Model):
+ _name = 'test_new_api.related_translation_3'
+ _description = 'A model to test translation for related fields'
+
+ parent_id = fields.Many2one('test_new_api.related_translation_2', string='Parent Model')
+ name = fields.Char('Name Related', related='parent_id.name', readonly=False)
+ html = fields.Html('HTML Related', related='parent_id.html', readonly=False)
diff --git a/odoo/addons/test_new_api/security/ir.model.access.csv b/odoo/addons/test_new_api/security/ir.model.access.csv
index 961e623492c..db6a005784e 100644
--- a/odoo/addons/test_new_api/security/ir.model.access.csv
+++ b/odoo/addons/test_new_api/security/ir.model.access.csv
@@ -96,3 +96,6 @@ access_test_new_api_precompute_monetary,access_test_new_api_precompute_monetary,
access_test_new_api_prefetch,access_test_new_api_prefetch,model_test_new_api_prefetch,,1,0,0,0
access_test_new_api_modified,access_test_new_api_modified,model_test_new_api_modified,base.group_user,1,1,1,1
access_test_new_api_modified_line,access_test_new_api_modified_line,model_test_new_api_modified_line,base.group_user,1,1,1,1
+access_test_new_api_related_translation_1,access_test_new_api_related_translation_1,model_test_new_api_related_translation_1,,1,1,1,1
+access_test_new_api_related_translation_2,access_test_new_api_related_translation_2,model_test_new_api_related_translation_2,,1,1,1,1
+access_test_new_api_related_translation_3,access_test_new_api_related_translation_3,model_test_new_api_related_translation_3,,1,1,1,1
diff --git a/odoo/addons/test_new_api/tests/__init__.py b/odoo/addons/test_new_api/tests/__init__.py
index 8b9033c0250..c2a4b097bc9 100644
--- a/odoo/addons/test_new_api/tests/__init__.py
+++ b/odoo/addons/test_new_api/tests/__init__.py
@@ -7,6 +7,7 @@ from . import test_one2many
from . import test_many2many
from . import test_properties
from . import test_qweb_float
+from . import test_related_translation
from . import test_ui
from . import test_domain
from . import test_schema
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 3eb16dabbbb..dc5a4805991 100644
--- a/odoo/addons/test_new_api/tests/test_new_fields.py
+++ b/odoo/addons/test_new_api/tests/test_new_fields.py
@@ -2155,7 +2155,6 @@ class TestFields(TransactionCaseWithUserDemo):
self.assertEqual(discussion2.categories.ids, category21.ids)
def test_80_copy(self):
- Translations = self.env['ir.translation']
discussion = self.env.ref('test_new_api.discussion_0')
message = self.env.ref('test_new_api.message_0_0')
message1 = self.env.ref('test_new_api.message_0_1')
@@ -2165,27 +2164,20 @@ class TestFields(TransactionCaseWithUserDemo):
self.env['res.lang']._activate_lang('fr_FR')
- def count(msg):
- # return the number of translations of msg.label
- return Translations.search_count([
- ('name', '=', 'test_new_api.message,label'),
- ('res_id', '=', msg.id),
- ])
-
# set a translation for message.label
email.with_context(lang='fr_FR').label = "bonjour"
- self.assertEqual(count(message), 1)
- self.assertEqual(count(message1), 0)
+ self.assertEqual(message.with_context(lang='fr_FR').label, 'bonjour')
+ self.assertFalse(message1.label)
# setting the parent record should not copy its translations
email.copy({'message': message1.id})
- self.assertEqual(count(message), 1)
- self.assertEqual(count(message1), 0)
+ self.assertEqual(message.with_context(lang='fr_FR').label, 'bonjour')
+ self.assertFalse(message1.label)
# setting a one2many should not copy translations on the lines
discussion.copy({'messages': [Command.set(message1.ids)]})
- self.assertEqual(count(message), 1)
- self.assertEqual(count(message1), 0)
+ self.assertEqual(message.with_context(lang='fr_FR').label, 'bonjour')
+ self.assertFalse(message1.label)
def test_85_binary_guess_zip(self):
from odoo.addons.base.tests.test_mimetypes import ZIP
@@ -2553,11 +2545,11 @@ class TestFields(TransactionCaseWithUserDemo):
# translated '_rec_name' field should be prefetched
self.assertTrue(Model.name.prefetch)
- # parameter 'prefetch' can be always overridden
+ # translated fields should be prefetch=True by default
self.assertTrue(Model.description.prefetch)
self.assertTrue(Model.html_description.prefetch)
- # translated fields should be prefetch=False by default
+ # parameter 'prefetch' can be always overridden
self.assertFalse(Model.rare_description.prefetch)
self.assertFalse(Model.rare_html_description.prefetch)
@@ -2587,9 +2579,9 @@ class TestFields(TransactionCaseWithUserDemo):
with self.assertQueries(["""
SELECT
"test_new_api_prefetch"."id" AS "id",
- "test_new_api_prefetch"."name" AS "name",
- "test_new_api_prefetch"."description" AS "description",
- "test_new_api_prefetch"."html_description" AS "html_description",
+ "test_new_api_prefetch"."name"->>'en_US' AS "name",
+ "test_new_api_prefetch"."description"->>'en_US' AS "description",
+ "test_new_api_prefetch"."html_description"->>'en_US' AS "html_description",
"test_new_api_prefetch"."create_uid" AS "create_uid",
"test_new_api_prefetch"."create_date" AS "create_date",
"test_new_api_prefetch"."write_uid" AS "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 2486e6d8ca1..d23689554c6 100644
--- a/odoo/addons/test_new_api/tests/test_properties.py
+++ b/odoo/addons/test_new_api/tests/test_properties.py
@@ -167,7 +167,7 @@ class PropertiesCase(TransactionCase):
expected_queries = [
# read the properties field value
'SELECT "test_new_api_message"."id" AS "id", "test_new_api_message"."attributes" AS "attributes" FROM "test_new_api_message" WHERE "test_new_api_message".id IN %s',
- 'SELECT "test_new_api_message"."id" AS "id", "test_new_api_message"."discussion" AS "discussion", "test_new_api_message"."body" AS "body", "test_new_api_message"."author" AS "author", "test_new_api_message"."name" AS "name", "test_new_api_message"."important" AS "important", "test_new_api_message"."priority" AS "priority", "test_new_api_message"."create_uid" AS "create_uid", "test_new_api_message"."create_date" AS "create_date", "test_new_api_message"."write_uid" AS "write_uid", "test_new_api_message"."write_date" AS "write_date" FROM "test_new_api_message" WHERE "test_new_api_message".id IN %s',
+ 'SELECT "test_new_api_message"."id" AS "id", "test_new_api_message"."discussion" AS "discussion", "test_new_api_message"."body" AS "body", "test_new_api_message"."author" AS "author", "test_new_api_message"."name" AS "name", "test_new_api_message"."important" AS "important", "test_new_api_message"."label"->>\'en_US\' AS "label", "test_new_api_message"."priority" AS "priority", "test_new_api_message"."create_uid" AS "create_uid", "test_new_api_message"."create_date" AS "create_date", "test_new_api_message"."write_uid" AS "write_uid", "test_new_api_message"."write_date" AS "write_date" FROM "test_new_api_message" WHERE "test_new_api_message".id IN %s',
# read the definition on the definition record
'SELECT "test_new_api_discussion"."id" AS "id", "test_new_api_discussion"."attributes_definition" AS "attributes_definition" FROM "test_new_api_discussion" WHERE "test_new_api_discussion".id IN %s',
# check the many2one existence
diff --git a/odoo/addons/test_new_api/tests/test_related_translation.py b/odoo/addons/test_new_api/tests/test_related_translation.py
new file mode 100644
index 00000000000..64c67e875c8
--- /dev/null
+++ b/odoo/addons/test_new_api/tests/test_related_translation.py
@@ -0,0 +1,174 @@
+# -*- coding: utf-8 -*-
+import io
+
+import odoo.tests
+from odoo.tools import trans_load_data
+
+
+@odoo.tests.tagged('post_install', '-at_install')
+class TestRelatedTranslation(odoo.tests.TransactionCase):
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.env['res.lang']._activate_lang('fr_FR')
+ cls.test1 = cls.env['test_new_api.related_translation_1'].with_context(lang='en_US').create({
+ 'name': 'Knife',
+ 'html': '
English 2'
+ # 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:
+ return False
+ new_translations = old_translations
+ for lang, translation in translations.items():
+ old_value = new_translations.get(lang) or new_translations.get('en_US')
+ translation_safe = {}
+ if digest:
+ old_terms = field.get_trans_terms(old_value)
+ old_terms_digested2value = {digest(old_term): old_term for old_term in old_terms}
+ translation = {old_terms_digested2value[key]: value for key, value in translation.items() if key in old_terms_digested2value}
+ for key, value in translation.items():
+ new_term = field.translate.term_converter(value)
+ if len(field.get_trans_terms(new_term)) == 1: # drop illegal new terms
+ translation_safe[key] = new_term
+ new_translations[lang] = field.translate(translation_safe.get, old_value)
+ self.env.cache.update_raw(self, field, [new_translations], dirty=True)
+ self.modified([field_name])
+ return True
+
+ def get_field_translations(self, field_name, langs=None):
+ """ get model/model_term translations for records
+ :param str field_name: field name
+ :param list langs: languages
+
+ :return dict translations: [(lang, val_en, val_lang)]
+ In the UI, translation_dialog.js
+ for model: val_en will be shown as the translation
+ for model term: val_en will be shown as the src
+ """
+ self.ensure_one()
+ 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]
+ if not callable(field.translate):
+ val_lang_func = lambda val_lang: val_lang if val_lang != val_en else ''
+ translations = [{
+ 'lang': lang,
+ 'source': val_en,
+ 'value': val_lang_func(self.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}
+ )
+ translations = [{
+ 'lang': lang,
+ 'source': term_en,
+ 'value': term_lang if term_lang != term_en else ''
+ } for term_en, translations in translation_dictionary.items()
+ for lang, term_lang in translations.items()]
+ context = {}
+ context['translation_type'] = 'text' if field.type in ['text', 'html'] else 'char'
+ context['translation_show_source'] = False
+ if callable(field.translate):
+ context['translation_show_source'] = True
+
+ return translations, context
+
def _read_format(self, fnames, load='_classic_read'):
"""Returns a list of dictionaries mapping field names to their values,
with one dictionary per record that exists.
@@ -3519,6 +3641,7 @@ class BaseModel(metaclass=MetaModel):
# determine columns fields and those with their own read() method
column_fields = []
other_fields = []
+ translated_field_names = []
for name in field_names:
if name == 'id':
continue
@@ -3527,11 +3650,12 @@ class BaseModel(metaclass=MetaModel):
_logger.warning("%s._read() with unknown field %r", self._name, name)
continue
if field.base_field.store and field.base_field.column_type:
- if not (field.inherited and callable(field.base_field.translate)):
- column_fields.append(field)
+ column_fields.append(field)
elif field.store and not field.column_type:
# non-column fields: for the sake of simplicity, we ignore inherited fields
other_fields.append(field)
+ if field.store and field.translate:
+ translated_field_names.append(field.name)
if column_fields:
cr, context = self.env.cr, self.env.context
@@ -3540,6 +3664,15 @@ class BaseModel(metaclass=MetaModel):
# an impact on checking security rules, as they are injected into
# the query. However, we don't need to flush the fields to fetch,
# as explained below when putting values in cache.
+
+ # Since only one language translation is fetched from database,
+ # we must flush these translated fields before read
+ # E.g. in database, the {'en_US': 'English'},
+ # write record.with_context(lang='en_US').name = 'English2'
+ # then record.with_context(lang='fr_FR').name => cache miss => _read
+ # 'English2'should is flushed before query as it is the fallback of empty 'fr_FR'
+ if translated_field_names:
+ self.flush_recordset(translated_field_names)
self._flush_search([], order='id')
# make a query object for selecting ids, and apply security rules to it
@@ -3583,11 +3716,6 @@ class BaseModel(metaclass=MetaModel):
# overwrite values in cache.
for field in column_fields:
values = next(column_values)
- # post-process translations
- if context.get('lang') and not field.inherited and callable(field.translate):
- if any(values):
- translate = field.get_trans_func(fetched)
- values = [translate(id_, value) for id_, value in zip(ids, values)]
# store values in cache, but without overwriting
self.env.cache.insert_missing(fetched, field, values)
@@ -4134,7 +4262,8 @@ class BaseModel(metaclass=MetaModel):
vals.setdefault('write_date', self.env.cr.now())
# determine SQL values
- columns = {} # {column_name: value}
+ columns = []
+ params = []
for name, val in sorted(vals.items()):
if self._log_access and name in LOG_ACCESS_COLUMNS and not val:
@@ -4142,13 +4271,21 @@ class BaseModel(metaclass=MetaModel):
field = self._fields[name]
assert field.store
assert field.column_type
- columns[name] = val
+ if field.translate is True and val:
+ # The first param is for the fallback value {'en_US': 'first_written_value'}
+ # which fills the 'en_US' key of jsonb only when the old column value is NULL.
+ # The second param is for the real value {'fr_FR': 'French', 'nl_NL': 'Dutch'}
+ columns.append(f'''"{name}" = %s || COALESCE("{name}", '{{}}'::jsonb) || %s''')
+ params.append(Json({} if 'en_US' in val.adapted else {'en_US': next(iter(val.adapted.values()))}))
+ params.append(val)
+ else:
+ columns.append(f'"{name}" = %s')
+ params.append(val)
# update columns
if columns:
- template = ', '.join(f'"{name}" = %s' for name in columns)
+ template = ', '.join(columns)
query = f'UPDATE "{self._table}" SET {template} WHERE id IN %s'
- params = list(columns.values())
for sub_ids in cr.split_for_in_conditions(self._ids):
cr.execute(query, params + [sub_ids])
@@ -4397,7 +4534,6 @@ class BaseModel(metaclass=MetaModel):
# insert rows in batches of maximum INSERT_BATCH_SIZE
ids = [] # ids of created records
other_fields = OrderedSet() # non-column fields
- translated_fields = OrderedSet() # translated fields
for data_sublist in split_every(INSERT_BATCH_SIZE, data_list):
stored_list = [data['stored'] for data in data_sublist]
@@ -4408,12 +4544,14 @@ class BaseModel(metaclass=MetaModel):
for fname in fnames:
field = self._fields[fname]
if field.column_type:
- if field.translate is True:
- translated_fields.add(field)
columns.append(fname)
for stored, row in zip(stored_list, rows):
if fname in stored:
- row.append(field.convert_to_column(stored[fname], self, stored))
+ colval = field.convert_to_column(stored[fname], self, stored)
+ if field.translate is True and colval:
+ if 'en_US' not in colval.adapted:
+ colval.adapted['en_US'] = next(iter(colval.adapted.values()))
+ row.append(colval)
else:
row.append(SQL_DEFAULT)
else:
@@ -4505,18 +4643,6 @@ class BaseModel(metaclass=MetaModel):
# check Python constraints for stored fields
records._validate_fields(name for data in data_list for name in data['stored'])
records.check_access_rule('create')
-
- # add translations
- if self.env.lang and self.env.lang != 'en_US':
- Translations = self.env['ir.translation']
- for field in translated_fields:
- tname = "%s,%s" % (field.model_name, field.name)
- for data in data_list:
- if field.name in data['stored']:
- record = data['record']
- val = data['stored'][field.name]
- Translations._set_ids(tname, 'model', self.env.lang, record.ids, val, val)
-
return records
def _compute_field_value(self, field):
@@ -4761,26 +4887,6 @@ class BaseModel(metaclass=MetaModel):
parent_alias = self._inherits_join_add(self, parent_model_name, query)
expression.expression(domain, parent_model.sudo(), parent_alias, query)
- @api.model
- def _generate_translated_field(self, table_alias, field, query):
- """
- Add possibly missing JOIN with translations table to ``query`` and
- generate the expression for the translated field.
-
- :return: the qualified field name (or expression) to use for ``field``
- """
- if self.env.lang:
- # for the COALESCE to work properly, the column must be flushed
- self.flush_model([field])
- alias = query.left_join(
- table_alias, 'id', 'ir_translation', 'res_id', field,
- extra='"{rhs}"."type" = \'model\' AND "{rhs}"."name" = %s AND "{rhs}"."lang" = %s AND "{rhs}"."value" != %s',
- extra_params=["%s,%s" % (self._name, field), self.env.lang, ""],
- )
- return 'COALESCE("%s"."%s", "%s"."%s")' % (alias, 'value', table_alias, field)
- else:
- return '"%s"."%s"' % (table_alias, field)
-
@api.model
def _generate_m2o_order_by(self, alias, order_field, query, reverse_direction, seen):
"""
@@ -5072,19 +5178,6 @@ class BaseModel(metaclass=MetaModel):
return
seen_map[old._name].add(old.id)
- def get_trans(field, old, new):
- """ Return the 'name' of the translations to search for, together
- with the record ids corresponding to ``old`` and ``new``.
- """
- if field.inherited:
- pname = field.related.split('.')[0]
- return get_trans(field.related_field, old[pname], new[pname])
- return "%s,%s" % (field.model_name, field.name), old.id, new.id
-
- # removing the lang to compare untranslated values
- old_wo_lang, new_wo_lang = (old + new).with_context(lang=None)
- Translation = old.env['ir.translation']
-
for name, field in old._fields.items():
if not field.copy:
continue
@@ -5105,30 +5198,26 @@ class BaseModel(metaclass=MetaModel):
# don't pass excluded as it is not about those lines
old_line.copy_translations(new_line)
- elif field.translate:
+ elif field.translate and field.store and name not in excluded and old[name]:
# for translatable fields we copy their translations
- trans_name, source_id, target_id = get_trans(field, old, new)
- domain = [('name', '=', trans_name), ('res_id', '=', source_id)]
- new_val = new_wo_lang[name]
- if old.env.lang and callable(field.translate):
- # the new value *without lang* must be the old value without lang
- new_wo_lang[name] = old_wo_lang[name]
- vals_list = []
- for vals in Translation.search_read(domain):
- del vals['id']
- del vals['module'] # duplicated vals is not linked to any module
- vals['res_id'] = target_id
- if not callable(field.translate):
- vals['src'] = new_wo_lang[name]
- if vals['lang'] == old.env.lang and field.translate is True:
- # update master record if the new_val was not changed by copy override
- if new_val == old[name]:
- new_wo_lang[name] = old_wo_lang[name]
- vals['src'] = old_wo_lang[name]
- # the value should be the new value (given by copy())
- vals['value'] = new_val
- vals_list.append(vals)
- Translation._upsert_translations(vals_list)
+ old_translations = field._get_stored_translations(old)
+ if not old_translations:
+ continue
+ lang = self.env.lang or 'en_US'
+ old_value_lang = old_translations.pop(lang, old_translations['en_US'])
+ if not old_translations:
+ continue
+ if not callable(field.translate):
+ new.update_field_translations(name, old_translations)
+ else:
+ # {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
+ new.update_field_translations(name, translations)
@api.returns('self', lambda value: value.id)
def copy(self, default=None):
@@ -5143,10 +5232,10 @@ class BaseModel(metaclass=MetaModel):
"""
self.ensure_one()
vals = self.with_context(active_test=False).copy_data(default)[0]
- # To avoid to create a translation in the lang of the user, copy_translation will do it
- new = self.with_context(lang=None).create(vals)
- self.with_context(from_copy_translation=True).copy_translations(new, excluded=default or ())
- return new
+ record_copy = self.create(vals)
+ self.with_context(from_copy_translation=True).copy_translations(record_copy, excluded=default or ())
+
+ return record_copy
@api.returns('self')
def exists(self):
@@ -5983,8 +6072,11 @@ class BaseModel(metaclass=MetaModel):
f" Context: {self.env.context}\n" \
f" Cache: {self.env.cache!r}"
for record, value in zip(records, values):
- value = field.convert_to_write(value, record)
- value = field.convert_to_column(value, record)
+ if not field.translate:
+ value = field.convert_to_write(value, record)
+ value = field.convert_to_column(value, record)
+ else:
+ value = field._convert_from_cache_to_column(value)
id_vals[record.id][field.name] = value
process(model, id_vals)
@@ -6791,10 +6883,15 @@ class BaseModel(metaclass=MetaModel):
cache = self.env.cache
for fname in fnames:
field = lines._fields[fname]
- cache.update(new_lines, field, [
- field.convert_to_cache(value, new_line, validate=False)
- for value, new_line in zip(cache.get_values(lines, field), new_lines)
- ])
+ if not field.translate:
+ cache.update(new_lines, field, [
+ field.convert_to_cache(value, new_line, validate=False)
+ for value, new_line in zip(cache.get_values(lines, field), new_lines)
+ ])
+ else:
+ cache.update_raw(
+ new_lines, field, map(copy.copy, cache.get_values(lines, field)),
+ )
# Isolate changed values, to handle inconsistent data sent from the
# client side: when a form view contains two one2many fields that
diff --git a/odoo/modules/db.py b/odoo/modules/db.py
index 3ab3c2a7bdb..872c54177b0 100644
--- a/odoo/modules/db.py
+++ b/odoo/modules/db.py
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
+from psycopg2.extras import Json
import logging
from enum import IntEnum
@@ -56,13 +57,13 @@ def initialize(cr):
category_id, auto_install, state, web, license, application, icon, sequence, summary) \
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id', (
info['author'],
- info['website'], i, info['name'],
- info['description'], category_id,
+ info['website'], i, Json({'en_US': info['name']}),
+ Json({'en_US': info['description']}), category_id,
info['auto_install'] is not False, state,
info['web'],
info['license'],
info['application'], info['icon'],
- info['sequence'], info['summary']))
+ info['sequence'], Json({'en_US': info['summary']})))
id = cr.fetchone()[0]
cr.execute('INSERT INTO ir_model_data \
(name,model,module, res_id, noupdate) VALUES (%s,%s,%s,%s,%s)', (
@@ -129,7 +130,7 @@ def create_categories(cr, categories):
if not c_id:
cr.execute('INSERT INTO ir_module_category \
(name, parent_id) \
- VALUES (%s, %s) RETURNING id', (categories[0], p_id))
+ VALUES (%s, %s) RETURNING id', (Json({'en_US': categories[0]}), p_id))
c_id = cr.fetchone()[0]
cr.execute('INSERT INTO ir_model_data (module, name, res_id, model, noupdate) \
VALUES (%s, %s, %s, %s, %s)', ('base', xml_id, c_id, 'ir.module.category', True))
diff --git a/odoo/modules/registry.py b/odoo/modules/registry.py
index 209e88c0993..5862ccaac0d 100644
--- a/odoo/modules/registry.py
+++ b/odoo/modules/registry.py
@@ -456,7 +456,7 @@ class Registry(Mapping):
def check_indexes(self, cr, model_names):
""" Create or drop column indexes for the given models. """
expected = [
- (f"{Model._table}_{field.name}_index", Model._table, field.name, field.index, getattr(field, 'unaccent', False))
+ (f"{Model._table}_{field.name}_index", Model._table, field, getattr(field, 'unaccent', False))
for model_name in model_names
for Model in [self.models[model_name]]
if Model._auto and not Model._abstract
@@ -470,18 +470,16 @@ class Registry(Mapping):
[tuple(row[0] for row in expected)])
existing = {row[0] for row in cr.fetchall()}
- for indexname, tablename, column_name, index, unaccent in expected:
+ for indexname, tablename, field, unaccent in expected:
+ column_expression = f'"{field.name}"'
+ index = field.index
assert index in ('btree', 'btree_not_null', 'trigram', True, False, None)
- if index and indexname not in existing:
- column_expression = f'"{column_name}"'
- method = 'btree'
- operator = ''
- where = ''
- if index == 'btree_not_null':
- where = f'{column_expression} IS NOT NULL'
- elif index == 'trigram' and self.has_trigram:
- method = 'gin'
- operator = 'gin_trgm_ops'
+ if index and indexname not in existing and \
+ ((not field.translate and index != 'trigram') or (index == 'trigram' and self.has_trigram)):
+
+ if index == 'trigram':
+ if field.translate:
+ column_expression = f'''(jsonb_path_query_array({column_expression}, '$.*')::text)'''
# add `unaccent` to the trigram index only because the
# trigram indexes are mainly used for (i/=)like search and
# unaccent is added only in these cases when searching
@@ -493,9 +491,15 @@ class Registry(Mapping):
"PostgreSQL function 'unaccent' is present but not immutable, "
"therefore trigram indexes may not be effective.",
)
+ expression = f'{column_expression} gin_trgm_ops'
+ method = 'gin'
+ where = ''
+ else: # index in ['btree', 'btree_not_null', True]
+ expression = f'{column_expression}'
+ method = 'btree'
+ where = f'{column_expression} IS NOT NULL' if index == 'btree_not_null' else ''
try:
with cr.savepoint(flush=False):
- expression = f'{column_expression} {operator}'
sql.create_index(cr, indexname, tablename, [expression], method, where)
except psycopg2.OperationalError:
_schema.error("Unable to add index for %s", self)
diff --git a/odoo/osv/expression.py b/odoo/osv/expression.py
index 535d63d391f..59aa704d66b 100644
--- a/odoo/osv/expression.py
+++ b/odoo/osv/expression.py
@@ -959,7 +959,7 @@ class expression(object):
expr, params = self.__leaf_to_sql(leaf, model, alias)
push_result(expr, params)
- elif field.translate is True and right:
+ elif field.translate and right:
need_wildcard = operator in ('like', 'ilike', 'not like', 'not ilike')
sql_operator = {'=like': 'like', '=ilike': 'ilike'}.get(operator, operator)
if need_wildcard:
@@ -968,8 +968,12 @@ class expression(object):
right = tuple(right)
unaccent = self._unaccent(field) if sql_operator.endswith('like') else lambda x: x
-
- left = unaccent(model._generate_translated_field(alias, left, self.query))
+ if need_wildcard:
+ # Inactive languages are not removed in DB and can be searched by using this function
+ left = unaccent(f'jsonb_path_query_array("{alias}"."{left}", \'$.*\')::text')
+ else:
+ lang = model.env.lang or 'en_US'
+ left = unaccent(f'COALESCE("{alias}"."{left}"->>\'{lang}\', "{alias}"."{left}"->>\'en_US\')')
instr = unaccent('%s')
push_result(f"{left} {sql_operator} {instr}", [right])
diff --git a/odoo/tools/mail.py b/odoo/tools/mail.py
index 3f169cbd66b..2202b5ceea7 100644
--- a/odoo/tools/mail.py
+++ b/odoo/tools/mail.py
@@ -31,7 +31,7 @@ _logger = logging.getLogger(__name__)
safe_attrs = clean.defs.safe_attrs | frozenset(
['style',
'data-o-mail-quote', # quote detection
- 'data-oe-model', 'data-oe-id', 'data-oe-field', 'data-oe-type', 'data-oe-expression', 'data-oe-translation-id', 'data-oe-nodeid',
+ 'data-oe-model', 'data-oe-id', 'data-oe-field', 'data-oe-type', 'data-oe-expression', 'data-oe-translation-initial-sha', 'data-oe-nodeid',
'data-publish', 'data-id', 'data-res_id', 'data-interval', 'data-member_id', 'data-scroll-background-ratio', 'data-view-id',
'data-class', 'data-mimetype', 'data-original-src', 'data-original-id', 'data-gl-filter', 'data-quality', 'data-resize-width',
'data-shape', 'data-shape-colors', 'data-file-name', 'data-original-mimetype',
diff --git a/odoo/tools/sql.py b/odoo/tools/sql.py
index ff22a746c3f..0cb8168e684 100644
--- a/odoo/tools/sql.py
+++ b/odoo/tools/sql.py
@@ -56,15 +56,16 @@ def table_kind(cr, tablename):
# prescribed column order by type: columns aligned on 4 bytes, columns aligned
# on 1 byte, columns aligned on 8 bytes(values have been chosen to minimize
# padding in rows; unknown column types are put last)
-SQL_ORDER_BY_TYPE = defaultdict(lambda: 9, {
+SQL_ORDER_BY_TYPE = defaultdict(lambda: 16, {
'int4': 1, # 4 bytes aligned on 4 bytes
'varchar': 2, # variable aligned on 4 bytes
'date': 3, # 4 bytes aligned on 4 bytes
- 'text': 4, # variable aligned on 4 bytes
- 'numeric': 5, # variable aligned on 4 bytes
- 'bool': 6, # 1 byte aligned on 1 byte
- 'timestamp': 7, # 8 bytes aligned on 8 bytes
- 'float8': 8, # 8 bytes aligned on 8 bytes
+ 'jsonb': 4, # jsonb
+ 'text': 5, # variable aligned on 4 bytes
+ 'numeric': 6, # variable aligned on 4 bytes
+ 'bool': 7, # 1 byte aligned on 1 byte
+ 'timestamp': 8, # 8 bytes aligned on 8 bytes
+ 'float8': 9, # 8 bytes aligned on 8 bytes
})
def create_model_table(cr, tablename, comment=None, columns=()):
@@ -121,21 +122,56 @@ def rename_column(cr, tablename, columnname1, columnname2):
def convert_column(cr, tablename, columnname, columntype):
""" Convert the column to the given type. """
+ using = f'"{columnname}"::{columntype}'
+ _convert_column(cr, tablename, columnname, columntype, using)
+
+def convert_column_translatable(cr, tablename, columnname, columntype):
+ """ Convert the column from/to a 'jsonb' translated field column. """
+ drop_index(cr, f"{tablename}_{columnname}_index", tablename)
+ if columntype == "jsonb":
+ using = f"""CASE WHEN "{columnname}" IS NOT NULL THEN jsonb_build_object('en_US', "{columnname}"::varchar) END"""
+ else:
+ using = f""""{columnname}"->>'en_US'"""
+ _convert_column(cr, tablename, columnname, columntype, using)
+
+def _convert_column(cr, tablename, columnname, columntype, using):
+ query = f'''
+ ALTER TABLE "{tablename}"
+ ALTER COLUMN "{columnname}" DROP DEFAULT,
+ ALTER COLUMN "{columnname}" TYPE {columntype} USING {using}
+ '''
try:
with cr.savepoint(flush=False):
- cr.execute('ALTER TABLE "{}" ALTER COLUMN "{}" TYPE {}'.format(tablename, columnname, columntype),
- log_exceptions=False)
+ cr.execute(query, log_exceptions=False)
except psycopg2.NotSupportedError:
- # can't do inplace change -> use a casted temp column
- query = '''
- ALTER TABLE "{0}" RENAME COLUMN "{1}" TO __temp_type_cast;
- ALTER TABLE "{0}" ADD COLUMN "{1}" {2};
- UPDATE "{0}" SET "{1}"= __temp_type_cast::{2};
- ALTER TABLE "{0}" DROP COLUMN __temp_type_cast CASCADE;
- '''
- cr.execute(query.format(tablename, columnname, columntype))
+ drop_depending_views(cr, tablename, columnname)
+ cr.execute(query)
_schema.debug("Table %r: column %r changed to type %s", tablename, columnname, columntype)
+def drop_depending_views(cr, table, column):
+ """drop views depending on a field to allow the ORM to resize it in-place"""
+ for v, k in get_depending_views(cr, table, column):
+ cr.execute("DROP {0} VIEW IF EXISTS {1} CASCADE".format("MATERIALIZED" if k == "m" else "", v))
+ _schema.debug("Drop view %r", v)
+
+def get_depending_views(cr, table, column):
+ # http://stackoverflow.com/a/11773226/75349
+ q = """
+ SELECT distinct quote_ident(dependee.relname), dependee.relkind
+ FROM pg_depend
+ JOIN pg_rewrite ON pg_depend.objid = pg_rewrite.oid
+ JOIN pg_class as dependee ON pg_rewrite.ev_class = dependee.oid
+ JOIN pg_class as dependent ON pg_depend.refobjid = dependent.oid
+ JOIN pg_attribute ON pg_depend.refobjid = pg_attribute.attrelid
+ AND pg_depend.refobjsubid = pg_attribute.attnum
+ WHERE dependent.relname = %s
+ AND pg_attribute.attnum > 0
+ AND pg_attribute.attname = %s
+ AND dependee.relkind in ('v', 'm')
+ """
+ cr.execute(q, [table, column])
+ return cr.fetchall()
+
def set_not_null(cr, tablename, columnname):
""" Add a NOT NULL constraint on the given column. """
query = 'ALTER TABLE "{}" ALTER COLUMN "{}" SET NOT NULL'.format(tablename, columnname)
diff --git a/odoo/tools/translate.py b/odoo/tools/translate.py
index fa3f8d5bd62..6620842cf65 100644
--- a/odoo/tools/translate.py
+++ b/odoo/tools/translate.py
@@ -20,8 +20,10 @@ from os.path import join
from pathlib import Path
from babel.messages import extract
from lxml import etree, html
+from psycopg2.extras import Json
import odoo
+from odoo.modules.module import get_resource_path
from . import config, pycompat
from .misc import file_open, get_iso_codes, SKIPPED_ELEMENT_TYPES
@@ -301,6 +303,16 @@ def xml_translate(callback, value):
# remove tags
and
from result
return serialize_xml(result)[5:-6]
+def xml_term_converter(value):
+ """ Convert the HTML fragment ``value`` to XML if necessary
+ """
+ # wrap value inside a div and parse it as HTML
+ div = f"
{value}
"
+ root = etree.fromstring(div, etree.HTMLParser())
+ # root is html > body > div
+ # serialize div as XML and discard surrounding tags
+ return etree.tostring(root[0][0], encoding='unicode')[5:-6]
+
def html_translate(callback, value):
""" Translate an HTML value (string), using `callback` for translating text
appearing in `value`.
@@ -319,6 +331,16 @@ def html_translate(callback, value):
return value
+def html_term_converter(value):
+ """ Convert the HTML fragment ``value`` to XML if necessary
+ """
+ # wrap value inside a div and parse it as HTML
+ div = f"
{value}
"
+ root = etree.fromstring(div, etree.HTMLParser())
+ # root is html > body > div
+ # serialize div as HTML and discard surrounding tags
+ return etree.tostring(root[0][0], encoding='unicode', method='html')[5:-6]
+
def get_text_content(term):
""" Return the textual content of the given term. """
@@ -327,32 +349,13 @@ def get_text_content(term):
xml_translate.get_text_content = get_text_content
html_translate.get_text_content = get_text_content
-
-#
-# Warning: better use self.env['ir.translation']._get_source if you can
-#
-def translate(cr, name, source_type, lang, source=None):
- if source and name:
- cr.execute('select value from ir_translation where lang=%s and type=%s and name=%s and src=%s and md5(src)=md5(%s)', (lang, source_type, str(name), source, source))
- elif name:
- cr.execute('select value from ir_translation where lang=%s and type=%s and name=%s', (lang, source_type, str(name)))
- elif source:
- cr.execute('select value from ir_translation where lang=%s and type=%s and src=%s and md5(src)=md5(%s)', (lang, source_type, source, source))
- res_trans = cr.fetchone()
- res = res_trans and res_trans[0] or False
- return res
+xml_translate.term_converter = xml_term_converter
+html_translate.term_converter = html_term_converter
def translate_sql_constraint(cr, key, lang):
cr.execute("""
- SELECT COALESCE(t.value, c.message) as message
+ SELECT COALESCE(c.message->>%s, c.message->>'en_US') as message
FROM ir_model_constraint c
- LEFT JOIN
- (SELECT res_id, value FROM ir_translation
- WHERE type='model'
- AND name='ir.model.constraint,message'
- AND lang=%s
- AND value!='') AS t
- ON c.id=t.res_id
WHERE name=%s and type='u'
""", (lang, key))
return cr.fetchone()[0]
@@ -446,38 +449,22 @@ class GettextAlias(object):
_logger.exception('Bad translation %r for string %r', bad, source)
return translation
- def _get_translation(self, source):
- res = source
- cr = None
- is_new_cr = False
+ def _get_translation(self, source, module=None):
try:
- frame = inspect.currentframe()
- if frame is None:
- return source
- frame = frame.f_back
- if not frame:
- return source
- frame = frame.f_back
- if not frame:
- return source
+ frame = inspect.currentframe().f_back.f_back
lang = self._get_lang(frame)
- if lang:
- cr, is_new_cr = self._get_cr(frame)
- if cr:
- # Try to use ir.translation to benefit from global cache if possible
- env = odoo.api.Environment(cr, odoo.SUPERUSER_ID, {})
- res = env['ir.translation']._get_source(None, ('code',), lang, source)
- else:
- _logger.debug('no context cursor detected, skipping translation for "%r"', source)
+ if lang and lang != 'en_US':
+ if not module:
+ path = inspect.getfile(frame)
+ path_info = odoo.modules.get_resource_from_path(path)
+ module = path_info[0] if path_info else None
+ return code_translations.get_python_translations(module, lang).get(source, source)
else:
_logger.debug('no translation language detected, skipping translation for "%r" ', source)
except Exception:
_logger.debug('translation went wrong for "%r", skipped', source)
# if so, double-check the root/base translations filenames
- finally:
- if cr and is_new_cr:
- cr.close()
- return res or ''
+ return source
@functools.total_ordering
@@ -498,16 +485,22 @@ class _lt:
works as expected (unlike the classic GettextAlias implementation).
"""
- __slots__ = ['_source', '_args']
+ __slots__ = ['_source', '_args', '_module']
+
def __init__(self, source, *args, **kwargs):
self._source = source
assert not (args and kwargs)
self._args = args or kwargs
+ frame = inspect.currentframe().f_back
+ path = inspect.getfile(frame)
+ path_info = odoo.modules.get_resource_from_path(path)
+ self._module = path_info[0] if path_info else None
+
def __str__(self):
# Call _._get_translation() like _() does, so that we have the same number
# of stack frames calling _get_translation()
- translation = _._get_translation(self._source)
+ translation = _._get_translation(self._source, self._module)
if self._args:
try:
return translation % self._args
@@ -908,7 +901,7 @@ class TranslationModuleReader:
retrieve source terms only if not set
"""
- def __init__(self, cr, modules=None, lang=None):
+ def __init__(self, cr, modules=None, lang='en_US'):
self._cr = cr
self._modules = modules or ['all']
self._lang = lang
@@ -923,20 +916,12 @@ class TranslationModuleReader:
self._export_translatable_records()
self._export_translatable_resources()
-
def __iter__(self):
""" Export ir.translation values for all retrieved records """
+ for module, source, name, res_id, ttype, comments, _record_id, value in self._to_translate:
+ yield (module, ttype, name, res_id, source, encode(odoo.tools.ustr(value)), comments)
- IrTranslation = self.env['ir.translation']
- for module, source, name, res_id, ttype, comments, record_id in self._to_translate:
- trans = (
- IrTranslation._get_source(name if type != "code" else None, ttype, self._lang, source, res_id=record_id)
- if self._lang
- else ""
- )
- yield (module, ttype, name, res_id, source, encode(trans) or '', comments)
-
- def _push_translation(self, module, ttype, name, res_id, source, comments=None, record_id=None):
+ def _push_translation(self, module, ttype, name, res_id, source, comments=None, record_id=None, value=None):
""" Insert a translation that will be used in the file generation
In po file will create an entry
#: ::
@@ -951,7 +936,7 @@ class TranslationModuleReader:
sanitized_term = re.sub(r'\W+', '', sanitized_term)
if not sanitized_term or len(sanitized_term) <= 1:
return
- self._to_translate.append((module, source, name, res_id, ttype, tuple(comments or ()), record_id))
+ self._to_translate.append((module, source, name, res_id, ttype, tuple(comments or ()), record_id, value))
def _get_translatable_records(self, imd_records):
""" Filter the records that are translatable
@@ -1033,15 +1018,22 @@ class TranslationModuleReader:
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():
- if field.translate:
+ # ir_actions_actions.name is filtered because unlike other inherited fields,
+ # this field is inherited as postgresql inherited columns.
+ # From our business perspective, the parent column is no need to be translated,
+ # but it is need to be set to jsonb column, since the child columns need to be translated
+ # And export the parent field may make one value to be translated twice in transifex
+ if field.translate and not (field.model_name == 'ir.actions.actions' and field.name == 'name'):
name = model + "," + field_name
try:
- value = record[field_name] or ''
+ value_en = record[field_name] or ''
+ value_lang = record.with_context(lang=self._lang)[field_name] or ''
except Exception:
continue
- for term in set(field.get_trans_terms(value)):
- trans_type = 'model_terms' if callable(field.translate) else 'model'
- self._push_translation(module, trans_type, name, xml_name, term, record_id=record.id)
+ trans_type = 'model_terms' if callable(field.translate) else 'model'
+ for term_en, term_langs in field.get_translation_dictionary(value_en, {self._lang: value_lang}).items():
+ term_lang = term_langs.get(self._lang)
+ self._push_translation(module, trans_type, name, xml_name, term_en, record_id=record.id, value=term_lang if term_lang != term_en else '')
def _get_module_from_path(self, path):
for (mp, rec) in self._path_list:
@@ -1074,13 +1066,17 @@ class TranslationModuleReader:
options = {}
if extract_method == 'python':
options['encoding'] = 'UTF-8'
+ translations = code_translations.get_python_translations(module, self._lang)
+ else:
+ translations = code_translations.get_web_translations(module, self._lang)
try:
for extracted in extract.extract(extract_method, src_file, keywords=extract_keywords, options=options):
# Babel 0.9.6 yields lineno, message, comments
# Babel 1.3 yields lineno, message, comments, context
lineno, message, comments = extracted[:3]
+ value = translations.get(message, '')
self._push_translation(module, trans_type, display_path, lineno,
- encode(message), comments + extra_comments)
+ encode(message), comments + extra_comments, value=value)
except Exception:
_logger.exception("Failed to extract terms from %s", fabsolutepath)
finally:
@@ -1088,7 +1084,7 @@ class TranslationModuleReader:
def _export_translatable_resources(self):
""" Export translations for static terms
-
+
This will include:
- the python strings marked with _() or _lt()
- the javascript strings marked with _t() or _lt() inside static/src/js/
@@ -1125,24 +1121,22 @@ class TranslationModuleReader:
break
-def trans_load(cr, filename, lang, verbose=True, create_empty_translation=False, overwrite=False):
+def trans_load(cr, filename, lang, verbose=True, overwrite=False):
try:
with file_open(filename, mode='rb') as fileobj:
_logger.info("loading %s", filename)
fileformat = os.path.splitext(filename)[-1][1:].lower()
return trans_load_data(cr, fileobj, fileformat, lang,
verbose=verbose,
- create_empty_translation=create_empty_translation,
overwrite=overwrite)
except IOError:
if verbose:
_logger.error("couldn't read translation file %s", filename)
return None
-
def trans_load_data(cr, fileobj, fileformat, lang,
- verbose=True, create_empty_translation=False, overwrite=False):
- """Populates the ir_translation table.
+ verbose=True, overwrite=False):
+ """Load translations.
:param cr:
:param fileobj: buffer open to a translation file
@@ -1150,52 +1144,20 @@ def trans_load_data(cr, fileobj, fileformat, lang,
:param lang: language code of the translations contained in `fileobj`
language must be present and activated in the database
:param verbose: increase log output
- :param create_empty_translation: create an ir.translation record, even if no value
- is provided in the translation entry
- :param overwrite: if an ir.translation already exists for a term, replace it with
+ :param overwrite: if a translation already exists for a term, replace it with
the one in `fileobj`
"""
if verbose:
_logger.info('loading translation file for language %s', lang)
-
- env = odoo.api.Environment(cr, odoo.SUPERUSER_ID, {})
-
+ env = odoo.api.Environment(cr, odoo.SUPERUSER_ID, {'lang': lang})
+ if not env['res.lang']._lang_get(lang):
+ _logger.error("Couldn't read translation for lang '%s', language not found", lang)
+ return None
try:
- if not env['res.lang']._lang_get(lang):
- _logger.error("Couldn't read translation for lang '%s', language not found", lang)
- return None
-
- # now, the serious things: we read the language file
fileobj.seek(0)
reader = TranslationFileReader(fileobj, fileformat=fileformat)
+ _trans_load_data(cr, reader, lang, overwrite)
- # read the rest of the file with a cursor-like object for fast inserting translations"
- Translation = env['ir.translation']
- irt_cursor = Translation._get_import_cursor(overwrite)
-
- def process_row(row):
- """Process a single PO (or POT) entry."""
- # dictionary which holds values for this line of the csv file
- # {'lang': ..., 'type': ..., 'name': ..., 'res_id': ...,
- # 'src': ..., 'value': ..., 'module':...}
- dic = dict.fromkeys(('type', 'name', 'res_id', 'src', 'value',
- 'comments', 'imd_model', 'imd_name', 'module'))
- dic['lang'] = lang
- dic.update(row)
-
- # do not import empty values
- if not create_empty_translation and not dic['value']:
- return
-
- irt_cursor.push(dic)
-
- # First process the entries from the PO file (doing so also fills/removes
- # the entries from the POT file).
- for row in reader:
- process_row(row)
-
- irt_cursor.finish()
- Translation.clear_caches()
if verbose:
_logger.info("translation file loaded successfully")
@@ -1205,6 +1167,132 @@ def trans_load_data(cr, fileobj, fileformat, lang,
_logger.exception("couldn't read translation file %s", filename)
+def _trans_load_data(cr, reader, lang, overwrite=False, force_overwrite=False, xml_ids=None):
+ # logical overwrite: if a translation should be updated when it has already been in the DB before
+ # logical overwrite = force_overwrite or (not noudpate and overwrite)
+ env = odoo.api.Environment(cr, odoo.SUPERUSER_ID, {'lang': lang})
+ if force_overwrite:
+ overwrite = True
+
+ # load all translation in to dict
+ deep_defaultdict = lambda: defaultdict(deep_defaultdict)
+ # {model_name: {xmlid: {field_name, {src: value}}}}
+ translations = deep_defaultdict()
+ # [(module_name, imd_name), (module_name, imd_name), ...] only for model_terms translation
+ module_imd_names = []
+ # {model_name: {field_name: [xmlid, xmlid, ...]}}
+ model_name_to_field_name_to_xmlids = defaultdict(lambda: defaultdict(list))
+
+ for row in reader:
+ if not row.get('value') or not row.get('src'): # ignore empty translations
+ continue
+ if row.get('type') == 'code': # ignore code translations
+ continue
+ # TODO: CWG if the po file should not be trusted, we need to check each model term
+ model_name = row.get('imd_model')
+ module_name = row['module']
+ imd_name = row['imd_name']
+ if model_name in env:
+ field_name = row['name'].split(',')[1]
+ field = env[model_name]._fields.get(field_name)
+ if not field or not field.translate or not field.store:
+ continue
+ xmlid = module_name + '.' + row['imd_name']
+ if xml_ids and xmlid not in xml_ids:
+ continue
+ if row['type'] == 'model_terms':
+ module_imd_names.append((module_name, imd_name))
+ model_name_to_field_name_to_xmlids[model_name][field_name].append(xmlid)
+ translations[model_name][xmlid][field_name][row['src']] = row['value']
+
+ if not translations:
+ return
+ env.flush_all()
+
+ # get mapping xmlid_to_id, id_to_xmlid
+ xmlid_to_id = {}
+ for sub_module_imd_names in cr.split_for_in_conditions(module_imd_names):
+ query = "SELECT module || '.' || name, res_id, noupdate, model FROM ir_model_data WHERE "
+ query += " OR ".join(["module = %s AND name = %s"] * len(sub_module_imd_names))
+ cr.execute(query, [param for params in sub_module_imd_names for param in params])
+ # {xmlid: (id, noupdate, model_name)}
+ xmlid_to_id.update((
+ (xmlid, (id_, not force_overwrite and noupdate, model_name))
+ for xmlid, id_, noupdate, model_name in cr.fetchall()
+ ))
+ id_to_xmlid = {
+ (model_name, id_): (xmlid, noupdate)
+ for xmlid, (id_, noupdate, model_name) in xmlid_to_id.items()
+ }
+
+ # translate model terms
+ for model_name, model_dictionary in translations.items():
+ if model_name not in env:
+ continue
+ Model = env[model_name]
+ model_table = Model._table
+ field_name_to_xmlids = model_name_to_field_name_to_xmlids[model_name]
+ fields = Model._fields
+ field_type = defaultdict(list)
+ for field_name in field_name_to_xmlids.keys():
+ translate = fields[field_name].translate
+ field_type['no_translate' if not translate else 'model_terms' if callable(translate) else 'model'].append(field_name)
+ if field_type['no_translate']:
+ pass # ignore illegal translations
+ for field_name in field_type['model_terms']:
+ record_ids = {
+ it[0]
+ for it in map(xmlid_to_id.get, field_name_to_xmlids[field_name])
+ if it
+ }
+ field = fields[field_name]
+ for sub_ids in cr.split_for_in_conditions(record_ids):
+ cr.execute(f'SELECT id, "{field_name}" FROM "{model_table}" WHERE id IN %s', (sub_ids,))
+ for id_, values in cr.fetchall():
+ if not values:
+ continue
+ value_en = values.get('en_US')
+ if not value_en:
+ continue
+ value_lang = values.get(lang, value_en)
+ xmlid, noupdate = id_to_xmlid[(model_name, id_)]
+ field_dictionary = {k: v for k, v in model_dictionary[xmlid][field_name].items()} # len(field.get_trans_terms(v)) == 1
+ translation_dictionary = field.get_translation_dictionary(value_en, {lang: value_lang})
+
+ # update translation_dictionary using new translations
+ for term_en, term in translation_dictionary.items():
+ if not overwrite and noupdate and term[lang] != term_en:
+ continue
+ term[lang] = field_dictionary.get(term_en, term[lang])
+
+ new_value_lang = field.translate(lambda term: translation_dictionary.get(term, {}).get(lang), value_en)
+ values[lang] = new_value_lang
+ env.cache.update_raw(Model.browse(id_), field, [values], dirty=True)
+ for field_name in field_type['model']:
+ field_translations = []
+ for xmlid in field_name_to_xmlids[field_name]:
+ src, value = list(model_dictionary[xmlid][field_name].items())[0]
+ field_translations.extend([xmlid.split('.')[-1], src, Json({lang: value})])
+ if field_translations:
+ if not force_overwrite:
+ value_query = f"""CASE WHEN {overwrite} is True AND imd.noupdate is False
+ THEN m."{field_name}" || t.value
+ ELSE t.value || m."{field_name}"END"""
+ else:
+ value_query = f'm."{field_name}" || t.value'
+ env.cr.execute(f"""
+ UPDATE "{model_table}" AS m
+ SET "{field_name}" = {value_query}
+ FROM (
+ VALUES {', '.join(['(%s, %s, %s::jsonb)'] * int(len(field_translations) / 3))}
+ ) AS t(imd_name, src, value)
+ JOIN "ir_model_data" AS imd
+ ON imd."model" = '{model_name}' AND imd.name = t.imd_name
+ WHERE imd."res_id" = m."id" AND m."{field_name}"->>'en_US' = t.src
+ """, field_translations)
+ env.invalidate_all()
+
+
def get_locales(lang=None):
if lang is None:
lang = locale.getdefaultlocale()[0]
@@ -1255,5 +1343,153 @@ def load_language(cr, lang):
l10n flavor (ex: 'fr', 'fr_BE', but not 'fr-BE')
"""
env = odoo.api.Environment(cr, odoo.SUPERUSER_ID, {})
- installer = env['base.language.install'].create({'lang_ids': [(6, 0, [env['res.lang'].search([('code', '=', lang)]).id])]})
+ lang_ids = env['res.lang'].with_context(active_test=False).search([('code', '=', lang)]).ids
+ installer = env['base.language.install'].create({'lang_ids': [(6, 0, lang_ids)]})
installer.lang_install()
+
+
+class CodeTranslations:
+ def __init__(self):
+ # {(module_name, lang): {src: value}}
+ self.python_translations = {}
+ # {(module_name, lang): {'message': [{'id': src, 'string': value}]}
+ self.web_translations = {}
+
+ def _get_po_paths(self, mod, lang):
+ lang_base = lang.split('_')[0]
+ po_paths = [get_resource_path(mod, 'i18n', lang_base + '.po'),
+ get_resource_path(mod, 'i18n', lang + '.po'),
+ get_resource_path(mod, 'i18n_extra', lang_base + '.po'),
+ get_resource_path(mod, 'i18n_extra', lang + '.po')]
+ return [path for path in po_paths if path]
+
+ def _trans_load_code_python(self, fileobj, fileformat, lang):
+ python_translations = {}
+ fileobj.seek(0)
+ reader = TranslationFileReader(fileobj, fileformat=fileformat)
+ for row in reader:
+ if row.get('value') and row.get('src') and row.get('type') == 'code':
+ python_translations[row['src']] = row['value']
+ return python_translations
+
+ def _trans_load_code_webclient(self, fileobj, fileformat, lang):
+ # current, we assume the fileobj is from the source code, which only contains the translation for the current module
+ # don't use it in the import logic
+ webclient_translations = {}
+ fileobj.seek(0)
+ reader = TranslationFileReader(fileobj, fileformat=fileformat)
+ for row in reader:
+ if row.get('value') and row.get('src') and row.get('type') == 'code' and WEB_TRANSLATION_COMMENT in row[
+ 'comments']:
+ webclient_translations[row['src']] = row['value']
+ return webclient_translations
+
+ def _load_python_translations(self, module_name, lang):
+ po_paths = self._get_po_paths(module_name, lang)
+ translations = {}
+ for po_path in po_paths:
+ try:
+ with file_open(po_path, mode='rb') as fileobj:
+ p = self._trans_load_code_python(fileobj, 'po', lang)
+ translations.update(p)
+ except IOError:
+ iso_lang = get_iso_codes(lang)
+ filename = '[lang: %s][format: %s]' % (iso_lang or 'new', 'po')
+ _logger.exception("couldn't read translation file %s", filename)
+ self.python_translations[(module_name, lang)] = translations
+
+ def _load_web_translations(self, module_name, lang):
+ po_paths = self._get_po_paths(module_name, lang)
+ translations = {}
+ for po_path in po_paths:
+ try:
+ with file_open(po_path, mode='rb') as fileobj:
+ p = self._trans_load_code_webclient(fileobj, 'po', lang)
+ translations.update(p)
+ except IOError:
+ iso_lang = get_iso_codes(lang)
+ filename = '[lang: %s][format: %s]' % (iso_lang or 'new', 'po')
+ _logger.exception("couldn't read translation file %s", filename)
+ self.web_translations[(module_name, lang)] = {
+ "messages": [{"id": src, "string": value} for src, value in translations.items()]
+ }
+
+ def get_python_translations(self, module_name, lang):
+ if (module_name, lang) not in self.python_translations:
+ self._load_python_translations(module_name, lang)
+ return self.python_translations[(module_name, lang)]
+
+ def get_web_translations(self, module_name, lang):
+ if (module_name, lang) not in self.web_translations:
+ self._load_web_translations(module_name, lang)
+ return self.web_translations[(module_name, lang)]
+
+
+code_translations = CodeTranslations()
+
+
+def _get_translation_upgrade_queries(cr, field):
+ """ Return a pair of lists ``migrate_queries, cleanup_queries`` of SQL queries. The queries in
+ ``migrate_queries`` do migrate the data from table ``_ir_translation`` to the corresponding
+ field's column, while the queries in ``cleanup_queries`` remove the corresponding data from
+ table ``_ir_translation``.
+ """
+ Model = odoo.registry(cr.dbname)[field.model_name]
+ translation_name = f"{field.model_name},{field.name}"
+ migrate_queries = []
+ cleanup_queries = []
+
+ if field.translate is True:
+ query = f"""
+ WITH t AS (
+ SELECT res_id, jsonb_object_agg(lang, value) AS value
+ FROM _ir_translation
+ WHERE type = 'model' AND name = %s AND state = 'translated'
+ GROUP BY res_id
+ )
+ UPDATE {Model._table} m
+ SET "{field.name}" = m."{field.name}" || t.value
+ FROM t
+ WHERE t.res_id = m.id
+ """
+ migrate_queries.append(cr.mogrify(query, [translation_name]).decode())
+
+ query = "DELETE FROM _ir_translation WHERE type = 'model' AND name = %s"
+ cleanup_queries.append(cr.mogrify(query, [translation_name]).decode())
+
+ # upgrade model_terms translation: one update per field per record
+ if callable(field.translate):
+ cr.execute(f"""
+ WITH t0 AS (
+ -- aggregate translations by source term --
+ SELECT res_id, lang, jsonb_object_agg(src, value) AS value
+ FROM _ir_translation
+ WHERE type = 'model_terms' AND name = %s AND state = 'translated'
+ GROUP BY res_id, lang
+ ),
+ t AS (
+ -- aggregate translations by lang --
+ SELECT res_id, jsonb_object_agg(lang, value) AS value
+ FROM t0
+ GROUP BY res_id
+ )
+ SELECT t.res_id, m."{field.name}"->>'en_US', t.value
+ FROM t
+ JOIN "{Model._table}" m ON t.res_id = m.id
+ """, [translation_name])
+ for id_, source_value, translations in cr.fetchall():
+ if not source_value:
+ continue
+ new_value = {
+ lang: field.translate(terms_mapping.get, source_value)
+ for lang, terms_mapping in translations.items()
+ }
+ if "en_US" not in new_value:
+ new_value["en_US"] = field.translate(lambda v: None, source_value)
+ query = f'UPDATE "{Model._table}" SET "{field.name}" = %s WHERE id = %s'
+ migrate_queries.append(cr.mogrify(query, [Json(new_value), id_]).decode())
+
+ query = "DELETE FROM _ir_translation WHERE type = 'model_terms' AND name = %s"
+ cleanup_queries.append(cr.mogrify(query, [translation_name]).decode())
+
+ return migrate_queries, cleanup_queries