diff --git a/odoo/addons/base/wizard/base_export_language.py b/odoo/addons/base/wizard/base_export_language.py
index d53a3a284e2..31fcd733638 100644
--- a/odoo/addons/base/wizard/base_export_language.py
+++ b/odoo/addons/base/wizard/base_export_language.py
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
+import ast
import base64
import contextlib
import io
@@ -23,8 +24,13 @@ class BaseLanguageExport(models.TransientModel):
lang = fields.Selection(_get_languages, string='Language', required=True, default=NEW_LANG_KEY)
format = fields.Selection([('csv','CSV File'), ('po','PO File'), ('tgz', 'TGZ Archive')],
string='File Format', required=True, default='po')
+ export_type = fields.Selection([('module', 'Module'), ('model', 'Model')],
+ string='Export Type', required=True, default='module')
modules = fields.Many2many('ir.module.module', 'rel_modules_langexport', 'wiz_id', 'module_id',
string='Apps To Export', domain=[('state','=','installed')])
+ model_id = fields.Many2one('ir.model', string='Model to Export', domain=[('transient', '=', False)])
+ model_name = fields.Char(string="Model Name", related="model_id.model")
+ domain = fields.Char(string="Model Domain", default='[]')
data = fields.Binary('File', readonly=True, attachment=False)
state = fields.Selection([('choose', 'choose'), ('get', 'get')], # choose language or get the file
default='choose')
@@ -32,15 +38,21 @@ class BaseLanguageExport(models.TransientModel):
def act_getfile(self):
this = self[0]
lang = this.lang if this.lang != NEW_LANG_KEY else False
- mods = sorted(this.mapped('modules.name')) or ['all']
with contextlib.closing(io.BytesIO()) as buf:
- tools.trans_export(lang, mods, buf, this.format, self._cr)
+ if this.export_type == 'model':
+ ids = self.env[this.model_name].search(ast.literal_eval(this.domain)).ids
+ tools.trans_export_records(lang, this.model_name, ids, buf, this.format, self._cr)
+ else:
+ mods = sorted(this.mapped('modules.name')) or ['all']
+ tools.trans_export(lang, mods, buf, this.format, self._cr)
out = base64.encodebytes(buf.getvalue())
filename = 'new'
if lang:
filename = tools.get_iso_codes(lang)
+ elif this.export_type == 'model':
+ filename = this.model_name.replace('.', '_')
elif len(mods) == 1:
filename = mods[0]
extension = this.format
diff --git a/odoo/addons/base/wizard/base_export_language_views.xml b/odoo/addons/base/wizard/base_export_language_views.xml
index 98ef89dcf69..42f405bd6a9 100644
--- a/odoo/addons/base/wizard/base_export_language_views.xml
+++ b/odoo/addons/base/wizard/base_export_language_views.xml
@@ -11,7 +11,11 @@
-
+
+
+
+
+
Export Complete
diff --git a/odoo/addons/test_translation_import/data/test_translation_import_data.xml b/odoo/addons/test_translation_import/data/test_translation_import_data.xml
index 4e8678b376f..ca0a7e7eccf 100644
--- a/odoo/addons/test_translation_import/data/test_translation_import_data.xml
+++ b/odoo/addons/test_translation_import/data/test_translation_import_data.xml
@@ -4,4 +4,7 @@
Tableware
+
+
+
diff --git a/odoo/addons/test_translation_import/models/models.py b/odoo/addons/test_translation_import/models/models.py
index b0c7530a5dd..7218008f109 100644
--- a/odoo/addons/test_translation_import/models/models.py
+++ b/odoo/addons/test_translation_import/models/models.py
@@ -25,3 +25,11 @@ class TestTranslationImportModel1(models.Model):
def get_code_named_placeholder_translation(self, *args, **kwargs):
return _('Code, %(num)s, %(symbol)s, English', *args, **kwargs)
+
+
+class TestTranslationImportModel2(models.Model):
+ _inherits = {'test.translation.import.model1': 'model1_id'}
+ _name = 'test.translation.import.model2'
+ _description = 'Translation Test 2'
+
+ model1_id = fields.Many2one('test.translation.import.model1', required=True, ondelete='cascade')
diff --git a/odoo/addons/test_translation_import/security/ir.model.access.csv b/odoo/addons/test_translation_import/security/ir.model.access.csv
index 228691f2d88..1a6694da293 100644
--- a/odoo/addons/test_translation_import/security/ir.model.access.csv
+++ b/odoo/addons/test_translation_import/security/ir.model.access.csv
@@ -1,2 +1,3 @@
"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink"
-"access_test_translation_import_model1","access.test.translation.import.model1","model_test_translation_import_model1",,0,0,0,0
\ No newline at end of file
+"access_test_translation_import_model1","access.test.translation.import.model1","model_test_translation_import_model1",,0,0,0,0
+"access_test_translation_import_model2","access.test.translation.import.model2","model_test_translation_import_model2",,0,0,0,0
diff --git a/odoo/addons/test_translation_import/tests/test_term_count.py b/odoo/addons/test_translation_import/tests/test_term_count.py
index cbfa00e68ea..41b66bb490b 100644
--- a/odoo/addons/test_translation_import/tests/test_term_count.py
+++ b/odoo/addons/test_translation_import/tests/test_term_count.py
@@ -5,7 +5,7 @@ import io
from odoo.tests import common, tagged
from odoo.tools.misc import file_open, mute_logger
-from odoo.tools.translate import TranslationModuleReader, code_translations, CodeTranslations, PYTHON_TRANSLATION_COMMENT, JAVASCRIPT_TRANSLATION_COMMENT, WEB_TRANSLATION_COMMENT
+from odoo.tools.translate import TranslationModuleReader, TranslationRecordReader, code_translations, CodeTranslations, PYTHON_TRANSLATION_COMMENT, JAVASCRIPT_TRANSLATION_COMMENT, WEB_TRANSLATION_COMMENT
from odoo import Command
from odoo.addons.base.models.ir_fields import BOOLEAN_TRANSLATIONS
@@ -386,3 +386,35 @@ class TestTranslationFlow(common.TransactionCase):
'with spaces',
'hello \\"world\\"',
})
+
+ def test_export_records(self):
+ self.env["base.language.install"].create({
+ 'overwrite': True,
+ 'lang_ids': [(6, 0, [self.env.ref('base.lang_fr').id])],
+ }).lang_install()
+
+ model1_ids = self.env.ref('test_translation_import.test_translation_import_model1_record1').ids
+ po_reader = TranslationRecordReader(self.env.cr, 'test.translation.import.model1', model1_ids, lang='fr_FR')
+ translations = {line[4]: line[5] for line in po_reader}
+ self.assertDictEqual(
+ translations,
+ {
+ 'Fork': 'Fourchette',
+ 'Knife': 'Couteau',
+ 'Spoon': 'Cuillère',
+ 'Tableware': 'Vaisselle',
+ }
+ )
+
+ model2_ids = self.env.ref('test_translation_import.test_translation_import_model2_record1').ids
+ po_reader = TranslationRecordReader(self.env.cr, 'test.translation.import.model2', model2_ids, lang='fr_FR')
+ translations = {line[4]: line[5] for line in po_reader}
+ self.assertDictEqual(
+ translations,
+ {
+ 'Fork': 'Fourchette',
+ 'Knife': 'Couteau',
+ 'Spoon': 'Cuillère',
+ 'Tableware': 'Vaisselle',
+ }
+ )
diff --git a/odoo/tools/translate.py b/odoo/tools/translate.py
index 745ec634bbc..5b3fc6e9a39 100644
--- a/odoo/tools/translate.py
+++ b/odoo/tools/translate.py
@@ -848,6 +848,13 @@ def trans_export(lang, modules, buffer, format, cr):
writer = TranslationFileWriter(buffer, fileformat=format, lang=lang)
writer.write_rows(reader)
+# pylint: disable=redefined-builtin
+def trans_export_records(lang, model_name, ids, buffer, format, cr):
+ reader = TranslationRecordReader(cr, model_name, ids, lang=lang)
+ writer = TranslationFileWriter(buffer, fileformat=format, lang=lang)
+ writer.write_rows(reader)
+
+
def _push(callback, term, source_line):
""" Sanity check before pushing translation terms """
term = (term or "").strip()
@@ -974,33 +981,14 @@ def extract_spreadsheet_terms(fileobj, keywords, comment_tags, options):
ImdInfo = namedtuple('ExternalId', ['name', 'model', 'res_id', 'module'])
-class TranslationModuleReader:
- """ Retrieve translated records per module
-
- :param cr: cursor to database to export
- :param modules: list of modules to filter the exported terms, can be ['all']
- records with no external id are always ignored
- :param lang: language code to retrieve the translations
- retrieve source terms only if not set
- """
-
- def __init__(self, cr, modules=None, lang=None):
+class TranslationReader:
+ def __init__(self, cr, lang=None):
self._cr = cr
- self._modules = modules or ['all']
self._lang = lang or 'en_US'
self.env = odoo.api.Environment(cr, odoo.SUPERUSER_ID, {})
self._to_translate = []
- self._path_list = [(path, True) for path in odoo.addons.__path__]
- self._installed_modules = [
- m['name']
- for m in self.env['ir.module.module'].search_read([('state', '=', 'installed')], fields=['name'])
- ]
-
- 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)
@@ -1021,6 +1009,35 @@ class TranslationModuleReader:
return
self._to_translate.append((module, source, name, res_id, ttype, tuple(comments or ()), record_id, value))
+ def _export_imdinfo(self, model: str, imd_per_id: dict[int, ImdInfo]):
+ records = self._get_translatable_records(imd_per_id.values())
+ if not records:
+ return
+
+ for record in records:
+ 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():
+ # 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 not field.translate or not field.store or str(field) == 'ir.actions.actions.name':
+ continue
+ name = model + "," + field_name
+ value_en = record[field_name] or ''
+ value_lang = record.with_context(lang=self._lang)[field_name] or ''
+ trans_type = 'model_terms' if callable(field.translate) else 'model'
+ try:
+ translation_dictionary = field.get_translation_dictionary(value_en, {self._lang: value_lang})
+ except Exception:
+ _logger.exception("Failed to extract terms from %s %s", xml_name, name)
+ continue
+ for term_en, term_langs in translation_dictionary.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_translatable_records(self, imd_records):
""" Filter the records that are translatable
@@ -1072,6 +1089,84 @@ class TranslationModuleReader:
return records
+class TranslationRecordReader(TranslationReader):
+ """ Retrieve translations for specified records, the reader will
+ 1. create external ids for records without external ids
+ 2. export translations for stored translated and inherited translated fields
+ :param cr: cursor to database to export
+ :param model_name: model_name for the records to export
+ :param ids: ids of the records to export
+ :param field_names: field names to export, if not set, export all translatable fields
+ :param lang: language code to retrieve the translations retrieve source terms only if not set
+ """
+ def __init__(self, cr, model_name, ids, field_names=None, lang=None):
+ super().__init__(cr, lang)
+ self._records = self.env[model_name].browse(ids)
+ self._field_names = field_names or list(self._records._fields.keys())
+
+ self._export_translatable_records(self._records, self._field_names)
+
+ def _export_translatable_records(self, records, field_names):
+ """ Export translations of all stored/inherited translated fields. Create external id if needed. """
+ if not records:
+ return
+
+ fields = records._fields
+
+ if records._inherits:
+ inherited_fields = defaultdict(list)
+ for field_name in field_names:
+ field = records._fields[field_name]
+ if field.translate and not field.store and field.inherited_field:
+ inherited_fields[field.inherited_field.model_name].append(field_name)
+ for parent_mname, parent_fname in records._inherits.items():
+ if parent_mname in inherited_fields:
+ self._export_translatable_records(records[parent_fname], inherited_fields[parent_mname])
+
+ if not any(fields[field_name].translate and fields[field_name].store for field_name in field_names):
+ return
+
+ records._BaseModel__ensure_xml_id()
+
+ model_name = records._name
+ query = """SELECT min(concat(module, '.', name)), res_id
+ FROM ir_model_data
+ WHERE model = %s
+ AND res_id = ANY(%s)
+ GROUP BY model, res_id"""
+
+ self._cr.execute(query, (model_name, records.ids))
+
+ imd_per_id = {
+ res_id: ImdInfo((tmp := module_xml_name.split('.', 1))[1], model_name, res_id, tmp[0])
+ for module_xml_name, res_id in self._cr.fetchall()
+ }
+
+ self._export_imdinfo(model_name, imd_per_id)
+
+
+class TranslationModuleReader(TranslationReader):
+ """ Retrieve translated records per module
+
+ :param cr: cursor to database to export
+ :param modules: list of modules to filter the exported terms, can be ['all']
+ records with no external id are always ignored
+ :param lang: language code to retrieve the translations
+ retrieve source terms only if not set
+ """
+
+ def __init__(self, cr, modules=None, lang=None):
+ super().__init__(cr, lang)
+ self._modules = modules or ['all']
+ self._path_list = [(path, True) for path in odoo.addons.__path__]
+ self._installed_modules = [
+ m['name']
+ for m in self.env['ir.module.module'].search_read([('state', '=', 'installed')], fields=['name'])
+ ]
+
+ self._export_translatable_records()
+ self._export_translatable_resources()
+
def _export_translatable_records(self):
""" Export translations of all translated records having an external id """
@@ -1093,31 +1188,7 @@ class TranslationModuleReader:
records_per_model[model][res_id] = ImdInfo(xml_name, model, res_id, module)
for model, imd_per_id in records_per_model.items():
- records = self._get_translatable_records(imd_per_id.values())
- if not records:
- continue
-
- for record in records:
- 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():
- # 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 field.store and str(field) != 'ir.actions.actions.name':
- name = model + "," + field_name
- try:
- value_en = record[field_name] or ''
- value_lang = record.with_context(lang=self._lang)[field_name] or ''
- except Exception:
- _logger.exception("Failed to extract terms from %s", name)
- continue
- 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 '')
+ self._export_imdinfo(model, imd_per_id)
def _get_module_from_path(self, path):
for (mp, rec) in self._path_list:
@@ -1275,7 +1346,6 @@ class TranslationImporter:
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']
if model_name not in self.env: