From 70c776f21f96b917bd0d1f1f20b4652d556887ef Mon Sep 17 00:00:00 2001 From: Raphael Collet Date: Mon, 5 Sep 2022 12:11:46 +0200 Subject: [PATCH 01/10] [FIX] core: CLI --load-language was broken --- odoo/tools/translate.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/odoo/tools/translate.py b/odoo/tools/translate.py index fa3f8d5bd62..d081973210a 100644 --- a/odoo/tools/translate.py +++ b/odoo/tools/translate.py @@ -1255,5 +1255,6 @@ 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() From 50767ef90eadeca2ed05b9400238af8bdbe77fb3 Mon Sep 17 00:00:00 2001 From: Raphael Collet Date: Tue, 30 Aug 2022 14:30:09 +0200 Subject: [PATCH 02/10] [IMP] core: simplify code for column conversion in fields --- odoo/fields.py | 25 +++---------------------- odoo/tools/sql.py | 2 +- 2 files changed, 4 insertions(+), 23 deletions(-) diff --git a/odoo/fields.py b/odoo/fields.py index 2f49423ff8d..722fa48988b 100644 --- a/odoo/fields.py +++ b/odoo/fields.py @@ -247,7 +247,6 @@ class Field(MetaField('DummyField', (object,), {})): translate = False # whether the field is translated column_type = None # database column type (ident, spec) - column_cast_from = () # column types that may be cast to this write_sequence = 0 # field ordering for write() args = None # the parameters given to __init__() @@ -1009,17 +1008,9 @@ class Field(MetaField('DummyField', (object,), {})): return if column['udt_name'] == self.column_type[0]: return - if column['udt_name'] in self.column_cast_from: - sql.convert_column(model._cr, model._table, self.name, self.column_type[1]) - else: - newname = (self.name + '_moved{}').format - i = 0 - while sql.column_exists(model._cr, model._table, newname(i)): - i += 1 - if column['is_nullable'] == 'NO': - sql.drop_not_null(model._cr, model._table, self.name) - sql.rename_column(model._cr, model._table, self.name, newname(i)) - sql.create_column(model._cr, model._table, self.name, self.column_type[1], self.string) + if column['is_nullable'] == 'NO': + sql.drop_not_null(model._cr, model._table, self.name) + sql.convert_column(model._cr, model._table, self.name, self.column_type[1]) def update_db_notnull(self, model, column): """ Add or remove the NOT NULL constraint on ``self``. @@ -1462,8 +1453,6 @@ class Float(Field): """ type = 'float' - column_cast_from = ('int4', 'numeric', 'float8') - _digits = None # digits argument passed to class initializer group_operator = 'sum' @@ -1533,9 +1522,7 @@ class Monetary(Field): """ type = 'monetary' write_sequence = 10 - column_type = ('numeric', 'numeric') - column_cast_from = ('float8',) currency_field = None group_operator = 'sum' @@ -1767,8 +1754,6 @@ class Char(_String): :type translate: bool or callable """ type = 'char' - column_cast_from = ('text',) - size = None # maximum size of values (deprecated) trim = True # whether value is trimmed (only by web client) @@ -1821,7 +1806,6 @@ class Text(_String): """ type = 'text' column_type = ('text', 'text') - column_cast_from = ('varchar',) def convert_to_cache(self, value, record, validate=True): if value is None or value is False: @@ -1846,7 +1830,6 @@ class Html(_String): """ type = 'html' column_type = ('text', 'text') - column_cast_from = ('varchar',) sanitize = True # whether value must be sanitized sanitize_overridable = False # whether the sanitation can be bypassed by the users part of the `base.group_sanitize_override` group @@ -1947,7 +1930,6 @@ class Date(Field): """ Encapsulates a python :class:`date ` object. """ type = 'date' column_type = ('date', 'date') - column_cast_from = ('timestamp',) start_of = staticmethod(date_utils.start_of) end_of = staticmethod(date_utils.end_of) @@ -2046,7 +2028,6 @@ class Datetime(Field): """ Encapsulates a python :class:`datetime ` object. """ type = 'datetime' column_type = ('timestamp', 'timestamp') - column_cast_from = ('date',) start_of = staticmethod(date_utils.start_of) end_of = staticmethod(date_utils.end_of) diff --git a/odoo/tools/sql.py b/odoo/tools/sql.py index ff22a746c3f..05c1fb4e495 100644 --- a/odoo/tools/sql.py +++ b/odoo/tools/sql.py @@ -125,7 +125,7 @@ def convert_column(cr, tablename, columnname, columntype): with cr.savepoint(flush=False): cr.execute('ALTER TABLE "{}" ALTER COLUMN "{}" TYPE {}'.format(tablename, columnname, columntype), log_exceptions=False) - except psycopg2.NotSupportedError: + except (psycopg2.NotSupportedError, psycopg2.errors.DatatypeMismatch): # can't do inplace change -> use a casted temp column query = ''' ALTER TABLE "{0}" RENAME COLUMN "{1}" TO __temp_type_cast; From ef00294e7189359c47638c4a71626f1937395edb Mon Sep 17 00:00:00 2001 From: "Chong Wang (cwg)" Date: Thu, 1 Sep 2022 19:39:00 +0200 Subject: [PATCH 03/10] [IMP] core: store translated fields as JSONB columns Translated fields no longer use the model ir.translation. Instead they store all their values as JSON, and store them into JSONB columns in the model's table. The field's column value is either NULL or a JSON dict mapping language codes to text (the field's value in the corresponding language), and must contain an entry for key 'en_US' (as it is used as a fallback for all other languages). Empty text is allowed in translation values, but not NULL. Here are examples for a field with translate=True: NULL {"en_US": "Foo"} {"en_US": "Foo", "fr_FR": "Bar", "nl_NL": "Baz"} {"en_US": "Foo", "fr_FR": "", "nl_NL": "Baz"} Like before, writing False to the field makes it NULL, i.e., False in all languages. However, writing "" to the field makes its value empty in the current language, but does not discard the values in the other languages. Here are examples for a field with translate=xml_translate: NULL {"en_US": "
Foo

Bar

", "fr_FR": "
Fou

Barre

