Public release from ruodoo-project: 19.0 - 2026-11-01 22:52:25 UTC

This commit is contained in:
CI Publish Bot
2026-11-01 22:54:30 +00:00
commit 26250aa580
2492 changed files with 664277 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
from . import translation_helper_wizard
from . import remote_translation_sync_wizard
from . import local_translation_sync_wizard

View File

@@ -0,0 +1,217 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
"""
Wizard: Загрузка перевода из локального хранилища.
Scans the module's own ``translations/<lang>/*.po`` folder, lists each found
file, lets the user drop unwanted rows, and applies the remaining PO files to
the current database using the same ``translation.remote.service.apply_po``
pipeline as the remote wizard. Dropping a row only excludes it from this run —
the file on disk is never deleted.
"""
import logging
import os
from datetime import datetime
import polib
from odoo import _, api, fields, models
from odoo.exceptions import UserError
from odoo.modules.module import get_module_path
_logger = logging.getLogger(__name__)
class LocalTranslationSyncLine(models.TransientModel):
_name = "translation.local.sync.line"
_description = "Строка локальной загрузки перевода"
wizard_id = fields.Many2one(
"translation.local.sync.wizard",
string="Визард",
required=True,
ondelete="cascade",
)
module_name = fields.Char(
string="Модуль",
readonly=True,
)
file_path = fields.Char(
string="Путь",
readonly=True,
)
file_date = fields.Datetime(
string="Дата файла",
readonly=True,
)
class LocalTranslationSyncWizard(models.TransientModel):
_name = "translation.local.sync.wizard"
_description = "Загрузка перевода из локального хранилища"
language = fields.Many2one(
"res.lang",
string="Язык",
required=True,
default=lambda self: self.env["res.lang"].search(
[("code", "=", "ru_RU")], limit=1
),
)
line_ids = fields.One2many(
"translation.local.sync.line",
"wizard_id",
string="Файлы переводов",
)
overwrite = fields.Boolean(
string="Перезаписать существующие переводы",
default=True,
help="Заменять уже имеющиеся переводы значениями из локальных PO-файлов",
)
state = fields.Selection(
[("form", "Форма"), ("done", "Готово")],
default="form",
readonly=True,
)
result_message = fields.Text(
string="Результат",
readonly=True,
)
# ------------------------------------------------------------------
# Scan helpers
# ------------------------------------------------------------------
@api.model
def _scan_local_po(self, lang):
"""Scan ``translations/<folder>/*.po`` for *lang* and return a list of
``(0, 0, vals)`` command tuples for the one2many ``line_ids``.
The on-disk folder name is derived from the language's ``iso_code``
(falling back to the first segment of its ``code``), matching the
layout written by this module (e.g. ``ru`` for ``ru_RU``).
"""
if not lang:
return []
base = get_module_path("translation_helper", display_warning=False)
if not base:
return []
folder = lang.iso_code or (lang.code or "").split("_")[0]
if not folder:
return []
po_dir = os.path.join(base, "translations", folder)
if not os.path.isdir(po_dir):
return []
commands = []
for fname in sorted(os.listdir(po_dir)):
if not fname.endswith(".po"):
continue
full = os.path.join(po_dir, fname)
if not os.path.isfile(full):
continue
mtime = datetime.fromtimestamp(os.path.getmtime(full))
commands.append((0, 0, {
"module_name": fname[:-3],
"file_path": full,
"file_date": fields.Datetime.to_string(mtime),
}))
return commands
@api.model
def default_get(self, fields_list):
defaults = super().default_get(fields_list)
res_lang = self.env["res.lang"].browse(defaults.get("language"))
if not res_lang:
res_lang = self.env["res.lang"].search(
[("code", "=", "ru_RU")], limit=1
)
defaults["line_ids"] = self._scan_local_po(res_lang)
defaults.setdefault("language", res_lang.id)
return defaults
@api.onchange("language")
def _onchange_language(self):
"""Rebuild the file list when the selected language changes."""
if self.language:
self.line_ids = [(5, 0, 0)] + self._scan_local_po(self.language)
# ------------------------------------------------------------------
# Actions
# ------------------------------------------------------------------
def action_rescan(self):
"""Server-side rescan of the local folder for the chosen language."""
self.ensure_one()
self.line_ids = [(5, 0, 0)] + self._scan_local_po(self.language)
return self._reopen()
def action_sync(self):
"""Read each remaining PO file and apply it via the shared service."""
self.ensure_one()
if not self.line_ids:
raise UserError(_("Нет файлов для загрузки."))
service = self.env["translation.remote.service"]
lang_code = self.language.code
modules = []
total_entries = 0
translated_entries = 0
errors = []
for line in self.line_ids:
try:
with open(line.file_path, "rb") as fh:
po_bytes = fh.read()
service.apply_po(po_bytes, lang_code, overwrite=self.overwrite)
po = polib.pofile(po_bytes.decode("utf-8", errors="replace"))
total_entries += len(po)
translated_entries += sum(1 for e in po if e.msgstr and e.msgid)
modules.append(line.module_name)
except Exception as exc: # noqa: BLE001 - isolate per-file failures
_logger.exception(
"Local translation import failed for %s", line.file_path
)
errors.append(_("%(module)s: %(err)s",
module=line.module_name, err=exc))
msg = _(
"Загрузка завершена!\n\n"
"Файлов обработано: %(files)d\n"
"Модули: %(modules)s\n"
"Язык: %(lang)s\n"
"Переводов в файлах: %(total)d\n"
"Из них переведено: %(translated)d\n"
"Перезапись: %(overwrite)s",
files=len(modules),
modules=", ".join(modules) or _("нет"),
lang=self.language.name,
total=total_entries,
translated=translated_entries,
overwrite=_("да") if self.overwrite else _("нет"),
)
if errors:
msg += "\n\n" + _("Ошибки:") + "\n" + "\n".join(errors)
self.write({"result_message": msg, "state": "done"})
_logger.info(
"Local translation sync completed for %s (lang=%s)",
modules, lang_code,
)
return self._reopen()
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _reopen(self):
"""Return action that re-opens this wizard record."""
return {
"name": _("Загрузка перевода из локального хранилища"),
"type": "ir.actions.act_window",
"res_model": self._name,
"res_id": self.id,
"view_mode": "form",
"target": "new",
}

