diff --git a/addons/account/models/account_journal.py b/addons/account/models/account_journal.py index 7a5b1cdfe15..a9fddc24301 100644 --- a/addons/account/models/account_journal.py +++ b/addons/account/models/account_journal.py @@ -668,7 +668,15 @@ class AccountJournal(models.Model): if not has_liquidity_accounts: default_account_code = self.env['account.account']._search_new_account_code(company, digits, liquidity_account_prefix) default_account_vals = self._prepare_liquidity_account_vals(company, default_account_code, vals) - vals['default_account_id'] = self.env['account.account'].create(default_account_vals).id + default_account = self.env['account.account'].create(default_account_vals) + self.env['ir.model.data']._update_xmlids([ + { + 'xml_id': f"account.{str(company.id)}_{journal_type}_journal_default_account_{default_account.id}", + 'record': default_account, + 'noupdate': True, + } + ]) + vals['default_account_id'] = default_account.id if journal_type in ('cash', 'bank') and not has_profit_account: vals['profit_account_id'] = company.default_cash_difference_income_account_id.id if journal_type in ('cash', 'bank') and not has_loss_account: diff --git a/addons/account/models/chart_template.py b/addons/account/models/chart_template.py index f3eeb4bae50..bc1f2edcac6 100644 --- a/addons/account/models/chart_template.py +++ b/addons/account/models/chart_template.py @@ -16,8 +16,9 @@ from odoo import Command, _, models, api from odoo.addons.base.models.ir_model import MODULE_UNINSTALL_FLAG from odoo.addons.account import SYSCOHADA_LIST from odoo.exceptions import AccessError, UserError -from odoo.tools import file_open, groupby -from odoo.tools.translate import TranslationImporter +from odoo.modules import get_resource_from_path +from odoo.tools import file_open, get_lang, groupby, SQL +from odoo.tools.translate import code_translations, TranslationImporter _logger = logging.getLogger(__name__) @@ -49,7 +50,15 @@ def template(template=None, model='template_data'): # remove the template code argument as we already know it from the decorator args, kwargs = args[:1], {} return func(*args, **kwargs) - return api.attrsetter('_l10n_template', (template, model))(wrapper) + + # the module the function originates from is used for code translations + path = func.__globals__['__file__'] + path_info = get_resource_from_path(path) + module = path_info[0] if path_info else 'account' + + wrapper._module = module + wrapper._l10n_template = (template, model) + return wrapper return decorator @@ -162,11 +171,16 @@ class AccountChartTemplate(models.AbstractModel): self.env.reset() # clear the envs with an old registry self = self.env()['account.chart.template'] # create a new env with the new registry + # To be able to use code translation we load everything in 'en_US' + # The demo data is still loaded "normally" since code translations cannot be used for them reliably. + # (Since we rely on the "@template functions" to determine the module to take the code translations from.) + original_context_lang = self.env.context.get('lang') self = self.with_context( default_company_id=company.id, allowed_company_ids=[company.id], tracking_disable=True, delay_account_group_sync=True, + lang='en_US', ) company = company.with_env(self.env) @@ -190,8 +204,8 @@ class AccountChartTemplate(models.AbstractModel): install_demo = False data = self._pre_load_data(template_code, company, template_data, data) self._load_data(data) - self._load_translations(companies=company) self._post_load_data(template_code, company, template_data) + self._load_translations(companies=company) # Manual sync because disable above (delay_account_group_sync) AccountGroup = self.env['account.group'].with_context(delay_account_group_sync=False) @@ -202,6 +216,8 @@ class AccountChartTemplate(models.AbstractModel): if install_demo and self.ref('base.module_account').demo and not reload_template: try: with self.env.cr.savepoint(): + self = self.with_context(lang=original_context_lang) + company = company.with_env(self.env) self.sudo()._load_data(self._get_demo_data(company)) self._post_load_demo_data(company) except Exception: @@ -411,6 +427,21 @@ class AccountChartTemplate(models.AbstractModel): if fname not in company._fields: del data['res.company'][company.id][fname] + # Translate the untranslatable fields we want to translate anyway + untranslatable_model_fields = self._get_untranslatable_fields_to_translate() + untranslatable_target_lang = self._get_untranslatable_fields_target_language(template_code, company) + for model_name, records in data.items(): + untranslatable_fields = untranslatable_model_fields.get(model_name, []) + if not untranslatable_fields: + continue + for _xmlid, record in records.items(): + for field in untranslatable_fields: + if field not in record: + continue + translation = self._get_field_translation(record, field, untranslatable_target_lang) + if translation: + record[field] = translation + return data def _load_data(self, data): @@ -506,7 +537,7 @@ class AccountChartTemplate(models.AbstractModel): for xml_id, record in data.items(): # Extract the translations from the values for key in list(record): - if '@' in key: + if '@' in key or key == '__translation_module__': del record[key] # Manage ids given as database id or xml_id @@ -582,11 +613,15 @@ class AccountChartTemplate(models.AbstractModel): def _get_chart_template_data(self, template_code): template_data = defaultdict(lambda: defaultdict(dict)) template_data['res.company'] # ensure it's the first property when iterating + translatable_model_fields = self._get_translatable_template_model_fields() + untranslatable_model_fields = self._get_untranslatable_fields_to_translate() for code in [None] + self._get_parent_template(template_code): for model, funcs in sorted( self._template_register[code].items(), key=lambda i: TEMPLATE_MODELS.index(i[0]) if i[0] in TEMPLATE_MODELS else 1000 ): + translatable_fields = translatable_model_fields.get(model, []) + untranslatable_fields = untranslatable_model_fields.get(model, []) for func in funcs: data = func(self, template_code) if data is not None: @@ -594,6 +629,13 @@ class AccountChartTemplate(models.AbstractModel): template_data[model].update(data) else: for xmlid, record in data.items(): + # Store information about which module each field value originates from (for code translations). + # The final value of different fields may be determined by different functions. + # The last function to modify the record may not modify all or any of the translatable fields. + for field in translatable_fields + untranslatable_fields: + if field in record: + record.setdefault('__translation_module__', {})[field] = func._module + template_data[model][xmlid].update(record) return template_data @@ -669,7 +711,14 @@ class AccountChartTemplate(models.AbstractModel): for company_attr_name in accounts_data: company[company_attr_name] = company.parent_ids[0][company_attr_name] else: - accounts = self.env['account.account'].create(accounts_data.values()) + accounts = self.env['account.account']._load_records([ + { + 'xml_id': f"account.{str(self.env.company.id)}_{xml_id}", + 'values': values, + 'noupdate': True, + } + for xml_id, values in accounts_data.items() + ]) for company_attr_name, account in zip(accounts_data.keys(), accounts): company[company_attr_name] = account @@ -1031,6 +1080,127 @@ class AccountChartTemplate(models.AbstractModel): _logger.debug("No file %s found for template '%s'", model, module) return res + def _get_untranslatable_fields_target_language(self, template_code, company): + """Return the code of the language we want to translate the untranslatable fields into. + """ + # Note: In case this function is called during module installation + # * The active user is the super user. + # * There is no 'lang' in the context. + return company.partner_id.lang or get_lang(self.env).code + + def _get_untranslatable_fields_to_translate(self): + """Return information about the untranslatable fields we want to translate anyway. + + :param langs: The codes of the languages into which we want to translate the records. + :type langs: list[str] + :param companies: Records belonging to these companies will be considered. + :type companies: Model + :return: Dictionary (model -> list of fields) where the list of fields contains + all the untranslatable fields of the model we want to translate anyway + :rtype: dict[str, list[str]] + """ + return { + 'account.journal': [ + 'code', + ], + } + + def _get_translatable_template_model_fields(self): + return { + model: [fieldname for (fieldname, field) in self.env[model]._fields.items() if field.translate] + for model in TEMPLATE_MODELS + } + + def _get_untranslated_translatable_template_model_records(self, langs, companies): + """Return information about the records of any model in TEMPLATE_MODELS (and belonging to companies) that need to be translated. + Records are in need of translation if they have a translatable field which is missing a translation (into any of the languages given in langs). + + :param langs: The codes of the languages into which we want to translate the records. + :type langs: list[str] + :param companies: Records belonging to these companies will be considered. + :type companies: Model + :return: The records which information will be returned are those records that have at least 1 untranslated translatable field. + A field is 'untranslated' if it does not have a translation for all languages in langs. + The returned value is a List of tuples: + (model, xmlid (without module prefix), module, dictionary from name to value for each translatable field) + :rtype: list[tuple(str, str, str, dict[str, str])] + """ + if not langs or not companies: + return [] + + company_ids = tuple(companies.ids) + + translatable_model_fields = self._get_translatable_template_model_fields() + + # Generate a list of queries; exactly 1 per model + queries = [] + for model in TEMPLATE_MODELS: + translatable_fields = translatable_model_fields[model] + if not translatable_fields: + continue + + self.env[model].flush_model(['id', 'company_id'] + translatable_model_fields[model]) + + # We only want records that have at least 1 missing translation in any of its translatable fields + missing_translation_clauses = [ + SQL("(%s ->> %s) IS NULL", SQL.identifier('model', field), lang) + for field in translatable_fields + for lang in langs + ] + + translatable_field_column_args = [] + for field in translatable_fields: + translatable_field_column_args.extend((SQL("%s", field), SQL.identifier('model', field))) + + queries.append(SQL( + """ + SELECT %(model)s AS model, + model_data.name AS xmlid, + model_data.module AS module, + json_build_object(%(translatable_field_column_args)s) AS fields + FROM %(table)s model + JOIN ir_model_data model_data ON model_data.model = %(model)s + AND model.id = model_data.res_id + WHERE (%(missing_translation_clauses)s) + AND model.company_id IN %(company_ids)s + """, + model=model, + translatable_field_column_args=SQL(", ").join(translatable_field_column_args), + table=SQL.identifier(self.env[model]._table), + company_ids=company_ids, + missing_translation_clauses=SQL(" OR ").join(missing_translation_clauses), + )) + + query = (SQL(' UNION ALL ').join(queries)) + # the queried models have been flushed already as part of the loop building the queries per model + self.env['ir.model.data'].flush_model(['res_id', 'model', 'name']) + + self._cr.execute(query) + return self._cr.fetchall() + + def _get_field_translation(self, record, fname, lang): + """Return the value for language lang for field with fname from record (or None if none exists). + + :param record: record formatted like in the template data (generated by _get_chart_template_data) + :type record: dict + :param fname: the name of a field (in record) as string + :type str + :param lang: the code of a res.lang + :type str + :return record[fname] translated into lang (or None) + :rtype str + """ + generic_lang = lang.split('_')[0] # manage generic locale (i.e. `fr` instead of `fr_BE`) + translation_module = record.get('__translation_module__', {}).get(fname, 'account') + translation = record.get(f"{fname}@{lang}") or record.get(f"{fname}@{generic_lang}") + if translation or fname not in record: + return translation + else: + return ( + code_translations.get_python_translations(translation_module, lang).get(record[fname]) + or code_translations.get_python_translations(translation_module, generic_lang).get(record[fname]) + ) + def _load_translations(self, langs=None, companies=None): """Load the translations of the chart template. @@ -1045,19 +1215,43 @@ class AccountChartTemplate(models.AbstractModel): companies = companies or self.env['res.company'].search([('chart_template', 'in', available_template_codes)]) translation_importer = TranslationImporter(self.env.cr, verbose=False) + + # Gather translations for records that are created from the chart_template data for chart_template, chart_companies in groupby(companies, lambda c: c.chart_template): template_data = self.env['account.chart.template']._get_chart_template_data(chart_template) template_data.pop('template_data', None) for mname, data in template_data.items(): for _xml_id, record in data.items(): - fnames = {fname.split('@')[0] for fname in record} + fnames = {fname.split('@')[0] for fname in record if fname != '__translation_module__'} for lang in langs: for fname in fnames: - value = record.get(f"{fname}@{lang}") - if not value: # manage generic locale (i.e. `fr` instead of `fr_BE`) - value = record.get(f"{fname}@{lang.split('_')[0]}") - if value: + field = self.env[mname]._fields.get(fname) + if not field or not field.translate: + continue + field_translation = self._get_field_translation(record, fname, lang) + if field_translation: for company in chart_companies: xml_id = f"account.{company.id}_{_xml_id}" - translation_importer.model_translations[mname][fname][xml_id][lang] = value + translation_importer.model_translations[mname][fname][xml_id][lang] = field_translation + + # Gather translations for the TEMPLATE_MODELS records that are not created from the chart_template data + translation_langs = [lang for lang in langs if lang != 'en_US'] # there are no code translations for 'en_US' (original language) + for (mname, _xml_id, module, fields) in self._get_untranslated_translatable_template_model_records(translation_langs, companies): + for (field, value) in fields.items(): + if not value or 'en_US' not in value: + continue + value_en_US = value['en_US'] + xml_id = f"{module}.{_xml_id}" + for lang in [lang for lang in translation_langs if lang not in value]: + if lang in translation_importer.model_translations[mname][field][xml_id]: + continue + value_translated = None + for code_module in ([module, 'account'] if module != 'account' else ['account']): + value_translated = code_translations.get_python_translations(code_module, lang).get(value_en_US) + if not value_translated: # manage generic locale (i.e. `fr` instead of `fr_BE`) + value_translated = code_translations.get_python_translations(code_module, lang.split('_')[0]).get(value_en_US) + if value_translated: + translation_importer.model_translations[mname][field][xml_id][lang] = value_translated + break + translation_importer.save(overwrite=False) diff --git a/addons/account/models/company.py b/addons/account/models/company.py index cbf73234e11..0014eb3a7ca 100644 --- a/addons/account/models/company.py +++ b/addons/account/models/company.py @@ -470,12 +470,18 @@ class ResCompany(models.Model): ('code', '=', str(code)), ]): code -= 1 - return self.env['account.account'].create({ - 'code': str(code), - 'name': _('Undistributed Profits/Losses'), - 'account_type': unaffected_earnings_type, - 'company_id': self.id, - }) + return self.env['account.account']._load_records([ + { + 'xml_id': f"account.{str(self.id)}_unaffected_earnings_account", + 'values': { + 'code': str(code), + 'name': _('Undistributed Profits/Losses'), + 'account_type': unaffected_earnings_type, + 'company_id': self.id, + }, + 'noupdate': True, + } + ]) def get_opening_move_differences(self, opening_move_lines): # TO BE REMOVED IN MASTER diff --git a/addons/account/tests/test_chart_template.py b/addons/account/tests/test_chart_template.py index 8b3442a6934..325cf852613 100644 --- a/addons/account/tests/test_chart_template.py +++ b/addons/account/tests/test_chart_template.py @@ -1,6 +1,7 @@ from unittest.mock import patch from odoo import Command +from odoo.addons.account.models.chart_template import code_translations from odoo.addons.account.models.chart_template import AccountChartTemplate from odoo.addons.account.models.chart_template import TEMPLATE_MODELS from odoo.addons.account.tests.common import instantiate_accountman @@ -550,3 +551,177 @@ class TestChartTemplate(TransactionCase): } with self.assertRaisesRegex(UserError, 'update your localization'): self.env['account.chart.template']._deref_account_tags('test', {'tax1': tax_to_load}) + + def test_install_with_translations(self): + """ Ensure that the translations are loaded correctly when installing chart data; i.e. test '_load_translations' and that the untranslatable fields are translated correctly. + Note: The '_load_translations' function depends on the '_get_chart_template_data' function for some information. + The result of '_get_chart_template_data' is mocked (correctly) in this test (and not tested). + """ + + # Local mock for '_get_chart_template_mapping' + # We will use / install a dedicated new chart 'translation' (not just reload 'test') + # To have control over the original / en_US values. + def local_get_mapping(self, get_all=False): + return {'translation': { + 'name': 'translation', + 'country_id': None, + 'country_code': None, + 'modules': ['account'], + 'parent': None, + }} + + company = self.company_1 + + # Create records that are not part of the chart template + # They will be translated via code translations. + # The module used to source the translation is the module from the xml_id or 'account' (as fallback) + + non_chart_data = { + 'account.group': { + # try module 'no_translation'; fallback to 'account' + 'no_translation.test_chart_template_company_test_free_account_group': { + 'name': 'Free Account Group', + 'code_prefix_start': 333330, + 'code_prefix_end': 333339, + 'company_id': company.id, + }, + }, + 'account.account': { + # translate via 'translation' module + 'translation.test_chart_template_company_test_free_account': { + 'name': 'Free Account', + 'code': '333331', + 'account_type': 'asset_current', + 'company_id': company.id, + }, + }, + 'account.tax': { + # translate via 'translation' module; + # 2 translatable fields ('name' and 'description') + 'translation.test_chart_template_company_test_free_tax': { + "name": "Free Tax", + "description": "Free Tax Description", + "amount": "0.00", + "company_id": company.id, + }, + }, + } + + # Local function to "extend" '_post_load_data' to ensure the creation of the records from 'non_chart_data' + def test_post_load_data(template_code, company, template_data): + for model, data in non_chart_data.items(): + for xml_id, values in data.items(): + self.env[model]._load_records([{ + 'xml_id': xml_id, + 'values': values, + }]) + + # Create a local mock of '_get_chart_template_data'; "extend" 'test_get_data' with the translation info + + translation_update_for_test_get_data = { + # Use code translations from module 'translation' + 'account.journal': { + 'cash': { + 'name': "Cash", + 'code': "C", # untranslatable field; shortened due to length restriction (for _translation) + '__translation_module__': { + 'name': 'translation', + 'code': 'translation', + }, + }, + }, + # Different modules for code translations of 'name' and 'description' + 'account.tax': { + 'test_tax_1_template': { + 'name': "Tax 1", + 'description': "Tax 1 Description", + '__translation_module__': { + 'name': 'translation', + 'description': 'translation2', + }, + }, + }, + # Use 'name@' and not code translation + 'account.tax.group': { + 'tax_group_taxes': { + 'name': "Taxes", + 'name@fr': "Taxes FR", + '__translation_module__': { + 'name': 'translation', + }, + }, + }, + } + + def local_get_data(self, template_code): + data = test_get_data(self, template_code) + for model, record_info in translation_update_for_test_get_data.items(): + for xmlid, data_update in record_info.items(): + data[model][xmlid].update(data_update) + return data + + # Tranlations should fall back to more generic locale 'fr' + + # Target lang for untranslatable fields + company.partner_id.lang = self.env['res.lang']._activate_lang('fr_BE').code + + # Init empty mock translations to make sure we do not use unintended translation + mock_python_translations = {} + + for module, lang, value, translation in [ + # wrong translations + ('translation', 'fr', "Taxes", "WRONG"), # there is a value in the chart data + ('translation', 'fr', "Free Account", "Free Account FR"), # there is a value for fr_BE + # correct translations + ('translation', 'fr', "Cash", "Cash FR"), + ('translation', 'fr', "C", "C FR"), + ('translation', 'fr', "Tax 1", "Tax 1 FR"), + ('translation', 'fr_BE', "Free Account", "Free Account FR_BE"), + ('translation', 'fr', "Free Tax", "Free Tax FR"), + ('translation', 'fr', "Free Tax Description", "Free Tax Description FR"), + ('translation2', 'fr', "Tax 1 Description", "Tax 1 Description translation2/FR"), + ('account', 'fr', "Free Account Group", "Free Account Group account/FR"), + ]: + mock_python_translations.setdefault((module, lang), {})[value] = translation + + with patch.object(AccountChartTemplate, '_get_chart_template_mapping', side_effect=local_get_mapping, autospec=True): + with patch.object(AccountChartTemplate, '_get_chart_template_data', side_effect=local_get_data, autospec=True): + with patch.object(AccountChartTemplate, '_post_load_data', wraps=test_post_load_data): + with patch.object(code_translations, 'python_translations', mock_python_translations): + self.env['account.chart.template'].try_loading('translation', company=company, install_demo=False) + + # Check translations + translatable_model_fields = self.env['account.chart.template']._get_translatable_template_model_fields() + untranslatable_model_fields = self.env['account.chart.template']._get_untranslatable_fields_to_translate() + fields_to_translate = { + model: set(translatable_model_fields.get(model, []) + untranslatable_model_fields.get(model, [])) + for model in TEMPLATE_MODELS + } + + self.assertEqual({ + f'{xmlid}.{field}@{lang}': self.env['account.chart.template'].ref(xmlid).with_context(lang=lang)[field] + for chart_like_data in [non_chart_data, translation_update_for_test_get_data] + for model, data in chart_like_data.items() + for xmlid, record_data in data.items() + for field in record_data if field in fields_to_translate.get(model, set()) + for lang in ['en_US', 'fr_BE'] + }, { + 'cash.code@en_US': 'C FR', # untranslatable field loaded in lang fr_BE + 'cash.code@fr_BE': 'C FR', + 'cash.name@en_US': 'Cash', + 'cash.name@fr_BE': 'Cash FR', + 'no_translation.test_chart_template_company_test_free_account_group.name@en_US': 'Free Account Group', + 'no_translation.test_chart_template_company_test_free_account_group.name@fr_BE': 'Free Account Group account/FR', # fallback to account + 'tax_group_taxes.name@en_US': 'Taxes', + 'tax_group_taxes.name@fr_BE': 'Taxes FR', + 'test_tax_1_template.description@en_US': 'Tax 1 Description', + 'test_tax_1_template.description@fr_BE': 'Tax 1 Description translation2/FR', + 'test_tax_1_template.name@en_US': 'Tax 1', + 'test_tax_1_template.name@fr_BE': 'Tax 1 FR', + 'translation.test_chart_template_company_test_free_account.name@en_US': 'Free Account', + 'translation.test_chart_template_company_test_free_account.name@fr_BE': 'Free Account FR_BE', # do not use generic lang + 'translation.test_chart_template_company_test_free_tax.description@en_US': 'Free Tax Description', + 'translation.test_chart_template_company_test_free_tax.description@fr_BE': 'Free Tax Description FR', + 'translation.test_chart_template_company_test_free_tax.name@en_US': 'Free Tax', + 'translation.test_chart_template_company_test_free_tax.name@fr_BE': 'Free Tax FR', + })