"} Change for callable(translate) fields: one can now write any value in any language on such a field. The new value will be adapted in all languages, based on the mapping of terms between languages in the old values. Basically the structure of the value must remain the same in all languages, like before. Reading a translated field is now both simpler and faster than the former implementation. We fetch the value of the field in the current language by coalescing its value with the 'en_US' value of the field: SELECT id, COALESCE(name->>'fr_FR', name->>'en_US') AS name ... The raw cache of the field contains either None or a dict which is conceptually a subset of the JSON value in database (except for missing languages). For the sake of simplicity, most cache operations deal with the dict and return the text value in the current language. Trigram indexes have been adapted to the new storing strategy, and should enable to search in any language. Before this change, only the source value of the field ('en_US') could be indexed. Computed stored translated fields are not supported by the framework, because of the complexity of the computation itself: the field would need to be computed in all active languages. We chose to not provide any hook to compute a field in all languages at once, and the framework always invokes a compute method once to recompute it. Code translations are no longer stored into the database. They become static, and are extracted from the PO files when needed. The worker simply uses a cache with extracted code translations for performance. This is reasonable, since fr_FR code translations for all modules takes around 2MB of memory, and the cache can be shared among all registries in the worker. Changing code translations requires to update the corresponding PO file and reloading the worker(s). Performance summary: (+) reading 'model' translated fields is faster (+) reading 'model_terms' translated fields is much faster (no need to inject translations into the source value) (+) searching translated fields with operator 'ilike' is much faster when the field is indexed with 'trigram' (+) updating translated fields requires less ORM flushing (-) importing translations from PO files is 2x slower Some extra fixes: - make field 'name' of ir.actions.actions translated; because of the PG inheritance, this is necessary to make the column definition consistent in all models that inherit from ir.actions.actions. - add some backend API for the web/website client for editing translations - move methods get_field_string() to model ir.model.fields - move _load_module_terms to model ir.module.module - adapt tests in test_impex, test_new_api - because env.lang is injected into SQL queries, its returned value is now guaranteed to correspond to a valid active language or None - remove wizard to insert missing translations (no longer makes sense) task-id: 2081307 Co-authored-by: Fabien Pinckaers Co-authored-by: Raphael Collet --- addons/base_import/models/base_import.py | 11 +- addons/hr/models/hr_employee.py | 4 +- addons/http_routing/models/ir_http.py | 2 +- .../l10n_multilang/models/l10n_multilang.py | 21 +- addons/mail/models/ir_http.py | 2 +- addons/mail_plugin/controllers/mail_plugin.py | 2 +- .../mrp_subcontracting/controllers/portal.py | 2 +- addons/project/controllers/portal.py | 2 +- addons/web/controllers/webclient.py | 2 +- addons/web/models/ir_http.py | 2 +- .../src/webclient/actions/debug_items.js | 20 - addons/web_editor/models/__init__.py | 1 - addons/web_editor/models/ir_qweb_fields.py | 11 +- addons/web_editor/models/ir_translation.py | 46 - addons/web_editor/models/models.py | 9 + addons/website/models/__init__.py | 1 - addons/website/models/ir_module_module.py | 67 ++ addons/website/models/ir_translation.py | 64 -- odoo/addons/base/__manifest__.py | 2 - odoo/addons/base/data/base_data.sql | 12 +- odoo/addons/base/i18n/base.pot | 24 + odoo/addons/base/models/__init__.py | 1 - odoo/addons/base/models/ir_actions.py | 6 +- odoo/addons/base/models/ir_actions_report.py | 1 - odoo/addons/base/models/ir_cron.py | 4 +- odoo/addons/base/models/ir_fields.py | 70 +- odoo/addons/base/models/ir_filters.py | 8 +- odoo/addons/base/models/ir_http.py | 44 + odoo/addons/base/models/ir_model.py | 175 ++-- odoo/addons/base/models/ir_module.py | 47 +- odoo/addons/base/models/ir_translation.py | 927 ------------------ odoo/addons/base/models/ir_ui_view.py | 9 + odoo/addons/base/models/res_lang.py | 2 - odoo/addons/base/security/ir.model.access.csv | 3 - odoo/addons/base/tests/test_api.py | 11 + odoo/addons/base/tests/test_expression.py | 14 +- odoo/addons/base/tests/test_translate.py | 636 +++++------- odoo/addons/base/tests/test_views.py | 72 +- .../base/views/ir_translation_views.xml | 112 --- odoo/addons/base/wizard/__init__.py | 1 - .../base/wizard/base_language_install.py | 1 - .../base/wizard/base_update_translations.py | 38 - .../wizard/base_update_translations_views.xml | 29 - odoo/addons/test_impex/models.py | 10 +- odoo/addons/test_impex/tests/test_load.py | 67 +- .../test_new_api/models/test_new_api.py | 35 +- .../test_new_api/security/ir.model.access.csv | 3 + odoo/addons/test_new_api/tests/__init__.py | 1 + .../test_new_api/tests/test_new_fields.py | 30 +- .../test_new_api/tests/test_properties.py | 2 +- .../tests/test_related_translation.py | 174 ++++ odoo/api.py | 99 +- odoo/fields.py | 272 +++-- odoo/models.py | 295 ++++-- odoo/modules/db.py | 9 +- odoo/modules/registry.py | 30 +- odoo/osv/expression.py | 10 +- odoo/tools/mail.py | 2 +- odoo/tools/sql.py | 70 +- odoo/tools/translate.py | 459 ++++++--- 60 files changed, 1852 insertions(+), 2234 deletions(-) delete mode 100644 addons/web_editor/models/ir_translation.py delete mode 100644 addons/website/models/ir_translation.py delete mode 100644 odoo/addons/base/models/ir_translation.py delete mode 100644 odoo/addons/base/views/ir_translation_views.xml delete mode 100644 odoo/addons/base/wizard/base_update_translations.py delete mode 100644 odoo/addons/base/wizard/base_update_translations_views.xml create mode 100644 odoo/addons/test_new_api/tests/test_related_translation.py diff --git a/addons/base_import/models/base_import.py b/addons/base_import/models/base_import.py index 39b5a93a8e6..9c670161a28 100644 --- a/addons/base_import/models/base_import.py +++ b/addons/base_import/models/base_import.py @@ -727,8 +727,15 @@ class Import(models.TransientModel): if '/' not in header: # Then, try exact match - IrTranslation = self.env['ir.translation'] - translated_header = IrTranslation._get_source('ir.model.fields,field_description', 'model', self.env.lang, header).lower() + if header: + field_rec = ( + self.env['ir.model.fields'].sudo().with_context(lang='en_US') + .search([('field_description', '=', header)], limit=1) + .with_env(self.env) + ) + translated_header = (field_rec.sudo().field_description or header).lower() + else: + translated_header = "" for field in fields_tree: # exact match found based on the field technical name if header.casefold() == field['name'].casefold(): diff --git a/addons/hr/models/hr_employee.py b/addons/hr/models/hr_employee.py index daf467eddd8..da7e57f20e3 100644 --- a/addons/hr/models/hr_employee.py +++ b/addons/hr/models/hr_employee.py @@ -193,7 +193,9 @@ class HrEmployeePrivate(models.Model): public.read(fields) for fname in fields: values = self.env.cache.get_values(public, public._fields[fname]) - self.env.cache.update(self, self._fields[fname], values) + if self._fields[fname].translate: + values = [(value.copy() if value else None) for value in values] + self.env.cache.update_raw(self, self._fields[fname], values) @api.model def _cron_check_work_permit_validity(self): diff --git a/addons/http_routing/models/ir_http.py b/addons/http_routing/models/ir_http.py index 6b76757fadf..3579a10f93b 100644 --- a/addons/http_routing/models/ir_http.py +++ b/addons/http_routing/models/ir_http.py @@ -297,7 +297,7 @@ class IrHttp(models.AbstractModel): modules = IrHttpModel.get_translation_frontend_modules() user_context = request.session.context if request.session.uid else {} lang = user_context.get('lang') - translation_hash = request.env['ir.translation'].get_web_translations_hash(modules, lang) + translation_hash = request.env['ir.http'].get_web_translations_hash(modules, lang) session_info.update({ 'translationURL': '/website/translations', diff --git a/addons/l10n_multilang/models/l10n_multilang.py b/addons/l10n_multilang/models/l10n_multilang.py index 8b5cc601e48..dc133f5a509 100644 --- a/addons/l10n_multilang/models/l10n_multilang.py +++ b/addons/l10n_multilang/models/l10n_multilang.py @@ -38,26 +38,9 @@ class AccountChartTemplate(models.Model): :return: True """ - xlat_obj = self.env['ir.translation'] - #find the source from Account Template for lang in langs: - #find the value from Translation - value = xlat_obj._get_ids(in_ids._name + ',' + in_field, 'model', lang, in_ids.ids) - counter = 0 - for element in in_ids.with_context(lang=None): - if value[element.id]: - #copy Translation from Source to Destination object - xlat_obj._set_ids( - out_ids._name + ',' + in_field, - 'model', - lang, - out_ids[counter].ids, - value[element.id], - element[in_field] - ) - else: - _logger.info('Language: %s. Translation from template: there is no translation available for %s!' % (lang, element[in_field])) - counter += 1 + for in_id, out_id in zip(in_ids.with_context(lang=lang), out_ids.with_context(lang=lang)): + out_id[in_field] = in_id[in_field] return True def process_coa_translations(self): diff --git a/addons/mail/models/ir_http.py b/addons/mail/models/ir_http.py index 2c99c97c2b3..ff89ab3a5fb 100644 --- a/addons/mail/models/ir_http.py +++ b/addons/mail/models/ir_http.py @@ -18,7 +18,7 @@ class IrHttp(models.AbstractModel): user_context = {'lang': guest.lang} mods = odoo.conf.server_wide_modules or [] lang = user_context.get("lang") - translation_hash = request.env['ir.translation'].sudo().get_web_translations_hash(mods, lang) + translation_hash = request.env['ir.http'].sudo().get_web_translations_hash(mods, lang) result['cache_hashes']['translations'] = translation_hash result.update({ 'name': guest.name, diff --git a/addons/mail_plugin/controllers/mail_plugin.py b/addons/mail_plugin/controllers/mail_plugin.py index 06ce25c6dfb..c2ed36c748a 100644 --- a/addons/mail_plugin/controllers/mail_plugin.py +++ b/addons/mail_plugin/controllers/mail_plugin.py @@ -434,7 +434,7 @@ class MailPluginController(http.Controller): def _prepare_translations(self): lang = request.env['res.users'].browse(request.uid).lang - translations_per_module = request.env["ir.translation"].get_translations_for_webclient( + translations_per_module = request.env["ir.http"].get_translations_for_webclient( self._translation_modules_whitelist(), lang)[0] translations_dict = {} for module in self._translation_modules_whitelist(): diff --git a/addons/mrp_subcontracting/controllers/portal.py b/addons/mrp_subcontracting/controllers/portal.py index 6343fdadfdb..a76948e31ea 100644 --- a/addons/mrp_subcontracting/controllers/portal.py +++ b/addons/mrp_subcontracting/controllers/portal.py @@ -91,7 +91,7 @@ class CustomerPortal(portal.CustomerPortal): user_context = dict(request.env.context) if request.session.uid else {} mods = conf.server_wide_modules or [] lang = user_context.get("lang") - translation_hash = request.env['ir.translation'].get_web_translations_hash(mods, lang) + translation_hash = request.env['ir.http'].get_web_translations_hash(mods, lang) cache_hashes = { "translations": translation_hash, } diff --git a/addons/project/controllers/portal.py b/addons/project/controllers/portal.py index 105fbe6cb1d..f2d7351f9e4 100644 --- a/addons/project/controllers/portal.py +++ b/addons/project/controllers/portal.py @@ -139,7 +139,7 @@ class ProjectCustomerPortal(CustomerPortal): # Update Cache user_context['lang'] = lang lang = user_context.get("lang") - translation_hash = request.env['ir.translation'].get_web_translations_hash(mods, lang) + translation_hash = request.env['ir.http'].get_web_translations_hash(mods, lang) cache_hashes = { "translations": translation_hash, } diff --git a/addons/web/controllers/webclient.py b/addons/web/controllers/webclient.py index d5e1b7e76ac..7b5fdc4ce90 100644 --- a/addons/web/controllers/webclient.py +++ b/addons/web/controllers/webclient.py @@ -96,7 +96,7 @@ class WebClient(http.Controller): elif mods is None: mods = list(request.env.registry._init_modules) + (odoo.conf.server_wide_modules or []) - translations_per_module, lang_params = request.env["ir.translation"].get_translations_for_webclient(mods, lang) + translations_per_module, lang_params = request.env["ir.http"].get_translations_for_webclient(mods, lang) body = json.dumps({ 'lang': lang, diff --git a/addons/web/models/ir_http.py b/addons/web/models/ir_http.py index 1be2dfa9f20..8ebc6dc1f8a 100644 --- a/addons/web/models/ir_http.py +++ b/addons/web/models/ir_http.py @@ -104,7 +104,7 @@ class Http(models.AbstractModel): "max_file_upload_size": max_file_upload_size, "home_action_id": user.action_id.id, "cache_hashes": { - "translations": request.env['ir.translation'].sudo().get_web_translations_hash( + "translations": request.env['ir.http'].sudo().get_web_translations_hash( mods, request.session.context['lang'] ) if session_uid else None, }, diff --git a/addons/web/static/src/webclient/actions/debug_items.js b/addons/web/static/src/webclient/actions/debug_items.js index 87ab7ec8b7b..ec4bf238512 100644 --- a/addons/web/static/src/webclient/actions/debug_items.js +++ b/addons/web/static/src/webclient/actions/debug_items.js @@ -101,25 +101,6 @@ function manageFilters({ action, env }) { }; } -function technicalTranslation({ action, env }) { - if (!action.res_model) { - return null; - } - return { - type: "item", - description: env._t("Technical Translation"), - callback: async () => { - const result = await env.services.orm.call( - "ir.translation", - "get_technical_translations", - [action.res_model] - ); - env.services.action.doAction(result); - }, - sequence: 140, - }; -} - function viewAccessRights({ accessRights, action, env }) { if (!action.res_model || !accessRights.canSeeModelAccess) { return null; @@ -190,7 +171,6 @@ debugRegistry .add("editAction", editAction) .add("viewFields", viewFields) .add("manageFilters", manageFilters) - .add("technicalTranslation", technicalTranslation) .add("accessSeparator", accessSeparator) .add("viewAccessRights", viewAccessRights) .add("viewRecordRules", viewRecordRules); diff --git a/addons/web_editor/models/__init__.py b/addons/web_editor/models/__init__.py index f24f2756867..393b0721b56 100644 --- a/addons/web_editor/models/__init__.py +++ b/addons/web_editor/models/__init__.py @@ -5,7 +5,6 @@ from . import ir_attachment from . import ir_qweb_fields from . import ir_ui_view from . import ir_http -from . import ir_translation from . import ir_websocket from . import models diff --git a/addons/web_editor/models/ir_qweb_fields.py b/addons/web_editor/models/ir_qweb_fields.py index e2ea35cddbd..d3272ccb715 100644 --- a/addons/web_editor/models/ir_qweb_fields.py +++ b/addons/web_editor/models/ir_qweb_fields.py @@ -155,10 +155,13 @@ class Field(models.AbstractModel): attrs['placeholder'] = placeholder if options['translate'] and field.type in ('char', 'text'): - name = "%s,%s" % (record._name, field_name) - domain = [('name', '=', name), ('res_id', '=', record.id), ('type', '=', 'model'), ('lang', '=', options.get('lang'))] - translation = record.env['ir.translation'].search(domain, limit=1) - attrs['data-oe-translation-state'] = translation and translation.state or 'to_translate' + lang = record.env.lang or 'en_US' + if lang == 'en_US': + attrs['data-oe-translation-state'] = 'translated' + else: + value_en = record.with_context(lang='en_US')[field_name] + value_lang = record.with_context(lang=lang)[field_name] + attrs['data-oe-translation-state'] = 'translated' if value_en != value_lang else 'to_translate' return attrs diff --git a/addons/web_editor/models/ir_translation.py b/addons/web_editor/models/ir_translation.py deleted file mode 100644 index 0a0e34dafde..00000000000 --- a/addons/web_editor/models/ir_translation.py +++ /dev/null @@ -1,46 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -from lxml import etree - -from odoo import models, api -from odoo.tools.translate import encode, xml_translate, html_translate - - -def edit_translation_mapping(data): - data = dict(data, model=data['name'].partition(',')[0], value=data['value'] or data['src']) - return '%(value)s' % data - - -class IrTranslation(models.Model): - _inherit = 'ir.translation' - - @api.model - def _get_terms_mapping(self, field, records): - if self._context.get('edit_translations'): - self.insert_missing(field, records) - return edit_translation_mapping - return super(IrTranslation, self)._get_terms_mapping(field, records) - - def save_html(self, value): - """ Convert the HTML fragment ``value`` to XML if necessary, and write - it as the value of translation ``self``. - """ - assert len(self) == 1 and self.type == 'model_terms' - mname, fname = self.name.split(',') - field = self.env[mname]._fields[fname] - if field.translate == xml_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 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/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..24ed8667643 100644 --- a/addons/website/models/ir_module_module.py +++ b/addons/website/models/ir_module_module.py @@ -456,3 +456,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/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..bc3cc04a423 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() 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 = '
%s
' - terms_en = ('Bread and cheese',) - terms_fr = ('Pain et fromage',) - terms_nl = ('Brood and kaas',) + archf = '
%s
%s
' + 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 = '
%s
%s
' - 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 = '
%s
%s
' + 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, '
Bread and cheese
Fork2
') + self.assertEqual(view.with_context(lang='en_US').arch_db, '
Bread and cheese
Fork2
') + self.assertEqual(view.with_context(lang='fr_FR').arch_db, '
Pain et fromage
Fourchette2
') + self.assertEqual(view.with_context(lang='nl_NL').arch_db, view_nl) + + view.invalidate_recordset() + self.assertEqual(view.arch_db, '
Bread and cheese
Fork2
') + self.assertEqual(view.with_context(lang='en_US').arch_db, '
Bread and cheese
Fork2
') + self.assertEqual(view.with_context(lang='fr_FR').arch_db, '
Pain et fromage
Fourchette2
') + 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='
Bar
') - 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, '
Bar
') # 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/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': '

Knife

Fork

Spoon

', + }) + cls.test1.with_context(lang='fr_FR').write({ + 'name': 'Couteau', + }) + cls.test1.update_field_translations('html', {'fr_FR': { + 'Knife': 'Couteau', + 'Fork': 'Fourchette', + 'Spoon': 'Cuiller', + }}) + cls.test12 = cls.env['test_new_api.related_translation_1'].with_context(lang='en_US').create({ + 'name': 'Knife 2', + 'html': '