View File

@@ -0,0 +1,215 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
"""
Wizard: Загрузка перевода из удалённой базы.
Connects to a remote Odoo 19 via XML-RPC, runs the standard PO export on
the remote side, downloads the resulting PO, and imports it locally using
the standard TranslationImporter pipeline.
"""
import base64
import logging
from odoo import _, api, fields, models
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class RemoteTranslationSyncWizard(models.TransientModel):
_name = "translation.remote.sync.wizard"
_description = "Загрузка перевода из удалённой базы"
# ---- Connection ----
remote_url = fields.Char(
string="URL удалённого Odoo",
required=True,
help="Адрес удалённого сервера (например, http://192.168.1.10:8069)",
)
remote_db = fields.Char(
string="База данных",
required=True,
help="Имя базы на удалённом сервере",
)
remote_user = fields.Char(
string="Логин",
required=True,
default="admin",
)
remote_password = fields.Char(
string="Пароль",
required=True,
)
# ---- What to sync ----
modules = fields.Many2many(
"ir.module.module",
string="Модули",
required=True,
domain=[("state", "=", "installed")],
help="Модули, переводы которых нужно загрузить",
)
language = fields.Many2one(
"res.lang",
string="Язык",
required=True,
default=lambda self: self.env["res.lang"].search(
[("code", "=", "ru_RU")], limit=1
),
)
# ---- Options ----
overwrite = fields.Boolean(
string="Перезаписать существующие переводы",
default=True,
help="Заменять уже имеющиеся переводы значениями из удалённой базы",
)
save_credentials = fields.Boolean(
string="Запомнить учётные данные",
default=True,
help="Сохранить URL, базу, логин и пароль для повторного использования",
)
# ---- State / results ----
state = fields.Selection(
[("form", "Форма"), ("preview", "Просмотр"), ("done", "Готово")],
default="form",
readonly=True,
)
preview_lines = fields.Text(
string="Просмотр",
readonly=True,
)
result_message = fields.Text(
string="Результат",
readonly=True,
)
# hidden binary to cache PO between preview ↔ sync
_po_cache = fields.Binary(attachment=False)
# ------------------------------------------------------------------
# Defaults from saved credentials
# ------------------------------------------------------------------
@api.model
def default_get(self, fields_list):
defaults = super().default_get(fields_list)
ICP = self.env["ir.config_parameter"].sudo()
defaults.setdefault("remote_url", ICP.get_param("translation_helper.remote_url", ""))
defaults.setdefault("remote_db", ICP.get_param("translation_helper.remote_db", ""))
defaults.setdefault("remote_user", ICP.get_param("translation_helper.remote_user", "admin"))
defaults.setdefault("remote_password", ICP.get_param("translation_helper.remote_password", ""))
return defaults
# ------------------------------------------------------------------
# Actions
# ------------------------------------------------------------------
def action_preview(self):
"""Fetch PO from remote, show preview, cache for later import."""
self.ensure_one()
service = self.env["translation.remote.service"]
po_bytes, _filename = service.fetch_po_from_remote(
remote_url=self.remote_url,
remote_db=self.remote_db,
remote_user=self.remote_user,
remote_password=self.remote_password,
module_names=self.modules.mapped("name"),
lang_code=self.language.code,
)
preview_text = service.preview_po(po_bytes)
self.write({
"preview_lines": preview_text,
"state": "preview",
"_po_cache": base64.b64encode(po_bytes),
})
return self._reopen()
def action_sync(self):
"""Import PO into local database."""
self.ensure_one()
service = self.env["translation.remote.service"]
# Use cached PO if available (from preview), otherwise re-fetch
if self._po_cache:
po_bytes = base64.b64decode(self._po_cache)
else:
po_bytes, _filename = service.fetch_po_from_remote(
remote_url=self.remote_url,
remote_db=self.remote_db,
remote_user=self.remote_user,
remote_password=self.remote_password,
module_names=self.modules.mapped("name"),
lang_code=self.language.code,
)
# Apply
service.apply_po(po_bytes, self.language.code, overwrite=self.overwrite)
# Count translated entries for the message
import polib
po = polib.pofile(po_bytes.decode("utf-8", errors="replace"))
translated_count = sum(1 for e in po if e.msgstr and e.msgid)
msg = _(
"Загрузка завершена!\n\n"
"Модули: %(modules)s\n"
"Язык: %(lang)s\n"
"Переводов в файле: %(total)d\n"
"Из них переведено: %(translated)d\n"
"Перезапись: %(overwrite)s",
modules=", ".join(self.modules.mapped("name")),
lang=self.language.name,
total=len(po),
translated=translated_count,
overwrite=_("да") if self.overwrite else _("нет"),
)
# Save credentials
if self.save_credentials:
self._save_credentials()
msg += "\n\n" + _("Учётные данные сохранены.")
self.write({
"result_message": msg,
"state": "done",
"_po_cache": False,
})
_logger.info("Remote translation sync completed for %s", self.modules.mapped("name"))
return self._reopen()
def action_open_local_sync(self):
"""Open the local-storage translation wizard."""
self.ensure_one()
return {
"name": _("Загрузка перевода из локального хранилища"),
"type": "ir.actions.act_window",
"res_model": "translation.local.sync.wizard",
"view_mode": "form",
"target": "new",
"context": {"default_language": self.language.id},
}
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _save_credentials(self):
ICP = self.env["ir.config_parameter"].sudo()
ICP.set_param("translation_helper.remote_url", self.remote_url or "")
ICP.set_param("translation_helper.remote_db", self.remote_db or "")
ICP.set_param("translation_helper.remote_user", self.remote_user or "admin")
ICP.set_param("translation_helper.remote_password", self.remote_password or "")
def _reopen(self):
"""Return action that re-opens this wizard record."""
return {
"name": _("Загрузка перевода из удалённой базы"),
"type": "ir.actions.act_window",
"res_model": self._name,
"res_id": self.id,
"view_mode": "form",
"target": "new",
}

