diff --git a/.docker_files/main/__manifest__.py b/.docker_files/main/__manifest__.py index 00ca9bc4..1947f237 100644 --- a/.docker_files/main/__manifest__.py +++ b/.docker_files/main/__manifest__.py @@ -13,6 +13,7 @@ "depends": [ "admin_light_base", "base_extended_security", + "base_translate_labels", "lang_fr_activated", "test_http_request" ], diff --git a/Dockerfile b/Dockerfile index d6d46155..a4463fdf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,6 +15,7 @@ USER odoo COPY admin_light_base /mnt/extra-addons/admin_light_base COPY base_extended_security /mnt/extra-addons/base_extended_security +COPY base_translate_labels /mnt/extra-addons/base_translate_labels COPY lang_fr_activated /mnt/extra-addons/lang_fr_activated COPY test_http_request /mnt/extra-addons/test_http_request diff --git a/base_translate_labels/README.rst b/base_translate_labels/README.rst new file mode 100644 index 00000000..03778824 --- /dev/null +++ b/base_translate_labels/README.rst @@ -0,0 +1,47 @@ +=============================== +Canada French Accounting Labels +=============================== + +Context +======= +Odoo's default French terms are based on European terminology. In Quebec and Canada, some important accounting terms are significantly different, which can lead to confusion for local users. + +The issue with these translations is that they appear hundreds of times in Odoo PO files across different modules. Updating and maintaining each of these translations manually would require a major and constant effort. + +Description +=========== +This module sanitizes and automatically adapts the labels of the accounting application for the French Canada (fr_CA) language. + +Here is an overview of the main terminology changes applied by this module: +* Avoir -> Note de crédit +* Balance âgée -> Âge des comptes +* Lettrage -> Conciliation bancaire + +Translations to update can be as specific as the following: "Compte utilisé sur les lignes de taxes des avoirs. Laissez vide pour utiliser le compte de dépenses." + +Usage +===== +To use this module and trigger the translation replacements: + +1. Navigate to **Accounting / Configuration / CA Accounting Terms**. +2. Add, edit, or remove the specific French to Canadian French term mappings. +3. Click the **"Apply translation"** button located in the header of the list view to apply changes across all installed modules. + +Screenshots +=========== + +Menu Items Adjustments +---------------------- +The missing `s` is added to the menu item `Accounting / Vendors / Refund`. + +.. image:: static/description/supplier_refund_menu_item_en.png + :alt: Supplier Refund Menu Item EN + :width: 600 px + +.. image:: static/description/supplier_refund_menu_item_fr.png + :alt: Supplier Refund Menu Item FR + :width: 600 px + +Contributors +============ +* Numigi (tm) and all its contributors (https://bit.ly/numigiens) \ No newline at end of file diff --git a/base_translate_labels/__init__.py b/base_translate_labels/__init__.py new file mode 100644 index 00000000..69c43860 --- /dev/null +++ b/base_translate_labels/__init__.py @@ -0,0 +1,16 @@ +# Copyright 2026 Numigi and all its contributors (https://bit.ly/numigiens) +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + + +from . import models + + +def _get_installed_modules(env): + # Fetch all modules that are currently installed in the database + return env["ir.module.module"].search([("state", "=", "installed")]) + + +def post_init_hook(env): + # Force translation reload immediately after the module installation is complete + installed_modules = _get_installed_modules(env) + installed_modules._update_translations(["fr_FR"], True) diff --git a/base_translate_labels/__manifest__.py b/base_translate_labels/__manifest__.py new file mode 100644 index 00000000..80819aec --- /dev/null +++ b/base_translate_labels/__manifest__.py @@ -0,0 +1,22 @@ +# Copyright 2026-today Numigi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +{ + "name": "Canada French Labels", + "version": "18.0.1.0.0", + "author": "Numigi", + "maintainer": "Numigi", + "website": "https://www.numigi.com", + "license": "LGPL-3", + "category": "Accounting", + "summary": "Sanitize the accounting terms for Canada French", + "depends": ["base", "lang_fr_activated"], + "data": [ + "security/ir.model.access.csv", + # "data/base_translate.csv", + "views/base_translate.xml", + ], + "installable": True, + "auto-install": True, + "post_init_hook": "post_init_hook", +} diff --git a/base_translate_labels/data/base_translate.csv b/base_translate_labels/data/base_translate.csv new file mode 100644 index 00000000..ee3ea393 --- /dev/null +++ b/base_translate_labels/data/base_translate.csv @@ -0,0 +1,3 @@ +id,lang_id,model_id,term,new_term +translate_fr_ca_1,,,poste,job + diff --git a/base_translate_labels/i18n/fr.po b/base_translate_labels/i18n/fr.po new file mode 100644 index 00000000..97b45772 --- /dev/null +++ b/base_translate_labels/i18n/fr.po @@ -0,0 +1,77 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * base_fr_ca_labels +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-07-21 12:17+0000\n" +"PO-Revision-Date: 2026-07-21 12:17+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: base_fr_ca_labels +#: model_terms:ir.ui.view,arch_db:base_fr_ca_labels.view_account_journal_group_tree +msgid "Appliquer les traductions" +msgstr "Appliquer les traductions" + +#. module: base_fr_ca_labels +#: model_terms:ir.ui.view,arch_db:base_fr_ca_labels.view_account_journal_group_tree +msgid "Apply translation" +msgstr "Appliquer la traduction" + +#. module: base_fr_ca_labels +#: model:ir.actions.act_window,name:base_fr_ca_labels.action_translate_new_term +#: model:ir.ui.menu,name:base_fr_ca_labels.menu_translate_new_term +msgid "CA Accouting Terms" +msgstr "Termes comptables CA" + +#. module: base_fr_ca_labels +#: model:ir.model.fields,field_description:base_fr_ca_labels.field_translate_new_term__create_uid +msgid "Created by" +msgstr "Créé par" + +#. module: base_fr_ca_labels +#: model:ir.model.fields,field_description:base_fr_ca_labels.field_translate_new_term__create_date +msgid "Created on" +msgstr "Créé le" + +#. module: base_fr_ca_labels +#: model:ir.model.fields,field_description:base_fr_ca_labels.field_translate_new_term__display_name +msgid "Display Name" +msgstr "Nom affiché" + +#. module: base_fr_ca_labels +#: model:ir.model.fields,field_description:base_fr_ca_labels.field_translate_new_term__id +msgid "ID" +msgstr "ID" + +#. module: base_fr_ca_labels +#: model:ir.model.fields,field_description:base_fr_ca_labels.field_translate_new_term__write_uid +msgid "Last Updated by" +msgstr "Dernière mise à jour par" + +#. module: base_fr_ca_labels +#: model:ir.model.fields,field_description:base_fr_ca_labels.field_translate_new_term__write_date +msgid "Last Updated on" +msgstr "Dernière modification le" + +#. module: base_fr_ca_labels +#: model:ir.model.fields,field_description:base_fr_ca_labels.field_translate_new_term__new_term +msgid "Term CA" +msgstr "Terme CA" + +#. module: base_fr_ca_labels +#: model:ir.model.fields,field_description:base_fr_ca_labels.field_translate_new_term__term +msgid "Term Fr" +msgstr "Terme Fr" + +#. module: base_fr_ca_labels +#: model:ir.model,name:base_fr_ca_labels.model_translate_new_term +msgid "Translation Terms (FR to CA)" +msgstr "Termes de traduction (FR vers CA)" \ No newline at end of file diff --git a/base_translate_labels/models/__init__.py b/base_translate_labels/models/__init__.py new file mode 100644 index 00000000..d92a6a0d --- /dev/null +++ b/base_translate_labels/models/__init__.py @@ -0,0 +1,7 @@ +# Copyright 2026 Numigi and all its contributors (https://bit.ly/numigiens) +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from . import base +from . import base_translate +from . import translate + diff --git a/base_translate_labels/models/base.py b/base_translate_labels/models/base.py new file mode 100644 index 00000000..1ef63d74 --- /dev/null +++ b/base_translate_labels/models/base.py @@ -0,0 +1,59 @@ +# Copyright 2026 Numigi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo import models + + +class BaseTranslationCA(models.AbstractModel): + _inherit = "base" + + def action_translate_to_ca(self): + mapping_dict = self._get_ca_translation_mapping() + translatable_fields = self._get_translatable_fields() + self._apply_ca_translations_to_records(translatable_fields, mapping_dict) + + def _get_ca_translation_mapping(self): + term_model = self.env["base.translate"] + return term_model.get_mapping_for_model(self._name) + + def _get_translatable_fields(self): + return [ + field_name + for field_name, field_def in self._fields.items() + if field_def.translate + ] + + def _apply_ca_translations_to_records(self, fields_list, mapping_dict): + for record in self: + record._translate_record_fields(fields_list, mapping_dict) + + def _translate_record_fields(self, fields_list, mapping_dict): + for field_name in fields_list: + self._translate_single_field(field_name, mapping_dict) + + def _translate_single_field(self, field_name, mapping_dict): + fr_value = self.with_context(lang="fr_FR")[field_name] + if isinstance(fr_value, str): + ca_value = self._replace_terms_in_text(fr_value, mapping_dict) + self._write_ca_value(field_name, ca_value) + + def _replace_terms_in_text(self, text_value, mapping_dict): + sorted_keys = sorted( + mapping_dict.keys(), + key=lambda k: len(str(k)), + reverse=True, + ) + for old_term in sorted_keys: + text_value = self._replace_single_term(text_value, old_term, mapping_dict) + return text_value + + def _replace_single_term(self, text_value, old_term, mapping_dict): + if old_term and str(old_term) in text_value: + text_value = text_value.replace( + str(old_term), + str(mapping_dict[old_term]), + ) + return text_value + + def _write_ca_value(self, field_name, ca_value): + self.with_context(lang="fr_CA").write({field_name: ca_value}) diff --git a/base_translate_labels/models/base_translate.py b/base_translate_labels/models/base_translate.py new file mode 100644 index 00000000..9bf0c642 --- /dev/null +++ b/base_translate_labels/models/base_translate.py @@ -0,0 +1,68 @@ +# Copyright 2026 Numigi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo import fields, models, api + + +class BaseTranslate(models.Model): + _name = "base.translate" + _description = "Database Translation Terms (FR to CA)" + + lang_id = fields.Many2one( + comodel_name="res.lang", + default=lambda self: self._get_default_lang(), + string="Language", + required=True, + ) + model_id = fields.Many2one( + comodel_name="ir.model", + string="Model", + ondelete="cascade", + help="Optional: Leave empty to apply globally (menus, forms, reports).", + ) + term = fields.Char(string="Label", required=True) + new_term = fields.Char(string="New Label", required=True) + + def _get_default_lang(self): + return self.env["res.lang"].search([("code", "=", "fr_FR")], limit=1) + + def action_apply_translations(self): + lang_codes = self._get_active_lang_codes() + installed_modules = self._get_installed_modules() + installed_modules._update_translations(lang_codes, True) + self.env.registry.clear_cache() + return self._build_success_notification() + + def _get_active_lang_codes(self): + codes = list(set(self.search([]).mapped("lang_id.code"))) + if not codes: + codes = ["fr_FR"] + return codes + + def _get_installed_modules(self): + return self.env["ir.module.module"].search([("state", "=", "installed")]) + + def _build_success_notification(self): + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": "Success", + "message": "Translations applied on all the system", + "type": "success", + "sticky": False, + }, + } + + @api.model + def get_mapping_for_model(self, model_name): + domain = [ + "|", + ("model_id", "=", False), + ("model_id.model", "=", model_name), + ] + records = self.search(domain) + return self._format_mapping_dictionary(records) + + def _format_mapping_dictionary(self, records): + return {record.term: record.new_term for record in records} diff --git a/base_translate_labels/models/translate.py b/base_translate_labels/models/translate.py new file mode 100644 index 00000000..e89ac6b1 --- /dev/null +++ b/base_translate_labels/models/translate.py @@ -0,0 +1,191 @@ +# Copyright 2026 Numigi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +import logging +import threading +import psycopg2 + +from odoo import api, SUPERUSER_ID, sql_db +from odoo.tools.translate import TranslationImporter as BaseTranslationImporter +from odoo.tools.translate import CodeTranslations as BaseCodeTranslations +from odoo.tools.misc import ReadonlyDict + +_logger = logging.getLogger(__name__) + +base_load_translation = BaseTranslationImporter._load +base_get_web_translations = BaseCodeTranslations.get_web_translations +base_get_python_translations = BaseCodeTranslations.get_python_translations + + +class TranslationImporter(BaseTranslationImporter): + def _load(self, reader, lang, xmlids=None): + base_load_translation(self, reader, lang, xmlids) + mapping_dict = get_translation_mapping(self.env, lang) + if mapping_dict: + self.model_translations = replace_values(self.model_translations, mapping_dict, lang) + self.model_terms_translations = replace_values(self.model_terms_translations, mapping_dict, lang) + + +class CodeTranslations(BaseCodeTranslations): + def get_python_translations(self, module_name, lang): + if (module_name, lang) not in self.python_translations: + BaseCodeTranslations._load_python_translations(self, module_name, lang) + _process_python_translations(self, module_name, lang) + return self.python_translations[(module_name, lang)] + + def get_web_translations(self, module_name, lang): + if (module_name, lang) not in self.web_translations: + BaseCodeTranslations._load_web_translations(self, module_name, lang) + _process_web_translations(self, module_name, lang) + return self.web_translations[(module_name, lang)] + + +def _process_python_translations(instance, module_name, lang): + try: + _apply_python_translations_safely(instance, module_name, lang) + except Exception as error: + _logger.error("Error in python translations for %s: %s", module_name, error) + + +def _apply_python_translations_safely(instance, module_name, lang): + environment, db_cursor = get_odoo_environment() + if not environment or not db_cursor: + return + mapping_dict = get_translation_mapping(environment, lang) + db_cursor.close() + if mapping_dict: + _update_python_translations(instance, module_name, lang, mapping_dict) + + +def _update_python_translations(instance, module_name, lang, mapping_dict): + orig = instance.python_translations.get((module_name, lang), {}) + new_dict = {src: _apply_mapping(val, mapping_dict) for src, val in orig.items()} + instance.python_translations[(module_name, lang)] = ReadonlyDict(new_dict) + + +def _process_web_translations(instance, module_name, lang): + try: + _apply_web_translations_safely(instance, module_name, lang) + except Exception as error: + _logger.error("Error in web translations for %s: %s", module_name, error) + + +def _apply_web_translations_safely(instance, module_name, lang): + environment, db_cursor = get_odoo_environment() + if not environment or not db_cursor: + return + mapping_dict = get_translation_mapping(environment, lang) + db_cursor.close() + if mapping_dict: + _update_web_translations(instance, module_name, lang, mapping_dict) + + +def _update_web_translations(instance, module_name, lang, mapping_dict): + orig = instance.web_translations.get((module_name, lang), {}) + new_messages = _build_new_web_messages(orig, mapping_dict) + instance.web_translations[(module_name, lang)] = ReadonlyDict({ + "messages": new_messages + }) + + +def _build_new_web_messages(orig, mapping_dict): + new_messages = [] + for msg in orig.get("messages", ()): + new_str = _apply_mapping(msg.get("string", ""), mapping_dict) + new_messages.append(ReadonlyDict({ + "id": msg["id"], + "string": new_str + })) + return tuple(new_messages) + + +def replace_values(data_dict, mapping, target_lang): + for key, value in data_dict.items(): + _process_dict_item(data_dict, key, value, mapping, target_lang) + return data_dict + + +def _process_dict_item(data_dict, key, value, mapping, target_lang): + if isinstance(value, dict): + replace_values(value, mapping, target_lang) + elif _is_target_language_string(key, value, target_lang): + _apply_mapping_to_dict_safely(data_dict, key, value, mapping) + + +def _is_target_language_string(key, value, target_lang): + return key == target_lang and isinstance(value, str) + + +def _apply_mapping_to_dict_safely(data_dict, key, value, mapping): + try: + data_dict[key] = _apply_mapping(value, mapping) + except Exception as error: + _logger.error("Failed mapping for key '%s': %s", key, error) + + +def get_translation_mapping(environment, lang_code): + term_model = "base.translate" + if environment and term_model in environment.registry.models: + return _fetch_mapping_from_db(environment, term_model, lang_code) + return {} + + +def _fetch_mapping_from_db(environment, term_model, lang_code): + mapping_dict = {} + try: + records = environment[term_model].search([("lang_id.code", "=", lang_code)]) + mapping_dict = {record.term: record.new_term for record in records} + except psycopg2.errors.UndefinedTable: + _logger.warning("Table %s not defined yet. Skipping.", term_model) + except Exception as error: + _logger.error("Database error while fetching mapping: %s", error) + return mapping_dict + + +def get_odoo_environment(): + db_name = getattr(threading.current_thread(), "dbname", None) + if not db_name: + db_name = _get_db_name_from_thread_db_attribute() + return _connect_to_database(db_name) + + +def _get_db_name_from_thread_db_attribute(): + try: + db_obj = getattr(threading.current_thread(), "db", None) + if hasattr(db_obj, "dbname"): + return db_obj.dbname + except Exception: + pass + return None + + +def _connect_to_database(db_name): + if not db_name: + return None, None + database = sql_db.db_connect(db_name) + if database is None: + return None, None + db_cursor = database.cursor() + environment = api.Environment(db_cursor, SUPERUSER_ID, {}) + return environment, db_cursor + + +def _apply_mapping(value, mapping): + if not isinstance(value, str): + return value + sorted_keys = sorted(mapping.keys(), key=lambda k: len(str(k)), reverse=True) + for old_term in sorted_keys: + value = _replace_term_in_string(value, old_term, mapping[old_term]) + return value + + +def _replace_term_in_string(value, old_term, new_term): + if old_term and str(old_term) in value: + return value.replace(str(old_term), str(new_term)) + return value + + +# Apply overrides on Odoo core classes +BaseTranslationImporter._load = TranslationImporter._load +BaseCodeTranslations.get_web_translations = CodeTranslations.get_web_translations +BaseCodeTranslations.get_python_translations = CodeTranslations.get_python_translations diff --git a/base_translate_labels/security/ir.model.access.csv b/base_translate_labels/security/ir.model.access.csv new file mode 100644 index 00000000..b77056ee --- /dev/null +++ b/base_translate_labels/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_base_translate,access_base_translate,model_base_translate,base.group_erp_manager,1,1,1,1 + diff --git a/base_translate_labels/static/description/icon.png b/base_translate_labels/static/description/icon.png new file mode 100644 index 00000000..92a86b10 Binary files /dev/null and b/base_translate_labels/static/description/icon.png differ diff --git a/base_translate_labels/static/description/supplier_refund_menu_item_en.png b/base_translate_labels/static/description/supplier_refund_menu_item_en.png new file mode 100644 index 00000000..3d7d3e09 Binary files /dev/null and b/base_translate_labels/static/description/supplier_refund_menu_item_en.png differ diff --git a/base_translate_labels/static/description/supplier_refund_menu_item_fr.png b/base_translate_labels/static/description/supplier_refund_menu_item_fr.png new file mode 100644 index 00000000..d5a64eff Binary files /dev/null and b/base_translate_labels/static/description/supplier_refund_menu_item_fr.png differ diff --git a/base_translate_labels/tests/__init__.py b/base_translate_labels/tests/__init__.py new file mode 100644 index 00000000..84f8c8ce --- /dev/null +++ b/base_translate_labels/tests/__init__.py @@ -0,0 +1,5 @@ +# Copyright 2024 Numigi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + + +from . import test_base_translate diff --git a/base_translate_labels/tests/test_base_translate.py b/base_translate_labels/tests/test_base_translate.py new file mode 100644 index 00000000..3d9c62bd --- /dev/null +++ b/base_translate_labels/tests/test_base_translate.py @@ -0,0 +1,58 @@ +# Copyright 2026 Numigi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo.tests.common import TransactionCase + + +class TestTranslationFrCa(TransactionCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls._setup_test_languages() + cls.term_model = cls.env["base.translate"] + cls.category_model = cls.env["res.partner.category"] + cls._create_test_terms() + + @classmethod + def _setup_test_languages(cls): + cls._activate_language("fr_FR", "French") + cls._activate_language("fr_CA", "French (Canada)") + + @classmethod + def _activate_language(cls, code, name): + lang = cls.env["res.lang"].with_context(active_test=False).search([("code", "=", code)]) + if lang: + lang.write({"active": True}) + else: + cls._create_language(code, name) + + @classmethod + def _create_language(cls, code, name): + cls.env["res.lang"].create({ + "name": name, + "code": code, + "url_code": code.replace("_", "-").lower(), + }) + + @classmethod + def _create_test_terms(cls): + cls.term_model.create({ + "term": "facture", + "new_term": "facture canadienne", + }) + + def test_translation_applied_to_record_field(self): + category = self._create_test_category("Voici votre facture") + category.action_translate_to_ca() + ca_value = category.with_context(lang="fr_CA").name + self.assertEqual(ca_value, "Voici votre facture canadienne") + + def test_global_mapping_retrieval(self): + mapping = self.term_model.get_mapping_for_model("res.partner.category") + self.assertEqual(mapping.get("facture"), "facture canadienne") + + def _create_test_category(self, name_text): + return self.category_model.with_context(lang="fr_FR").create({ + "name": name_text, + }) diff --git a/base_translate_labels/views/base_translate.xml b/base_translate_labels/views/base_translate.xml new file mode 100644 index 00000000..0ce9cfb3 --- /dev/null +++ b/base_translate_labels/views/base_translate.xml @@ -0,0 +1,42 @@ + + + + + base.translate.tree + base.translate + 1 + + + +
+
+ + + + +