Knife 2

Fork 2

Spoon 2

', + }) + cls.test12.with_context(lang='fr_FR').write({ + 'name': 'Couteau 2', + }) + cls.test12.update_field_translations('html', {'fr_FR': { + 'Knife 2': 'Couteau 2', + 'Fork 2': 'Fourchette 2', + 'Spoon 2': 'Cuiller 2', + }}) + cls.test2 = cls.env['test_new_api.related_translation_2'].with_context(lang='en_US').create({ + 'parent_id': cls.test1.id, + }) + cls.test3 = cls.env['test_new_api.related_translation_3'].with_context(lang='en_US').create({ + 'parent_id': cls.test2.id, + }) + + def test_read(self): + self.assertEqual(self.test1.with_context(lang='en_US').name, 'Knife') + self.assertEqual(self.test1.with_context(lang='fr_FR').name, 'Couteau') + self.assertEqual(self.test2.with_context(lang='en_US').name, 'Knife') + self.assertEqual(self.test2.with_context(lang='fr_FR').name, 'Couteau') + self.assertEqual(self.test3.with_context(lang='en_US').name, 'Knife') + self.assertEqual(self.test3.with_context(lang='fr_FR').name, 'Couteau') + + def test_write_from_ori(self): + self.test1.with_context(lang='en_US').name = 'New knife' + self.assertEqual(self.test1.with_context(lang='en_US').name, 'New knife') + self.assertEqual(self.test1.with_context(lang='fr_FR').name, 'Couteau') + self.assertEqual(self.test2.with_context(lang='en_US').name, 'New knife') + self.assertEqual(self.test2.with_context(lang='fr_FR').name, 'Couteau') + self.assertEqual(self.test3.with_context(lang='en_US').name, 'New knife') + self.assertEqual(self.test3.with_context(lang='fr_FR').name, 'Couteau') + self.test1.with_context(lang='fr_FR').name = 'Nouveau couteau' + self.assertEqual(self.test1.with_context(lang='en_US').name, 'New knife') + self.assertEqual(self.test1.with_context(lang='fr_FR').name, 'Nouveau couteau') + self.assertEqual(self.test2.with_context(lang='en_US').name, 'New knife') + self.assertEqual(self.test2.with_context(lang='fr_FR').name, 'Nouveau couteau') + self.assertEqual(self.test3.with_context(lang='en_US').name, 'New knife') + self.assertEqual(self.test3.with_context(lang='fr_FR').name, 'Nouveau couteau') + + def test_write_from_related(self): + self.test2.with_context(lang='en_US').name = 'New knife' + self.assertEqual(self.test1.with_context(lang='en_US').name, 'New knife') + self.assertEqual(self.test1.with_context(lang='fr_FR').name, 'Couteau') + self.assertEqual(self.test2.with_context(lang='en_US').name, 'New knife') + self.assertEqual(self.test2.with_context(lang='fr_FR').name, 'Couteau') + self.assertEqual(self.test3.with_context(lang='en_US').name, 'New knife') + self.assertEqual(self.test3.with_context(lang='fr_FR').name, 'Couteau') + self.test3.with_context(lang='fr_FR').name = 'Nouveau couteau' + self.assertEqual(self.test1.with_context(lang='en_US').name, 'New knife') + self.assertEqual(self.test1.with_context(lang='fr_FR').name, 'Nouveau couteau') + self.assertEqual(self.test2.with_context(lang='en_US').name, 'New knife') + self.assertEqual(self.test2.with_context(lang='fr_FR').name, 'Nouveau couteau') + self.assertEqual(self.test3.with_context(lang='en_US').name, 'New knife') + self.assertEqual(self.test3.with_context(lang='fr_FR').name, 'Nouveau couteau') + + def test_translate_from_ori(self): + self.test1.update_field_translations('name', {'en_US': 'New knife'}) + self.assertEqual(self.test1.with_context(lang='en_US').name, 'New knife') + self.assertEqual(self.test1.with_context(lang='fr_FR').name, 'Couteau') + self.assertEqual(self.test2.with_context(lang='en_US').name, 'New knife') + self.assertEqual(self.test2.with_context(lang='fr_FR').name, 'Couteau') + self.assertEqual(self.test3.with_context(lang='en_US').name, 'New knife') + self.assertEqual(self.test3.with_context(lang='fr_FR').name, 'Couteau') + self.test1.update_field_translations('name', {'fr_FR': 'Nouveau couteau'}) + self.assertEqual(self.test1.with_context(lang='en_US').name, 'New knife') + self.assertEqual(self.test1.with_context(lang='fr_FR').name, 'Nouveau couteau') + self.assertEqual(self.test2.with_context(lang='en_US').name, 'New knife') + self.assertEqual(self.test2.with_context(lang='fr_FR').name, 'Nouveau couteau') + self.assertEqual(self.test3.with_context(lang='en_US').name, 'New knife') + self.assertEqual(self.test3.with_context(lang='fr_FR').name, 'Nouveau couteau') + + def test_translate_from_related(self): + self.test2.update_field_translations('name', {'en_US': 'New knife'}) + self.assertEqual(self.test1.with_context(lang='en_US').name, 'New knife') + self.assertEqual(self.test1.with_context(lang='fr_FR').name, 'Couteau') + self.assertEqual(self.test2.with_context(lang='en_US').name, 'New knife') + self.assertEqual(self.test2.with_context(lang='fr_FR').name, 'Couteau') + self.assertEqual(self.test3.with_context(lang='en_US').name, 'New knife') + self.assertEqual(self.test3.with_context(lang='fr_FR').name, 'Couteau') + self.test3.update_field_translations('name', {'fr_FR': 'Nouveau couteau'}) + self.assertEqual(self.test1.with_context(lang='en_US').name, 'New knife') + self.assertEqual(self.test1.with_context(lang='fr_FR').name, 'Nouveau couteau') + self.assertEqual(self.test2.with_context(lang='en_US').name, 'New knife') + self.assertEqual(self.test2.with_context(lang='fr_FR').name, 'Nouveau couteau') + self.assertEqual(self.test3.with_context(lang='en_US').name, 'New knife') + self.assertEqual(self.test3.with_context(lang='fr_FR').name, 'Nouveau couteau') + + def test_import_from_po(self): + self.assertEqual(self.test2.with_context(lang='fr_FR').name, 'Couteau') + test1_xml_id = self.test1.export_data(['id']).get('datas')[0][0] + po_string = ''' + #. module: test_new_api + #: model:test_new_api.related_translation_1,name:%s + msgid "Knife" + msgstr "Nouveau couteau" + ''' % test1_xml_id + with io.BytesIO(bytes(po_string, encoding='utf-8')) as f: + f.name = 'dummy' + trans_load_data(self.test1.env.cr, f, 'po', 'fr_FR', verbose=True, overwrite=True) + self.assertEqual(self.test2.with_context(lang='fr_FR').name, 'Nouveau couteau') + + def test_translate_from_ori_term(self): + self.assertEqual(self.test1.with_context(lang='en_US').html, '

Knife

Fork

Spoon

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

Couteau

Fourchette

Cuiller

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

Knife

Fork

Spoon

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

Couteau

Fourchette

Cuiller

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

Knife

Fork

Spoon

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

Couteau

Fourchette

Cuiller

') + self.test1.update_field_translations('html', {'fr_FR': {'Couteau': 'Nouveau couteau'}}) + self.assertEqual(self.test1.with_context(lang='en_US').html, '

Knife

Fork

Spoon

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

Nouveau couteau

Fourchette

Cuiller

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

Knife

Fork

Spoon

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

Nouveau couteau

Fourchette

Cuiller

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

Knife

Fork

Spoon

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

Nouveau couteau

Fourchette

Cuiller

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

Knife

Fork

Spoon

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

Couteau

Fourchette

Cuiller

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

Knife

Fork

Spoon

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

Couteau

Fourchette

Cuiller

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

Knife

Fork

Spoon

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

Couteau

Fourchette

Cuiller

') + self.test3.update_field_translations('html', {'fr_FR': {'Couteau': 'Nouveau couteau'}}) + self.assertEqual(self.test1.with_context(lang='en_US').html, '

Knife

Fork

Spoon

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

Nouveau couteau

Fourchette

Cuiller

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

Knife

Fork

Spoon

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

Nouveau couteau

Fourchette

Cuiller

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

Knife

Fork

Spoon

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

Nouveau couteau

Fourchette

Cuiller

') + + def test_translate_change_many2one(self): + self.assertEqual(self.test2.with_context(lang='en_US').name, 'Knife') + self.assertEqual(self.test2.with_context(lang='fr_FR').name, 'Couteau') + self.test2.with_context(lang='fr_FR').parent_id = self.test12 + self.assertEqual(self.test2.with_context(lang='en_US').name, 'Knife 2') + self.assertEqual(self.test2.with_context(lang='fr_FR').name, 'Couteau 2') + self.assertEqual(self.test3.with_context(lang='en_US').html, '

Knife 2

Fork 2

Spoon 2

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

Couteau 2

Fourchette 2

Cuiller 2

') + self.test2.invalidate_recordset() + self.assertEqual(self.test2.with_context(lang='en_US').name, 'Knife 2') + self.assertEqual(self.test2.with_context(lang='fr_FR').name, 'Couteau 2') + self.assertEqual(self.test3.with_context(lang='en_US').html, '

Knife 2

Fork 2

Spoon 2

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

Couteau 2

Fourchette 2

Cuiller 2

