Odoo i18n Skill — Complete Internationalization Reference
This skill provides deep expertise in Odoo internationalization (i18n) and localization (l10n). It covers extracting translatable strings, validating .po files, generating translation reports, and handling Arabic/RTL layouts across Odoo versions 14 through 19.
- Odoo Translation Architecture
1.1 File Types
Odoo uses the GNU gettext standard for translations:
File Purpose Location
.pot
Portable Object Template — master template with all source strings module/i18n/module.pot
.po
Portable Object — translated strings for a specific language module/i18n/ar.po
.mo
Machine Object — compiled binary, auto-generated from .po Runtime cache only
1.2 Directory Structure
Every Odoo module that supports translations must have an i18n/ directory:
my_module/ ├── manifest.py ├── i18n/ │ ├── my_module.pot ← Template (all source strings, no translations) │ ├── ar.po ← Arabic translations │ ├── ar_SA.po ← Saudi Arabic variant (optional) │ ├── fr.po ← French translations │ └── tr.po ← Turkish translations ├── models/ ├── views/ └── ...
1.3 .po File Anatomy
Arabic translation of my_module
Copyright (C) 2024 TaqaTechno
This file is distributed under the same license as the my_module package.
Translator: Ahmed Al-Rashidi <ahmed@taqat.com>, 2024
msgid "" msgstr "" "Project-Id-Version: Odoo Server 17.0\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2024-01-15 10:30+0300\n" "PO-Revision-Date: 2024-01-20 14:00+0300\n" "Last-Translator: Ahmed Al-Rashidi <ahmed@taqat.com>\n" "Language-Team: Arabic\n" "Language: ar\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5;\n"
#. module: my_module #: model:ir.model,name:my_module.model_my_record msgid "My Record" msgstr "سجلي"
#. module: my_module #: model_terms:ir.ui.view,arch_db:my_module.view_my_form msgid "Save" msgstr "حفظ"
#. module: my_module #: code:addons/my_module/models/my_model.py:45 #, python-format msgid "Record %s not found" msgstr "السجل %s غير موجود"
1.4 How Odoo Loads Translations
-
On module install/update: Odoo reads .po files from i18n/ directory
-
On language install: Odoo loads all .po files for that language across all installed modules
-
At runtime: translations are cached in memory and applied based on res.lang of the user
-
For website: translations are applied based on the URL language prefix (/ar/ , /en/ ) or session language
1.5 Translation Loading Priority
Odoo applies translations in this priority order (highest to lowest):
-
User-specific overrides (from Translations menu)
-
Module .po file
-
Base Odoo translations
-
Source string (fallback)
- Python Translations
2.1 Basic Import and Usage
from odoo import _, api, fields, models from odoo.exceptions import UserError, ValidationError
class MyModel(models.Model): _name = 'my.model' _description = 'My Model'
def action_validate(self):
for rec in self:
if not rec.name:
# CORRECT: wrap literal string in _()
raise UserError(_('Name is required before validation.'))
2.2 String Interpolation — Critical Rules
CORRECT: Variable substitution OUTSIDE _()
raise UserError(('Record "%s" cannot be deleted.') % record.name) raise UserError(('Found %d records matching criteria.') % count)
CORRECT: Named format specifiers (preferred for clarity)
raise UserError(_('Invoice %(number)s is in state %(state)s.') % { 'number': invoice.name, 'state': invoice.state, })
CORRECT: .format() can work but % is conventional in Odoo
raise UserError(_('Hello {}!').format(partner.name))
WRONG: f-strings break i18n — the string is evaluated before _() sees it
raise UserError(_(f'Record {record.name} not found')) # DO NOT USE
WRONG: String concatenation breaks i18n
raise UserError(_('Record ') + record.name + _(' not found')) # DO NOT USE
WRONG: Building string before _() — translator never sees the full context
msg = 'Record not found' raise UserError(_(msg)) # Technically works but makes extraction hard
2.3 Lazy Translation with _lt()
Use _lt() for module-level strings that are defined at class load time, not at call time:
from odoo.tools.translate import _lt
class MyModel(models.Model): _name = 'my.model'
# CORRECT: Use _lt() for class-level string definitions
state = fields.Selection([
('draft', _lt('Draft')),
('confirmed', _lt('Confirmed')),
('validated', _lt('Validated')),
('cancelled', _lt('Cancelled')),
], string='Status', default='draft')
# CORRECT: Class attribute strings
_description = 'My Model' # This does NOT need _() — it's not end-user facing
# WRONG: Using _() at class level causes issues because no request context exists
# state = fields.Selection([('draft', _('Draft'))], ...) # Avoid this
2.4 Field String Attributes
Field string parameters are automatically translated by Odoo — do NOT wrap them in _() :
class MyModel(models.Model): _name = 'my.model'
# CORRECT: string= is auto-translated by Odoo's i18n system
name = fields.Char(string='Name', required=True)
partner_id = fields.Many2one('res.partner', string='Customer')
amount_total = fields.Float(string='Total Amount', digits=(16, 2))
# The help attribute is also auto-translated
ref = fields.Char(
string='Reference',
help='Internal reference number for tracking purposes.'
)
2.5 Model Name and Description
class MyModel(models.Model): _name = 'my.model' _description = 'My Model' # Auto-translated; do NOT wrap in _()
2.6 Report Translations in Python
def _get_report_values(self, docids, data=None): docs = self.env['my.model'].browse(docids) return { 'doc_ids': docids, 'doc_model': 'my.model', 'docs': docs, 'report_title': _('Monthly Sales Report'), }
2.7 Email Template Translations
In Python-generated emails, wrap strings in _() . For XML-based templates, use <t t-esc> with Odoo's translation system.
def send_notification(self): subject = _('Your order %(ref)s has been confirmed') % {'ref': self.name} body = _('Dear %(name)s, your order is ready.') % {'name': self.partner_id.name} self.message_post(subject=subject, body=body)
- XML / QWeb Translations
3.1 View Field Translations
Field labels defined in views via string attribute are automatically translatable:
<odoo> <record id="view_my_model_form" model="ir.ui.view"> <field name="name">my.model.form</field> <field name="model">my.model</field> <field name="arch" type="xml"> <form string="My Record"> <sheet> <group> <!-- string= attributes in views are auto-translated --> <field name="name" string="Record Name"/> <field name="partner_id" string="Associated Partner"/> </group> <group string="Financial Information"> <field name="amount_total" string="Total"/> </group> </sheet> </form> </field> </record> </odoo>
3.2 Static Text in QWeb Templates
<!-- Website templates --> <template id="page_home" name="Home Page"> <t t-call="website.layout"> <div id="wrap"> <!-- Text nodes in templates ARE translatable --> <h1>Welcome to Our Website</h1> <p>We provide excellent services.</p>
<!-- Attribute translations use special markup -->
<img src="/static/img/hero.jpg" alt="Our Services"/>
<!-- Dynamic content with translation -->
<span t-field="record.name"/>
<!-- Explicit translation escape -->
<span t-esc="'Hello World'"/>
</div>
</t>
</template>
3.3 QWeb Report Translations
<template id="report_my_document"> <t t-call="web.html_container"> <t t-foreach="docs" t-as="doc"> <t t-call="web.external_layout"> <div class="page"> <!-- Text nodes are translatable in reports --> <h2>Sales Order</h2> <table class="table"> <thead> <tr> <th>Product</th> <th>Quantity</th> <th>Unit Price</th> <th>Subtotal</th> </tr> </thead> <tbody> <t t-foreach="doc.order_line" t-as="line"> <tr> <td><t t-esc="line.product_id.name"/></td> <td><t t-esc="line.product_uom_qty"/></td> <td><t t-esc="line.price_unit"/></td> <td><t t-esc="line.price_subtotal"/></td> </tr> </t> </tbody> </table> </div> </t> </t> </t> </template>
3.4 Disabling Translation for Specific Nodes
<!-- Disable translation for a specific block (e.g., code, technical content) --> <t t-translation="off"> <code>SELECT * FROM res_partner WHERE active = true</code> </t>
<!-- Numbers and codes should not be translated --> <span t-translation="off" t-esc="doc.reference"/>
3.5 t-esc vs t-out in Translation Context
<!-- t-esc: escapes HTML, used for plain text values --> <span t-esc="record.name"/>
<!-- t-out: outputs raw HTML, used when value may contain markup --> <div t-out="record.description"/> <!-- Odoo 15+ preferred syntax -->
<!-- In translation context, both work but t-field is preferred for model fields --> <span t-field="record.name"/> <!-- Best: uses field's widget for formatting -->
3.6 Menu Item and Action Translations
<!-- Menu items are auto-translated --> <menuitem id="menu_my_module" name="My Module" sequence="10"/> <menuitem id="menu_my_records" name="Records" parent="menu_my_module" sequence="1"/>
<!-- Action names are auto-translated --> <record id="action_my_records" model="ir.actions.act_window"> <field name="name">My Records</field> <field name="res_model">my.model</field> <field name="view_mode">list,form</field> </record>
3.7 Website Snippet Translations
<!-- Snippet options labels are translatable --> <template id="snippet_options" inherit_id="website.snippet_options"> <xpath expr="." position="inside"> <div data-snippet="s_my_snippet" data-name="My Snippet"> <t t-call="website.snippet_options_color_palette"/> </div> </xpath> </template>
- JavaScript Translations
4.1 Odoo 16+ (New Framework — owl-based)
/** @odoo-module **/
// Import translation function from new location import { _t } from "@web/core/l10n/translation";
// Simple translation const message = _t("Hello World");
// With variables — use template literals carefully, prefer %s pattern const errorMsg = _t("Record %s not found").replace('%s', recordName);
// In component import { Component } from "@odoo/owl"; import { _t } from "@web/core/l10n/translation";
export class MyComponent extends Component { static template = "my_module.MyComponent";
setup() {
this.title = _t("My Component Title");
}
get buttonLabel() {
return _t("Save Changes");
}
}
4.2 Odoo 14/15 (Legacy Framework)
/** @odoo-module **/
// Legacy import path import { _t, _lt } from "web.core";
// Simple translation const message = _t("Hello World");
// Lazy translation for module-level strings const STATUS_LABELS = { draft: _lt("Draft"), confirmed: _lt("Confirmed"), done: _lt("Done"), };
4.3 publicWidget with Translation
/** @odoo-module **/
import publicWidget from "@web/legacy/js/public/public_widget"; import { _t } from "@web/core/l10n/translation";
publicWidget.registry.MyWidget = publicWidget.Widget.extend({ selector: '.my-widget', disabledInEditableMode: false,
events: {
'click .btn-submit': '_onSubmit',
'click .btn-cancel': '_onCancel',
},
start: function () {
// Translation works in publicWidget context
this._super.apply(this, arguments);
if (!this.editableMode) {
this._initWidget();
}
},
_initWidget: function () {
const placeholder = _t("Enter your name here...");
this.$('input.name-field').attr('placeholder', placeholder);
},
_onSubmit: function (ev) {
ev.preventDefault();
const successMsg = _t("Your form has been submitted successfully.");
this._showMessage(successMsg, 'success');
},
_onCancel: function (ev) {
ev.preventDefault();
const confirmMsg = _t("Are you sure you want to cancel?");
if (window.confirm(confirmMsg)) {
window.history.back();
}
},
_showMessage: function (msg, type) {
const $alert = $(`<div class="alert alert-${type}">${msg}</div>`);
this.$el.prepend($alert);
setTimeout(() => $alert.fadeOut(() => $alert.remove()), 3000);
},
});
4.4 JavaScript Translation Extraction
Odoo's i18n extractor finds _t('...') and _lt('...') calls in .js files. Ensure:
-
Strings are literals, not variables
-
No dynamic string construction inside _t()
// CORRECT: Literal strings const msg1 = _t("Save"); const msg2 = _t("Delete record");
// WRONG: Dynamic strings won't be extracted const action = 'Save'; const msg = _t(action); // Extractor can't find this
// WRONG: Template literals don't work with extraction
const name = 'World';
const msg = _t(Hello ${name}); // Don't use this
- Extracting Translations
5.1 Using Odoo CLI
Export translations for a single module (creates/updates .po file)
python odoo-bin -c conf/myproject.conf
-d mydb
--i18n-export
--modules=my_module
--language=ar
--output=/path/to/ar.po
--stop-after-init
Export without language (creates .pot template)
python odoo-bin -c conf/myproject.conf
-d mydb
--i18n-export
--modules=my_module
--output=/path/to/my_module.pot
--stop-after-init
Export all modules for a language
python odoo-bin -c conf/myproject.conf
-d mydb
--i18n-export
--language=ar
--output=/path/to/all_ar.po
--stop-after-init
5.2 Using the Plugin Extractor Script
Extract strings from a module and create .pot/.po files
python odoo-i18n/scripts/i18n_extractor.py
--module /path/to/my_module
--lang ar
Extract with custom output directory
python odoo-i18n/scripts/i18n_extractor.py
--module /path/to/my_module
--lang fr
--output /path/to/output/
5.3 Manual .pot File Generation
The .pot file is the master template. Generate it once, derive .po files from it:
Step 1: Generate .pot
python odoo-bin -c conf/myproject.conf -d mydb
--i18n-export --modules=my_module
--output=my_module/i18n/my_module.pot
--stop-after-init
Step 2: Copy .pot to .po for each language
cp my_module/i18n/my_module.pot my_module/i18n/ar.po cp my_module/i18n/my_module.pot my_module/i18n/fr.po cp my_module/i18n/my_module.pot my_module/i18n/tr.po
Step 3: Edit each .po file and add translations
The msgstr entries in the copied .po files will be empty — fill them in
5.4 What Gets Extracted
Odoo's extraction process scans:
Source What is extracted
*.py
_('...') and _lt('...') string arguments
*.xml
Field string= attributes, text nodes in views, name= in menu items
*.js
_t('...') and _lt('...') string arguments
Model fields string , help , selection values
_description
Model description
Action name
Action display names
- Loading Translations
6.1 Load Language via CLI
Install Arabic language pack into a database
python odoo-bin -c conf/myproject.conf
-d mydb
--load-language=ar
--stop-after-init
Install multiple languages at once
python odoo-bin -c conf/myproject.conf
-d mydb
--load-language=ar,fr,tr
--stop-after-init
6.2 Import Specific .po File via CLI
Import a .po file into the database
python odoo-bin -c conf/myproject.conf
-d mydb
--i18n-import=/path/to/ar.po
--language=ar
--modules=my_module
--stop-after-init
Import and overwrite existing translations
python odoo-bin -c conf/myproject.conf
-d mydb
--i18n-import=/path/to/ar.po
--language=ar
--modules=my_module
--i18n-overwrite
--stop-after-init
6.3 Load via Odoo Shell
Interactive shell: python odoo-bin shell -d mydb
Load a language
self.env['res.lang'].with_context(active_test=False).search( [('code', '=', 'ar')] ).active = True self.env['base.language.install'].create({'lang_ids': [ (6, 0, [self.env['res.lang'].search([('code', '=', 'ar')]).id]) ]}).lang_install() self.env.cr.commit()
Simpler approach for existing language
self.env['res.lang'].load_lang('ar') self.env.cr.commit()
Load translations for a specific module
self.env['ir.translation'].load_module_terms(['my_module'], ['ar']) self.env.cr.commit()
6.4 Load via Database Menu
In Odoo backend: Settings > Translations > Languages > Add a language, then select from list.
- Arabic / RTL Support
7.1 What RTL Means in Odoo Context
Right-to-left (RTL) languages like Arabic, Hebrew, and Farsi require:
-
Text flows from right to left
-
UI elements mirror horizontally
-
Margins/paddings swap sides
-
Icons and arrows reverse direction
-
Navigation flows right-to-left
7.2 Activating RTL in Odoo Website
<!-- In your theme's primary template --> <template id="layout" inherit_id="website.layout"> <xpath expr="//html" position="attributes"> <!-- Odoo sets dir="rtl" automatically when Arabic is active language --> <!-- But you can force it for testing --> </xpath> </template>
Odoo automatically adds dir="rtl" to the <html> element when the active language is RTL (Arabic, Hebrew, Farsi, Urdu). You do NOT need to hardcode this.
7.3 Bootstrap RTL CSS
<!-- In your theme assets, load Bootstrap RTL for RTL languages --> <template id="assets_frontend" inherit_id="web.assets_frontend"> <xpath expr="." position="inside"> <!-- Bootstrap RTL is available in Odoo 16+ --> <!-- It's loaded automatically when dir="rtl" is set --> </xpath> </template>
Odoo 16+ automatically switches to Bootstrap RTL CSS when the language is RTL. In older versions (14/15), you may need to load it manually.
7.4 SCSS RTL Patterns
// In static/src/scss/rtl.scss or within your main stylesheet
// Global RTL overrides using the [dir="rtl"] selector [dir="rtl"] { // Text alignment .text-start { text-align: right !important; } .text-end { text-align: left !important; }
// Navigation
.nav-item { float: right; }
.navbar-nav { padding-right: 0; padding-left: inherit; }
// Breadcrumb separator
.breadcrumb-item + .breadcrumb-item::before {
float: right;
padding-right: 0;
padding-left: var(--bs-breadcrumb-item-padding-x);
content: "\\"; // Reverse separator direction
}
// Icons that indicate direction
.fa-chevron-right::before { content: "\f053"; } // Swap left/right chevrons
.fa-chevron-left::before { content: "\f054"; }
.fa-arrow-right::before { content: "\f060"; }
.fa-arrow-left::before { content: "\f061"; }
// Form elements
.input-group > .form-control:not(:last-child) {
border-radius: 0 var(--bs-border-radius) var(--bs-border-radius) 0;
}
.input-group > .form-control:not(:first-child) {
border-radius: var(--bs-border-radius) 0 0 var(--bs-border-radius);
}
// Dropdown
.dropdown-menu-end {
right: auto;
left: 0;
}
// Cards with horizontal layout
.card-horizontal { flex-direction: row-reverse; }
// Table
th, td {
text-align: right;
}
}
7.5 CSS Logical Properties (Modern Approach)
Instead of physical left /right , use logical properties that automatically adapt to RTL:
// Instead of margin-left, use margin-inline-start .my-element { margin-inline-start: 1rem; // = margin-left in LTR, margin-right in RTL margin-inline-end: 0.5rem; // = margin-right in LTR, margin-left in RTL padding-inline-start: 1.5rem; padding-inline-end: 0.75rem; border-inline-start: 3px solid var(--primary); // Left border in LTR, right in RTL text-align: start; // = left in LTR, right in RTL }
7.6 Flexbox and Grid with RTL
// Flexbox naturally mirrors in RTL without extra CSS .flex-container { display: flex; flex-direction: row; // Items flow right-to-left automatically in RTL gap: 1rem; }
// Only override if you specifically need LTR in RTL context [dir="rtl"] .force-ltr { direction: ltr; unicode-bidi: isolate; }
7.7 RTL Detection in JavaScript
/** @odoo-module **/
import publicWidget from "@web/legacy/js/public/public_widget";
publicWidget.registry.RTLAwareWidget = publicWidget.Widget.extend({ selector: '.my-section',
start: function () {
// Detect RTL from HTML element attribute
this.isRtl = document.documentElement.getAttribute('dir') === 'rtl';
if (this.isRtl) {
this._initRtlLayout();
} else {
this._initLtrLayout();
}
return this._super.apply(this, arguments);
},
_initRtlLayout: function () {
// Apply RTL-specific JavaScript behavior
// e.g., slider direction, animation direction
this.$('.carousel').attr('data-bs-direction', 'right');
},
_initLtrLayout: function () {
this.$('.carousel').attr('data-bs-direction', 'left');
},
});
- RTL Layout Patterns — Detailed
8.1 Navigation Menu
// RTL navigation fixes [dir="rtl"] { .navbar-collapse { // On mobile, menu should still flow correctly text-align: right; }
.navbar-nav .dropdown-menu {
// Dropdown should open to the left in RTL
left: auto;
right: 0;
}
.navbar-nav .nav-link {
// Padding should be on the right for RTL
padding-right: 0.5rem;
padding-left: 0.5rem;
}
}
8.2 Form Fields
[dir="rtl"] { // Label alignment .form-label { text-align: right; display: block; }
// Required asterisk position
.form-label::after {
// Move asterisk to right of label in RTL
}
// Input group buttons (search, etc.)
.input-group .btn:first-child {
border-radius: 0 var(--bs-border-radius) var(--bs-border-radius) 0;
}
.input-group .btn:last-child {
border-radius: var(--bs-border-radius) 0 0 var(--bs-border-radius);
}
// Checkboxes and radios
.form-check {
padding-left: 0;
padding-right: 1.5em;
}
.form-check-input {
float: right;
margin-left: 0;
margin-right: -1.5em;
}
}
8.3 Tables
[dir="rtl"] { // Table text alignment table { text-align: right; }
// Sortable column headers
.o_list_view .o_column_sortable::after {
margin-left: 0;
margin-right: 4px;
}
// Fixed first column (for action checkboxes)
td.o_list_record_selector {
text-align: center;
}
}
8.4 Footer Columns
[dir="rtl"] { // Footer columns should naturally reverse in RTL flex container // but may need explicit ordering for some layouts .footer .row { flex-direction: row-reverse; }
// Or use order property for specific columns
.footer .col-logo { order: 3; }
.footer .col-links { order: 2; }
.footer .col-contact { order: 1; }
}
8.5 Icons and Visual Indicators
[dir="rtl"] { // Font Awesome directional icons .fa-angle-left::before { content: "\f105"; } // Swap: angle-right .fa-angle-right::before { content: "\f104"; } // Swap: angle-left .fa-arrow-left::before { content: "\f061"; } // Swap: arrow-right .fa-arrow-right::before { content: "\f060"; } // Swap: arrow-left .fa-caret-left::before { content: "\f0d7"; } // Swap .fa-caret-right::before { content: "\f0d9"; } // Swap
// Bootstrap icons
.bi-chevron-left::before { content: "\F285"; } // Swap
.bi-chevron-right::before { content: "\F284"; } // Swap
}
- Bilingual Patterns (Arabic + English)
9.1 Side-by-Side Layout
<!-- Side-by-side Arabic/English layout --> <div class="bilingual-section"> <div class="row align-items-center"> <!-- Arabic content (right side) --> <div class="col-md-6 text-ar" dir="rtl" lang="ar"> <h2>مرحباً بكم</h2> <p>نحن نقدم خدمات متميزة في مجال التقنية.</p> </div> <!-- English content (left side) --> <div class="col-md-6 text-en" dir="ltr" lang="en"> <h2>Welcome</h2> <p>We provide excellent technology services.</p> </div> </div> </div>
9.2 Dual-Language Field Display
<!-- In Odoo backend views — show both name and name_ar --> <form string="Product"> <sheet> <group> <group string="English"> <field name="name" string="Name (EN)"/> </group> <group string="Arabic" attrs="{'invisible': []}"> <field name="name_ar" string="Name (AR)" options="{'direction': 'rtl'}"/> </group> </group> </sheet> </form>
9.3 Language Switcher Widget
/** @odoo-module **/
import publicWidget from "@web/legacy/js/public/public_widget";
publicWidget.registry.LanguageSwitcher = publicWidget.Widget.extend({ selector: '.language-switcher',
events: {
'click [data-lang]': '_onLanguageClick',
},
start: function () {
// Highlight current language
const currentLang = document.documentElement.lang || 'en';
this.$('[data-lang="' + currentLang + '"]').addClass('active');
return this._super.apply(this, arguments);
},
_onLanguageClick: function (ev) {
ev.preventDefault();
const lang = $(ev.currentTarget).data('lang');
const currentUrl = window.location.href;
// Let Odoo handle language switching via URL
window.location.href = '/web/set_lang?lang=' + lang +
'&next=' + encodeURIComponent(currentUrl);
},
});
9.4 Arabic Numeral Display
Arabic uses Eastern Arabic numerals (٠١٢٣٤٥٦٧٨٩) vs Western (0123456789):
// Force Western numerals in Arabic context (recommended for mixed content) [dir="rtl"] { // Use font-feature-settings to control numeral style body { font-feature-settings: "lnum" 1; // Use lining numerals }
// For currency/numbers specifically
.o_field_float, .o_field_monetary {
direction: ltr;
unicode-bidi: embed;
text-align: left;
}
}
In Python: format numbers respecting locale
from odoo.tools.misc import formatLang
In template context:
formatted_amount = formatLang(self.env, amount, currency_obj=currency)
- Translation Completeness
10.1 Using the Reporter Script
Check translation completeness for a module
python odoo-i18n/scripts/i18n_reporter.py
--module /path/to/my_module
--lang ar
Example output:
Translation Report: my_module (ar)
=====================================
Total translatable strings: 247
Translated: 198 (80.2%)
Missing: 49 (19.8%)
Fuzzy: 3
Missing translations:
[my_module/models/sale_order.py:45] "Order Confirmation"
[my_module/views/templates.xml:123] "Track Your Order"
...
10.2 Manual Completeness Check
Count total msgid entries in .po file
grep -c '^msgid ' my_module/i18n/ar.po
Count translated entries (non-empty msgstr)
grep -A1 '^msgid ' my_module/i18n/ar.po | grep -v '^msgid|^--$' | grep -cv '^msgstr ""$'
Count fuzzy entries
grep -c '#, fuzzy' my_module/i18n/ar.po
10.3 Odoo Shell — Check Translation Coverage
Interactive shell
translations = self.env['ir.translation'].search([ ('module', '=', 'my_module'), ('lang', '=', 'ar'), ]) total = len(translations) translated = len(translations.filtered(lambda t: t.value)) print(f"Translated: {translated}/{total} ({translated/total*100:.1f}%)")
- Version Differences (Odoo 14–19)
11.1 Odoo 14
-
Uses ir.translation model for all translations
-
_() imports from odoo — same as later versions
-
JavaScript: uses web.core import for _t
-
No native Bootstrap RTL support — manual CSS needed
-
Translation extraction: basic .po file generation
11.2 Odoo 15
-
Same as v14 for i18n core
-
Improved translation export performance
-
Added support for inline translations in website builder
11.3 Odoo 16
-
Major change: Bootstrap 5.1.3 with native RTL support
-
Bootstrap RTL CSS (bootstrap.rtl.min.css ) now included
-
Improved _t() in new Owl framework components
-
Translation model consolidation begins
-
Website language detection improved
11.4 Odoo 17
-
ir.translation → deprecated/replaced by ir.model.fields.selection for selection fields
-
Term-based translations (JSON) for improved performance
-
New _t() import: from "@web/core/l10n/translation" in owl components
-
Website RTL: automatic dir="rtl" on <html> based on active language
-
Better pluralization support
11.5 Odoo 18
-
Further consolidation of translation storage
-
Improved translation export/import with better conflict resolution
-
Enhanced RTL support in kanban/list views
-
Better support for RTL in PDF reports (wkhtmltopdf RTL)
11.6 Odoo 19
-
Modern translation framework with lazy loading
-
WebAssembly-based .mo compilation (performance)
-
Improved Arabic shaping support in reports
-
Native support for mixed-direction content
-
REST API endpoints for translation management
- Common Pitfalls
12.1 String Concatenation
WRONG: Translator sees two separate strings, loses context
msg = _('Record') + ' ' + record.name + ' ' + _('not found')
CORRECT: Give translator the full sentence with placeholder
msg = _('Record "%s" not found') % record.name
12.2 f-Strings Inside _()
WRONG: String is interpolated before _() processes it — translator gets nothing
error = _(f'Invoice {invoice.name} is overdue by {days} days')
CORRECT: Use % formatting
error = _('Invoice %(name)s is overdue by %(days)d days') % { 'name': invoice.name, 'days': days, }
12.3 Pluralization
WRONG: Simple approach that breaks for some languages
if count == 1: msg = _('1 record found') else: msg = _('%d records found') % count
BETTER: Use a single translatable string with ngettext equivalent
Odoo doesn't have a built-in ngettext, but you can handle it:
if count == 1: msg = _('One record found') else: msg = _('%d records found') % count
For Arabic (6 plural forms), you may need language-specific handling
12.4 Context in Translations
Sometimes the same word needs different translations based on context. Odoo supports translation context via the ir.translation model and the _() function's optional comment parameter.
12.5 Missing i18n/ Directory
If the i18n/ directory doesn't exist, Odoo silently skips translation loading. Always create it even if initially empty.
12.6 Wrong File Encoding
.po files MUST be UTF-8. Other encodings cause silent failures:
Check encoding
file -i my_module/i18n/ar.po
Should show: text/plain; charset=utf-8
Convert if needed
iconv -f ISO-8859-1 -t UTF-8 ar.po > ar_utf8.po
12.7 Stale Translations in Cache
After updating .po files, Odoo may serve cached translations. Solutions:
-
Update the module: python -m odoo -c conf.conf -d db -u my_module --stop-after-init
-
Restart the server
-
Clear browser cache (Ctrl+Shift+R)
-
In shell: self.env['ir.translation'].clear_caches()
12.8 Website Builder Overriding Translations
When using the Odoo website builder, inline edits create database-level translations that override .po file translations. To reset:
Shell: Remove website-level translation overrides for a specific language
self.env['ir.translation'].search([ ('lang', '=', 'ar'), ('module', 'like', 'website'), ('src', '=', 'original text'), ]).unlink() self.env.cr.commit()
- Lazy Translation — When to Use _lt() vs _()
When to Use _()
-
Inside functions, methods, and request handlers (runtime context)
-
When request environment (user, language) is available
-
In @api.constrains , @api.onchange , action methods
def action_confirm(self): for rec in self: if not rec.partner_id: raise UserError(_('Partner is required to confirm.'))
When to Use _lt()
-
Class-level definitions (executed at module import time)
-
Selection field values
-
Static error messages defined at class level
-
When no request context is available
from odoo.tools.translate import _lt
class PurchaseOrder(models.Model): _name = 'purchase.order'
# _lt() because this is evaluated at class definition time
state = fields.Selection([
('draft', _lt('Draft RFQ')),
('sent', _lt('RFQ Sent')),
('purchase', _lt('Purchase Order')),
('done', _lt('Locked')),
('cancel', _lt('Cancelled')),
], string='Status', default='draft')
# Exception messages as class constants — use _lt()
_LOCK_ERROR = _lt('Purchase Order is locked and cannot be modified.')
14. Module-Specific Patterns
14.1 Website Module
Website controllers — _() works in request context
from odoo import http, _ from odoo.http import request
class WebsiteController(http.Controller): @http.route('/shop', auth='public', website=True) def shop(self, **kwargs): title = _('Our Products') # Works fine in request context return request.render('my_module.shop_template', { 'title': title, })
<!-- Website templates — text is translatable by default --> <template id="shop_template"> <t t-call="website.layout"> <div id="wrap"> <!-- This text IS extracted and translated --> <h1>Our Products</h1> <p>Browse our complete catalog below.</p>
<!-- Dynamic content from controller -->
<h2 t-esc="title"/>
</div>
</t>
</template>
14.2 Mail Templates
<!-- Email templates use translation system --> <record id="email_template_order" model="mail.template"> <field name="name">Order Confirmation</field> <field name="model_id" ref="model_sale_order"/> <field name="subject">Order ${object.name} Confirmed</field> <!-- Body is translatable via website translation system --> <field name="body_html"><![CDATA[ <div> <p>Dear ${object.partner_id.name},</p> <p>Your order ${object.name} has been confirmed.</p> </div> ]]></field> <field name="lang">${object.partner_id.lang}</field> </record>
Note the lang field — Odoo uses the partner's language to translate the email template, allowing automatic multilingual emails.
14.3 QWeb Report Translations
In report controller
class MyReportController(models.AbstractModel): _name = 'report.my_module.my_report' _description = 'My Report'
@api.model
def _get_report_values(self, docids, data=None):
return {
'docs': self.env['my.model'].browse(docids),
'doc_model': 'my.model',
# These strings will be translated in the report context
'report_labels': {
'title': _('Sales Report'),
'date': _('Date'),
'total': _('Total'),
}
}
14.4 Scheduled Actions (Cron)
@api.model def _cron_send_reminders(self): # In cron jobs, use sudo() and set language context for partner in self.env['res.partner'].search([('active', '=', True)]): lang = partner.lang or 'en_US' # Translate using partner's language with self.env.cr.savepoint(): translated_msg = self.with_context(lang=lang).env[ 'ir.translation' ]._get_source('my_module', 'reminder_message', lang)
- Commands Reference
The plugin provides these slash commands:
Command Description
/odoo-i18n
Main i18n help and overview
/i18n-extract
Extract translatable strings from a module
/i18n-missing
Find missing translations
/i18n-validate
Validate .po file syntax and completeness
/i18n-export
Export translations using Odoo CLI
- Script Usage Reference
i18n_extractor.py
python odoo-i18n/scripts/i18n_extractor.py --module /path/to/module --lang ar python odoo-i18n/scripts/i18n_extractor.py --module /path/to/module --lang fr --output /custom/output/
i18n_validator.py
python odoo-i18n/scripts/i18n_validator.py --po-file /path/to/ar.po python odoo-i18n/scripts/i18n_validator.py --po-file /path/to/ar.po --strict
i18n_reporter.py
python odoo-i18n/scripts/i18n_reporter.py --module /path/to/module --lang ar python odoo-i18n/scripts/i18n_reporter.py --module /path/to/module --lang ar --format json
i18n_converter.py
python odoo-i18n/scripts/i18n_converter.py --action merge --base ar.po --new ar_new.po --output ar_merged.po python odoo-i18n/scripts/i18n_converter.py --action clean --po ar.po python odoo-i18n/scripts/i18n_converter.py --action stats --po ar.po