View File

@@ -0,0 +1,139 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import json
import logging
import os
import polib
from odoo import fields, models
from odoo.modules.module import get_module_path
_logger = logging.getLogger(__name__)
dir_path = os.path.dirname(os.path.realpath(__file__))
dir_path = os.path.join(dir_path, "..")
class TranslationHelperWizard(models.TransientModel):
_name = "translation.helper.wizard"
_description = "Translation Helper Wizard"
weblate_link = fields.Char(
string="Weblate Link",
help="Link to current term on Weblate server",
)
term_value = fields.Char(
string="Term",
help="Term value to translate",
)
translation_value = fields.Char(
string="Translation",
help="Translation for term value",
)
language_id = fields.Many2one(
string="Language",
help="The language for which the translation will be applied",
comodel_name="res.lang",
)
modules = fields.Char(
string="In Apps",
help="List of modules in which the translation term is defined",
)
metadata = fields.Json(
string="Metadata",
help="Metadata",
)
def save_translation(self):
for module_name in self.modules.split(", "):
self.update_term_translation_in_module(
module_name=module_name,
term_to_translate=self.term_value,
translation_value=self.translation_value,
lang=self.language_id.iso_code,
)
model_name = self.metadata.get("resModel")
field_name = self.metadata.get("field", {}).get("name")
translation_field_data = self.metadata.get("translation_field_data", {})
translation_field_data[self.language_id.code] = self.translation_value
query = """
UPDATE ir_model_fields SET field_description = %s
WHERE model = %s AND name = %s
"""
self.env.cr.execute(
query, [json.dumps(translation_field_data), model_name, field_name]
)
return {
"type": "ir.actions.client",
"tag": "translation_helper.response_from_wizard",
"name": "Translate info",
"params": {
"model_name": model_name,
"field_name": field_name,
"translation_field_data": translation_field_data,
},
}
def update_term_translation_in_module(
self, module_name, term_to_translate, translation_value, lang
):
# Always write to local translations/{lang}/ directory inside translation_helper module
local_po_dir = os.path.join(dir_path, "translations", lang.strip())
local_po_file = os.path.join(local_po_dir, f"{module_name}.po")
if os.path.exists(local_po_file):
# Local override already exists — update it
source_pofile = polib.pofile(local_po_file)
else:
# Try to find the system .po file to use as a base
source_pofile = None
module_path = get_module_path(module_name, display_warning=False)
if module_path:
for subdir in ("i18n", "i18n_extra"):
candidate = os.path.join(module_path, subdir, f"{lang}.po")
if os.path.exists(candidate):
source_pofile = polib.pofile(candidate)
break
if source_pofile is None:
_logger.warning(
"translation_helper: no .po file found for module %r, lang %r",
module_name,
lang,
)
return
# Ensure local directory exists
os.makedirs(local_po_dir, exist_ok=True)
# Build new pofile with updated translation
new_pofile = polib.POFile()
new_pofile.metadata = source_pofile.metadata
module_prefix = f"module: {module_name}"
for old_entry in source_pofile:
msgstr = old_entry.msgstr
if old_entry.msgid == term_to_translate:
msgstr = translation_value
# Odoo 19 translate.py requires "module: {name}" in every entry's comment
comment = old_entry.comment
if comment and "module:" not in comment:
comment = f"module: {module_name}\n{comment}"
elif not comment:
comment = f"module: {module_name}"
new_entry = polib.POEntry(
comment=comment,
occurrences=old_entry.occurrences,
flags=old_entry.flags,
msgid=old_entry.msgid,
msgstr=msgstr,
)
new_pofile.append(new_entry)
# Always save to local path (never to system directories)
new_pofile.save(local_po_file)

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="translation_helper_wizard_form" model="ir.ui.view">
<field name="name">translation.helper.wizard.form</field>
<field name="model">translation.helper.wizard</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<field name="weblate_link" readonly="1" widget="url" options="{'website_path': True}" />
<field name="term_value" readonly="1" />
<field name="translation_value" />
<field name="language_id" readonly="1" />
</group>
</sheet>
<footer>
<button name="save_translation" string="Apply" class="oe_highlight" type="object" data-hotkey="q" />
<button special="cancel" data-hotkey="x" string="Cancel" />
</footer>
</form>
</field>
</record>
</odoo>