') diff --git a/odoo/api.py b/odoo/api.py index 1b2203ab72c..aa275dcf826 100644 --- a/odoo/api.py +++ b/odoo/api.py @@ -682,7 +682,10 @@ class Environment(Mapping): :rtype: str """ - return self.context.get('lang') or None + lang = self.context.get('lang') + # _lang_get_id is cached and used to validate lang before return, + # because 'env.lang' may be injected in SQL queries + return lang if lang and self['res.lang']._lang_get_id(lang) else None def clear(self): """ Clear all record caches, and discard all fields to recompute. @@ -946,7 +949,15 @@ class Cache(object): def contains(self, record, field): """ Return whether ``record`` has a value for ``field``. """ - return record.id in self._get_field_cache(record, field) + field_cache = self._get_field_cache(record, field) + if field.translate: + cache_value = field_cache.get(record.id, EMPTY_DICT) + if cache_value is None: + return True + lang = record.env.lang or 'en_US' + return lang in cache_value + + return record.id in field_cache def contains_field(self, field): """ Return whether ``field`` has a value for at least one record. """ @@ -962,7 +973,11 @@ class Cache(object): """ Return the value of ``field`` for ``record``. """ try: field_cache = self._get_field_cache(record, field) - return field_cache[record._ids[0]] + cache_value = field_cache[record._ids[0]] + if field.translate and cache_value is not None: + lang = record.env.lang or 'en_US' + return cache_value[lang] + return cache_value except KeyError: if default is NOTHING: raise CacheMiss(record, field) @@ -980,7 +995,13 @@ class Cache(object): dirty must raise an exception """ field_cache = self._set_field_cache(record, field) + if field.translate and value is not None: + lang = record.env.lang or 'en_US' + cache_value = field_cache.get(record._ids[0]) or {} + cache_value[lang] = value + value = cache_value field_cache[record._ids[0]] = value + if not check_dirty: return if dirty: @@ -1007,6 +1028,25 @@ class Cache(object): :param check_dirty: whether updating a dirty field without making it dirty must raise an exception """ + if field.translate: + lang = records.env.lang or 'en_US' + field_cache = self._get_field_cache(records, field) + cache_values = [] + for id_, value in zip(records._ids, values): + if value is None: + cache_values.append(None) + else: + cache_value = field_cache.get(id_) or {} + cache_value[lang] = value + cache_values.append(cache_value) + values = cache_values + + self.update_raw(records, field, values, dirty, check_dirty) + + def update_raw(self, records, field, values, dirty=False, check_dirty=True): + """ This is a variant of method :meth:`~update` without the logic for + translated fields. + """ field_cache = self._set_field_cache(records, field) field_cache.update(zip(records._ids, values)) if not check_dirty: @@ -1032,8 +1072,18 @@ class Cache(object): existing values in cache. """ field_cache = self._set_field_cache(records, field) - for id_, val in zip(records._ids, values): - field_cache.setdefault(id_, val) + if field.translate: + lang = records.env.lang or 'en_US' + for id_, val in zip(records._ids, values): + if val is None: + field_cache.setdefault(id_, None) + else: + cache_value = field_cache.setdefault(id_, {}) + if cache_value is not None: + cache_value.setdefault(lang, val) + else: + for id_, val in zip(records._ids, values): + field_cache.setdefault(id_, val) def remove(self, record, field): """ Remove the value of ``field`` for ``record``. """ @@ -1056,10 +1106,19 @@ class Cache(object): def get_until_miss(self, records, field): """ Return the cached values of ``field`` for ``records`` until a value is not found. """ field_cache = self._get_field_cache(records, field) + if field.translate: + lang = records.env.lang or 'en_US' + + def get_value(id_): + cache_value = field_cache[id_] + return None if cache_value is None else cache_value[lang] + else: + get_value = field_cache.__getitem__ + vals = [] for record_id in records._ids: try: - vals.append(field_cache[record_id]) + vals.append(get_value(record_id)) except KeyError: break return vals @@ -1067,10 +1126,19 @@ class Cache(object): def get_records_different_from(self, records, field, value): """ Return the subset of ``records`` that has not ``value`` for ``field``. """ field_cache = self._get_field_cache(records, field) + if field.translate: + lang = records.env.lang or 'en_US' + + def get_value(id_): + cache_value = field_cache[id_] + return None if cache_value is None else cache_value[lang] + else: + get_value = field_cache.__getitem__ + ids = [] for record_id in records._ids: try: - val = field_cache[record_id] + val = get_value(record_id) except KeyError: ids.append(record_id) else: @@ -1092,14 +1160,25 @@ class Cache(object): def get_missing_ids(self, records, field): """ Return the ids of ``records`` that have no value for ``field``. """ field_cache = self._get_field_cache(records, field) - for record_id in records._ids: - if record_id not in field_cache: - yield record_id + if field.translate: + lang = records.env.lang or 'en_US' + for record_id in records._ids: + cache_value = field_cache.get(record_id, False) + if cache_value is False or not (cache_value is None or lang in cache_value): + yield record_id + else: + for record_id in records._ids: + if record_id not in field_cache: + yield record_id def get_dirty_fields(self): """ Return the fields that have dirty records in cache. """ return self._dirty.keys() + def get_dirty_records(self, model, field): + """ Return the records that for which ``field`` is dirty in cache. """ + return model.browse(self._dirty.get(field, ())) + def has_dirty_fields(self, records, fields=None): """ Return whether any of the given records has dirty fields. diff --git a/odoo/fields.py b/odoo/fields.py index 722fa48988b..efa9b118fc8 100644 --- a/odoo/fields.py +++ b/odoo/fields.py @@ -20,7 +20,10 @@ import warnings from markupsafe import Markup import psycopg2 +from psycopg2.extras import Json import pytz +from difflib import get_close_matches +from hashlib import sha256 from .tools import ( float_repr, float_round, float_compare, float_is_zero, html_sanitize, human_size, @@ -445,9 +448,6 @@ class Field(MetaField('DummyField', (object,), {})): attrs['inverse'] = self._inverse_company_dependent attrs['search'] = self._search_company_dependent attrs['depends_context'] = attrs.get('depends_context', ()) + ('company',) - if attrs.get('translate'): - # by default, translatable fields are context-dependent - attrs['depends_context'] = attrs.get('depends_context', ()) + ('lang',) # parameters 'depends' and 'depends_context' are stored in attributes # '_depends' and '_depends_context', respectively @@ -850,14 +850,14 @@ class Field(MetaField('DummyField', (object,), {})): def _description_string(self, env): if self.string and env.lang: model_name = self.base_field.model_name - field_string = env['ir.translation'].get_field_string(model_name) + field_string = env['ir.model.fields'].get_field_string(model_name) return field_string.get(self.name) or self.string return self.string def _description_help(self, env): if self.help and env.lang: model_name = self.base_field.model_name - field_help = env['ir.translation'].get_field_help(model_name) + field_help = env['ir.model.fields'].get_field_help(model_name) return field_help.get(self.name) or self.help return self.help @@ -909,7 +909,7 @@ class Field(MetaField('DummyField', (object,), {})): """ # spare the method lookup overhead convert = self.convert_to_record - return [convert(value, records) for value in values] + return [convert(value, record) for value, record in zip(values, records)] def convert_to_read(self, value, record, use_name_get=True): """ Convert ``value`` from the record format to the format returned by @@ -1010,6 +1010,10 @@ class Field(MetaField('DummyField', (object,), {})): return if column['is_nullable'] == 'NO': sql.drop_not_null(model._cr, model._table, self.name) + self._convert_db_column(model, column) + + def _convert_db_column(self, model, column): + """ Convert the given database column to the type of the field. """ sql.convert_column(model._cr, model._table, self.name, self.column_type[1]) def update_db_notnull(self, model, column): @@ -1602,7 +1606,6 @@ class Monetary(Field): class _String(Field): """ Abstract class for string fields. """ translate = False # whether the field is translated - prefetch = None unaccent = True def __init__(self, string=Default, **kwargs): @@ -1611,23 +1614,18 @@ class _String(Field): kwargs['translate'] = bool(kwargs['translate']) super(_String, self).__init__(string=string, **kwargs) - def setup_nonrelated(self, model): - super().setup_nonrelated(model) - if self.prefetch is None: - # translated fields are not prefetched by default except for _rec_name - self.prefetch = not self.translate or model._rec_name == self.name - - def setup_related(self, model): - super().setup_related(model) - if self.prefetch is None: - # translated fields are not prefetched by default except for _rec_name - self.prefetch = not self.translate or model._rec_name == self.name - _related_translate = property(attrgetter('translate')) def _description_translate(self, env): return bool(self.translate) + def _convert_db_column(self, model, column): + # specialized implementation for converting from/to translated fields + if self.translate or column['udt_name'] == 'jsonb': + sql.convert_column_translatable(model._cr, model._table, self.name, self.column_type[1]) + else: + sql.convert_column(model._cr, model._table, self.name, self.column_type[1]) + def get_trans_terms(self, value): """ Return the sequence of terms to translate found in `value`. """ if not callable(self.translate): @@ -1641,99 +1639,168 @@ class _String(Field): func = getattr(self.translate, 'get_text_content', lambda term: term) return func(term) + def convert_to_column(self, value, record, values=None, validate=True): + cache_value = self.convert_to_cache(value, record) + if cache_value is None: + return None + if callable(self.translate): + # pylint: disable=not-callable + cache_value = self.translate(lambda t: None, cache_value) + if self.translate: + cache_value = {'en_US': cache_value, record.env.lang or 'en_US': cache_value} + return self._convert_from_cache_to_column(cache_value) + + def _convert_from_cache_to_column(self, value): + """ Convert from cache_raw value to column value """ + if value is None: + return None + return Json(value) if self.translate else value + + def convert_to_cache(self, value, record, validate=True): + if value is None or value is False: + return None + return value + + def convert_to_record(self, value, record): + if value is None: + return False + if callable(self.translate) and record.env.context.get('edit_translations'): + value_en = record.with_context(edit_translations=None, lang='en_US')[self.name] + terms_en = self.get_trans_terms(value_en) + terms = self.get_trans_terms(value) + term_to_state = {term: "translated" if term_en != term else "to_translate" for term, term_en in zip(terms, terms_en)} + # use a wrapper to let the frontend js code identify each term and its metadata in the 'edit_translations' context + # pylint: disable=not-callable + value = self.translate( + lambda term: f'''{term}''', + value + ) + return value + + def convert_to_write(self, value, record): + return value + def get_trans_func(self, records): """ Return a translation function `translate` for `self` on the given records; the function call `translate(record_id, value)` translates the - field value to the language given by the environment of `records`. + field English value to the language given by the environment of `records`. """ + lang = records.env.lang or 'en_US' + if lang == 'en_US' or not self.translate: + return lambda record_id, value: value + # TODO: CWG: optimize it to one query + vals_en2lang = zip(records.with_context(lang='en_US').mapped(self.name), + records.with_context(lang=lang).mapped(self.name)) + translation_dictionaries = dict( + zip(records.ids, [self.get_translation_dictionary(val_en, {lang: val_lang}) for val_en, val_lang in vals_en2lang])) if callable(self.translate): - rec_src_trans = records.env['ir.translation']._get_terms_translations(self, records) - def translate(record_id, value): - src_trans = rec_src_trans[record_id] - return self.translate(src_trans.get, value) - - else: - rec_trans = records.env['ir.translation']._get_ids( - '%s,%s' % (self.model_name, self.name), 'model', records.env.lang, records.ids) - + translation_dictionary = translation_dictionaries[record_id] + # pylint: disable=not-callable + return self.translate(lambda term: translation_dictionary[term][lang], value) + else: # TODO CWG: TBD never used, useless? def translate(record_id, value): - return rec_trans.get(record_id) or value - + return translation_dictionaries.get(record_id).get(value, value) return translate + def get_translation_dictionary(self, from_lang_value, to_lang_values): + """ Build a dictionary from terms in from_lang_value to terms in to_lang_values + + :param str from_lang_value: from xml/html + :param dict to_lang_values: {lang: lang_value} + + :return: {from_lang_term: {lang: lang_term}} + :rtype: dict + """ + + from_lang_terms = self.get_trans_terms(from_lang_value) + dictionary = defaultdict(lambda: defaultdict(dict)) + + for lang, to_lang_value in to_lang_values.items(): + to_lang_terms = self.get_trans_terms(to_lang_value) + for from_lang_term, to_lang_term in zip(from_lang_terms, to_lang_terms): + dictionary[from_lang_term].update({lang: to_lang_term}) + return dictionary + + def _get_stored_translations(self, record): + """ + : return: {'en_US': 'value_en_US', 'fr_FR': 'French'} + """ + # assert (self.translate and self.store and record) + record.flush_recordset([self.name]) + cr = record.env.cr + cr.execute(f'SELECT "{self.name}" FROM "{record._table}" WHERE id = %s', (record.id,)) + return cr.fetchone()[0] + def write(self, records, value): - if not (self.translate and self.store and any(records._ids)): + if not self.translate or value is False or value is None: return super().write(records, value) - - # discard recomputation of self on records - records.env.remove_to_compute(self, records) - - # discard the records that are not modified cache = records.env.cache cache_value = self.convert_to_cache(value, records) records = cache.get_records_different_from(records, self, cache_value) if not records: return records - lang = records.env.lang - installed = records.env['res.lang'].get_installed() - single_lang = installed[0][0] if len(installed) <= 1 else None + # flush dirty None values + dirty_records = records & cache.get_dirty_records(records, self) + if any(v is None for v in cache.get_values(dirty_records, self)): + dirty_records.flush_recordset([self.name]) - # modify the column (source) - if single_lang or lang in (None, 'en_US') or callable(self.translate) or not cache_value: - if self.translate is True and cache_value: - tname = f"{self.model_name},{self.name}" - records.env['ir.translation']._set_source(tname, records._ids, value) - # invalidate the field in all languages because the fallback value - # for translations is modified - cache.invalidate([(self, records.ids)]) + dirty = self.store and any(records._ids) + lang = records.env.lang or 'en_US' + + # not dirty fields + if not dirty: + cache.update_raw(records, self, [{lang: cache_value} for _id in records._ids], dirty=False) + return records + + # model translation + if not callable(self.translate): + # invalidate clean fields because them may contain fallback value + clean_records = records - cache.get_dirty_records(records, self) + clean_records.invalidate_recordset([self.name]) cache.update(records, self, itertools.repeat(cache_value), dirty=True) - if single_lang and not lang: - # modifying with lang=None also updates the installed language - others = records.with_context(lang=single_lang) - cache.update(others, self, itertools.repeat(cache_value), dirty=True) - else: - # Ignore the dirty flag when updating the value in cache. This is - # necessary for translated fields, when you have to update the - # field's value in a language without making it dirty while the - # field's column value is already dirty. - cache.update(records, self, itertools.repeat(cache_value), check_dirty=False) + if lang != 'en_US' and not records.env['res.lang']._lang_get_id('en_US'): + # if 'en_US' is not active, we always write en_US to make sure value_en is meaningful + cache.update(records.with_context(lang='en_US'), self, itertools.repeat(cache_value), dirty=True) + return records - if callable(self.translate): - # the source value of self has been updated, synchronize translated - # terms when possible - records.env['ir.translation']._sync_terms_translations(self, records) - - elif lang: - # update translations - value = self.convert_to_column(value, records) - source_recs = records.with_context(lang=None) - source_value = first(source_recs)[self.name] - if not source_value: - source_recs[self.name] = value - source_value = value - tname = "%s,%s" % (self.model_name, self.name) - if not value: - records.env['ir.translation'].search([ - ('name', '=', tname), - ('type', '=', 'model'), - ('res_id', 'in', records._ids) - ]).unlink() - elif single_lang: - records.env['ir.translation']._update_translations([dict( - src=source_value, - value=value, - name=tname, - lang=lang, - type='model', - state='translated', - res_id=res_id) for res_id in records._ids]) - else: - records.env['ir.translation']._set_ids( - tname, 'model', lang, records._ids, value, source_value, - ) + # model term translation + new_translations_list = [] + # pylint: disable=not-callable + cache_value = self.translate(lambda t: None, cache_value) + new_terms = set(self.get_trans_terms(cache_value)) + for record in records: + # shortcut when no term needs to be translated + if not new_terms: + new_translations_list.append({'en_US': cache_value, lang: cache_value}) + continue + # _get_stored_translations can be refactored and prefetches translations for multi records, + # but it is really rare to write the same non-False/None/no-term value to multi records + old_translations = self._get_stored_translations(record) + if not old_translations: + new_translations_list.append({'en_US': cache_value, lang: cache_value}) + continue + from_lang_value = old_translations.get(lang, old_translations.get('en_US')) + translation_dictionary = self.get_translation_dictionary(from_lang_value, old_translations) + text2term = {self.get_text_content(term): term for term in new_terms} + for old_term in list(translation_dictionary.keys()): + if old_term not in new_terms: + old_term_text = self.get_text_content(old_term) + matches = get_close_matches(old_term_text, text2term, 1, 0.9) + if matches: + translation_dictionary[text2term[matches[0]]] = translation_dictionary.pop(old_term) + # pylint: disable=not-callable + new_translations = { + l: self.translate(lambda term: translation_dictionary.get(term, {l: None})[l], cache_value) + for l in old_translations.keys() + } + new_translations[lang] = cache_value + new_translations.setdefault('en_US', cache_value) + new_translations_list.append(new_translations) + # Maybe we can use Cache.update(records.with_context(cache_update_raw=True), self, new_translations_list, dirty=True) + cache.update_raw(records, self, new_translations_list, dirty=True) return records @@ -1764,16 +1831,17 @@ class Char(_String): @property def column_type(self): - return ('varchar', pg_varchar(self.size)) + return ('jsonb', 'jsonb') if self.translate else ('varchar', pg_varchar(self.size)) def update_db_column(self, model, column): if ( - column and column['udt_name'] == 'varchar' and column['character_maximum_length'] and + column and self.column_type[0] == 'varchar' and + column['udt_name'] == 'varchar' and column['character_maximum_length'] and (self.size is None or column['character_maximum_length'] < self.size) ): # the column's varchar size does not match self.size; convert it sql.convert_column(model._cr, model._table, self.name, self.column_type[1]) - super(Char, self).update_db_column(model, column) + super().update_db_column(model, column) _related_size = property(attrgetter('size')) _related_trim = property(attrgetter('trim')) @@ -1785,7 +1853,7 @@ class Char(_String): return None # we need to convert the string to a unicode object to be able # to evaluate its length (and possibly truncate it) reliably - return pycompat.to_text(value)[:self.size] + return super().convert_to_column(pycompat.to_text(value)[:self.size], record, values, validate) def convert_to_cache(self, value, record, validate=True): if value is None or value is False: @@ -1805,7 +1873,10 @@ class Text(_String): :type translate: bool or callable """ type = 'text' - column_type = ('text', 'text') + + @property + def column_type(self): + return ('jsonb', 'jsonb') if self.translate else ('text', 'text') def convert_to_cache(self, value, record, validate=True): if value is None or value is False: @@ -1829,7 +1900,6 @@ class Html(_String): :param bool strip_classes: whether to strip classes attributes (default: ``False``) """ type = 'html' - column_type = ('text', 'text') sanitize = True # whether value must be sanitized sanitize_overridable = False # whether the sanitation can be bypassed by the users part of the `base.group_sanitize_override` group @@ -1848,6 +1918,10 @@ class Html(_String): attrs['translate'] = html_translate return attrs + @property + def column_type(self): + return ('jsonb', 'jsonb') if self.translate else ('text', 'text') + _related_sanitize = property(attrgetter('sanitize')) _related_sanitize_tags = property(attrgetter('sanitize_tags')) _related_sanitize_attributes = property(attrgetter('sanitize_attributes')) @@ -1863,7 +1937,7 @@ class Html(_String): _description_strip_classes = property(attrgetter('strip_classes')) def convert_to_column(self, value, record, values=None, validate=True): - return self._convert(value, record, True) + return super().convert_to_column(self._convert(value, record, True), record, values, validate) def convert_to_cache(self, value, record, validate=True): return self._convert(value, record, validate) @@ -2588,7 +2662,7 @@ class Selection(Field): # translate selection labels if env.lang: - return env['ir.translation'].get_field_selection(self.model_name, self.name) + return env['ir.model.fields'].get_field_selection(self.model_name, self.name) else: return selection diff --git a/odoo/models.py b/odoo/models.py index 84a4b7be48c..65c9163cafe 100644 --- a/odoo/models.py +++ b/odoo/models.py @@ -23,6 +23,7 @@ import collections import contextlib +import copy import datetime import dateutil import fnmatch @@ -46,6 +47,7 @@ import babel.dates import dateutil.relativedelta import psycopg2 import psycopg2.extensions +from psycopg2.extras import Json from lxml import etree from lxml.builder import E @@ -1319,7 +1321,7 @@ class BaseModel(metaclass=MetaModel): """ field_names = {name: field.string for name, field in self._fields.items()} if self.env.lang: - field_names.update(self.env['ir.translation'].get_field_string(self._name)) + field_names.update(self.env['ir.model.fields'].get_field_string(self._name)) convert = self.env['ir.fields.converter'].for_model(self) @@ -2900,11 +2902,11 @@ class BaseModel(metaclass=MetaModel): extra=extra, extra_params=extra_params, ) return '"%s"."%s"' % (rel_alias, field.column2) - - elif field.translate is True: - # handle the case where the field is translated - return model._generate_translated_field(alias, fname, query) - + elif field.translate: + lang = self.env.lang or 'en_US' + if lang == 'en_US': + return f'"{alias}"."{fname}"->>\'en_US\'' + return f'COALESCE("{alias}"."{fname}"->>\'{lang}\', "{alias}"."{fname}"->>\'en_US\')' else: return '"%s"."%s"' % (alias, fname) @@ -3460,6 +3462,126 @@ class BaseModel(metaclass=MetaModel): return self._read_format(fnames=fields, load=load) + def update_field_translations(self, field_name, translations): + """ Update the values of a translated field. + + :param str field_name: field name + :param dict translations: if the field has ``translate=True``, it should be a dictionary + like ``{lang: new_value}``; if ``translate`` is a callable, it should be like + ``{lang: {old_term: new_term}}`` + """ + return self._update_field_translations(field_name, translations) + + def _update_field_translations(self, field_name, translations, digest=None): + """ Private implementation of :meth:`~update_field_translations`. + The main difference comes from the extra function ``digest``, which may + be used to make identifiers for old terms. + + :param dict translations: if the field has ``translate=True``, it should be a dictionary + like ``{lang: new_value}``; if ``translate`` is a callable, it should be like + ``{lang: {old_term: new_term}}``, or ``{lang: {digest(old_term): new_term}}`` when + ``digest`` is a callable + :param digest: an optional digest function for the old_term + """ + self.ensure_one() + + field = self._fields[field_name] + + if not field.translate: + return False # or raise error + + if not field.store and not field.related and field.compute: + # a non-related non-stored computed field cannot be translated, even if it has inverse function + return False + + if field.translate is True: + for lang, translation in translations.items(): + if translation is not None: + self.with_context(lang=lang)[field_name] = translation + else: + # Strictly speaking, a translated related/computed field cannot be stored + # because the compute function only support one language + # `not field.store` is a redundant logic. + # But some developers store translated related fields. + # In these cases, only all translations of the first stored translation field will be updated + # For other stored related translated field, the translation for the flush language will be updated + if field.related and not field.store: + related_path, field_name = field.related.rsplit(".", 1) + return self.mapped(related_path)._update_field_translations(field_name, translations, digest) + + self.check_access_rights('write') + self.check_field_access_rights('write', [field_name]) + self.check_access_rule('write') + + # Note: + # update terms in 'en_US' will not change its value other translated values + # record_en = Model_en.create({'html': '
English 1
English 2
' + # record_en.update_field_translations('html', {'fr_FR': {'English 2': 'French 2'}} + # record_en.update_field_translations('html', {'en_US': {'English 1': 'English 3'}} + # assert record_en == '
English 3
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 05c1fb4e495..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) - except (psycopg2.NotSupportedError, psycopg2.errors.DatatypeMismatch): - # 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)) + cr.execute(query, log_exceptions=False) + except psycopg2.NotSupportedError: + 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 d081973210a..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] @@ -1258,3 +1346,150 @@ def load_language(cr, lang): 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 From 1b473cf0db4d85c2b523ac87fbab533bce5f3e21 Mon Sep 17 00:00:00 2001 From: "Chong Wang (cwg)" Date: Wed, 7 Sep 2022 21:13:34 +0200 Subject: [PATCH 04/10] [IMP] website, web_editor: frontend for new translate api --- .../static/src/js/editor/snippets.editor.js | 4 +- .../static/src/js/wysiwyg/wysiwyg.js | 39 ++++++++++++------- .../src/components/translator/translator.js | 25 ++++++------ addons/website/static/tests/tours/rte.js | 2 +- 4 files changed, 39 insertions(+), 31 deletions(-) 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/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', From b43d117e0ecd849d6bfab0c90153567edd97e845 Mon Sep 17 00:00:00 2001 From: "Chong Wang (cwg)" Date: Wed, 7 Sep 2022 21:13:44 +0200 Subject: [PATCH 05/10] [IMP] web, web_editor: translation dialog for new translate api --- .../legacy/js/views/basic/basic_controller.js | 16 +-- .../legacy/js/widgets/translation_dialog.js | 71 ++++++----- .../src/legacy/xml/translation_dialog.xml | 8 +- .../src/views/fields/translation_button.js | 15 +-- .../src/views/fields/translation_dialog.js | 50 +++++--- .../tests/legacy/fields/basic_fields_tests.js | 82 ++++-------- .../static/tests/legacy/views/form_tests.js | 59 ++------- .../static/tests/legacy/views/list_tests.js | 35 +----- .../tests/views/fields/char_field_tests.js | 112 ++++++----------- .../tests/views/fields/text_field_tests.js | 42 +++---- .../tests/views/form/form_view_tests.js | 117 +++++++----------- .../web/static/tests/views/list_view_tests.js | 43 ++----- .../static/tests/field_html_tests.js | 30 ++--- 13 files changed, 237 insertions(+), 443 deletions(-) diff --git a/addons/web/static/src/legacy/js/views/basic/basic_controller.js b/addons/web/static/src/legacy/js/views/basic/basic_controller.js index bd48e2ab3bf..8fc49d99031 100644 --- a/addons/web/static/src/legacy/js/views/basic/basic_controller.js +++ b/addons/web/static/src/legacy/js/views/basic/basic_controller.js @@ -944,25 +944,15 @@ var BasicController = AbstractController.extend(FieldManagerMixin, { } var record = this.model.get(ev.data.id, { raw: true }); var res_id = record.res_id || record.res_ids[0]; - var result = await this._rpc({ - route: '/web/dataset/call_button', - params: { - model: 'ir.translation', - method: 'translate_fields', - args: [record.model, res_id, ev.data.fieldName], - kwargs: { context: record.getContext() }, - } - }); this.translationDialog = new TranslationDialog(this, { - domain: result.domain, - searchName: result.context.search_default_name, fieldName: ev.data.fieldName, + resId: res_id, userLanguageValue: ev.target.value || '', + dataPointModel: record.model, dataPointID: record.id, isComingFromTranslationAlert: ev.data.isComingFromTranslationAlert, - isText: result.context.translation_type === 'text', - showSrc: result.context.translation_show_src, + context: record.getContext(), }); return this.translationDialog.open(); }, diff --git a/addons/web/static/src/legacy/js/widgets/translation_dialog.js b/addons/web/static/src/legacy/js/widgets/translation_dialog.js index 4e145bff33e..2655304a3d0 100644 --- a/addons/web/static/src/legacy/js/widgets/translation_dialog.js +++ b/addons/web/static/src/legacy/js/widgets/translation_dialog.js @@ -14,29 +14,24 @@ odoo.define('web.TranslationDialog', function (require) { * @constructor * @param {Widget} parent * @param {Object} [options] - * @param {string} [options.domain] the domain needed to get the translation terms * @param {string} [options.fieldName] the name of the field currently translated (from the model of the form view) - * @param {string} [options.searchName] the name of the actual field that is the reference for translation (in the form of model,field) + * @param {integer} [options.resId] the ID of record currently translated * @param {string} [options.userLanguageValue] the value of the translation in the language of the user, as seen in the from view (might be empty) * @param {string} [options.dataPointID] the data point id of the record for which we do the translations * @param {boolean} [options.isComingFromTranslationAlert] the initiator of the dialog, might be a link on a field or the translation alert on top of the form - * @param {boolean} [options.isText] is the field a text field (multiline) or char (single line) - * @param {boolean} [options.showSrc] is the source of the translation should be rendered (for partial translations, i.e. XML content) * */ init: function (parent, options) { options = options || {}; this.fieldName = options.fieldName; - this.domain = options.domain; - this.searchName = options.searchName; + this.resId = options.resId; this.userLanguageValue = options.userLanguageValue; - this.domain.push(['name', "=", `${this.searchName}`]); + this.dataPointModel = options.dataPointModel; this.dataPointID = options.dataPointID; this.isComingFromTranslationAlert = options.isComingFromTranslationAlert; this.currentInterfaceLanguage = session.user_context.lang; - this.isText = options.isText; - this.showSrc = options.showSrc; + this.context = options.context; this._super(parent, _.extend({ size: 'large', @@ -56,31 +51,35 @@ odoo.define('web.TranslationDialog', function (require) { this._loadLanguages().then((l) => { this.languages = l; return this._loadTranslations().then((t) => { - this.translations = t; + [this.translations, this.context] = t; + let id = 1; + this.translations.forEach((t) => t['id'] = id++); + this.isText = this.context.translation_type === 'text'; + this.showSource = this.context.translation_show_source; }); }), ]).then(() => { this.data = this.translations.map((term) => { let relatedLanguage = this.languages.find((language) => language[0] === term.lang); - if (!term.value && !this.showSrc) { - term.value = term.src; + if (!term.value && !this.showSource) { + term.value = term.source; } return { id: term.id, lang: term.lang, langName: relatedLanguage[1], - source: term.src, + source: term.source, // we set the translation value coming from the database, except for the language // the user is currently utilizing. Then we set the translation value coming // from the value of the field in the form value: (term.lang === this.currentInterfaceLanguage && - !this.showSrc && + !this.showSource && !this.isComingFromTranslationAlert) ? this.userLanguageValue : term.value || '' }; }); this.data.sort((left, right) => - (left.langName < right.langName || (left.langName === right.langName && left.source < right.source)) ? -1 : 1); + left.langName.localeCompare(right.langName)); }); }, @@ -92,12 +91,11 @@ odoo.define('web.TranslationDialog', function (require) { * @private */ _loadTranslations: function () { - const domain = [...this.domain, ['lang', 'in', this.languages.map(l => l[0])]]; return this._rpc({ - model: 'ir.translation', - method: 'search_read', - fields: ['lang', 'src', 'value'], - domain: domain, + model: this.dataPointModel, + method: 'get_field_translations', + args: [[this.resId], this.fieldName], + context: this.context, }); }, /** @@ -137,16 +135,16 @@ odoo.define('web.TranslationDialog', function (require) { this.el.querySelectorAll('input[type=text],textarea').forEach((t) => { var initialValue = this.data.find((d) => d.id == t.dataset.id); if (initialValue.value !== t.value) { - updatedTerm[t.dataset.id] = t.value; + updatedTerm[t.dataset.id] = {lang: initialValue.lang, source: initialValue.source, value: t.value}; - if (initialValue.lang === this.currentInterfaceLanguage && !this.showSrc) { + if (initialValue.lang === this.currentInterfaceLanguage && !this.showSource) { // when the user has changed the term for the language he is // using in the interface, this change should be reflected // in the form view // partial translations being handled server side are // also ignored var changes = {}; - changes[this.fieldName] = updatedTerm[initialValue.id]; + changes[this.fieldName] = t.value; updateFormViewField = { dataPointID: this.dataPointID, changes: changes, @@ -158,16 +156,23 @@ odoo.define('web.TranslationDialog', function (require) { // updatedTerm only contains the id and values of the terms that // have been updated by the user - var saveUpdatedTermsProms = Object.keys(updatedTerm).map((id) => { - var writeTranslation = { - model: 'ir.translation', - method: 'write', - context: this.context, - args: [[parseInt(id, 10)], { value: updatedTerm[id] }] - }; - return this._rpc(writeTranslation); - }); - return Promise.all(saveUpdatedTermsProms).then(() => { + const translations = {}; + if (this.showSource) { // model terms translation + Object.entries(updatedTerm).forEach(([id, term]) => { + if (!translations[term.lang]) { + translations[term.lang] = {}; + } + translations[term.lang][term.source] = term.value; + }); + } + else { // model translation + Object.entries(updatedTerm).forEach(([id, term]) => translations[term.lang] = term.value); + } + return this._rpc({ + model: this.dataPointModel, + method: 'update_field_translations', + args: [[this.resId], this.fieldName, translations], + }).then(() => { // we might have to update the value of the field on the form // view that opened the translation dialog if (updateFormViewField) { diff --git a/addons/web/static/src/legacy/xml/translation_dialog.xml b/addons/web/static/src/legacy/xml/translation_dialog.xml index 2ccfc981ec1..37bc49a1a61 100644 --- a/addons/web/static/src/legacy/xml/translation_dialog.xml +++ b/addons/web/static/src/legacy/xml/translation_dialog.xml @@ -2,13 +2,13 @@
-
+
-
+
-
+
diff --git a/addons/web/static/src/views/fields/translation_button.js b/addons/web/static/src/views/fields/translation_button.js index 769ecffaea0..a92a79ce66e 100644 --- a/addons/web/static/src/views/fields/translation_button.js +++ b/addons/web/static/src/views/fields/translation_button.js @@ -17,7 +17,6 @@ const { Component, useEnv } = owl; */ export function useTranslationDialog() { const dialog = useService("dialog"); - const rpc = useService("rpc"); const env = useEnv(); async function openTranslationDialog({ record, fieldName, updateField }) { @@ -43,22 +42,14 @@ export function useTranslationDialog() { return; } } - const { resModel, resId, context } = record; - const result = await rpc("/web/dataset/call_button", { - model: "ir.translation", - method: "translate_fields", - args: [resModel, resId, fieldName], - kwargs: { context }, - }); + const { resModel, resId } = record; dialog.add(TranslationDialog, { - domain: result.domain, - searchName: result.context.search_default_name, fieldName: fieldName, + resId: resId, + resModel: resModel, userLanguageValue: record.data[fieldName] || "", isComingFromTranslationAlert: false, - isText: result.context.translation_type === "text", - showSource: result.context.translation_show_src, updateField, }); } diff --git a/addons/web/static/src/views/fields/translation_dialog.js b/addons/web/static/src/views/fields/translation_dialog.js index 0cf8348e013..5e227d8d517 100644 --- a/addons/web/static/src/views/fields/translation_dialog.js +++ b/addons/web/static/src/views/fields/translation_dialog.js @@ -20,18 +20,22 @@ export class TranslationDialog extends Component { onWillStart(async () => { const languages = await loadLanguages(this.orm); - const translations = await this.loadTranslations(languages); + const [translations, context] = await this.loadTranslations(languages); + let id = 1; + translations.forEach((t) => (t.id = id++)); + this.props.isText = context.translation_type === "text"; + this.props.showSource = context.translation_show_source; this.terms = translations.map((term) => { const relatedLanguage = languages.find((l) => l[0] === term.lang); if (!term.value && !this.props.showSource) { - term.value = term.src; + term.value = term.source; } return { id: term.id, lang: term.lang, langName: relatedLanguage[1], - source: term.src, + source: term.source, // we set the translation value coming from the database, except for the language // the user is currently utilizing. Then we set the translation value coming // from the value of the field in the form @@ -43,11 +47,7 @@ export class TranslationDialog extends Component { : term.value || "", }; }); - this.terms.sort((a, b) => - a.langName < b.langName || (a.langName === b.langName && a.source < b.source) - ? -1 - : 1 - ); + this.terms.sort((a, b) => a.langName.localeCompare(b.langName)); }); } @@ -63,23 +63,37 @@ export class TranslationDialog extends Component { * Load the translation terms for the installed language, for the current model and res_id */ async loadTranslations(languages) { - const domain = [...this.domain, ["lang", "in", languages.map((l) => l[0])]]; - return this.orm.searchRead("ir.translation", domain, ["lang", "src", "value"]); + return this.orm.call(this.props.resModel, "get_field_translations", [ + [this.props.resId], + this.props.fieldName, + ]); } /** * Save all the terms that have been updated */ async onSave() { - await Promise.all( - this.terms.map(async (term) => { - if (term.id in this.updatedTerms && term.value !== this.updatedTerms[term.id]) { - await this.orm.write("ir.translation", [term.id], { - value: this.updatedTerms[term.id], - }); + const translations = {}; + + this.terms.map((term) => { + const updatedTermValue = this.updatedTerms[term.id]; + if (term.id in this.updatedTerms && term.value !== updatedTermValue) { + if (this.props.showSource) { + if (!translations[term.lang]) { + translations[term.lang] = {}; + } + translations[term.lang][term.source] = updatedTermValue; + } else { + translations[term.lang] = updatedTermValue; } - }) - ); + } + }); + + await this.orm.call(this.props.resModel, "update_field_translations", [ + [this.props.resId], + this.props.fieldName, + translations, + ]); // we might have to update the value of the field on the form // view that opened the translation dialog diff --git a/addons/web/static/tests/legacy/fields/basic_fields_tests.js b/addons/web/static/tests/legacy/fields/basic_fields_tests.js index 17b41385726..4f2152ea32c 100644 --- a/addons/web/static/tests/legacy/fields/basic_fields_tests.js +++ b/addons/web/static/tests/legacy/fields/basic_fields_tests.js @@ -160,19 +160,6 @@ QUnit.module('Legacy basic_fields', { position: "after", }] }, - "ir.translation": { - fields: { - lang: {type: "char"}, - value: {type: "char"}, - res_id: {type: "integer"} - }, - records: [{ - id: 99, - res_id: 37, - value: '', - lang: 'en_US' - }] - }, }; target = getFixture(); @@ -1653,25 +1640,18 @@ QUnit.module('Legacy basic_fields', { user_context: {lang: 'en_US'}, }, mockRPC: function (route, args) { - if (route === "/web/dataset/call_button" && args.method === 'translate_fields') { - assert.deepEqual(args.args, ["partner",1,"foo"], 'should call "call_button" route'); - return Promise.resolve({ - domain: [], - context: {search_default_name: 'partnes,foo'}, - }); + if (route === "/web/dataset/call_kw/partner/get_field_translations") { + assert.deepEqual(args.args, [[1],"foo"], "should translate the foo field of the record"); + return Promise.resolve([ + [{lang: "en_US", source: "yop", value: "yop"}, {lang: "fr_BE", source: "yop", value: "valeur français"}], + {translation_type: "char", translation_show_source: false}, + ]); } if (route === "/web/dataset/call_kw/res.lang/get_installed") { return Promise.resolve([["en_US", "English"], ["fr_BE", "French (Belgium)"]]); } - if (args.method === "search_read" && args.model == "ir.translation") { - return Promise.resolve([ - {lang: 'en_US', src: 'yop', value: 'yop', id: 42}, - {lang: 'fr_BE', src: 'yop', value: 'valeur français', id: 43} - ]); - } - if (args.method === "write" && args.model == "ir.translation") { - assert.deepEqual(args.args[1], {value: "english value"}, - "the new translation value should be written"); + if (route === "/web/dataset/call_kw/partner/update_field_translations") { + assert.deepEqual(args.args, [[1], "foo", {"en_US": "english value"}], "the new translation value should be written"); return Promise.resolve(); } return this._super.apply(this, arguments); @@ -1741,31 +1721,21 @@ QUnit.module('Legacy basic_fields', { user_context: {lang: 'en_US'}, }, mockRPC: function (route, args) { - if (route === "/web/dataset/call_button" && args.method === 'translate_fields') { - assert.deepEqual(args.args, ["partner",1,"foo"], 'should call "call_button" route'); - return Promise.resolve({ - domain: [], - context: { - search_default_name: 'partner,foo', - translation_type: 'char', - translation_show_src: true, - }, - }); + if (route === "/web/dataset/call_kw/partner/get_field_translations") { + assert.deepEqual(args.args, [[1],"foo"], "should translate the foo 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: "char", translation_show_source: true}, + ]); } if (route === "/web/dataset/call_kw/res.lang/get_installed") { return Promise.resolve([["en_US", "English"], ["fr_BE", "French (Belgium)"]]); } - if (args.method === "search_read" && args.model == "ir.translation") { - return Promise.resolve([ - {lang: 'en_US', src: 'first paragraph', value: 'first paragraph', id: 42}, - {lang: 'en_US', src: 'second paragraph', value: 'second paragraph', id: 43}, - {lang: 'fr_BE', src: 'first paragraph', value: 'premier paragraphe', id: 44}, - {lang: 'fr_BE', src: 'second paragraph', value: 'deuxième paragraphe', id: 45}, - ]); - } - if (args.method === "write" && args.model == "ir.translation") { - assert.deepEqual(args.args[1], {value: "first paragraph modified"}, - "Wrong update on translation"); + if (route === "/web/dataset/call_kw/partner/update_field_translations") { + assert.deepEqual(args.args, [[1], "foo", {"en_US": {"first paragraph": "first paragraph modified"}}], "the new translation value should be written"); return Promise.resolve(); } return this._super.apply(this, arguments); @@ -2694,15 +2664,15 @@ QUnit.module('Legacy basic_fields', { '', res_id: 1, mockRPC: function (route, args) { - if (route === "/web/dataset/call_button" && args.method === 'translate_fields') { - assert.deepEqual(args.args, ["partner",1,"txt"], 'should call "call_button" route'); - return Promise.resolve({ - domain: [], - context: {search_default_name: 'partnes,foo'}, - }); + if (route === "/web/dataset/call_kw/partner/get_field_translations") { + assert.deepEqual(args.args, [[1],"txt"], "should translate the txt field of the record"); + return Promise.resolve([ + [{lang: "en_US", source: "yop", value: "yop"}, {lang: "fr_BE", source: "yop", value: "valeur français"}], + {translation_type: "text", translation_show_source: false}, + ]); } 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/web/static/tests/legacy/views/form_tests.js b/addons/web/static/tests/legacy/views/form_tests.js index 3d075d30439..96e348b6b7b 100644 --- a/addons/web/static/tests/legacy/views/form_tests.js +++ b/addons/web/static/tests/legacy/views/form_tests.js @@ -140,19 +140,6 @@ QUnit.module('LegacyViews', { {id: 14, display_name: "silver", color: 5}, ] }, - "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' - }] - }, user: { fields: { name: {string: "Name", type: "char"}, @@ -7382,16 +7369,7 @@ QUnit.module('LegacyViews', { }); QUnit.test('translation alerts preserved on reverse breadcrumb', async function (assert) { - assert.expect(2); - - serverData.models['ir.translation'] = { - fields: { - name: { string: "name", type: "char" }, - source: {string: "Source", type: "char"}, - value: {string: "Value", type: "char"}, - }, - records: [], - }; + assert.expect(1); serverData.models.partner.fields.foo.translate = true; @@ -7402,12 +7380,6 @@ QUnit.module('LegacyViews', { '' + '', 'partner,false,search': '', - 'ir.translation,false,list': '' + - '' + - '' + - '' + - '', - 'ir.translation,false,search': '', }; serverData.actions = { @@ -7418,14 +7390,6 @@ QUnit.module('LegacyViews', { type: 'ir.actions.act_window', views: [[false, 'form']], }, - 2: { - id: 2, - name: 'Translate', - res_model: 'ir.translation', - type: 'ir.actions.act_window', - views: [[false, 'list']], - target: 'current', - } }; const webClient = await createWebClient({ serverData }); @@ -7440,13 +7404,6 @@ QUnit.module('LegacyViews', { assert.containsOnce(target, '.o_legacy_form_view .alert > div', "should have a translation alert"); - - await doAction(webClient, 2); - - await testUtils.dom.click($('.o_control_panel .breadcrumb a:first')); - await legacyExtraNextTick(); - assert.containsOnce(target, '.o_legacy_form_view .alert > div', - "should have a translation alert"); }); QUnit.test('translate event correctly handled with multiple controllers', async function (assert) { @@ -7485,16 +7442,16 @@ QUnit.module('LegacyViews', { if (route === '/web/dataset/call_kw/product/get_formview_id') { return Promise.resolve(false); } - if (route === "/web/dataset/call_button" && args.method === 'translate_fields') { - assert.deepEqual(args.args, ["product",37,"name"], 'should call "call_button" route'); + if (route === "/web/dataset/call_kw/product/get_field_translations") { + assert.deepEqual(args.args, [[37],"name"], "should translate the name field of the record"); nbTranslateCalls++; - return Promise.resolve({ - domain: [], - context: {search_default_name: 'partnes,foo'}, - }); + return Promise.resolve([ + [{lang: "en_US", source: "yop", value: "yop"}, {lang: "fr_BE", source: "yop", value: "valeur français"}], + {translation_type: "char", translation_show_source: false}, + ]); } 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/web/static/tests/legacy/views/list_tests.js b/addons/web/static/tests/legacy/views/list_tests.js index 40bf500d8e5..3e6190f3334 100644 --- a/addons/web/static/tests/legacy/views/list_tests.js +++ b/addons/web/static/tests/legacy/views/list_tests.js @@ -123,31 +123,6 @@ QUnit.module('LegacyViews', { {id: "2-20170808020000", name: "virtual"}, ] }, - "ir.translation": { - fields: { - lang_code: {type: "char"}, - src: {type: "char"}, - value: {type: "char"}, - res_id: {type: "integer"}, - name: {type: "char"}, - lang: {type: "char"}, - }, - records: [{ - id: 99, - res_id: 1, - value: '', - lang_code: 'en_US', - lang: 'en_US', - name: 'foo,foo' - },{ - id: 100, - res_id: 1, - value: '', - lang_code: 'fr_BE', - lang: 'fr_BE', - name: 'foo,foo' - }] - }, }; serverData = { models: this.data }; @@ -3620,11 +3595,11 @@ QUnit.module('LegacyViews', { model: 'foo', data: this.data, mockRPC: function (route, args) { - if (route === "/web/dataset/call_button" && args.method === 'translate_fields') { - return Promise.resolve({ - domain: [], - context: {search_default_name: 'foo,foo'}, - }); + if (route === "/web/dataset/call_kw/foo/get_field_translations") { + return Promise.resolve([ + [{lang: "en_US", source: "yop", value: "yop"}, {lang: "fr_BE", source: "yop", value: "valeur français"}], + {translation_type: "char", translation_show_source: false}, + ]); } if (route === "/web/dataset/call_kw/res.lang/get_installed") { return Promise.resolve([["en_US","English"], ["fr_BE", "Frenglish"]]); diff --git a/addons/web/static/tests/views/fields/char_field_tests.js b/addons/web/static/tests/views/fields/char_field_tests.js index 213c0473996..05bd6634daf 100644 --- a/addons/web/static/tests/views/fields/char_field_tests.js +++ b/addons/web/static/tests/views/fields/char_field_tests.js @@ -123,21 +123,6 @@ QUnit.module("Fields", (hooks) => { }, ], }, - "ir.translation": { - fields: { - lang: { type: "char" }, - value: { type: "char" }, - res_id: { type: "integer" }, - }, - records: [ - { - id: 99, - res_id: 37, - value: "", - lang: "en_US", - }, - ], - }, }, }; @@ -301,7 +286,7 @@ QUnit.module("Fields", (hooks) => { }); QUnit.test("char field translatable", async function (assert) { - assert.expect(13); + assert.expect(11); serverData.models.partner.fields.foo.translate = true; serviceRegistry.add("localization", makeFakeLocalizationService({ multiLang: true }), { @@ -325,33 +310,25 @@ QUnit.module("Fields", (hooks) => { `, mockRPC(route, { args, method, model }) { - if (route === "/web/dataset/call_button" && method === "translate_fields") { - assert.deepEqual( - args, - ["partner", 1, "foo"], - 'should call "call_button" route' - ); - return Promise.resolve({ - domain: [], - context: { search_default_name: "partnes,foo" }, - }); - } if (route === "/web/dataset/call_kw/res.lang/get_installed") { return Promise.resolve([ ["en_US", "English"], ["fr_BE", "French (Belgium)"], ]); } - if (method === "search_read" && model === "ir.translation") { + if (route === "/web/dataset/call_kw/partner/get_field_translations") { return Promise.resolve([ - { lang: "en_US", src: "yop", value: "yop", id: 42 }, - { lang: "fr_BE", src: "yop", value: "valeur français", id: 43 }, + [ + { lang: "en_US", source: "yop", value: "yop" }, + { lang: "fr_BE", source: "yop", value: "valeur français" }, + ], + { translation_type: "char", translation_show_source: false }, ]); } - if (method === "write" && model === "ir.translation") { + if (route === "/web/dataset/call_kw/partner/update_field_translations") { assert.deepEqual( - args[1], - { value: "english value" }, + args[2], + { en_US: "english value" }, "the new translation value should be written" ); return Promise.resolve(null); @@ -417,7 +394,7 @@ QUnit.module("Fields", (hooks) => { }); QUnit.test("html field translatable", async function (assert) { - assert.expect(6); + assert.expect(5); serverData.models.partner.fields.foo.translate = true; serviceRegistry.add("localization", makeFakeLocalizationService({ multiLang: true }), { @@ -441,55 +418,48 @@ QUnit.module("Fields", (hooks) => { `, mockRPC(route, { args, method, model }) { - if (route === "/web/dataset/call_button" && method === "translate_fields") { - assert.deepEqual( - args, - ["partner", 1, "foo"], - `should call "call_button" route` - ); - return Promise.resolve({ - domain: [], - context: { - search_default_name: "partner,foo", - translation_type: "char", - translation_show_src: true, - }, - }); - } if (route === "/web/dataset/call_kw/res.lang/get_installed") { return Promise.resolve([ ["en_US", "English"], ["fr_BE", "French (Belgium)"], ]); } - if (method === "search_read" && model === "ir.translation") { + if (route === "/web/dataset/call_kw/partner/get_field_translations") { return Promise.resolve([ - { lang: "en_US", src: "first paragraph", value: "first paragraph", id: 42 }, + [ + { + 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", + }, + ], { - lang: "en_US", - src: "second paragraph", - value: "second paragraph", - id: 43, - }, - { - lang: "fr_BE", - src: "first paragraph", - value: "premier paragraphe", - id: 44, - }, - { - lang: "fr_BE", - src: "second paragraph", - value: "deuxième paragraphe", - id: 45, + translation_type: "char", + translation_show_source: true, }, ]); } - if (method === "write" && model === "ir.translation") { + + if (route === "/web/dataset/call_kw/partner/update_field_translations") { assert.deepEqual( - args[1], - { value: "first paragraph modified" }, - "Wrong update on translation" + args[2], + { en_US: { "first paragraph": "first paragraph modified" } }, + "the new translation value should be written" ); return Promise.resolve(null); } diff --git a/addons/web/static/tests/views/fields/text_field_tests.js b/addons/web/static/tests/views/fields/text_field_tests.js index 3c878d98426..e216d15286c 100644 --- a/addons/web/static/tests/views/fields/text_field_tests.js +++ b/addons/web/static/tests/views/fields/text_field_tests.js @@ -49,21 +49,6 @@ QUnit.module("Fields", (hooks) => { }, ], }, - "ir.translation": { - fields: { - lang: { type: "char" }, - value: { type: "char" }, - res_id: { type: "integer" }, - }, - records: [ - { - id: 99, - res_id: 37, - value: "", - lang: "en_US", - }, - ], - }, }, }; @@ -370,7 +355,7 @@ QUnit.module("Fields", (hooks) => { }); QUnit.test("text field translatable", async function (assert) { - assert.expect(4); + assert.expect(3); serverData.models.partner.fields.txt.translate = true; serviceRegistry.add("localization", makeFakeLocalizationService({ multiLang: true }), { @@ -391,19 +376,20 @@ QUnit.module("Fields", (hooks) => { `, mockRPC(route, { args, method }) { - if (route === "/web/dataset/call_button" && method === "translate_fields") { - assert.deepEqual( - args, - ["partner", 1, "txt"], - `should call "call_button" route` - ); - return Promise.resolve({ - domain: [], - context: { search_default_name: "partnes,foo" }, - }); - } 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)"], + ]); + } + if (route === "/web/dataset/call_kw/partner/get_field_translations") { + return Promise.resolve([ + [ + { lang: "en_US", source: "yop", value: "yop" }, + { lang: "fr_BE", source: "yop", value: "valeur français" }, + ], + { translation_type: "text", translation_show_source: false }, + ]); } }, }); diff --git a/addons/web/static/tests/views/form/form_view_tests.js b/addons/web/static/tests/views/form/form_view_tests.js index 8cf882e1ae7..270d27a7209 100644 --- a/addons/web/static/tests/views/form/form_view_tests.js +++ b/addons/web/static/tests/views/form/form_view_tests.js @@ -180,21 +180,6 @@ QUnit.module("Views", (hooks) => { { id: 14, display_name: "silver", color: 5 }, ], }, - "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", - }, - ], - }, user: { fields: { name: { string: "Name", type: "char" }, @@ -8466,20 +8451,16 @@ QUnit.module("Views", (hooks) => { ["CUST2", "second custom"], ]; } - if (args.method === "translate_fields") { + if (route === "/web/dataset/call_kw/partner/get_field_translations") { assert.step(`translate args ${JSON.stringify(args.args)}`); assert.step(`translate context ${JSON.stringify(args.kwargs.context)}`); - return { - domain: [["res_id", "=", args.args[1]]], - context: { search_default_name: "partner_type,foo" }, - }; - } - if (args.method === "search_read" && args.model === "ir.translation") { - assert.step( - `search_read translations args: ${JSON.stringify( - args.args - )} ; kwargs: ${JSON.stringify(args.kwargs)}` - ); + return Promise.resolve([ + [ + { lang: "CUST", source: "yop", value: "yop" }, + { lang: "CUST2", source: "yop", value: "valeur français" }, + ], + { translation_type: "char", translation_show_source: false }, + ]); } }, }); @@ -8487,9 +8468,8 @@ QUnit.module("Views", (hooks) => { await click(target, ".o_field_translate.btn-link"); assert.verifySteps([ - `translate args ["partner",1,"foo"]`, + `translate args [[1],"foo"]`, `translate context {"lang":"en","uid":7,"tz":"taht"}`, - `search_read translations args: [] ; kwargs: {"domain":[["res_id","=",1],["name","=","partner_type,foo"],["lang","in",["CUST","CUST2"]]],"fields":["lang","src","value"],"context":{"lang":"en","uid":7,"tz":"taht"}}`, ]); assert.containsOnce(target, ".modal"); @@ -8522,6 +8502,15 @@ QUnit.module("Views", (hooks) => { ["CUST2", "second custom"], ]; } + if (route === "/web/dataset/call_kw/partner/get_field_translations") { + return Promise.resolve([ + [ + { lang: "CUST", source: "yop", value: "yop" }, + { lang: "CUST2", source: "yop", value: "valeur français" }, + ], + { translation_type: "char", translation_show_source: false }, + ]); + } }, }); @@ -8536,7 +8525,7 @@ QUnit.module("Views", (hooks) => { await click(target, ".o_field_translate.btn-link"); await click(target.querySelectorAll(".modal-footer button")[0]); // save - assert.verifySteps(["create", "read", "translate_fields", "get_installed", "search_read"]); + assert.verifySteps(["create", "read", "get_installed", "get_field_translations"]); assert.containsOnce(target, ".modal"); assert.strictEqual(target.querySelector(".modal-title").textContent, "Translate: foo"); }); @@ -8583,12 +8572,21 @@ QUnit.module("Views", (hooks) => { ["CUST2", "second custom"], ]; } + if (route === "/web/dataset/call_kw/partner/get_field_translations") { + return Promise.resolve([ + [ + { lang: "CUST", source: "yop", value: "yop" }, + { lang: "CUST2", source: "yop", value: "valeur français" }, + ], + { translation_type: "char", translation_show_source: false }, + ]); + } }, }); assert.verifySteps(["get_views", "read"]); await clickEdit(target); await click(target, ".o_field_legacy_char .o_field_translate.btn-link"); - assert.verifySteps(["translate_fields", "get_installed", "search_read"]); + assert.verifySteps(["get_installed", "get_field_translations"]); assert.containsOnce(target, ".modal"); assert.strictEqual(target.querySelector(".modal-title").textContent, "Translate: foo"); }); @@ -8644,22 +8642,11 @@ QUnit.module("Views", (hooks) => { }); QUnit.test("translation alerts preserved on reverse breadcrumb", async function (assert) { - serverData.models["ir.translation"] = { - fields: { - name: { string: "name", type: "char" }, - source: { string: "Source", type: "char" }, - value: { string: "Value", type: "char" }, - }, - records: [], - }; - serverData.models.partner.fields.foo.translate = true; serverData.views = { "partner,false,form": `
`, "partner,false,search": "", - "ir.translation,false,list": ``, - "ir.translation,false,search": "", }; serverData.actions = { @@ -8670,15 +8657,6 @@ QUnit.module("Views", (hooks) => { type: "ir.actions.act_window", views: [[false, "form"]], }, - 2: { - id: 2, - name: "Translate", - res_model: "ir.translation", - type: "ir.actions.act_window", - views: [[false, "list"]], - target: "current", - flags: { search_view: true, action_buttons: true }, - }, }; const webClient = await createWebClient({ serverData }); @@ -8692,17 +8670,12 @@ QUnit.module("Views", (hooks) => { await clickSave(target); assert.containsOnce(target, ".alert .o_field_translate", "should have a translation alert"); - - await doAction(webClient, 2); - - await click(target.querySelector(".o_control_panel .breadcrumb a:nth-child(1)")); - assert.containsOnce(target, ".alert .o_field_translate", "should have a translation alert"); }); QUnit.test( "translate event correctly handled with multiple controllers", async function (assert) { - assert.expect(3); + assert.expect(2); serverData.models.product.fields.name.translate = true; serverData.models.partner.records[0].product_id = 37; @@ -8742,23 +8715,21 @@ QUnit.module("Views", (hooks) => { if (route === "/web/dataset/call_kw/product/get_formview_id") { return false; } - if ( - route === "/web/dataset/call_button" && - args.method === "translate_fields" - ) { - assert.deepEqual( - args.args, - ["product", 37, "name"], - 'should call "call_button" route' - ); - nbTranslateCalls++; - return { - domain: [], - context: { search_default_name: "partnes,foo" }, - }; - } if (route === "/web/dataset/call_kw/res.lang/get_installed") { - return [["en_US"], ["fr_BE"]]; + return [ + ["en_US", "English"], + ["fr_BE", "French (Belgium)"], + ]; + } + if (route === "/web/dataset/call_kw/product/get_field_translations") { + nbTranslateCalls++; + return Promise.resolve([ + [ + { lang: "en_US", source: "yop", value: "yop" }, + { lang: "fr_BE", source: "yop", value: "valeur français" }, + ], + { translation_type: "char", translation_show_source: false }, + ]); } }, }); diff --git a/addons/web/static/tests/views/list_view_tests.js b/addons/web/static/tests/views/list_view_tests.js index 8af79664a08..89193d519b9 100644 --- a/addons/web/static/tests/views/list_view_tests.js +++ b/addons/web/static/tests/views/list_view_tests.js @@ -197,34 +197,6 @@ QUnit.module("Views", (hooks) => { }, records: [{ id: "2-20170808020000", name: "virtual" }], }, - "ir.translation": { - fields: { - lang_code: { type: "char" }, - src: { type: "char" }, - value: { type: "char" }, - res_id: { type: "integer" }, - name: { type: "char" }, - lang: { type: "char" }, - }, - records: [ - { - id: 99, - res_id: 1, - value: "", - lang_code: "en_US", - lang: "en_US", - name: "foo,foo", - }, - { - id: 100, - res_id: 1, - value: "", - lang_code: "fr_BE", - lang: "fr_BE", - name: "foo,foo", - }, - ], - }, }, }; setupViewRegistries(); @@ -4096,18 +4068,21 @@ QUnit.module("Views", (hooks) => { resModel: "foo", serverData, mockRPC(route, args) { - if (route === "/web/dataset/call_button" && args.method === "translate_fields") { - return Promise.resolve({ - domain: [], - context: { search_default_name: "foo,foo" }, - }); - } if (route === "/web/dataset/call_kw/res.lang/get_installed") { return Promise.resolve([ ["en_US", "English"], ["fr_BE", "Frenglish"], ]); } + if (route === "/web/dataset/call_kw/foo/get_field_translations") { + return Promise.resolve([ + [ + { lang: "en_US", source: "yop", value: "yop" }, + { lang: "fr_BE", source: "yop", value: "valeur français" }, + ], + { translation_type: "char", translation_show_source: false }, + ]); + } }, arch: '' + '' + "", }); 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); }, From e5735d0a467afcfc66a2b17275b27d4cf80789bf Mon Sep 17 00:00:00 2001 From: "Chong Wang (cwg)" Date: Wed, 15 Jun 2022 19:16:07 +0200 Subject: [PATCH 06/10] [IMP] web: improve Edit Translations button Previously, 'Edit Translations' button is used to translate the English field ir_ui_view.arch A dialog for list view of all translations is opened After this commit, a new 'EN' button is used to translate the Engligsh field ir_ui_view.arch A translation dialog (like other translatable fields) for all translations is opened General idea of the implementation: 1. add the invisible ir_ui_view.arch_db field to the form view before ir_ui_view.arch 2. show the 'Lang' button in the edit mode and make it look like a button for its next field ir_ui_view.arch 3. Forcely change the 'Lang' button to 'EN' --- addons/web/static/src/legacy/scss/form_view.scss | 14 ++++++++++++++ .../web/static/src/views/form/form_controller.scss | 14 ++++++++++++++ odoo/addons/base/models/ir_ui_view.py | 4 ---- odoo/addons/base/views/ir_ui_view_views.xml | 3 +-- 4 files changed, 29 insertions(+), 6 deletions(-) diff --git a/addons/web/static/src/legacy/scss/form_view.scss b/addons/web/static/src/legacy/scss/form_view.scss index 691d2a8a06b..9fd8d25a8c0 100644 --- a/addons/web/static/src/legacy/scss/form_view.scss +++ b/addons/web/static/src/legacy/scss/form_view.scss @@ -713,6 +713,20 @@ $o-form-label-margin-right: 0px; padding-right: 25px; } } + .oe_no_translation_content { + &:not(.btn) { + display: none; + } + &.btn { + width: 100% !important; + margin-left: 0px; + visibility: hidden; + } + &.btn:after { + content: 'EN'; + visibility: visible; + } + } // Text field with oe_inline class .o_field_text.oe_inline { diff --git a/addons/web/static/src/views/form/form_controller.scss b/addons/web/static/src/views/form/form_controller.scss index c269c4d179b..09af88c8b16 100644 --- a/addons/web/static/src/views/form/form_controller.scss +++ b/addons/web/static/src/views/form/form_controller.scss @@ -436,6 +436,20 @@ $o-form-label-margin-right: 0px; padding-right: 25px; } } + .oe_no_translation_content { + :not(.btn) { + display: none; + } + .btn { + width: 100% !important; + margin-left: 0px; + visibility: hidden; + } + .btn:after { + content: 'EN'; + visibility: visible; + } + } iframe.wysiwyg_iframe + .o_field_translate { right: 30px !important; top: 7px !important; diff --git a/odoo/addons/base/models/ir_ui_view.py b/odoo/addons/base/models/ir_ui_view.py index bc3cc04a423..22ae309fac8 100644 --- a/odoo/addons/base/models/ir_ui_view.py +++ b/odoo/addons/base/models/ir_ui_view.py @@ -2037,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/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 @@
-