# Part of Odoo. See LICENSE file for full copyright and licensing details. """ Service for fetching translations from a remote Odoo 19 instance. Odoo 16+ / 19 stores translations in JSONB columns, not in ir.translation. The only reliable cross-version way to export / import translations is through the standard PO pipeline: Remote: base.language.export → PO bytes (base64) Local: TranslationImporter → write JSONB values This module automates the whole round-trip via XML-RPC. """ import base64 import logging import tempfile from xmlrpc.client import ServerProxy from odoo import _, api, models from odoo.exceptions import UserError from odoo.tools.translate import TranslationImporter _logger = logging.getLogger(__name__) class RemoteTranslationService(models.AbstractModel): _name = "translation.remote.service" _description = "Remote Translation Service" # ------------------------------------------------------------------ # Remote connection helpers # ------------------------------------------------------------------ @api.model def _rpc_authenticate(self, url, db, user, password): """Authenticate on remote Odoo via XML-RPC. Returns uid.""" common = ServerProxy(f"{url}/xmlrpc/2/common") try: uid = common.authenticate(db, user, password, {}) except Exception as exc: raise UserError( _("Could not connect to %(url)s:\n%(err)s", url=url, err=exc) ) from exc if not uid: raise UserError( _("Authentication failed for user '%(user)s' on database '%(db)s'.", user=user, db=db) ) return uid @api.model def _rpc_object(self, url, db, uid, password): """Return a ServerProxy pointing at /xmlrpc/2/object.""" return ServerProxy(f"{url}/xmlrpc/2/object") # ------------------------------------------------------------------ # Public API # ------------------------------------------------------------------ @api.model def fetch_po_from_remote(self, remote_url, remote_db, remote_user, remote_password, module_names, lang_code): """ Connect to remote Odoo and export PO for *module_names* / *lang_code*. Returns ------- bytes Raw PO file content (decoded from base64). """ if not all([remote_url, remote_db, remote_user, remote_password]): raise UserError(_("Не заполнены все поля подключения к удалённому серверу.")) if not module_names: raise UserError(_("Выберите хотя бы один модуль.")) remote_url = remote_url.rstrip("/") uid = self._rpc_authenticate(remote_url, remote_db, remote_user, remote_password) obj = self._rpc_object(remote_url, remote_db, uid, remote_password) _logger.info( "Remote export: modules=%s lang=%s url=%s db=%s", module_names, lang_code, remote_url, remote_db, ) try: # 1. Find module ids on remote remote_module_ids = obj.execute_kw( remote_db, uid, remote_password, "ir.module.module", "search", [[("name", "in", module_names), ("state", "=", "installed")]], ) if not remote_module_ids: raise UserError( _("На удалённом сервере не найдены установленные модули: %s") % ", ".join(module_names) ) # 2. Create export wizard on remote wiz_id = obj.execute_kw( remote_db, uid, remote_password, "base.language.export", "create", [{ "lang": lang_code, "format": "po", "export_type": "module", "modules": [(6, 0, remote_module_ids)], }], ) # 3. Execute act_getfile — generates the PO and stores in data field obj.execute_kw( remote_db, uid, remote_password, "base.language.export", "act_getfile", [[wiz_id]], ) # 4. Read the resulting base64 data result = obj.execute_kw( remote_db, uid, remote_password, "base.language.export", "read", [[wiz_id], ["data", "name"]], ) if not result or not result[0].get("data"): raise UserError( _("Удалённый сервер вернул пустой PO-файл.") ) po_b64 = result[0]["data"] filename = result[0].get("name", "export.po") po_bytes = base64.b64decode(po_b64) _logger.info( "Fetched PO from remote: file=%s size=%d bytes modules=%s", filename, len(po_bytes), module_names, ) return po_bytes, filename except UserError: raise except Exception as exc: _logger.exception("Remote PO export failed") raise UserError( _("Ошибка экспорта переводов с удалённого сервера:\n%s") % exc ) from exc @api.model def apply_po(self, po_bytes, lang_code, overwrite=True): """ Import PO bytes into the current database using the standard TranslationImporter pipeline. Returns ------- int Number of translation entries processed (approximate). """ importer = TranslationImporter(self.env.cr) with tempfile.NamedTemporaryFile(suffix=".po", delete=True) as tmp: tmp.write(po_bytes) tmp.flush() tmp.seek(0) importer.load(tmp, "po", lang_code) importer.save(overwrite=overwrite) self.env.cr.precommit.run() # flush pending writes _logger.info( "Applied PO (%d bytes) for lang=%s overwrite=%s", len(po_bytes), lang_code, overwrite, ) return len(po_bytes) @api.model def preview_po(self, po_bytes): """ Parse a PO file and return a short textual preview. """ import polib po = polib.pofile(po_bytes.decode("utf-8", errors="replace")) translated = [e for e in po if e.msgstr and e.msgid] lines = [] lines.append(_("Всего записей: %d") % len(po)) lines.append(_("Из них переведено: %d") % len(translated)) lines.append("") lines.append("%-50s │ %s" % (_("Оригинал"), _("Перевод"))) lines.append("─" * 50 + "─┼─" + "─" * 50) for entry in translated[:25]: src = (entry.msgid or "")[:50] val = (entry.msgstr or "")[:50] lines.append(f"{src:50s} │ {val}") if len(translated) > 25: lines.append("") lines.append(_("… и ещё %d записей") % (len(translated) - 25)) return "\n".join(lines)