199 lines
7.2 KiB
Python
199 lines
7.2 KiB
Python
# 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)
|