Public release from ruodoo-project: 19.0 - 2026-11-01 22:52:25 UTC
This commit is contained in:
8
translation_helper/models/__init__.py
Normal file
8
translation_helper/models/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from . import base
|
||||
from . import translation_service
|
||||
from . import remote_translation_service
|
||||
from . import res_config_settings
|
||||
from . import ir_module
|
||||
from . import ir_model_fields
|
||||
from . import ir_http
|
||||
from . import ir_qweb
|
||||
87
translation_helper/models/base.py
Normal file
87
translation_helper/models/base.py
Normal file
@@ -0,0 +1,87 @@
|
||||
import logging
|
||||
|
||||
from odoo import models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Model(models.AbstractModel):
|
||||
_inherit = "base"
|
||||
|
||||
def _get_record_modules(self):
|
||||
"""Возвращает список модулей, в которых определены текущие записи."""
|
||||
xml_items = (
|
||||
self.env["ir.model.data"]
|
||||
.sudo()
|
||||
.search(
|
||||
[
|
||||
("res_id", "in", self.ids),
|
||||
("model", "=", self._name),
|
||||
]
|
||||
)
|
||||
)
|
||||
return list({item.module for item in xml_items if item.module})
|
||||
|
||||
def update_field_translations(self, field_name, translations):
|
||||
"""OVERRIDE
|
||||
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}}``
|
||||
|
||||
Odoo 19: calls super().update_field_translations() to preserve original behaviour,
|
||||
which internally dispatches to _update_field_translations.
|
||||
"""
|
||||
modules = self._get_record_modules()
|
||||
if not modules:
|
||||
_logger.warning(
|
||||
"Не удалось определить модуль для модели %s (ids: %s)",
|
||||
self._name,
|
||||
self.ids,
|
||||
)
|
||||
return super().update_field_translations(field_name, translations)
|
||||
|
||||
original_value = self.with_context(lang=None)[field_name]
|
||||
if not original_value:
|
||||
_logger.warning(
|
||||
"Оригинальное значение поля '%s' пустое для модели %s (ids: %s)",
|
||||
field_name,
|
||||
self._name,
|
||||
self.ids,
|
||||
)
|
||||
return super().update_field_translations(field_name, translations)
|
||||
|
||||
# Обновляем перевод для каждой локали
|
||||
for lang, translated_value in translations.items():
|
||||
if not translated_value:
|
||||
_logger.info("Пропущен пустой перевод для языка: %s", lang)
|
||||
continue
|
||||
|
||||
lang_record = (
|
||||
self.env["res.lang"].sudo().search([("code", "=", lang)], limit=1)
|
||||
)
|
||||
iso_code = lang_record.iso_code or lang.split("_")[0]
|
||||
source_value = original_value
|
||||
translated_value_string = translated_value
|
||||
if isinstance(translated_value, dict):
|
||||
source_value = translated_value.get("source", original_value)
|
||||
translated_value_string = list(translated_value.values())[0]
|
||||
|
||||
self.env[
|
||||
"translation.helper.wizard"
|
||||
].sudo().update_term_translation_in_module(
|
||||
modules[0], source_value, translated_value_string, iso_code
|
||||
)
|
||||
_logger.info(
|
||||
"Обновлен перевод для '%s' (%s -> %s) [lang: %s]",
|
||||
field_name,
|
||||
original_value,
|
||||
translated_value,
|
||||
lang,
|
||||
)
|
||||
|
||||
# Odoo 19: use super() instead of calling _update_field_translations directly,
|
||||
# as the internal method signature may have changed between versions.
|
||||
return super().update_field_translations(field_name, translations)
|
||||
41
translation_helper/models/ir_http.py
Normal file
41
translation_helper/models/ir_http.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from odoo import models
|
||||
from odoo.http import request
|
||||
from odoo.tools.misc import str2bool
|
||||
|
||||
ALLOWED_TRANSLATE_MODES = ["", "1"]
|
||||
|
||||
|
||||
class IrHttp(models.AbstractModel):
|
||||
_inherit = "ir.http"
|
||||
|
||||
@classmethod
|
||||
def _handle_translate(cls):
|
||||
"""Reads ?translate= from request params and stores in session['translate'].
|
||||
Only updates session if the parameter is explicitly present in the request.
|
||||
"""
|
||||
translate = request.httprequest.args.get("translate")
|
||||
if translate is not None:
|
||||
request.session["translate"] = ",".join(
|
||||
mode
|
||||
if mode in ALLOWED_TRANSLATE_MODES
|
||||
else "1"
|
||||
if str2bool(mode, mode)
|
||||
else ""
|
||||
for mode in (translate or "").split(",")
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _pre_dispatch(cls, rule, args):
|
||||
"""OVERRIDE: calls _handle_translate after super() to store translate in session."""
|
||||
res = super()._pre_dispatch(rule, args)
|
||||
cls._handle_translate()
|
||||
return res
|
||||
|
||||
def session_info(self):
|
||||
"""OVERRIDE: adds translate to bundle_params in session_info."""
|
||||
session_info = super().session_info()
|
||||
translate = request.session.get("translate", "")
|
||||
if "bundle_params" not in session_info:
|
||||
session_info["bundle_params"] = {}
|
||||
session_info["bundle_params"]["translate"] = translate
|
||||
return session_info
|
||||
20
translation_helper/models/ir_model_fields.py
Normal file
20
translation_helper/models/ir_model_fields.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from odoo import api, models, tools
|
||||
|
||||
|
||||
class IrModelFields(models.Model):
|
||||
_inherit = "ir.model.fields"
|
||||
|
||||
@api.model
|
||||
@tools.ormcache("model_name")
|
||||
def get_field_string(self, model_name):
|
||||
"""
|
||||
Переопределение функции нам нужно для того, чтобы обойти ограничение платформы.
|
||||
В нашем случае при вызове оригинальной функции перевод берется из кэша системы,
|
||||
что приводи к тому, что для его появления на экране нужно перезагружать экземпляр.
|
||||
Для того, чтобы обойти это мы очищаем кэш.
|
||||
|
||||
"""
|
||||
# TODO Необходимо будет сделать сброс кэша не безусловным, а по какому либо флагу,
|
||||
# например сделать режим переводчика, по аналогии с режимом разработчика
|
||||
self.env.registry.clear_cache()
|
||||
return super().get_field_string(model_name)
|
||||
47
translation_helper/models/ir_module.py
Normal file
47
translation_helper/models/ir_module.py
Normal file
@@ -0,0 +1,47 @@
|
||||
import logging
|
||||
import os
|
||||
|
||||
from odoo import api, models
|
||||
from odoo.modules import get_module_path
|
||||
from odoo.tools.translate import TranslationImporter
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
module_lname = os.path.basename(
|
||||
os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
)
|
||||
|
||||
|
||||
class Module(models.Model):
|
||||
_inherit = "ir.module.module"
|
||||
|
||||
@api.model
|
||||
def _load_module_terms(self, modules, langs, overwrite=False):
|
||||
if module_lname not in modules:
|
||||
return super()._load_module_terms(modules, langs, overwrite=overwrite)
|
||||
# load i18n files
|
||||
translation_importer = TranslationImporter(self.env.cr, verbose=False)
|
||||
for module_name in modules:
|
||||
modpath = get_module_path(module_name)
|
||||
if not modpath:
|
||||
continue
|
||||
for lang in langs:
|
||||
po_paths = []
|
||||
for subdir in ("i18n", "i18n_extra"):
|
||||
po_path = os.path.join(modpath, subdir, "%s.po" % lang)
|
||||
if os.path.exists(po_path):
|
||||
po_paths.append(po_path)
|
||||
for po_path in po_paths:
|
||||
_logger.info(
|
||||
"module %s: loading translation file %s for language %s",
|
||||
module_name,
|
||||
po_path,
|
||||
lang,
|
||||
)
|
||||
translation_importer.load_file(po_path, lang)
|
||||
if lang != "en_US" and not po_paths:
|
||||
_logger.info(
|
||||
"module %s: no translation for language %s", module_name, lang
|
||||
)
|
||||
|
||||
translation_importer.save(overwrite=overwrite, force_overwrite=overwrite)
|
||||
13
translation_helper/models/ir_qweb.py
Normal file
13
translation_helper/models/ir_qweb.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from odoo import models
|
||||
from odoo.http import request
|
||||
|
||||
|
||||
class IrQWeb(models.AbstractModel):
|
||||
_inherit = "ir.qweb"
|
||||
|
||||
def _prepare_environment(self, values):
|
||||
result = super()._prepare_environment(values)
|
||||
if not values.get("minimal_qcontext"):
|
||||
translate = request.session.get("translate", "") if request else ""
|
||||
values.setdefault("translate", translate)
|
||||
return result
|
||||
198
translation_helper/models/remote_translation_service.py
Normal file
198
translation_helper/models/remote_translation_service.py
Normal file
@@ -0,0 +1,198 @@
|
||||
# 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)
|
||||
33
translation_helper/models/res_config_settings.py
Normal file
33
translation_helper/models/res_config_settings.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class ResConfigSettings(models.TransientModel):
|
||||
_inherit = "res.config.settings"
|
||||
|
||||
translation_weblate_server_address = fields.Char(
|
||||
string="Адрес",
|
||||
help="Запишите адрес вашего Weblate сервера",
|
||||
config_parameter="translation_helper.translation_weblate_server_address",
|
||||
)
|
||||
translation_weblate_server_protocol = fields.Selection(
|
||||
string="Протокол",
|
||||
help="Выберите протокол вашего Weblate сервера",
|
||||
selection=[
|
||||
("http", "HTTP"),
|
||||
("https", "HTTPS"),
|
||||
],
|
||||
config_parameter="translation_helper.translation_weblate_server_protocol",
|
||||
)
|
||||
|
||||
translation_weblate_project_alias = fields.Char(
|
||||
string="Алиас проекта",
|
||||
help="Запишите алиас вашего Weblate проекта",
|
||||
config_parameter="translation_helper.translation_weblate_project_alias",
|
||||
)
|
||||
|
||||
translation_weblate_project_language = fields.Many2one(
|
||||
string="Язык проекта",
|
||||
help="Выберите язык вашего алиаса проекта",
|
||||
comodel_name="res.lang",
|
||||
config_parameter="translation_helper.translation_weblate_project_language",
|
||||
)
|
||||
135
translation_helper/models/translation_service.py
Normal file
135
translation_helper/models/translation_service.py
Normal file
@@ -0,0 +1,135 @@
|
||||
from siphashc import siphash
|
||||
|
||||
from odoo import _, api, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
DEFAULT_WEBLATE_ADDRESS = "weblate.rudoo.ru"
|
||||
DEFAULT_WEBLATE_PROTOCOL = "https"
|
||||
DEFAULT_WEBLATE_PROJECT_ALIAS = "testovyj-proekt"
|
||||
|
||||
|
||||
def raw_hash(*parts: str) -> int:
|
||||
"""Calculate checksum identifying translation."""
|
||||
if not parts:
|
||||
data = ""
|
||||
elif len(parts) == 1:
|
||||
data = parts[0]
|
||||
else:
|
||||
data = "".join(part for part in parts)
|
||||
return siphash("Weblate Sip Hash", data)
|
||||
|
||||
|
||||
def calculate_checksum(*parts: str):
|
||||
"""Calculate siphashc checksum for given strings."""
|
||||
return format(raw_hash(*parts), "016x")
|
||||
|
||||
|
||||
class TranslationHelper(models.AbstractModel):
|
||||
_name = "translation.service"
|
||||
_description = "Translation Helper"
|
||||
|
||||
@api.model
|
||||
def get_action(self, data=None):
|
||||
model_name = data.get("resModel")
|
||||
field_name = data.get("field", {}).get("name")
|
||||
# Use current user's language as the default language (Odoo 19 compatible)
|
||||
default_lang = self.env.user.lang
|
||||
query = """
|
||||
SELECT field_description
|
||||
FROM ir_model_fields
|
||||
WHERE model = %s AND name = %s
|
||||
"""
|
||||
self.env.cr.execute(query, [model_name, field_name])
|
||||
translation_field_data = self.env.cr.fetchone()
|
||||
current_user_language = self.env.context.get("lang", default_lang)
|
||||
translation_value = translation_field_data[0].get(current_user_language)
|
||||
term_to_translate = translation_field_data[0].get(default_lang)
|
||||
if not term_to_translate:
|
||||
term_to_translate = data.get("label")
|
||||
data["translation_field_data"] = translation_field_data[0]
|
||||
|
||||
field = (
|
||||
self.env["ir.model.fields"]
|
||||
.sudo()
|
||||
.search([("model", "=", model_name), ("name", "=", field_name)])
|
||||
)
|
||||
if not field:
|
||||
raise UserError(
|
||||
_("Field '%s' not found on model '%s'.") % (field_name, model_name)
|
||||
)
|
||||
# field.modules may be empty or a comma-separated string in Odoo 19
|
||||
if field.modules:
|
||||
module_names_list = field.modules.split(", ")
|
||||
else:
|
||||
module_names_list = []
|
||||
checksum = calculate_checksum(term_to_translate)
|
||||
|
||||
weblate_address = (
|
||||
self.env["ir.config_parameter"]
|
||||
.sudo()
|
||||
.get_param(
|
||||
"translation_helper.translation_weblate_server_address",
|
||||
default=DEFAULT_WEBLATE_ADDRESS,
|
||||
)
|
||||
)
|
||||
weblate_protocol = (
|
||||
self.env["ir.config_parameter"]
|
||||
.sudo()
|
||||
.get_param(
|
||||
"translation_helper.translation_weblate_server_protocol",
|
||||
default=DEFAULT_WEBLATE_PROTOCOL,
|
||||
)
|
||||
)
|
||||
weblate_project_alias = (
|
||||
self.env["ir.config_parameter"]
|
||||
.sudo()
|
||||
.get_param(
|
||||
"translation_helper.translation_weblate_project_alias",
|
||||
default=DEFAULT_WEBLATE_PROJECT_ALIAS,
|
||||
)
|
||||
)
|
||||
weblate_project_language = (
|
||||
self.env["res.lang"]
|
||||
.sudo()
|
||||
.search(
|
||||
[
|
||||
("code", "=", current_user_language),
|
||||
"|",
|
||||
("active", "=", True),
|
||||
("active", "=", False),
|
||||
]
|
||||
)
|
||||
)
|
||||
for module_name in module_names_list:
|
||||
url_for_module = f"{weblate_protocol}://{weblate_address}/translate/{weblate_project_alias}/{module_name}/{weblate_project_language.iso_code}/?checksum={checksum}"
|
||||
wizard_record = (
|
||||
self.env["translation.helper.wizard"]
|
||||
.sudo()
|
||||
.create(
|
||||
{
|
||||
"weblate_link": url_for_module,
|
||||
"term_value": term_to_translate,
|
||||
"language_id": weblate_project_language.id,
|
||||
"translation_value": translation_value,
|
||||
"modules": field.modules,
|
||||
"metadata": data,
|
||||
}
|
||||
)
|
||||
)
|
||||
window_action = {
|
||||
"name": _("Write your translation"),
|
||||
"target": "new",
|
||||
"view_mode": "form",
|
||||
"res_model": "translation.helper.wizard",
|
||||
"type": "ir.actions.act_window",
|
||||
"res_id": wizard_record.id,
|
||||
"views": [
|
||||
[
|
||||
self.env.ref(
|
||||
"translation_helper.translation_helper_wizard_form"
|
||||
).id,
|
||||
"form",
|
||||
]
|
||||
],
|
||||
}
|
||||
return window_action
|
||||
Reference in New Issue
Block a user