6 Commits
100 changed files with 10 additions and 3740 deletions
@@ -1,11 +0,0 @@
Odoo Proprietary License v1.0
This software and associated files (the “Software”) may only be used (executed, modified, executed after modifications) if you have purchased a valid license from the authors, typically via Odoo Apps, or if you have received a written agreement from the authors of the Software (see the COPYRIGHT file).
You may develop Odoo modules that use the Software as a library (typically by depending on it, importing it and using its resources), but without copying any source code or material from the Software. You may distribute those modules under the license of your choice, provided that this license is compatible with the terms of the Odoo Proprietary License (For example: LGPL, MIT, or proprietary licenses similar to this one).
It is forbidden to publish, distribute, sublicense, or sell copies of the Software or modified copies of the Software.
The above copyright notice and this permission notice must be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -1,54 +0,0 @@
==================
Otoolkit Auth
==================
This module allows you to configure your Otoolkit account in odoo. You will then be able to use all the Otoolkit apps.
Question and contact
==========
If you have any questions or recommendations about this module, please contact us at contact@otoolkit.app
Installation
============
To install this module:
1. Download and place the module in your Odoo addons directory.
2. Access your Odoo server and navigate to the Apps menu.
3. Enable debug mode, then refresh the list by selecting Update Apps List.
4. Finally, click the Install button to complete the installation.
Upgrade
============
To upgrade this module:
1. Download the updated module and replace the existing one in your Odoo addons directory.
2. Restart your Odoo server.
3. Log in, go to the Apps menu, and click the Upgrade button for the module.
Configuration
=============
To configure the module, follow these steps:
1. Go to the Settings menu of your Odoo instance.
2. Go to the Otoolkit section.
3. Add your API Key from your Otoolkit account.
How to Generate an API Key ?
1. Visit https://otoolkit.app and create an account (if you dont already have one).
2. Once logged in, navigate to the Developers section in your Otoolkit dashboard.
3. Generate a new API Key and copy it.
4. Paste the copied key into the Otoolkit section in your Odoo settings.
Credits
=======
Author & Maintainer
-------------------
This module is maintained by the Otoolkit team.
@@ -1 +0,0 @@
from . import models
@@ -1,27 +0,0 @@
# -*- coding: utf-8 -*-
{
'name': "O'Toolkit",
'summary': "This app is the basic block of the Otoolkit integration. It allows you to connect your Otoolkit account to your Odoo instance.",
'description': """""",
'author': "O'Solutions Company",
'website': "https://osolutions.app/products/o-toolkit",
'category': 'Other',
'version': '19.0.1.0.0',
'depends': [],
"external_dependencies": {"python": ["requests"]},
'data': [
# security
"security/otoolkit_security.xml",
# views
"views/menu_views.xml",
"views/res_config_settings_views.xml",
# data
"data/ir_config_parameter.xml"
],
'images': ['static/description/cover.jpeg'],
'license': 'OPL-1',
'installable': True,
'application': True,
}
@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="otoolkit_api_endpoint" model="ir.config_parameter">
<field name="key">otoolkit.api.endpoint</field>
<field name="value">https://api.otoolkit.app</field>
</record>
</data>
</odoo>
@@ -1,54 +0,0 @@
==================
Otoolkit Auth
==================
This module allows you to configure your Otoolkit account in odoo. You will then be able to use all the Otoolkit apps.
Question and contact
==========
If you have any questions or recommendations about this module, please contact us at contact@otoolkit.app
Installation
============
To install this module:
1. Download and place the module in your Odoo addons directory.
2. Access your Odoo server and navigate to the Apps menu.
3. Enable debug mode, then refresh the list by selecting Update Apps List.
4. Finally, click the Install button to complete the installation.
Upgrade
============
To upgrade this module:
1. Download the updated module and replace the existing one in your Odoo addons directory.
2. Restart your Odoo server.
3. Log in, go to the Apps menu, and click the Upgrade button for the module.
Configuration
=============
To configure the module, follow these steps:
1. Go to the Settings menu of your Odoo instance.
2. Go to the Otoolkit section.
3. Add your API Key from your Otoolkit account.
How to Generate an API Key ?
1. Visit https://otoolkit.app and create an account (if you dont already have one).
2. Once logged in, navigate to the Developers section in your Otoolkit dashboard.
3. Generate a new API Key and copy it.
4. Paste the copied key into the Otoolkit section in your Odoo settings.
Credits
=======
Author & Maintainer
-------------------
This module is maintained by the Otoolkit team.
@@ -1,109 +0,0 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * otoolkit_auth
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 17.0+e\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-01-16 10:59+0000\n"
"PO-Revision-Date: 2025-01-16 10:59+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: otoolkit_auth
#: model_terms:ir.ui.view,arch_db:otoolkit_auth.res_config_settings_view_form
msgid ""
"<i class=\"o_button_icon oi oi-fw oi-arrow-right me-1\" aria-hidden=\"true\"/>\n"
" <span class=\"pl-2\">Open Website</span>"
msgstr ""
"<i class=\"o_button_icon oi oi-fw oi-arrow-right me-1\" aria-hidden=\"true\"/>\n"
"<span class=\"pl-2\">Aller sur le site</span>"
#. module: otoolkit_auth
#: model_terms:ir.ui.view,arch_db:otoolkit_auth.res_config_settings_view_form
msgid "API Authentication"
msgstr "Authentification"
#. module: otoolkit_auth
#: model_terms:ir.ui.view,arch_db:otoolkit_auth.res_config_settings_view_form
msgid "API Key"
msgstr "Cléf API"
#. module: otoolkit_auth
#: model_terms:ir.ui.view,arch_db:otoolkit_auth.res_config_settings_view_form
msgid "Available balance on the account"
msgstr "Solde disponible sur votre compte"
#. module: otoolkit_auth
#: model:ir.model,name:otoolkit_auth.model_res_config_settings
msgid "Config Settings"
msgstr "Paramètres de configuration"
#. module: otoolkit_auth
#: model_terms:ir.ui.view,arch_db:otoolkit_auth.res_config_settings_view_form
msgid "Enter an API key of your Otoolkit account here"
msgstr "Entrez une clé API de votre compte Otoolkit ici"
#. module: otoolkit_auth
#: model_terms:ir.ui.view,arch_db:otoolkit_auth.res_config_settings_view_form
msgid "Otoolkit"
msgstr ""
#. module: otoolkit_auth
#: model:ir.model.fields,field_description:otoolkit_auth.field_res_config_settings__otoolkit_api_key
msgid "Otoolkit API key"
msgstr "Clés API Otoolkit"
#. module: otoolkit_auth
#: model_terms:ir.ui.view,arch_db:otoolkit_auth.res_config_settings_view_form
msgid "Otoolkit Account"
msgstr "Compte Otoolkit"
#. module: otoolkit_auth
#: model_terms:ir.ui.view,arch_db:otoolkit_auth.res_config_settings_view_form
msgid "Otoolkit Tokens"
msgstr "Crédits Otookit"
#. module: otoolkit_auth
#: model_terms:ir.ui.view,arch_db:otoolkit_auth.res_config_settings_view_form
msgid "Refresh token count"
msgstr "Rafraîchir le nombre de crédits"
#. module: otoolkit_auth
#: model_terms:ir.ui.view,arch_db:otoolkit_auth.res_config_settings_view_form
msgid "The API key is invalid. Please update your settings."
msgstr "La clé API n'est pas valide. Veuillez mettre à jour vos paramètres."
#. module: otoolkit_auth
#: model:ir.model.fields,help:otoolkit_auth.field_res_config_settings__otoolkit_api_key
msgid "This API key allow you to communicate with the otoolkit api."
msgstr "Cette clé API vous permet de communiquer avec l'API otoolkit."
#. module: otoolkit_auth
#: model_terms:ir.ui.view,arch_db:otoolkit_auth.res_config_settings_view_form
msgid ""
"To generate an API key, you need to create an Otoolkit account. To do this, "
"follow the link below."
msgstr ""
"Pour générer une clé API, vous devez créer un compte Otoolkit. Pour ce "
"faire, suivez le lien ci-dessous."
#. module: otoolkit_auth
#: model_terms:ir.ui.view,arch_db:otoolkit_auth.res_config_settings_view_form
msgid "To use otoolkit services, you need a valid api key."
msgstr "Pour utiliser les services otoolkit, vous avez besoin d'une clé api valide."
#. module: otoolkit_auth
#: model:ir.model.fields,field_description:otoolkit_auth.field_res_config_settings__otoolkit_api_token_count
msgid "Token Count"
msgstr "Nombre de crédits"
#. module: otoolkit_auth
#: model_terms:ir.ui.view,arch_db:otoolkit_auth.res_config_settings_view_form
msgid "tokens"
msgstr "crédits"
@@ -1,2 +0,0 @@
from . import res_config_settings
from . import schema_builder
@@ -1,79 +0,0 @@
import logging
import requests
from odoo import models, fields, api
_logger = logging.getLogger(__name__)
# Sentinel values for token count status
_NO_KEY = -1
_ERROR = -2
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
otoolkit_api_key = fields.Char(
string="O'Toolkit API Key",
config_parameter='otoolkit_api_key',
help="API key from your O'Solutions portal (portal.osolutions.app).",
)
otoolkit_api_token_count = fields.Integer(
string='Token Balance',
compute='_compute_token_count',
config_parameter='otoolkit_api_token_count',
readonly=True,
default=_NO_KEY,
)
otoolkit_key_status = fields.Selection(
[('valid', 'Valid'), ('invalid', 'Invalid'), ('error', 'Connection Error'), ('none', 'No Key')],
string='Key Status',
compute='_compute_token_count',
)
def _get_token_balance(self, api_key):
"""Fetch token balance from O'Toolkit API (which validates via FastAPI)."""
api_endpoint = self.env['ir.config_parameter'].sudo().get_param(
'otoolkit.api.endpoint', 'https://api.otoolkit.app'
)
if not api_key:
return _NO_KEY, 'none'
headers = {
'Odoo-Api-Key': api_key,
'Content-Type': 'application/json',
}
try:
response = requests.get(
f"{api_endpoint.rstrip('/')}/api/get-token-count/",
headers=headers,
timeout=10,
)
except requests.exceptions.RequestException as e:
_logger.warning("O'Toolkit API connection error: %s", e)
return _ERROR, 'error'
if response.status_code == 200:
data = response.json()
return data.get('token_count', 0), 'valid'
if response.status_code == 401:
return 0, 'invalid'
_logger.warning("O'Toolkit API returned %s: %s", response.status_code, response.text[:200])
return _ERROR, 'error'
@api.depends('otoolkit_api_key')
def _compute_token_count(self):
for record in self:
api_key = record.otoolkit_api_key
count, status = record._get_token_balance(api_key)
record.otoolkit_api_token_count = count
record.otoolkit_key_status = status
self.env['ir.config_parameter'].sudo().set_param(
'otoolkit_api_token_count', str(count)
)
def refresh_token_count(self):
self._compute_token_count()
@@ -1,242 +0,0 @@
import logging
from odoo import models, api
_logger = logging.getLogger(__name__)
# Default business models, grouped by category.
# Only included if the corresponding module is installed.
_DEFAULT_BUSINESS_MODELS = {
'sale': ['sale.order', 'sale.order.line'],
'account': ['account.move', 'account.move.line', 'account.payment'],
'product': ['product.template', 'product.product', 'product.category'],
'base': ['res.partner'],
'stock': ['stock.picking', 'stock.move', 'stock.quant'],
'purchase': ['purchase.order', 'purchase.order.line'],
'crm': ['crm.lead'],
'project': ['project.project', 'project.task'],
'helpdesk': ['helpdesk.ticket'],
'hr': ['hr.employee', 'hr.leave'],
}
# Fields to always skip (technical/internal)
_SKIP_FIELDS = {
'__last_update', 'write_date', 'write_uid', 'create_date', 'create_uid',
'display_name', 'message_ids', 'message_follower_ids', 'message_partner_ids',
'message_channel_ids', 'message_attachment_count', 'message_has_error',
'message_has_error_counter', 'message_has_sms_error', 'message_is_follower',
'message_main_attachment_id', 'message_needaction', 'message_needaction_counter',
'message_unread', 'message_unread_counter', 'website_message_ids',
'activity_ids', 'activity_state', 'activity_user_id', 'activity_type_id',
'activity_date_deadline', 'activity_summary', 'activity_exception_decoration',
'activity_exception_icon', 'activity_type_icon', 'activity_calendar_event_id',
'rating_ids', 'rating_last_value', 'rating_last_feedback', 'rating_last_image',
'rating_count', 'rating_avg', 'rating_percentage_satisfaction',
'access_url', 'access_token', 'access_warning',
}
# Type abbreviations for compact format
_TYPE_MAP = {
'char': 'char',
'text': 'text',
'html': 'html',
'integer': 'int',
'float': 'float',
'monetary': 'mon',
'boolean': 'bool',
'date': 'date',
'datetime': 'dt',
'binary': 'bin',
'selection': 'sel',
'many2one': 'm2o',
'one2many': 'o2m',
'many2many': 'm2m',
'reference': 'ref',
}
class SchemaBuilder(models.AbstractModel):
_name = 'otk.schema.builder'
_description = "O'Toolkit Schema Builder"
@api.model
def get_business_models(self):
"""Return list of business-relevant model names.
Only includes models whose parent module is installed.
Can be overridden via ir.config_parameter 'otoolkit.business_models'.
"""
ICP = self.env['ir.config_parameter'].sudo()
custom = ICP.get_param('otoolkit.business_models', '')
if custom:
return [m.strip() for m in custom.split(',') if m.strip()]
result = []
IrModule = self.env['ir.module.module'].sudo()
for module_name, model_names in _DEFAULT_BUSINESS_MODELS.items():
if module_name == 'base':
result.extend(model_names)
continue
installed = IrModule.search([
('name', '=', module_name),
('state', '=', 'installed'),
], limit=1)
if installed:
# Verify model actually exists in registry
for model_name in model_names:
if model_name in self.env:
result.append(model_name)
return result
@api.model
def check_model_access(self, model_name):
"""Check if current user has read access to the given model."""
try:
self.env[model_name].check_access('read')
return True
except Exception:
return False
@api.model
def build_model_schema(self, model_name, depth=1):
"""Build a schema dict for a model with field metadata.
Args:
model_name: Technical model name (e.g. 'sale.order')
depth: How deep to follow relational fields (0 = no relations)
Returns:
dict with model info, fields list, and nested relation schemas
"""
if model_name not in self.env:
return None
Model = self.env[model_name]
model_desc = Model._description or model_name
fields_info = Model.fields_get(attributes=[
'string', 'type', 'relation', 'selection', 'required',
'readonly', 'store',
])
schema_fields = []
related_schemas = {}
for fname, finfo in sorted(fields_info.items()):
if fname.startswith('_') or fname in _SKIP_FIELDS:
continue
if not finfo.get('store', True):
continue
field_data = {
'name': fname,
'type': finfo['type'],
'label': finfo.get('string', fname),
}
if finfo['type'] == 'selection' and finfo.get('selection'):
field_data['selection'] = finfo['selection']
if finfo['type'] in ('many2one', 'one2many', 'many2many') and finfo.get('relation'):
field_data['relation'] = finfo['relation']
if depth > 0 and finfo['relation'] not in related_schemas:
related_schemas[finfo['relation']] = self.build_model_schema(
finfo['relation'], depth=depth - 1
)
if finfo.get('required'):
field_data['required'] = True
schema_fields.append(field_data)
return {
'model': model_name,
'description': model_desc,
'fields': schema_fields,
'related': related_schemas,
}
@api.model
def compress_schema(self, schema):
"""Compress a schema dict into compact text format for API.
Saves ~75% tokens vs JSON.
Format:
=== sale.order (Sales Order) ===
id:int name:char state:sel[draft,sent,sale] date_order:dt
partner_id->res.partner amount_total:mon
"""
if not schema:
return ''
lines = []
lines.append(f"=== {schema['model']} ({schema['description']}) ===")
field_parts = []
for f in schema['fields']:
ftype = _TYPE_MAP.get(f['type'], f['type'])
if f['type'] == 'selection' and f.get('selection'):
# Include labels for AI to understand meaning (key=Label)
entries = [f"{s[0]}={s[1]}" for s in f['selection']]
part = f"{f['name']}:sel[{','.join(entries)}]"
elif f['type'] in ('many2one', 'one2many', 'many2many') and f.get('relation'):
arrow = '->' if f['type'] == 'many2one' else ':o2m->' if f['type'] == 'one2many' else ':m2m->'
if f['type'] == 'many2one':
part = f"{f['name']}->{f['relation']}"
else:
part = f"{f['name']}:{ftype}->{f['relation']}"
else:
part = f"{f['name']}:{ftype}"
field_parts.append(part)
# Group field parts into lines of ~120 chars
current_line = []
current_len = 0
for part in field_parts:
if current_len + len(part) + 1 > 120 and current_line:
lines.append(' '.join(current_line))
current_line = []
current_len = 0
current_line.append(part)
current_len += len(part) + 1
if current_line:
lines.append(' '.join(current_line))
# Add related model schemas (depth-1 only, no nested relations)
for rel_name, rel_schema in (schema.get('related') or {}).items():
if rel_schema:
lines.append('')
lines.append(self.compress_schema(rel_schema))
return '\n'.join(lines)
@api.model
def build_context_for_models(self, model_names=None, depth=1):
"""Build compressed schema context for a list of models.
Args:
model_names: List of model names. If None, uses get_business_models().
depth: Relation traversal depth.
Returns:
Compressed schema string ready to send to API.
"""
if model_names is None:
model_names = self.get_business_models()
parts = []
seen = set()
for model_name in model_names:
if model_name in seen:
continue
seen.add(model_name)
if not self.check_model_access(model_name):
continue
schema = self.build_model_schema(model_name, depth=depth)
if schema:
parts.append(self.compress_schema(schema))
return '\n\n'.join(parts)
@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="module_category_otoolkit" model="ir.module.category">
<field name="name">O'Toolkit</field>
<field name="sequence">90</field>
</record>
</odoo>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 616 KiB

@@ -1,13 +0,0 @@
<hr style="border: none; height: 1px; background: linear-gradient(to right, transparent, #ccc, transparent); margin: 20px auto;"/>
<section class="oe_container">
<div class="col-md-12 text-center" style="margin-top: 10px;">
<a target="_blank" href="https://otoolkit.app">
<h2>Otoolkit</h2>
</a>
<p>
<a href="https://otoolkit.app" target="_blank">Website: otoolkit.app</a><br>
Email: <a href="mailto:contact@otoolkit.app">contact@otoolkit.app</a>
</p>
</div>
</section>
@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<menuitem id="menu_otoolkit_root"
name="O'Toolkit"
web_icon="otoolkit_auth,static/description/icon.png"
sequence="90"/>
</odoo>
@@ -1,55 +0,0 @@
<odoo>
<record id="res_config_settings_view_form" model="ir.ui.view">
<field name="name">res.config.settings.view.form.inherit.otoolkit_api</field>
<field name="model">res.config.settings</field>
<field name="priority" eval="40"/>
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
<field name="arch" type="xml">
<xpath expr="//form" position="inside">
<app data-string="O'Toolkit" id="otoolkit_auth" string="O'Toolkit" name="otoolkit_auth">
<block title="API Authentication" id="api_authentication">
<setting id="otoolkit_api_key_settings" string="API Key"
help="Enter an O'Token key from your O'Solutions portal.">
<field name="otoolkit_api_key" class="o_light_label mt8"
placeholder="oso_k_..."/>
</setting>
<setting id="otoolkit_account_settings" string="O'Solutions Portal"
help="Manage your API keys, token balance and usage from the O'Solutions portal.">
<a href="https://portal.osolutions.app/dashboard/api-keys" target="_blank"
class="btn-link" role="button">
<i class="o_button_icon oi oi-fw oi-arrow-right me-1" aria-hidden="true"/>
<span>Open Portal</span>
</a>
</setting>
<setting id="otoolkit_tokens_settings" string="Token Balance"
help="Available O'Token balance on your account.">
<field name="otoolkit_key_status" invisible="1"/>
<div invisible="otoolkit_key_status != 'invalid'"
class="alert alert-danger mb-0" role="alert">
This API key is not valid. Please check your key in the
<a href="https://portal.osolutions.app/dashboard/api-keys"
target="_blank">O'Solutions Portal</a>.
</div>
<div invisible="otoolkit_key_status != 'error'"
class="alert alert-warning mb-0" role="alert">
Could not connect to the O'Toolkit API. Please try again later.
</div>
<div invisible="otoolkit_key_status != 'none'"
class="alert alert-info mb-0" role="alert">
Enter an API key to start using O'Toolkit services.
<a href="https://portal.osolutions.app/dashboard/api-keys"
target="_blank">Get your key</a>
</div>
<div invisible="otoolkit_key_status != 'valid'">
<span class="fw-bold fs-5"><field name="otoolkit_api_token_count" class="d-inline"/> tokens</span>
<button string="Refresh" type="object" name="refresh_token_count"
class="btn-link ms-2"/>
</div>
</setting>
</block>
</app>
</xpath>
</field>
</record>
</odoo>
@@ -1,11 +0,0 @@
Odoo Proprietary License v1.0
This software and associated files (the “Software”) may only be used (executed, modified, executed after modifications) if you have purchased a valid license from the authors, typically via Odoo Apps, or if you have received a written agreement from the authors of the Software (see the COPYRIGHT file).
You may develop Odoo modules that use the Software as a library (typically by depending on it, importing it and using its resources), but without copying any source code or material from the Software. You may distribute those modules under the license of your choice, provided that this license is compatible with the terms of the Odoo Proprietary License (For example: LGPL, MIT, or proprietary licenses similar to this one).
It is forbidden to publish, distribute, sublicense, or sell copies of the Software or modified copies of the Software.
The above copyright notice and this permission notice must be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -1,44 +0,0 @@
==================
Auto Translate Fields
==================
This module allows you to automatically translate all the fields that can be translated in Odoo.
It adds a button to the translation popup that will translate the content from the language activated for your user into the other languages of the instance.
Question and contact
==========
If you have any questions or recommendations about this module, please contact us at contact@otoolkit.app
Installation
============
To install this module:
1. Download and place the module in your Odoo addons directory.
2. Access your Odoo server and navigate to the Apps menu.
3. Enable debug mode, then refresh the list by selecting Update Apps List.
4. Finally, click the Install button to complete the installation.
Upgrade
============
To upgrade this module:
1. Download the updated module and replace the existing one in your Odoo addons directory.
2. Restart your Odoo server.
3. Log in, go to the Apps menu, and click the Upgrade button for the module.
Configuration
=============
This module operates directly, with no configuration required.
Credits
=======
Author & Maintainer
-------------------
This module is maintained by the Otoolkit team.
@@ -1,2 +0,0 @@
from . import models
from . import wizards
@@ -1,33 +0,0 @@
# -*- coding: utf-8 -*-
{
'name': "Auto Translate Fields",
'summary': "Seamlessly translate any translatable fields in Odoo across all available languages with a single click.",
'description': """An O'Toolkit app that allow you to seamlessly translate any translatable fields in Odoo across all available languages with a single click.""",
'author': "O'Solutions Company",
'website': "https://osolutions.app/products/o-toolkit",
'category': 'Tools',
'version': '19.0.3.0.0',
'depends': ['web', 'otoolkit_auth'],
'data': [
"security/ir.model.access.csv",
"views/translation_views.xml",
"views/translation_config_views.xml",
"wizards/bulk_translate.xml",
"data/ir_cron.xml"
],
"external_dependencies": {"python": ["requests"]},
'assets': {
'web.assets_backend': [
'otoolkit_auto_translate_fields/static/src/js/translation_dialog_auto_translate.js',
'otoolkit_auto_translate_fields/static/src/xml/translation_dialog_auto_translate.xml',
'otoolkit_auto_translate_fields/static/src/js/json_pretty_field.js'
]
},
'images': ['static/description/cover.gif'],
'license': 'OPL-1',
'installable': True,
'application': True
}
@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="otk_cron_process_bulk_translation" model="ir.cron">
<field name="name">Process Bulk Translation</field>
<field name="interval_number">5</field>
<field name="interval_type">minutes</field>
<field name="state">code</field>
<field name="active" eval="True" />
<field name="model_id" ref="otoolkit_auto_translate_fields.model_otk_translation_action"/>
<field name="code">model.cron_translate_action()</field>
</record>
</odoo>
@@ -1,44 +0,0 @@
==================
Auto Translate Fields
==================
This module allows you to automatically translate all the fields that can be translated in Odoo.
It adds a button to the translation popup that will translate the content from the language activated for your user into the other languages of the instance.
Question and contact
==========
If you have any questions or recommendations about this module, please contact us at contact@otoolkit.app
Installation
============
To install this module:
1. Download and place the module in your Odoo addons directory.
2. Access your Odoo server and navigate to the Apps menu.
3. Enable debug mode, then refresh the list by selecting Update Apps List.
4. Finally, click the Install button to complete the installation.
Upgrade
============
To upgrade this module:
1. Download the updated module and replace the existing one in your Odoo addons directory.
2. Restart your Odoo server.
3. Log in, go to the Apps menu, and click the Upgrade button for the module.
Configuration
=============
This module operates directly, with no configuration required.
Credits
=======
Author & Maintainer
-------------------
This module is maintained by the Otoolkit team.
@@ -1,833 +0,0 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * otoolkit_auto_translate_fields
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 19.0+e\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-06-10 18:10+0000\n"
"PO-Revision-Date: 2026-06-10 18:10+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: otoolkit_auto_translate_fields
#. odoo-python
#: code:addons/otoolkit_auto_translate_fields/models/translation_config.py:0
#, python-format
msgid "%d models have been configured for bulk translation."
msgstr "%d modèles ont été configurés pour la traduction en masse."
#. module: otoolkit_auto_translate_fields
#. odoo-python
#: code:addons/otoolkit_auto_translate_fields/wizards/bulk_translate.py:0
#, python-format
msgid ""
"%d records queued for translation.\n"
"%d records skipped (no translatable content in selected fields)."
msgstr ""
"%d enregistrements mis en file d'attente pour traduction.\n"
"%d enregistrements ignorés (aucun contenu traduisible dans les champs "
"sélectionnés)."
#. module: otoolkit_auto_translate_fields
#: model_terms:ir.ui.view,arch_db:otoolkit_auto_translate_fields.view_otoolkit_translation_action_form
msgid "<i class=\"fa fa-exclamation-triangle\" title=\"Skipped records\"/>"
msgstr "<i class=\"fa fa-exclamation-triangle\" title=\"Enregistrements ignorés\"/>"
#. module: otoolkit_auto_translate_fields
#: model_terms:ir.ui.view,arch_db:otoolkit_auto_translate_fields.view_otoolkit_translation_action_form
msgid "<i class=\"fa fa-spinner\" title=\"Translating\"/>"
msgstr "<i class=\"fa fa-spinner\" title=\"Traduction en cours\"/>"
#. module: otoolkit_auto_translate_fields
#: model_terms:ir.ui.view,arch_db:otoolkit_auto_translate_fields.view_otk_translation_config_form
msgid "<span>Translatable Fields</span>"
msgstr "<span>Champs traduisibles</span>"
#. module: otoolkit_auto_translate_fields
#: model_terms:ir.ui.view,arch_db:otoolkit_auto_translate_fields.view_otk_translation_config_form
msgid ""
"<strong>Warning:</strong> This model has no translatable fields. Bulk "
"translation will not be useful."
msgstr ""
"<strong>Attention :</strong> ce modèle n'a aucun champ traduisible. La "
"traduction en masse ne sera d'aucune utilité."
#. module: otoolkit_auto_translate_fields
#. odoo-python
#: code:addons/otoolkit_auto_translate_fields/models/translation_config.py:0
#, python-format
msgid "A configuration already exists for this model."
msgstr "Une configuration existe déjà pour ce modèle."
#. module: otoolkit_auto_translate_fields
#. odoo-python
#: code:addons/otoolkit_auto_translate_fields/models/translation.py:0
#, python-format
msgid "Access denied to the translation service"
msgstr "Accès refusé au service de traduction"
#. module: otoolkit_auto_translate_fields
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_config__active
#: model_terms:ir.ui.view,arch_db:otoolkit_auto_translate_fields.view_otk_translation_config_search
msgid "Active"
msgstr "Actif"
#. module: otoolkit_auto_translate_fields
#: model_terms:ir.actions.act_window,help:otoolkit_auto_translate_fields.action_otk_translation_config
msgid ""
"Add models here to enable the \"Bulk translate\" action on their list views.\n"
" No code changes required - just select a model and it will automatically\n"
" get the bulk translation feature."
msgstr ""
"Ajoutez ici des modèles pour activer l'action « Traduire en masse » sur leurs vues liste.\n"
" Aucune modification de code requise : sélectionnez simplement un modèle et il\n"
" bénéficiera automatiquement de la traduction en masse."
#. module: otoolkit_auto_translate_fields
#. odoo-javascript
#: code:addons/otoolkit_auto_translate_fields/static/src/js/translation_dialog_auto_translate.js:0
#, python-format
msgid "All translations are already filled."
msgstr "Toutes les traductions sont déjà remplies."
#. module: otoolkit_auto_translate_fields
#. odoo-python
#: code:addons/otoolkit_auto_translate_fields/models/translation.py:0
#, python-format
msgid ""
"An error has occurred (HTTP %d). You can restart the translation from the "
"parent action."
msgstr ""
"Une erreur s'est produite (HTTP %d). Vous pouvez relancer la traduction "
"depuis l'action parente."
#. module: otoolkit_auto_translate_fields
#. odoo-python
#: code:addons/otoolkit_auto_translate_fields/models/translation_dialog.py:0
#, python-format
msgid "An error occurred while contacting the translation service: %s"
msgstr ""
"Une erreur s'est produite lors du contact avec le service de traduction : %s"
#. module: otoolkit_auto_translate_fields
#: model:ir.model.fields.selection,name:otoolkit_auto_translate_fields.selection__otk_translation__status__api_key_error
msgid "Api Key Error"
msgstr "Erreur de clé API"
#. module: otoolkit_auto_translate_fields
#: model_terms:ir.ui.view,arch_db:otoolkit_auto_translate_fields.view_otk_translation_config_form
#: model_terms:ir.ui.view,arch_db:otoolkit_auto_translate_fields.view_otk_translation_config_search
msgid "Archived"
msgstr "Archivé"
#. module: otoolkit_auto_translate_fields
#. odoo-javascript
#: code:addons/otoolkit_auto_translate_fields/static/src/xml/translation_dialog_auto_translate.xml:0
#, python-format
msgid "Auto Translate"
msgstr "Tout traduire"
#. module: otoolkit_auto_translate_fields
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_bulk_translate__base_language_id
msgid "Base language"
msgstr "Langue de base"
#. module: otoolkit_auto_translate_fields
#. odoo-python
#: code:addons/otoolkit_auto_translate_fields/models/translation_config.py:0
#, python-format
msgid "Bulk Translation Enabled"
msgstr "Traduction en masse activée"
#. module: otoolkit_auto_translate_fields
#. odoo-python
#: code:addons/otoolkit_auto_translate_fields/models/translation_config.py:0
#: code:addons/otoolkit_auto_translate_fields/models/translation_config.py:0
#: code:addons/otoolkit_auto_translate_fields/models/translation_mixin.py:0
#: code:addons/otoolkit_auto_translate_fields/models/translation_mixin.py:0
#: model_terms:ir.ui.view,arch_db:otoolkit_auto_translate_fields.view_bulk_translate_wizard_form
#, python-format
msgid "Bulk translate"
msgstr "Traduire en masse"
#. module: otoolkit_auto_translate_fields
#: model:ir.model,name:otoolkit_auto_translate_fields.model_otk_translation_bulk_translate
msgid "Bulk translate Wizard"
msgstr "Assistant de traduction en masse"
#. module: otoolkit_auto_translate_fields
#: model:ir.ui.menu,name:otoolkit_auto_translate_fields.otoolkit_translation_actions_menu
msgid "Bulk translations"
msgstr "Traduction en masse"
#. module: otoolkit_auto_translate_fields
#: model_terms:ir.ui.view,arch_db:otoolkit_auto_translate_fields.view_bulk_translate_wizard_form
msgid "Cancel"
msgstr "Annuler"
#. module: otoolkit_auto_translate_fields
#: model_terms:ir.ui.view,arch_db:otoolkit_auto_translate_fields.view_otoolkit_translation_action_form
msgid "Configuration"
msgstr "Configuration"
#. module: otoolkit_auto_translate_fields
#: model_terms:ir.actions.act_window,help:otoolkit_auto_translate_fields.action_otk_translation_config
msgid "Configure models for bulk translation"
msgstr "Configurez les modèles pour la traduction en masse"
#. module: otoolkit_auto_translate_fields
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation__create_uid
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_action__create_uid
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_bulk_translate__create_uid
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_config__create_uid
msgid "Created by"
msgstr "Créé par"
#. module: otoolkit_auto_translate_fields
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation__create_date
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_action__create_date
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_bulk_translate__create_date
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_config__create_date
msgid "Created on"
msgstr "Créé le"
#. module: otoolkit_auto_translate_fields
#. odoo-javascript
#: code:addons/otoolkit_auto_translate_fields/static/src/xml/translation_dialog_auto_translate.xml:0
#, python-format
msgid "Discard"
msgstr "Annuler"
#. module: otoolkit_auto_translate_fields
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation__display_name
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_bulk_translate__display_name
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_config__display_name
msgid "Display Name"
msgstr "Nom"
#. module: otoolkit_auto_translate_fields
#: model:ir.model.fields.selection,name:otoolkit_auto_translate_fields.selection__otk_translation_action__status__done
msgid "Done"
msgstr "Terminé"
#. module: otoolkit_auto_translate_fields
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_action__done_record_ids
msgid "Done translations"
msgstr "Traductions terminées"
#. module: otoolkit_auto_translate_fields
#: model:ir.actions.server,name:otoolkit_auto_translate_fields.action_enable_all_translatable_models
msgid "Enable All Translatable Models"
msgstr "Activer tous les modèles traduisibles"
#. module: otoolkit_auto_translate_fields
#: model:ir.model.fields.selection,name:otoolkit_auto_translate_fields.selection__otk_translation__status__error
#: model:ir.model.fields.selection,name:otoolkit_auto_translate_fields.selection__otk_translation_action__status__error
msgid "Error"
msgstr "Erreur"
#. module: otoolkit_auto_translate_fields
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_action__error_record_ids
msgid "Error translations"
msgstr "Traductions en erreur"
#. module: otoolkit_auto_translate_fields
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_bulk_translate__field_ids
msgid "Fields"
msgstr "Champs"
#. module: otoolkit_auto_translate_fields
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_action__field_ids
msgid "Fields to translate"
msgstr "Champs à traduire"
#. module: otoolkit_auto_translate_fields
#. odoo-javascript
#: code:addons/otoolkit_auto_translate_fields/static/src/js/translation_dialog_auto_translate.js:0
#, python-format
msgid "Get tokens"
msgstr "Obtenir des crédits"
#. module: otoolkit_auto_translate_fields
#: model_terms:ir.ui.view,arch_db:otoolkit_auto_translate_fields.view_otk_translation_config_search
msgid "Group By"
msgstr "Regrouper par"
#. module: otoolkit_auto_translate_fields
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_config__has_translatable_fields
msgid "Has Translatable Fields"
msgstr "A des champs traduisibles"
#. module: otoolkit_auto_translate_fields
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation__id
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_action__id
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_bulk_translate__id
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_config__id
msgid "ID"
msgstr "ID"
#. module: otoolkit_auto_translate_fields
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation__initial_values
#: model_terms:ir.ui.view,arch_db:otoolkit_auto_translate_fields.view_otoolkit_translation_form
msgid "Initial value"
msgstr "Valeur de base"
#. module: otoolkit_auto_translate_fields
#. odoo-python
#: code:addons/otoolkit_auto_translate_fields/models/translation.py:0
#, python-format
msgid "Invalid request data"
msgstr "Données de requête invalides"
#. module: otoolkit_auto_translate_fields
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_action__base_language_id
msgid "Language"
msgstr "Langue"
#. module: otoolkit_auto_translate_fields
#: model_terms:ir.ui.view,arch_db:otoolkit_auto_translate_fields.view_otoolkit_translation_action_form
msgid ""
"Large contents can take several minutes. Results are fetched automatically every few minutes —\n"
" last check:"
msgstr ""
"Les contenus volumineux peuvent prendre plusieurs minutes. Les résultats sont récupérés automatiquement à intervalles réguliers —\n"
" dernière vérification :"
#. module: otoolkit_auto_translate_fields
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_mixin__last_translation_date
msgid "Last Translation Date"
msgstr "Date de dernière traduction"
#. module: otoolkit_auto_translate_fields
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation__write_uid
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_action__write_uid
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_bulk_translate__write_uid
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_config__write_uid
msgid "Last Updated by"
msgstr "Dernière modification par"
#. module: otoolkit_auto_translate_fields
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation__write_date
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_action__write_date
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_bulk_translate__write_date
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_config__write_date
msgid "Last Updated on"
msgstr "Dernière modification le"
#. module: otoolkit_auto_translate_fields
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation__last_server_check
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_action__last_server_check
msgid "Last server check"
msgstr "Dernière vérification du serveur"
#. module: otoolkit_auto_translate_fields
#: model:ir.model.fields,help:otoolkit_auto_translate_fields.field_otk_translation__last_server_check
msgid ""
"Last time the scheduled action polled the translation server for this task's"
" result. A recent timestamp with a 'Translating on server' status means the "
"translation is still running server-side — large contents can take several "
"minutes."
msgstr ""
"Dernière fois que l'action planifiée a interrogé le serveur de traduction "
"pour le résultat de cette tâche. Un horodatage récent avec le statut "
"« Traduction en cours sur le serveur » signifie que la traduction est "
"toujours en cours côté serveur — les contenus volumineux peuvent prendre "
"plusieurs minutes."
#. module: otoolkit_auto_translate_fields
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation__action_id
msgid "Linked action"
msgstr "Action liée"
#. module: otoolkit_auto_translate_fields
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation__model_id
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_action__model_id
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_bulk_translate__model_id
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_config__model_id
msgid "Model"
msgstr "Modèle"
#. module: otoolkit_auto_translate_fields
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_action__display_name
msgid "Name"
msgstr "Nom"
#. module: otoolkit_auto_translate_fields
#. odoo-javascript
#: code:addons/otoolkit_auto_translate_fields/static/src/js/translation_dialog_auto_translate.js:0
#, python-format
msgid "No other language available to translate from."
msgstr "Aucune autre langue disponible comme langue source."
#. module: otoolkit_auto_translate_fields
#. odoo-python
#: code:addons/otoolkit_auto_translate_fields/wizards/bulk_translate.py:0
#, python-format
msgid "No records selected for translation."
msgstr "Aucun enregistrement sélectionné pour la traduction."
#. module: otoolkit_auto_translate_fields
#. odoo-javascript
#: code:addons/otoolkit_auto_translate_fields/static/src/js/translation_dialog_auto_translate.js:0
#: code:addons/otoolkit_auto_translate_fields/static/src/js/translation_dialog_auto_translate.js:0
#, python-format
msgid "No translation returned for this term."
msgstr "Aucune traduction renvoyée pour ce terme."
#. module: otoolkit_auto_translate_fields
#. odoo-python
#: code:addons/otoolkit_auto_translate_fields/models/translation_dialog.py:0
#, python-format
msgid "No valid text fields found to translate."
msgstr "Aucun champ texte valide à traduire."
#. module: otoolkit_auto_translate_fields
#. odoo-python
#: code:addons/otoolkit_auto_translate_fields/wizards/bulk_translate.py:0
#, python-format
msgid ""
"None of the %d selected records have translatable content in the selected fields (%s).\n"
"\n"
"Please check that the records have content in the base language (%s)."
msgstr ""
"Aucun des %d enregistrements sélectionnés n'a de contenu traduisible dans les champs sélectionnés (%s).\n"
"\n"
"Veuillez vérifier que les enregistrements ont du contenu dans la langue de base (%s)."
#. module: otoolkit_auto_translate_fields
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_action__processing_count
msgid "On server"
msgstr "Sur le serveur"
#. module: otoolkit_auto_translate_fields
#. odoo-javascript
#: code:addons/otoolkit_auto_translate_fields/static/src/js/translation_dialog_auto_translate.js:0
#, python-format
msgid "Open Settings"
msgstr "Ouvrir les paramètres"
#. module: otoolkit_auto_translate_fields
#: model_terms:ir.ui.view,arch_db:otoolkit_auto_translate_fields.view_otoolkit_translation_form
msgid "Open record"
msgstr "Ouvrir l'enregistrement"
#. module: otoolkit_auto_translate_fields
#: model:ir.ui.menu,name:otoolkit_auto_translate_fields.otoolkit_menu
msgid "Otoolkit"
msgstr "Otoolkit"
#. module: otoolkit_auto_translate_fields
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_action__overwrite_existing
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_bulk_translate__overwrite_existing
msgid "Overwrite existing translations"
msgstr "Écraser les traductions existantes"
#. module: otoolkit_auto_translate_fields
#: model:ir.ui.menu,name:otoolkit_auto_translate_fields.otoolkit_translation_menu
msgid "Past translations"
msgstr "Traductions"
#. module: otoolkit_auto_translate_fields
#: model:ir.model.fields.selection,name:otoolkit_auto_translate_fields.selection__otk_translation_action__status__pending
msgid "Pending"
msgstr "En attente"
#. module: otoolkit_auto_translate_fields
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_action__pending_record_ids
msgid "Pending translations"
msgstr "Traductions en attente"
#. module: otoolkit_auto_translate_fields
#: model:ir.actions.server,name:otoolkit_auto_translate_fields.otk_cron_process_bulk_translation_ir_actions_server
msgid "Process Bulk Translation"
msgstr "Gérer les traductions de masse"
#. module: otoolkit_auto_translate_fields
#: model:ir.model.fields.selection,name:otoolkit_auto_translate_fields.selection__otk_translation_action__status__processing
msgid "Processing"
msgstr "En cours"
#. module: otoolkit_auto_translate_fields
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_action__processing_record_ids
msgid "Processing translations"
msgstr "Traductions en cours"
#. module: otoolkit_auto_translate_fields
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_action__progress
#: model_terms:ir.ui.view,arch_db:otoolkit_auto_translate_fields.view_otoolkit_translation_action_form
msgid "Progress"
msgstr "Progression"
#. module: otoolkit_auto_translate_fields
#: model:ir.model.fields,help:otoolkit_auto_translate_fields.field_otk_translation_action__overwrite_existing
#: model:ir.model.fields,help:otoolkit_auto_translate_fields.field_otk_translation_bulk_translate__overwrite_existing
msgid ""
"Re-translate every term, including terms that already have a translation in "
"the target languages."
msgstr ""
"Retraduire chaque terme, y compris ceux qui ont déjà une traduction dans "
"les langues cibles."
#. module: otoolkit_auto_translate_fields
#: model_terms:ir.ui.view,arch_db:otoolkit_auto_translate_fields.view_otoolkit_translation_action_form
msgid "Record IDs"
msgstr "ID des enregistrements"
#. module: otoolkit_auto_translate_fields
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation__record_id
msgid "Record id"
msgstr "ID de l'enregistrement"
#. module: otoolkit_auto_translate_fields
#: model_terms:ir.ui.view,arch_db:otoolkit_auto_translate_fields.view_otoolkit_translation_action_form
msgid "Retry translation"
msgstr "Réessayer de traduire"
#. module: otoolkit_auto_translate_fields
#: model:ir.model.fields.selection,name:otoolkit_auto_translate_fields.selection__otk_translation__status__revert
msgid "Revert"
msgstr "Rétablir"
#. module: otoolkit_auto_translate_fields
#: model_terms:ir.ui.view,arch_db:otoolkit_auto_translate_fields.view_otoolkit_translation_form
msgid "Revert translation"
msgstr "Rétablir à la valeur initiale"
#. module: otoolkit_auto_translate_fields
#. odoo-javascript
#: code:addons/otoolkit_auto_translate_fields/static/src/xml/translation_dialog_auto_translate.xml:0
#, python-format
msgid "Save"
msgstr "Sauvegarder"
#. module: otoolkit_auto_translate_fields
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_config__server_action_id
msgid "Server Action"
msgstr "Action serveur"
#. module: otoolkit_auto_translate_fields
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_action__skipped_count
msgid "Skipped"
msgstr "Ignorés"
#. module: otoolkit_auto_translate_fields
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_action__skipped_record_ids
msgid "Skipped (no content)"
msgstr "Ignorés (aucun contenu)"
#. module: otoolkit_auto_translate_fields
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation__status
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_action__status
msgid "Status"
msgstr "Statut"
#. module: otoolkit_auto_translate_fields
#: model:ir.model.fields.selection,name:otoolkit_auto_translate_fields.selection__otk_translation__status__success
msgid "Success"
msgstr "Succès"
#. module: otoolkit_auto_translate_fields
#. odoo-javascript
#: code:addons/otoolkit_auto_translate_fields/static/src/js/translation_dialog_auto_translate.js:0
#: code:addons/otoolkit_auto_translate_fields/static/src/js/translation_dialog_auto_translate.js:0
#, python-format
msgid "Successfully translated"
msgstr "Traduction réussie"
#. module: otoolkit_auto_translate_fields
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_action__target_language_ids
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_bulk_translate__target_language_ids
msgid "Target languages"
msgstr "Langues cibles"
#. module: otoolkit_auto_translate_fields
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation__task_id
msgid "Task ID"
msgstr "ID de tâche"
#. module: otoolkit_auto_translate_fields
#. odoo-python
#: code:addons/otoolkit_auto_translate_fields/models/translation_dialog.py:0
#, python-format
msgid "The default language text cannot be empty."
msgstr "Le texte de la langue par défaut ne peut pas être vide."
#. module: otoolkit_auto_translate_fields
#. odoo-python
#: code:addons/otoolkit_auto_translate_fields/models/translation_dialog.py:0
#, python-format
msgid ""
"The translation service is not configured. Please contact your "
"administrator."
msgstr ""
"Le service de traduction n'est pas configuré. Veuillez contacter votre "
"administrateur."
#. module: otoolkit_auto_translate_fields
#. odoo-python
#: code:addons/otoolkit_auto_translate_fields/models/translation_dialog.py:0
#, python-format
msgid ""
"The translation service is taking too long to respond. Please try again."
msgstr ""
"Le service de traduction met trop de temps à répondre. Veuillez réessayer."
#. module: otoolkit_auto_translate_fields
#. odoo-python
#: code:addons/otoolkit_auto_translate_fields/models/translation_dialog.py:0
#, python-format
msgid ""
"The translation service is temporarily unavailable (Error %d). Please try "
"again later."
msgstr ""
"Le service de traduction est temporairement indisponible (erreur %d). "
"Veuillez réessayer plus tard."
#. module: otoolkit_auto_translate_fields
#. odoo-python
#: code:addons/otoolkit_auto_translate_fields/models/translation_dialog.py:0
#, python-format
msgid "The translation service returned an invalid response."
msgstr "Le service de traduction a renvoyé une réponse invalide."
#. module: otoolkit_auto_translate_fields
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation__cost
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_action__token_cost
msgid "Token used"
msgstr "Crédits utilisés"
#. module: otoolkit_auto_translate_fields
#. odoo-python
#: code:addons/otoolkit_auto_translate_fields/models/translation.py:0
#, python-format
msgid "Too many requests. The service is rate-limited."
msgstr "Trop de requêtes. Le service est limité en débit."
#. module: otoolkit_auto_translate_fields
#. odoo-python
#: code:addons/otoolkit_auto_translate_fields/models/translation_dialog.py:0
#, python-format
msgid "Too many translation requests. Please wait a moment and try again."
msgstr ""
"Trop de demandes de traduction. Veuillez patienter un instant et réessayer."
#. module: otoolkit_auto_translate_fields
#: model:ir.actions.server,name:otoolkit_auto_translate_fields.bulk_translate_config_action_for_product_template
msgid "Traduire en masse"
msgstr "Traduire en masse"
#. module: otoolkit_auto_translate_fields
#. odoo-python
#: code:addons/otoolkit_auto_translate_fields/models/translation_config.py:0
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_config__translatable_field_ids
#: model_terms:ir.ui.view,arch_db:otoolkit_auto_translate_fields.view_otk_translation_config_form
#, python-format
msgid "Translatable Fields"
msgstr "Champs traduisibles"
#. module: otoolkit_auto_translate_fields
#: model_terms:ir.ui.view,arch_db:otoolkit_auto_translate_fields.view_bulk_translate_wizard_form
msgid "Translate"
msgstr "Traduire"
#. module: otoolkit_auto_translate_fields
#: model:ir.model.fields.selection,name:otoolkit_auto_translate_fields.selection__otk_translation__status__pending
msgid "Translating on server"
msgstr "Traduction en cours sur le serveur"
#. module: otoolkit_auto_translate_fields
#. odoo-javascript
#: code:addons/otoolkit_auto_translate_fields/static/src/xml/translation_dialog_auto_translate.xml:0
#, python-format
msgid "Translating..."
msgstr "Traduction en cours..."
#. module: otoolkit_auto_translate_fields
#: model:ir.model,name:otoolkit_auto_translate_fields.model_otk_translation_action
msgid "Translation Action"
msgstr "Action de traduction"
#. module: otoolkit_auto_translate_fields
#: model:ir.actions.act_window,name:otoolkit_auto_translate_fields.action_otk_translation_config
#: model:ir.model,name:otoolkit_auto_translate_fields.model_otk_translation_config
msgid "Translation Configuration"
msgstr "Configuration de traduction"
#. module: otoolkit_auto_translate_fields
#: model:ir.model,name:otoolkit_auto_translate_fields.model_translation_dialog
msgid "Translation Dialog"
msgstr "Dialogue de traduction"
#. module: otoolkit_auto_translate_fields
#: model:ir.model.fields.selection,name:otoolkit_auto_translate_fields.selection__otk_translation__status__translation_error
msgid "Translation Error"
msgstr "Erreur de traduction"
#. module: otoolkit_auto_translate_fields
#: model:ir.model,name:otoolkit_auto_translate_fields.model_otk_translation_mixin
msgid "Translation Mixin"
msgstr "Mixin de traduction"
#. module: otoolkit_auto_translate_fields
#: model:ir.ui.menu,name:otoolkit_auto_translate_fields.otoolkit_translation_config_menu
msgid "Translation Settings"
msgstr "Paramètres de traduction"
#. module: otoolkit_auto_translate_fields
#. odoo-python
#: code:addons/otoolkit_auto_translate_fields/wizards/bulk_translate.py:0
#, python-format
msgid "Translation Started"
msgstr "Traduction lancée"
#. module: otoolkit_auto_translate_fields
#: model:ir.actions.act_window,name:otoolkit_auto_translate_fields.action_otoolkit_translation
#: model:ir.actions.act_window,name:otoolkit_auto_translate_fields.action_otoolkit_translation_action
msgid "Translation actions"
msgstr "Actions de traduction"
#. module: otoolkit_auto_translate_fields
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_action__translation_count
msgid "Translation count"
msgstr "Nombre de traductions"
#. module: otoolkit_auto_translate_fields
#. odoo-python
#: code:addons/otoolkit_auto_translate_fields/models/translation_dialog.py:0
#, python-format
msgid "Translation error: %s"
msgstr "Erreur de traduction : %s"
#. module: otoolkit_auto_translate_fields
#: model:ir.model,name:otoolkit_auto_translate_fields.model_otk_translation
msgid "Translation object with the details of the translation, the cost..."
msgstr "Objet de traduction avec les détails de la traduction, le coût..."
#. module: otoolkit_auto_translate_fields
#. odoo-python
#: code:addons/otoolkit_auto_translate_fields/models/translation.py:0
#, python-format
msgid "Translation service endpoint not found"
msgstr "Point de terminaison du service de traduction introuvable"
#. module: otoolkit_auto_translate_fields
#. odoo-python
#: code:addons/otoolkit_auto_translate_fields/models/translation.py:0
#, python-format
msgid "Translation service internal error"
msgstr "Erreur interne du service de traduction"
#. module: otoolkit_auto_translate_fields
#. odoo-python
#: code:addons/otoolkit_auto_translate_fields/models/translation.py:0
#, python-format
msgid "Translation service is currently unavailable"
msgstr "Le service de traduction est actuellement indisponible"
#. module: otoolkit_auto_translate_fields
#. odoo-python
#: code:addons/otoolkit_auto_translate_fields/models/translation.py:0
#, python-format
msgid "Translation service request timed out"
msgstr "La requête au service de traduction a expiré"
#. module: otoolkit_auto_translate_fields
#. odoo-python
#: code:addons/otoolkit_auto_translate_fields/models/translation.py:0
#, python-format
msgid "Translation service temporarily unavailable (bad gateway)"
msgstr "Service de traduction temporairement indisponible (bad gateway)"
#. module: otoolkit_auto_translate_fields
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation__translations
#: model:ir.model.fields,field_description:otoolkit_auto_translate_fields.field_otk_translation_action__translation_ids
#: model_terms:ir.ui.view,arch_db:otoolkit_auto_translate_fields.view_otoolkit_translation_action_form
#: model_terms:ir.ui.view,arch_db:otoolkit_auto_translate_fields.view_otoolkit_translation_form
msgid "Translations"
msgstr "Traductions"
#. module: otoolkit_auto_translate_fields
#. odoo-python
#: code:addons/otoolkit_auto_translate_fields/models/translation_dialog.py:0
#, python-format
msgid ""
"Unable to connect to the translation service. Please check your internet "
"connection."
msgstr ""
"Impossible de se connecter au service de traduction. Veuillez vérifier "
"votre connexion Internet."
#. module: otoolkit_auto_translate_fields
#. odoo-python
#: code:addons/otoolkit_auto_translate_fields/models/translation_dialog.py:0
#, python-format
msgid ""
"Unexpected response from translation service (Error %d). Please try again."
msgstr ""
"Réponse inattendue du service de traduction (erreur %d). Veuillez "
"réessayer."
#. module: otoolkit_auto_translate_fields
#: model_terms:ir.ui.view,arch_db:otoolkit_auto_translate_fields.view_otk_translation_config_search
msgid "With Translatable Fields"
msgstr "Avec champs traduisibles"
#. module: otoolkit_auto_translate_fields
#. odoo-python
#: code:addons/otoolkit_auto_translate_fields/models/translation_dialog.py:0
#, python-format
msgid ""
"You do not have enough credits to translate. Please add credits to your "
"balance to use this function."
msgstr ""
"Vous n'avez pas assez de crédits pour traduire. Veuillez ajouter des crédits"
" à votre solde pour utiliser cette fonction."
#. module: otoolkit_auto_translate_fields
#. odoo-python
#: code:addons/otoolkit_auto_translate_fields/models/translation_dialog.py:0
#: code:addons/otoolkit_auto_translate_fields/models/translation_dialog.py:0
#, python-format
msgid ""
"Your API key is invalid. This may be due to an input error, deletion or "
"deactivation of the key. Please check your Otoolkit settings."
msgstr ""
"Votre clé API n'est pas valide. Cela peut être dû à une erreur de saisie, à "
"la suppression ou à la désactivation de la clé. Veuillez vérifier les "
"paramètres Otoolkit."
#. module: otoolkit_auto_translate_fields
#. odoo-python
#: code:addons/otoolkit_auto_translate_fields/models/translation.py:0
#, python-format
msgid ""
"Your API key is invalid. Use a valid key and restart the translation from "
"the parent action."
msgstr ""
"Votre clé API n'est pas valide. Utilisez une clé valide et relancez la "
"traduction depuis l'action parente."
#. module: otoolkit_auto_translate_fields
#: model_terms:ir.ui.view,arch_db:otoolkit_auto_translate_fields.view_otoolkit_translation_action_form
msgid "record(s) being translated on the O'Toolkit server."
msgstr "enregistrement(s) en cours de traduction sur le serveur O'Toolkit."
#. module: otoolkit_auto_translate_fields
#: model_terms:ir.ui.view,arch_db:otoolkit_auto_translate_fields.view_otoolkit_translation_action_form
msgid "records skipped"
msgstr "enregistrements ignorés"
#. module: otoolkit_auto_translate_fields
#: model_terms:ir.ui.view,arch_db:otoolkit_auto_translate_fields.view_otoolkit_translation_action_form
msgid ""
"— these records had no translatable content in the selected fields and were "
"not sent to the API (no tokens consumed)."
msgstr ""
"— ces enregistrements n'avaient aucun contenu traduisible dans les champs "
"sélectionnés et n'ont pas été envoyés à l'API (aucun crédit consommé)."
@@ -1,4 +0,0 @@
from . import translation_mixin
from . import translation_dialog
from . import translation
from . import translation_config
@@ -1,825 +0,0 @@
import json
import datetime
import logging
import requests
import time
from odoo import models, fields, api, _
_logger = logging.getLogger(__name__)
def _sanitize_null_bytes(value):
"""Remove NULL bytes that PostgreSQL cannot store in JSON/text fields.
PostgreSQL's jsonb type cannot handle \u0000 (NULL byte) characters.
These often appear in content copied from PDFs or due to encoding issues.
"""
if isinstance(value, str):
return value.replace('\x00', '').replace('\u0000', '')
elif isinstance(value, dict):
return {k: _sanitize_null_bytes(v) for k, v in value.items()}
elif isinstance(value, list):
return [_sanitize_null_bytes(v) for v in value]
return value
def _is_valid_translatable_value(value):
"""Check if a value is valid for translation.
Returns False for: None, False (bool), empty strings, whitespace-only strings.
Returns True for non-empty strings (including HTML content).
"""
if value is None or value is False:
return False
if isinstance(value, str):
return bool(value.strip())
return False
class TranslationAction(models.Model):
_name = 'otk.translation.action'
_description = 'Translation Action'
_rec_name = 'display_name'
_order = 'id desc'
display_name = fields.Char(string="Name", compute='_compute_display_name', store=True, readonly=True)
model_id = fields.Many2one('ir.model', string="Model", ondelete='cascade', readonly=True)
base_language_id = fields.Many2one('res.lang', string="Language", readonly=True)
target_language_ids = fields.Many2many('res.lang', string="Target languages", required=True, ondelete='cascade',
readonly=True)
field_ids = fields.Many2many(
'ir.model.fields',
string="Fields to translate",
domain="[('translate', 'in', ['standard', 'html_translate', 'xml_translate']), ('model_id', '=', model_id)]",
readonly=True,
)
overwrite_existing = fields.Boolean(
string="Overwrite existing translations",
default=False,
readonly=True,
help="Re-translate every term, including terms that already have a "
"translation in the target languages.",
)
pending_record_ids = fields.Json(default=list, string="Pending translations", readonly=True)
processing_record_ids = fields.Json(default=list, string="Processing translations", readonly=True)
done_record_ids = fields.Json(default=list, string="Done translations", readonly=True)
error_record_ids = fields.Json(default=list, string="Error translations", readonly=True)
skipped_record_ids = fields.Json(default=list, string="Skipped (no content)", readonly=True)
skipped_count = fields.Integer(string="Skipped", compute='_compute_skipped_count', store=True)
processing_count = fields.Integer(string="On server", compute='_compute_processing_count')
last_server_check = fields.Datetime(
string="Last server check", compute='_compute_last_server_check',
)
progress = fields.Float(store=True, string="Progress", compute="_compute_progress", readonly=True)
translation_count = fields.Integer(readonly=True, string="Translation count")
token_cost = fields.Integer(default=0, string="Token used", readonly=True)
translation_ids = fields.One2many(
comodel_name='otk.translation',
inverse_name='action_id',
string='Translations',
readonly=True)
status = fields.Selection(
[('pending', 'Pending'), ('done', 'Done'), ('error', 'Error'), ('processing', 'Processing')],
compute='_compute_status',
string='Status',
store=True
)
def _get_error_message_for_status(self, status_code, response_text=""):
"""Get a user-friendly error message based on HTTP status code."""
error_messages = {
400: _("Invalid request data"),
401: _("Your API key is invalid. Use a valid key and restart the translation from the parent action."),
403: _("Access denied to the translation service"),
404: _("Translation service endpoint not found"),
429: _("Too many requests. The service is rate-limited."),
500: _("Translation service internal error"),
502: _("Translation service temporarily unavailable (bad gateway)"),
503: _("Translation service is currently unavailable"),
504: _("Translation service request timed out"),
}
return error_messages.get(status_code, _("An error has occurred (HTTP %d). You can restart the translation from the parent action.") % status_code)
def _create_error_translation(self, record_id, status_code, error_details=""):
"""Create a translation record with error status and detailed message."""
if status_code == 401:
status = 'api_key_error'
else:
status = 'translation_error'
error_message = self._get_error_message_for_status(status_code)
if error_details:
error_message = f"{error_message}\nDetails: {error_details}"
self.env["otk.translation"].create({
'action_id': self.id,
'cost': 0,
'record_id': record_id,
'status': status,
'translations': error_message,
})
def _move_record_to_error(self, record_id):
"""Move a record from pending to error state."""
pending = self.pending_record_ids if self.pending_record_ids else []
error = self.error_record_ids if self.error_record_ids else []
if record_id in pending:
pending.remove(record_id)
if record_id not in error:
error.append(record_id)
self.write({
'pending_record_ids': pending,
'error_record_ids': error,
})
def _mark_pending_translations_as_error(self, translations, error_message):
"""Mark multiple pending translations as error with a message."""
for translation in translations:
translation.write({
'status': 'translation_error',
'translations': error_message,
})
# Move record to error state in the action
action = translation.action_id
if action:
processing = action.processing_record_ids if action.processing_record_ids else []
error_ids = action.error_record_ids if action.error_record_ids else []
record_id = translation.record_id
if record_id in processing:
try:
processing.remove(record_id)
except ValueError:
pass
if record_id not in error_ids:
error_ids.append(record_id)
action.write({
'processing_record_ids': processing,
'error_record_ids': error_ids,
})
def _request_terms_translation(self, terms, terms_meta, target_langs, base_lang_code, record_id):
"""Queue a terms-mode translation task on the OToolKit API.
:param dict terms: ``{field_name: [text, ...]}`` base-language texts
:param dict terms_meta: ``{field_name: [{"source": term_en, "langs": [...]}]}``
aligned with ``terms``; echoed back by the API so the cron can map
translations to Odoo's source terms at apply time
:return: the API task id, or False on error
"""
api_key = self.env['ir.config_parameter'].sudo().get_param('otoolkit_api_key')
api_endpoint = self.env['ir.config_parameter'].sudo().get_param('otoolkit.api.endpoint')
if not api_key:
_logger.error("OToolKit API key not configured for bulk translation")
self._create_error_translation(record_id, 401, "API key not configured")
self._move_record_to_error(record_id)
return False
if not api_endpoint:
_logger.error("OToolKit API endpoint not configured for bulk translation")
self._create_error_translation(record_id, 500, "API endpoint not configured")
self._move_record_to_error(record_id)
return False
payload = json.dumps({
"terms": terms,
"terms_meta": terms_meta,
"target_langs": target_langs,
"base_language": base_lang_code,
"record_id": record_id,
"odoo_user_id": self.env.user.id
})
headers = {
'Odoo-Api-Key': api_key,
'Content-Type': 'application/json'
}
url = f"{api_endpoint}/api/auto-translate-fields/v2/translate-terms/"
try:
response = requests.request("POST", url, headers=headers, data=payload, timeout=180)
except requests.exceptions.Timeout:
_logger.warning("OToolKit API request timed out for record %d: %s", record_id, url)
self._create_error_translation(record_id, 504, "Request timed out")
self._move_record_to_error(record_id)
return False
except requests.exceptions.ConnectionError as e:
_logger.error("OToolKit API connection error for record %d: %s - %s", record_id, url, str(e))
self._create_error_translation(record_id, 503, f"Connection error: {str(e)}")
self._move_record_to_error(record_id)
return False
except requests.exceptions.RequestException as e:
_logger.error("OToolKit API request failed for record %d: %s - %s", record_id, url, str(e))
self._create_error_translation(record_id, 500, f"Request failed: {str(e)}")
self._move_record_to_error(record_id)
return False
if response.status_code != 200:
# Try to extract error details from response
error_details = ""
try:
error_json = response.json()
error_details = error_json.get("error", "") or error_json.get("detail", "")
error_type = error_json.get("type", "")
if error_type:
error_details = f"[{error_type}] {error_details}"
except (json.JSONDecodeError, KeyError):
error_details = response.text[:200] if response.text else ""
_logger.warning(
"OToolKit API returned status %d for record %d: %s",
response.status_code, record_id, error_details
)
self._create_error_translation(record_id, response.status_code, error_details)
self._move_record_to_error(record_id)
return False
try:
data = response.json()
return data["task_id"]
except (json.JSONDecodeError, KeyError) as e:
_logger.error("Failed to parse OToolKit API response for record %d: %s", record_id, str(e))
self._create_error_translation(record_id, 500, f"Invalid response format: {str(e)}")
self._move_record_to_error(record_id)
return False
def cron_translate_action(self):
get_param = self.env['ir.config_parameter'].sudo().get_param
max_time = int(get_param('otoolkit.translate.cron_max_time', 60))
max_record = int(get_param('otoolkit.translate.cron_max_records', 10))
max_iter = 10
action = self.env['otk.translation.action'].sudo().search([('status', '=', 'pending')], order='id asc', limit=1)
start = int(time.time())
while action and max_iter > 0:
can_restart = self.translate_action(action, max_record)
if not can_restart:
break
action = self.env['otk.translation.action'].sudo().search([('status', '=', 'pending')], order='id asc',
limit=1)
if int(time.time()) - start > max_time:
break
max_iter -= 1
self.cron_retrieve_tasks()
def cron_retrieve_tasks(self):
pending_translations = self.env["otk.translation"].sudo().search(
[('status', '=', 'pending')], order='id asc', limit=100
)
if len(pending_translations) == 0:
return
api_key = self.env['ir.config_parameter'].sudo().get_param('otoolkit_api_key')
api_endpoint = self.env['ir.config_parameter'].sudo().get_param('otoolkit.api.endpoint')
if not api_key:
return
payload = json.dumps({
"task_ids": pending_translations.mapped('task_id'),
})
headers = {
'Odoo-Api-Key': api_key,
'Content-Type': 'application/json'
}
url = f"{api_endpoint}/api/tasks/"
try:
response = requests.request("POST", url, headers=headers, data=payload, timeout=120)
except requests.exceptions.Timeout:
_logger.error("OToolKit API timeout while retrieving tasks")
self._mark_pending_translations_as_error(pending_translations, "API timeout while retrieving translation status")
return
except requests.exceptions.ConnectionError as e:
_logger.error("OToolKit API connection error while retrieving tasks: %s", str(e))
self._mark_pending_translations_as_error(pending_translations, f"Connection error: {str(e)}")
return
except requests.exceptions.RequestException as e:
_logger.error("OToolKit API request failed while retrieving tasks: %s", str(e))
self._mark_pending_translations_as_error(pending_translations, f"Request failed: {str(e)}")
return
if response.status_code != 200:
error_msg = f"API returned status {response.status_code}"
try:
error_json = response.json()
error_detail = error_json.get("error", "") or error_json.get("detail", "")
if error_detail:
error_msg = f"{error_msg}: {error_detail}"
except (json.JSONDecodeError, KeyError):
if response.text:
error_msg = f"{error_msg}: {response.text[:200]}"
_logger.error("OToolKit API error while retrieving tasks: %s", error_msg)
self._mark_pending_translations_as_error(pending_translations, error_msg)
return
try:
tasks = response.json()["tasks"]
except (json.JSONDecodeError, KeyError) as e:
_logger.error("Failed to parse OToolKit API response for tasks: %s", str(e))
self._mark_pending_translations_as_error(pending_translations, f"Invalid API response format: {str(e)}")
return
task_map = {task["id"]: task for task in tasks}
# Every polled translation gets a check timestamp so the UI can show
# that a long-running server-side task is being watched, not stalled.
pending_translations.write({'last_server_check': fields.Datetime.now()})
action_update = {}
for translation in pending_translations:
action_id = translation.action_id.id
if action_id not in action_update:
action_update[action_id] = {
'error': [],
'completed': [],
'action': translation.action_id,
}
related_task = task_map.get(translation.task_id)
# Skip if task not found in API response (still pending on server side)
if not related_task:
continue
# Safely extract record_id with error handling
try:
active_id = related_task['params']['record_id']
except (KeyError, TypeError) as e:
_logger.error("Invalid task structure for task %s: missing params.record_id - %s", translation.task_id, str(e))
translation.write({
'status': 'translation_error',
'translations': f"Invalid task response structure: {str(e)}",
})
action_update[action_id]['error'].append(translation.record_id)
continue
if related_task["status"] == "completed":
try:
action_update[action_id]['completed'].append(active_id)
action = action_update[action_id]['action']
record = self.env[action.model_id.model].with_context(lang=action.base_language_id.code).browse(active_id)
if not record.exists():
translation.write({
'status': 'translation_error',
'translations': "Record was deleted before the translations could be applied",
})
action_update[action_id]['completed'].remove(active_id)
action_update[action_id]['error'].append(active_id)
continue
cost = related_task['result'].get('cost', 0)
applied, previous_values = self._apply_terms_translations(record, related_task)
translation.write({
'cost': cost,
'status': 'success',
'translations': applied,
'initial_values': previous_values,
})
# Update last_translation_date only if the field exists on the model
# (for backward compatibility with models using the mixin)
if 'last_translation_date' in self.env[action.model_id.model]._fields:
record.write({
"last_translation_date": datetime.datetime.now(),
})
except (KeyError, TypeError) as e:
_logger.error("Error processing completed task %s for record %d: %s", translation.task_id, active_id, str(e))
translation.write({
'status': 'translation_error',
'translations': f"Error processing translation result: {str(e)}",
})
# Move from completed to error
if active_id in action_update[action_id]['completed']:
action_update[action_id]['completed'].remove(active_id)
action_update[action_id]['error'].append(active_id)
except Exception as e:
_logger.error("Unexpected error processing task %s for record %d: %s", translation.task_id, active_id, str(e))
translation.write({
'status': 'translation_error',
'translations': f"Unexpected error: {str(e)}",
})
if active_id in action_update[action_id]['completed']:
action_update[action_id]['completed'].remove(active_id)
action_update[action_id]['error'].append(active_id)
elif related_task["status"] == "failed":
# Extract error message from the API response
error_message = "Translation failed"
try:
result = related_task.get('result', {})
if isinstance(result, dict):
error_message = result.get('error', '') or result.get('message', '') or result.get('detail', '')
if not error_message:
error_message = str(result) if result else "Translation failed (no details provided)"
except Exception:
error_message = "Translation failed (could not parse error details)"
_logger.warning("Translation task %s failed for record %d: %s", translation.task_id, active_id, error_message)
action_update[action_id]['error'].append(active_id)
translation.write({
'status': 'translation_error',
'translations': error_message,
})
for key, value in action_update.items():
action = value['action']
processing = action.processing_record_ids if action.processing_record_ids else []
error = action.error_record_ids if action.error_record_ids else []
done = action.done_record_ids if action.done_record_ids else []
for translation_id in value['completed']:
try:
processing.remove(translation_id)
except ValueError:
_logger.debug("Record %d not found in processing list", translation_id)
if translation_id not in done:
done.append(translation_id)
for translation_id in value['error']:
try:
processing.remove(translation_id)
except ValueError:
_logger.debug("Record %d not found in processing list", translation_id)
if translation_id not in error:
error.append(translation_id)
action.write({
'processing_record_ids': processing,
'error_record_ids': error,
'done_record_ids': done,
})
def _extract_record_terms(self, record, field_names, base_lang_code, target_langs, overwrite=False):
"""Build the per-term translation payload for one record.
Term extraction goes through ``get_field_translations`` so it matches
Odoo's own translation model exactly: per-language values keep an
identical structure (only text terms differ), and a term whose target
value is empty or identical to the source is untranslated. Only those
terms are sent, each restricted to the languages that actually need it
— unless ``overwrite`` re-translates everything.
:return: (terms, terms_meta) where
terms: ``{field_name: [text, ...]}`` base-language texts to translate
terms_meta: ``{field_name: [{"source": term_en, "langs": [...]}]}``
aligned with ``terms``
"""
terms = {}
terms_meta = {}
langs = list(set(target_langs) | {base_lang_code})
for field_name in field_names:
field = record._fields.get(field_name)
if field is None or not field.translate:
continue
try:
translations, _ctx = record.get_field_translations(field_name, langs=langs)
except Exception as e:
_logger.warning(
"Could not extract translation terms for %s.%s (record %d): %s",
record._name, field_name, record.id, str(e),
)
continue
by_source = {}
for entry in translations:
by_source.setdefault(entry['source'], {})[entry['lang']] = entry['value']
field_terms = []
field_meta = []
for term_en, values in by_source.items():
if callable(field.translate):
# value == '' means untranslated (or identical to the
# en_US term — Odoo's copy-of-source default)
needed = [l for l in target_langs if overwrite or not values.get(l)]
else:
# model-level translation: one term == the whole field
# value; untranslated langs fall back to the en_US value
needed = [
l for l in target_langs
if overwrite or not values.get(l) or values.get(l) == term_en
]
text = values.get(base_lang_code) or term_en
if not needed or not _is_valid_translatable_value(text):
continue
field_terms.append(_sanitize_null_bytes(text))
field_meta.append({"source": term_en, "langs": needed})
if field_terms:
terms[field_name] = field_terms
terms_meta[field_name] = field_meta
return terms, terms_meta
def _apply_terms_translations(self, record, task):
"""Apply a completed terms-translation task through the ORM.
Writes via ``update_field_translations`` so cache invalidation,
sanitization and ``write_date`` behave like any user write, and the
per-language values keep a structure identical to the source. Terms
whose source disappeared from the record between send and apply (the
record was edited meanwhile) simply don't match and are skipped.
:return: (applied, previous_values) where
applied: ``{field: {lang: {source_term: new_term}}}``
previous_values: revert payload — ``{field: {lang: {new_term: old_term}}}``
for term-translated fields, ``{field: {lang: old_value}}`` for
model-translated fields
"""
terms_meta = task['params'].get('terms_meta') or {}
translations_by_field = task['result'].get('translations') or {}
target_langs = task['params']['target_langs']
applied = {}
previous_values = {}
for field_name, field_meta in terms_meta.items():
field = record._fields.get(field_name)
if field is None or not field.translate:
continue
translated_terms = translations_by_field.get(field_name) or []
if len(translated_terms) != len(field_meta):
_logger.warning(
"Translation count mismatch for %s.%s (record %d): sent %d terms, got %d",
record._name, field_name, record.id, len(field_meta), len(translated_terms),
)
continue
# Map each en_US source term to its current value per language:
# update_field_translations matches old terms against the value as
# it currently stands in the target language, not the en_US source.
current, _ctx = record.get_field_translations(field_name, langs=target_langs)
current_map = {(e['lang'], e['source']): e['value'] for e in current}
payload = {}
revert = {}
field_applied = {}
for meta, term_translations in zip(field_meta, translated_terms):
source = meta['source']
for lang in meta['langs']:
new_term = term_translations.get(lang)
if not new_term or not isinstance(new_term, str):
_logger.warning(
"Missing translation for field %s, lang %s in record %d",
field_name, lang, record.id,
)
continue
new_term = _sanitize_null_bytes(new_term)
if callable(field.translate):
old_term = current_map.get((lang, source)) or source
if old_term == new_term:
continue
payload.setdefault(lang, {})[old_term] = new_term
revert.setdefault(lang, {})[new_term] = old_term
else:
old_value = current_map.get((lang, source)) or source
if old_value == new_term:
continue
payload[lang] = new_term
revert[lang] = old_value
field_applied.setdefault(lang, {})[source] = new_term
if payload:
record.update_field_translations(field_name, payload)
applied[field_name] = field_applied
previous_values[field_name] = revert
return applied, previous_values
def translate_action(self, action, max_record):
active_ids = action.pending_record_ids[0:max_record]
langs = action.target_language_ids
target_langs = []
for lang in langs:
if lang.code != action.base_language_id.code:
target_langs.append(lang.code)
error_count = 0
translations_to_create = []
model_name = action.model_id.model
base_lang_code = action.base_language_id.code
field_names = action.field_ids.mapped('name')
for active_id in active_ids:
record = self.env[model_name].browse(active_id)
terms = {}
terms_meta = {}
if record.exists():
terms, terms_meta = self._extract_record_terms(
record, field_names, base_lang_code, target_langs,
overwrite=action.overwrite_existing,
)
# Skip records with nothing to translate (no content, or every
# term already translated in every target language)
if not terms:
_logger.info(
"Skipping record %d (model=%s, lang=%s): no terms left to translate.",
active_id, model_name, base_lang_code,
)
pending = action.pending_record_ids if action.pending_record_ids else []
skipped = action.skipped_record_ids if action.skipped_record_ids else []
if active_id in pending:
pending.remove(active_id)
if active_id not in skipped:
skipped.append(active_id)
action.write({
'pending_record_ids': pending,
'skipped_record_ids': skipped,
})
continue
task_id = action._request_terms_translation(
terms, terms_meta, target_langs, base_lang_code, active_id
)
if not task_id:
error_count += 1
continue
translations_to_create.append({
'action_id': action.id,
'cost': 0,
'record_id': active_id,
'status': 'pending',
'translations': {},
'initial_values': {},
'task_id': task_id
})
pending = action.pending_record_ids if action.pending_record_ids else []
processing = action.processing_record_ids if action.processing_record_ids else []
pending.remove(active_id)
if active_id not in processing:
processing.append(active_id)
action.write({
'pending_record_ids': pending,
'processing_record_ids': processing,
})
if translations_to_create:
self.env["otk.translation"].create(translations_to_create)
return error_count != len(active_ids)
def retry_error_translation(self):
for record in self:
pending = record.pending_record_ids if record.pending_record_ids else []
error = record.error_record_ids if record.error_record_ids else []
pending.extend(error)
self.write({
'pending_record_ids': pending,
'error_record_ids': [],
})
@api.depends('pending_record_ids', 'done_record_ids')
def _compute_progress(self):
for record in self:
if not record.done_record_ids:
record.progress = 0.0
elif not record.pending_record_ids and not record.processing_record_ids:
record.progress = 100.0
else:
record.progress = len(record.done_record_ids) / record.translation_count * 100.0
@api.depends('skipped_record_ids')
def _compute_skipped_count(self):
for record in self:
record.skipped_count = len(record.skipped_record_ids) if record.skipped_record_ids else 0
@api.depends('processing_record_ids')
def _compute_processing_count(self):
for record in self:
record.processing_count = len(record.processing_record_ids) if record.processing_record_ids else 0
@api.depends('translation_ids.last_server_check')
def _compute_last_server_check(self):
for record in self:
checks = record.translation_ids.mapped('last_server_check')
record.last_server_check = max(filter(None, checks), default=False)
@api.depends('progress', 'error_record_ids')
def _compute_status(self):
for record in self:
if record.error_record_ids and len(record.error_record_ids) > 0 and (
not record.pending_record_ids or len(record.pending_record_ids) == 0) and (
not record.processing_record_ids or len(record.processing_record_ids) == 0):
record.status = 'error'
else:
record.status = 'done' if record.progress >= 100 else 'pending' if record.pending_record_ids and len(
record.pending_record_ids) > 0 else 'processing'
@api.depends('model_id')
def _compute_display_name(self):
for record in self:
record.display_name = "Action #" + str(record.id) + " - " + record.model_id.name
class Translation(models.Model):
_name = 'otk.translation'
_description = 'Translation object with the details of the translation, the cost...'
_order = 'id desc'
action_id = fields.Many2one('otk.translation.action', string="Linked action", ondelete='cascade', readonly=True)
record_id = fields.Integer(string='Record id', readonly=True)
model_id = fields.Many2one('ir.model', string='Model', readonly=True, related='action_id.model_id')
cost = fields.Float(string="Token used", readonly=True)
status = fields.Selection(
[
('error', "Error"),
('translation_error', "Translation Error"),
('api_key_error', "Api Key Error"),
('success', "Success"),
('revert', "Revert"),
('pending', "Translating on server"),
],
string="Status", readonly=True
)
last_server_check = fields.Datetime(
string="Last server check",
readonly=True,
help="Last time the scheduled action polled the translation server "
"for this task's result. A recent timestamp with a 'Translating "
"on server' status means the translation is still running "
"server-side — large contents can take several minutes.",
)
translations = fields.Json(string='Translations', readonly=True)
initial_values = fields.Json(string='Initial value', readonly=True)
task_id = fields.Integer(string="Task ID", readonly=True)
def revert_translation(self):
for record in self:
target = self.env[record.model_id.model].browse(record.record_id)
if not target.exists():
record.status = 'revert'
continue
for field_name, by_lang in (record.initial_values or {}).items():
field = target._fields.get(field_name)
if field is None or not field.translate or not by_lang:
continue
if callable(field.translate) and any(
not isinstance(value, dict) for value in by_lang.values()
):
# Records translated before the terms-mode upgrade stored a
# whole-jsonb snapshot per language; only a raw column
# restore can reapply those faithfully.
record._revert_legacy_field(field_name, by_lang)
continue
# Term-level revert: {lang: {translated_term: original_term}}
# for term-translated fields, {lang: original_value} for
# model-translated fields — both are exactly the payload
# update_field_translations expects.
target.update_field_translations(field_name, by_lang)
record.status = 'revert'
def _revert_legacy_field(self, field_name, lang_values):
"""Restore a pre-terms-mode whole-jsonb snapshot of one field."""
self.ensure_one()
table = self.env[self.model_id.model]._table
query = f"""
SELECT {field_name}
FROM {table}
WHERE id = %s
"""
self.env.cr.execute(query, (self.record_id,))
result = self.env.cr.fetchone()
translate = _sanitize_null_bytes(result[0]) if result and result[0] else {}
for lang, initial_value in lang_values.items():
translate[lang] = initial_value or False
update_query = f"""
UPDATE {table}
SET {field_name} = %s
WHERE id = %s
"""
self.env.cr.execute(update_query, (json.dumps(_sanitize_null_bytes(translate)), self.record_id))
self.env[self.model_id.model].browse(self.record_id).invalidate_recordset([field_name])
def open_record(self):
return {
'type': 'ir.actions.act_window',
'name': 'Linked record',
'res_model': self.model_id.model,
'res_id': self.record_id,
'view_mode': 'form',
'target': 'current', # or 'new' to open in a popup
}
@@ -1,186 +0,0 @@
from odoo import models, fields, api, _
from odoo.exceptions import ValidationError
class TranslationConfig(models.Model):
_name = 'otk.translation.config'
_description = 'Translation Configuration'
_rec_name = 'model_id'
model_id = fields.Many2one(
'ir.model',
string="Model",
required=True,
ondelete='cascade',
domain="[('transient', '=', False)]",
)
active = fields.Boolean(default=True)
has_translatable_fields = fields.Boolean(
compute='_compute_has_translatable_fields',
store=True,
)
translatable_field_ids = fields.Many2many(
'ir.model.fields',
string="Translatable Fields",
compute='_compute_has_translatable_fields',
store=True,
)
server_action_id = fields.Many2one(
'ir.actions.server',
string="Server Action",
readonly=True,
ondelete='set null',
)
@api.constrains('model_id')
def _check_unique_model_id(self):
for record in self:
if record.model_id:
if self.search([('model_id', '=', record.model_id.id), ('id', '!=', record.id)]):
raise ValidationError(_("A configuration already exists for this model."))
@api.depends('model_id')
def _compute_has_translatable_fields(self):
for record in self:
if record.model_id:
translatable_fields = self.env['ir.model.fields'].search([
('model_id', '=', record.model_id.id),
('translate', 'in', ['standard', 'html_translate', 'xml_translate']),
('related', '=', False),
('store', '=', True),
])
record.translatable_field_ids = translatable_fields
record.has_translatable_fields = bool(translatable_fields)
else:
record.translatable_field_ids = False
record.has_translatable_fields = False
@api.model_create_multi
def create(self, vals_list):
records = super().create(vals_list)
records._create_server_actions()
return records
def write(self, vals):
model_changed = 'model_id' in vals
result = super().write(vals)
if 'active' in vals or model_changed:
self._update_server_actions(model_changed=model_changed)
return result
def unlink(self):
self.server_action_id.unlink()
return super().unlink()
def _create_server_actions(self):
"""Create server actions for each configuration."""
for record in self:
if not record.server_action_id and record.model_id:
record._create_single_server_action()
def _create_single_server_action(self):
"""Create a single server action for this configuration."""
self.ensure_one()
if not self.model_id:
return
action_name = _('Bulk translate')
server_action = self.env['ir.actions.server'].sudo().create({
'name': action_name,
'model_id': self.model_id.id,
'binding_model_id': self.model_id.id,
'binding_view_types': 'list',
'state': 'code',
'code': "action = env['otk.translation.config'].action_bulk_translate_generic()",
})
# Create external ID for the server action
action_xml_id = 'bulk_translate_config_action_for_' + self.model_id.model.replace('.', '_')
existing_data = self.env['ir.model.data'].sudo().search([
('module', '=', 'otoolkit_auto_translate_fields'),
('name', '=', action_xml_id),
])
if existing_data:
existing_data.unlink()
self.env['ir.model.data'].sudo().create({
'name': action_xml_id,
'model': 'ir.actions.server',
'module': 'otoolkit_auto_translate_fields',
'res_id': server_action.id,
'noupdate': True,
})
self.server_action_id = server_action
def _update_server_actions(self, model_changed=False):
"""Update server actions when configuration changes."""
for record in self:
# If model changed, delete old action and create new one
if model_changed and record.server_action_id:
record.server_action_id.unlink()
record.server_action_id = False
if record.active and not record.server_action_id:
record._create_single_server_action()
elif not record.active and record.server_action_id:
record.server_action_id.unlink()
record.server_action_id = False
@api.model
def action_bulk_translate_generic(self):
"""Generic action to open bulk translate wizard - called from server actions."""
active_model = self.env.context.get('active_model')
if not active_model:
return {'type': 'ir.actions.act_window_close'}
return {
'type': 'ir.actions.act_window',
'name': _('Bulk translate'),
'res_model': 'otk.translation.bulk.translate',
'view_mode': 'form',
'view_id': self.env.ref('otoolkit_auto_translate_fields.view_bulk_translate_wizard_form').id,
'target': 'new',
}
def action_enable_all_translatable_models(self):
"""Enable bulk translation for all models that have translatable fields."""
# Find all models with translatable fields
translatable_models = self.env['ir.model.fields'].search([
('translate', 'in', ['standard', 'html_translate', 'xml_translate']),
('related', '=', False),
('store', '=', True),
]).mapped('model_id')
# Filter out transient models and already configured models
existing_model_ids = self.search([]).mapped('model_id.id')
models_to_add = translatable_models.filtered(
lambda m: m.id not in existing_model_ids and not m.transient
)
# Create configurations
vals_list = [{'model_id': model.id} for model in models_to_add]
created = self.create(vals_list)
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Bulk Translation Enabled'),
'message': _('%d models have been configured for bulk translation.') % len(created),
'type': 'success',
'sticky': False,
}
}
def action_view_translatable_fields(self):
"""View the translatable fields for this model."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _('Translatable Fields'),
'res_model': 'ir.model.fields',
'view_mode': 'list,form',
'domain': [('id', 'in', self.translatable_field_ids.ids)],
'target': 'current',
}
@@ -1,231 +0,0 @@
import requests
import json
import logging
from odoo import models, api, _
_logger = logging.getLogger(__name__)
class TranslationDialog(models.AbstractModel):
_name = "translation.dialog"
_description = "Translation Dialog"
def _get_api_config(self):
"""Get API configuration parameters."""
api_key = self.env['ir.config_parameter'].sudo().get_param('otoolkit_api_key')
api_endpoint = self.env['ir.config_parameter'].sudo().get_param('otoolkit.api.endpoint')
return api_key, api_endpoint
def _make_api_request(self, url, headers, payload, timeout=180):
"""Make an API request with proper error handling and logging.
Default timeout is 180 seconds (3 minutes) to allow for OpenAI processing
when translating to many languages.
"""
try:
response = requests.request(
"POST",
url,
headers=headers,
data=payload,
timeout=timeout
)
return response, None
except requests.exceptions.Timeout:
_logger.warning("OToolKit API request timed out: %s", url)
return None, _("The translation service is taking too long to respond. Please try again.")
except requests.exceptions.ConnectionError as e:
_logger.error("OToolKit API connection error: %s - %s", url, str(e))
return None, _("Unable to connect to the translation service. Please check your internet connection.")
except requests.exceptions.RequestException as e:
_logger.error("OToolKit API request failed: %s - %s", url, str(e))
return None, _("An error occurred while contacting the translation service: %s") % str(e)
def _handle_api_response(self, response):
"""Handle API response and return appropriate result."""
if response.status_code == 200:
return [True, response.json(), ""]
if response.status_code == 401:
_logger.warning("OToolKit API authentication failed (401)")
return [False, _("Your API key is invalid. This may be due to an input error, deletion or deactivation of the key. Please check your Otoolkit settings."), "settings"]
if response.status_code == 400:
try:
error = response.json()
error_type = error.get("type", "unknown")
if error_type == "empty_text":
return [False, _("The default language text cannot be empty."), ""]
if error_type == "insufficient_funds":
return [False, _("You do not have enough credits to translate. Please add credits to your balance to use this function."), "credits"]
if error_type == "invalid_object":
return [False, _("No valid text fields found to translate."), ""]
# Log unhandled 400 error types
_logger.warning("OToolKit API returned unhandled 400 error: %s", error)
return [False, _("Translation error: %s") % error.get("error", "Unknown error"), ""]
except (json.JSONDecodeError, KeyError) as e:
_logger.error("Failed to parse OToolKit API 400 response: %s", str(e))
return [False, _("The translation service returned an invalid response."), ""]
if response.status_code == 429:
_logger.warning("OToolKit API rate limited (429)")
return [False, _("Too many translation requests. Please wait a moment and try again."), ""]
if response.status_code >= 500:
_logger.error("OToolKit API server error (%d): %s", response.status_code, response.text[:500])
return [False, _("The translation service is temporarily unavailable (Error %d). Please try again later.") % response.status_code, ""]
# Log any other unexpected status codes
_logger.error("OToolKit API unexpected status code (%d): %s", response.status_code, response.text[:500])
return [False, _("Unexpected response from translation service (Error %d). Please try again.") % response.status_code, ""]
def _get_effective_value(self, term, updated_terms):
"""Get the effective value of a term, considering user edits in the dialog.
If the user edited the term (present in updated_terms), always use that
value — even if empty (the user explicitly cleared it).
"""
str_id = str(term["id"])
if str_id in updated_terms:
return (updated_terms[str_id] or "").strip()
return (term.get("value") or "").strip()
def _detect_source_and_filter(self, terms, updated_terms, source_lang_hint=None, target_lang_hint=None):
"""Detect the best source language and filter terms to only translate empty ones.
Returns (base_language, filtered_terms) or (None, []) if nothing to translate.
When the caller knows the source/target (single-term flow), it must pass
the hints — relying on auto-detection over `filled_langs` is unsafe
because `next(iter(set))` is non-deterministic and may pick the user's
target language as the source.
Auto-detection priority (used only when hints are absent):
1. User's language (if it has content)
2. en_US (Odoo's typical base language)
3. Any language other than the target hint with content
4. First language with content (last resort)
Terms whose value is identical to the source language's value are treated
as untranslated copies (Odoo duplicates the source value to all languages
by default) and will be sent for translation.
When all values are empty but source text exists (e.g. HTML fields where
translations haven't been set yet), the source text is used as the base
content for translation.
"""
user_lang = self.env.user.lang
# Build a map of lang -> effective value
lang_values = {}
for term in terms:
lang_values[term["lang"]] = self._get_effective_value(term, updated_terms)
# First pass: identify which languages have any content
filled_langs = {lang for lang, val in lang_values.items() if val}
source_from_source_field = False
if not filled_langs:
# All values are empty — fall back to source text if available
# (e.g. HTML fields where translations haven't been set yet)
for term in terms:
source = (term.get("source") or "").strip()
if source:
lang_values[term["lang"]] = source
filled_langs = {lang for lang, val in lang_values.items() if val}
if not filled_langs:
return None, []
source_from_source_field = True
# Determine source language. Explicit hints win — the JS knows which
# row the user clicked, so trust it over heuristic guessing.
if source_lang_hint and source_lang_hint in filled_langs:
source_lang = source_lang_hint
elif user_lang in filled_langs and user_lang != target_lang_hint:
source_lang = user_lang
elif "en_US" in filled_langs and "en_US" != target_lang_hint:
source_lang = "en_US"
else:
# Prefer any filled lang that is NOT the target — never pick the
# user's target as source.
non_target = [l for l in filled_langs if l != target_lang_hint]
source_lang = non_target[0] if non_target else next(iter(filled_langs))
source_value = lang_values[source_lang]
# Second pass: languages that are empty OR identical to source are
# considered untranslated (Odoo copies the source value by default)
empty_langs = set()
for lang, val in lang_values.items():
if lang == source_lang:
continue
if not val or val == source_value:
empty_langs.add(lang)
# If a target hint was provided, restrict the translation to that
# language only — a single-term click should never translate every
# untranslated peer in the dialog as a side effect.
if target_lang_hint:
empty_langs = {target_lang_hint} if target_lang_hint != source_lang else set()
if not empty_langs:
return None, []
keep_langs = {source_lang} | empty_langs
filtered_terms = [t for t in terms if t["lang"] in keep_langs]
if source_from_source_field:
# Populate the base language terms' value with source text so the
# API receives the actual content to translate from
filtered_terms = [
{**t, "value": (t.get("source") or "")} if t["lang"] == source_lang else t
for t in filtered_terms
]
return source_lang, filtered_terms
def otoolkit_api_translation(self, terms, updated_terms, source_lang_hint=None, target_lang_hint=None):
api_key, api_endpoint = self._get_api_config()
if not api_key:
return [False, _("Your API key is invalid. This may be due to an input error, deletion or deactivation of the key. Please check your Otoolkit settings."), "settings"]
if not api_endpoint:
_logger.error("OToolKit API endpoint not configured")
return [False, _("The translation service is not configured. Please contact your administrator."), ""]
base_language, filtered_terms = self._detect_source_and_filter(
terms, updated_terms,
source_lang_hint=source_lang_hint,
target_lang_hint=target_lang_hint,
)
if not base_language:
return [True, [], ""]
payload = json.dumps({
"terms": filtered_terms,
"updated_terms": updated_terms,
"base_language": base_language,
"odoo_user_id": self.env.user.id
})
headers = {
'Odoo-Api-Key': api_key,
'Content-Type': 'application/json'
}
url = f"{api_endpoint}/api/auto-translate-fields/translate/"
response, error_message = self._make_api_request(url, headers, payload)
if error_message:
return [False, error_message, ""]
return self._handle_api_response(response)
@api.model
def translate_text(self, terms, updated_terms, source_lang_hint=None, target_lang_hint=None):
return self.otoolkit_api_translation(
terms, updated_terms,
source_lang_hint=source_lang_hint,
target_lang_hint=target_lang_hint,
)
@@ -1,49 +0,0 @@
from odoo import models, fields, api, _
class TranslationMixin(models.AbstractModel):
_name = 'otk.translation.mixin'
_description = 'Translation Mixin'
last_translation_date = fields.Datetime('Last Translation Date')
def action_bulk_translate(self):
return {
'type': 'ir.actions.act_window',
'name': _('Bulk translate'),
'res_model': 'otk.translation.bulk.translate',
'view_mode': 'form',
'view_id': self.env.ref('otoolkit_auto_translate_fields.view_bulk_translate_wizard_form').id,
'target': 'new',
}
@api.model
def _get_view(self, view_id=None, view_type='search', **options):
arch, view = super()._get_view(view_id, view_type, **options)
if view_type == 'list':
# Here we check if the action already exists
model_name = self._name
action_xml_id = 'bulk_translate_action_for_' + model_name.replace('.', '_')
server_action = self.env.ref(f'otoolkit_auto_translate_fields.{action_xml_id}', raise_if_not_found=False)
if not server_action:
# Create the server action dynamically
model = self.env['ir.model']._get(model_name)
action_name = _('Bulk translate')
server_action = self.env['ir.actions.server'].sudo().create({
'name': action_name,
'model_id': model.id,
'binding_model_id': model.id,
'binding_view_types': 'list',
'state': 'code',
'code': "action = env['%s'].action_bulk_translate()" % model_name,
})
self.env['ir.model.data'].sudo().create({
'name': action_xml_id,
'model': 'ir.actions.server',
'module': "otoolkit_auto_translate_fields",
'res_id': server_action.id,
'noupdate': True,
})
return arch, view
@@ -1,5 +0,0 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_bulk_translate,access_bulk_translate,model_otk_translation_bulk_translate,base.group_user,1,1,1,1
access_translation_action,access_translation_action,model_otk_translation_action,base.group_user,1,1,1,1
access_translation,access_translation,model_otk_translation,base.group_user,1,1,1,1
access_translation_config,access_translation_config,model_otk_translation_config,base.group_system,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_bulk_translate access_bulk_translate model_otk_translation_bulk_translate base.group_user 1 1 1 1
3 access_translation_action access_translation_action model_otk_translation_action base.group_user 1 1 1 1
4 access_translation access_translation model_otk_translation base.group_user 1 1 1 1
5 access_translation_config access_translation_config model_otk_translation_config base.group_system 1 1 1 1
Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 536 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 961 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

@@ -1,71 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.4.1/dist/css/bootstrap.min.css" integrity="sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh" crossorigin="anonymous">
</head>
<body>
<div class="row position-relative pt-lg-3 px-lg-5 pb-lg-3 p-md-4 p-3 mx-auto d-flex align-items-center" style="color:#333333; font-weight:500; font-size:16px; width:95%">
<section class="oe_container">
<div class="col-md-12 text-center" style="margin-top: 10px;">
<a target="_blank" href="https://otoolkit.app">
<h2>Otoolkit</h2>
</a>
<p>
<a href="https://otoolkit.app" target="_blank">Website: otoolkit.app</a><br>
Email: <a href="mailto:contact@otoolkit.app">contact@otoolkit.app</a>
</p>
</div>
<div class="col-md-12 text-center" style="margin-top: 30px;">
<a target="_blank" href="https://otoolkit.app/apps/auto-translate-fields">
<h2>Auto Translate Fields</h2>
</a>
<p>The <strong>Auto Translate Fields</strong> module enhances the translation process in Odoo by adding a convenient "Translate" button to the translation popup of any translatable field. When clicked, it automatically translates the field content into all configured languages in the system, using the current user's language as the source. This feature simplifies and accelerates the workflow for multilingual environments, ensuring that translations are consistent and up-to-date across the platform. Ideal for businesses that operate in multiple languages, this module improves efficiency by reducing manual translation efforts.</p>
<p>Instructions on how to use this module can be found at <a href="https://otoolkit.app/apps/auto-translate-fields">https://otoolkit.app/apps/auto-translate-fields</a></p>
</div>
<div class="col-md-12 text-center" style="margin-top: 30px;text-align: left !important;">
<h2 style="text-align: center !important">
<b>How it works</b>
</h2>
<div class="row text-dark">
<div class="col-lg-8 col-sm-12 mt-5 mb-5">
<img src="assets/translate-field.webp" class="shadow" style="width: 100%; border-radius: 15px;" alt="Classic text input with auto translate in odoo">
</div>
<div class="col-lg-4 col-sm-12 mt-5 mb-5" style="display: flex; align-items: center;">
<div>
<h3>
<b>Auto Translate</b>
</h3>
<p class="mt-4">When you translate your fields, you'll find a new button called «Auto Translate». Click on it to translate your text into all the languages of your instance. The user's base language is used as the starting language.</p>
</div>
</div>
<hr class="d-lg-none">
<div class="col-lg-4 col-sm-12 mt-5 mb-5" style="display: flex; align-items: center;">
<div>
<h3>
<b>Also works with HTML fields</b>
</h3>
<p class="mt-4">Automatic translation also works with HTML fields. This saves time by avoiding line-by-line translation.</p>
</div>
</div>
<div class="col-lg-8 col-sm-12 mt-5 mb-5">
<img src="assets/markdown-input.webp" class="shadow" style="width: 100%; border-radius: 15px;" alt="Markdown input with auto translate in odoo">
</div>
</div>
</div>
<div class="col-md-12 text-center mt-5 p-4" style="background-color: #f8f9fa; border-radius: 15px; box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.1);">
<h3 style="color: rgb(124, 58, 237);">Have any questions?</h3>
<p class="mt-3" style="color: #333;">If you have any questions or need more information, feel free to visit our website or contact us directly.</p>
<p>
<span href="https://otoolkit.app" class="btn otoolkit-open-site" style="background-color: rgb(124, 58, 237); color: #fff; border-radius: 5px; padding: 10px 20px; text-decoration: none;">Visit Our Website</span>
<a href="mailto:contact@otoolkit.app" class="btn" style="background-color: #fff; color: rgb(124, 58, 237); border: 2px solid rgb(124, 58, 237); border-radius: 5px; padding: 10px 20px; text-decoration: none;">Email Us</a>
</p>
<a href="https://otoolkit.app">https://otoolkit.app</a>
</div>
</section>
</div>
</body>
</html>
@@ -1,25 +0,0 @@
/** @odoo-module **/
import { standardFieldProps } from "@web/views/fields/standard_field_props";
import { Component, xml } from "@odoo/owl";
import { registry } from "@web/core/registry";
export class JsonPrettyField extends Component {
static template = xml`
<p style="white-space: pre-wrap; background: #f5f5f5; padding: 12px; border-radius: 6px;">
<t t-esc="this.prettyJson"/>
</p>
`;
static props = standardFieldProps;
get prettyJson() {
try {
const value = this.props.record.data[this.props.name];
return JSON.stringify(value ?? {}, null, 4);
} catch (e) {
return "Invalid JSON";
}
}
}
registry.category("fields").add("json_pretty", { component: JsonPrettyField });
@@ -1,210 +0,0 @@
/** @odoo-module **/
import { TranslationDialog } from "@web/views/fields/translation_dialog";
import { patch } from "@web/core/utils/patch";
import { useService } from "@web/core/utils/hooks";
import { _t } from "@web/core/l10n/translation";
import { useState } from "@odoo/owl";
patch(TranslationDialog.prototype, {
setup() {
super.setup(...arguments);
this.orm = useService('orm');
this.notification = useService("notification");
this.action = useService("action");
this.state = useState({ translating: false, translatingTermIds: {} });
},
/**
* Find the best source term to translate from for a given target term.
* Picks a term in a different language with the same source text.
* Priority: any candidate with actual translated content > en_US > first
* candidate. en_US is deprioritized because Odoo populates en_US from the
* record's base value — which may itself be in another language (e.g. a
* record edited primarily in French) and would mislabel the source to GPT.
*/
_findSourceTerm(targetTerm) {
const candidates = this.terms.filter(
t => t.lang !== targetTerm.lang && t.source === targetTerm.source
);
if (candidates.length === 0) return null;
return candidates.find(t => (this.updatedTerms[t.id] ?? t.value ?? "").trim())
|| candidates.find(t => t.lang === "en_US")
|| candidates[0];
},
/**
* Handle translation error notifications.
* Returns true if an error was displayed (caller should stop).
*/
_handleTranslationNotification(result) {
if (!result[0]) {
if (result[2] === "credits") {
this.notification.add(result[1], {
type: "danger",
buttons: [{
onClick: () => window.open("https://osolutions.app/pricing"),
primary: false,
name: _t("Get tokens"),
}],
});
} else if (result[2] === "settings") {
this.notification.add(result[1], {
type: "danger",
buttons: [{
onClick: () => this.action.doAction({
type: "ir.actions.act_window",
res_model: "res.config.settings",
views: [[false, "form"]],
target: "current",
context: { module: "otoolkit_auth" },
}),
primary: false,
name: _t("Open Settings"),
}],
});
} else {
this.notification.add(result[1], { type: "danger" });
}
return true;
}
return false;
},
/**
* Apply translated terms to the dialog state.
* Returns the count of terms updated.
*/
_applyTranslationResults(translatedTerms) {
let count = 0;
for (const item of translatedTerms) {
if (item.translation) {
this.updatedTerms[item.id] = item.translation;
const target = this.terms.find(t => t.id === item.id);
if (target) {
target.value = item.translation;
count++;
}
}
}
return count;
},
/**
* Whether any translation (bulk or single-term) is currently running.
*/
get anyTranslating() {
if (this.state.translating) return true;
for (const id in this.state.translatingTermIds) {
if (this.state.translatingTermIds[id]) return true;
}
return false;
},
/**
* Bulk auto-translate all empty terms.
*/
async autoTranslate() {
this.state.translating = true;
try {
const result = await this._translateText(this.terms, this.updatedTerms, null, null);
if (this._handleTranslationNotification(result)) return;
if (result[1].length === 0) {
return this.notification.add(
_t("All translations are already filled."), { type: "info" }
);
}
const count = this._applyTranslationResults(result[1]);
this.render();
if (count > 0) {
this.notification.add(_t("Successfully translated"), { type: "success" });
}
} finally {
this.state.translating = false;
}
},
/**
* Translate a single empty term.
*/
async translateSingleTerm(targetTerm) {
const sourceTerm = this._findSourceTerm(targetTerm);
if (!sourceTerm) {
return this.notification.add(
_t("No other language available to translate from."), { type: "warning" }
);
}
this.state.translatingTermIds[targetTerm.id] = true;
try {
const result = await this._translateText(
[sourceTerm, targetTerm], this.updatedTerms,
sourceTerm.lang, targetTerm.lang,
);
if (this._handleTranslationNotification(result)) return;
if (result[1].length === 0) {
return this.notification.add(
_t("No translation returned for this term."), { type: "warning" }
);
}
// Apply only the target term's translation. The API also returns
// an "echo" entry for the source-language term (its `translation`
// is the source text itself) — applying it would overwrite the
// source row's displayed value, which is wrong when the source
// peer is an empty row whose displayed `value` is empty (HTML
// showSource case).
const targetOnly = result[1].filter(item => item.id === targetTerm.id);
const count = this._applyTranslationResults(targetOnly);
this.render();
if (count > 0) {
this.notification.add(_t("Successfully translated"), { type: "success" });
} else {
this.notification.add(
_t("No translation returned for this term."), { type: "warning" }
);
}
} finally {
delete this.state.translatingTermIds[targetTerm.id];
}
},
async _translateText(terms, updated_terms, source_lang_hint = null, target_lang_hint = null) {
return await this.orm.call(
"translation.dialog", "translate_text",
[terms, updated_terms, source_lang_hint, target_lang_hint],
);
},
async onSave() {
const translations = {};
this.terms.forEach((term) => {
const updatedTermValue = this.updatedTerms[term.id];
if (term.id in this.updatedTerms && updatedTermValue !== term.originalValue) {
if (this.props.showSource) {
if (!translations[term.lang]) {
translations[term.lang] = {};
}
const oldTermValue = term.originalValue ? term.originalValue : term.source;
translations[term.lang][oldTermValue] = updatedTermValue || term.source;
} else {
translations[term.lang] = updatedTermValue || false;
}
}
});
await this.orm.call(this.props.resModel, "update_field_translations", [
[this.props.resId],
this.props.fieldName,
translations,
]);
await this.props.onSave();
this.props.close();
},
});
@@ -1,53 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<t t-inherit="web.TranslationDialog" t-inherit-mode="extension">
<!-- Make translation column a flex container for inline button -->
<xpath expr="//div[hasclass('translation')]" position="attributes">
<attribute name="class" add="d-flex align-items-start gap-1" separator=" "/>
</xpath>
<!-- Make textarea fill remaining space -->
<xpath expr="//div[hasclass('translation')]//textarea" position="attributes">
<attribute name="class" add="flex-grow-1" separator=" "/>
</xpath>
<!-- Make input fill remaining space -->
<xpath expr="//div[hasclass('translation')]//input[@type='text']" position="attributes">
<attribute name="class" add="flex-grow-1" separator=" "/>
</xpath>
<!-- Single-term translate button (shown only for empty terms) -->
<xpath expr="//div[hasclass('translation')]" position="inside">
<button t-if="!term.value"
class="btn btn-sm btn-outline-primary px-2 o_translate_single_btn flex-shrink-0"
t-on-click="() => this.translateSingleTerm(term)"
t-att-disabled="anyTranslating"
t-att-title="'Translate to ' + term.langName">
<t t-if="state.translatingTermIds[term.id]">
<i class="fa fa-spinner fa-spin"/>
</t>
<t t-else="">
<i class="fa fa-language"/>
</t>
</button>
</xpath>
<!-- Footer: Save, Discard, Auto Translate -->
<xpath expr="//t[@t-set-slot='footer']" position="replace">
<t t-set-slot="footer">
<button class="btn btn-primary" t-on-click="onSave">
Save
</button>
<button class="btn btn-secondary" t-on-click="props.close">
Discard
</button>
<button type="button" class="btn btn-secondary ml-3" t-on-click="autoTranslate" t-att-disabled="anyTranslating">
<t t-if="state.translating">
<i class="fa fa-spinner fa-spin me-1"/>Translating...
</t>
<t t-else="">Auto Translate</t>
</button>
</t>
</xpath>
</t>
</templates>
@@ -1,101 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_otk_translation_config_list" model="ir.ui.view">
<field name="name">otk.translation.config.list</field>
<field name="model">otk.translation.config</field>
<field name="arch" type="xml">
<list>
<field name="model_id"/>
<field name="has_translatable_fields"/>
<field name="translatable_field_ids" widget="many2many_tags"/>
<field name="active" widget="boolean_toggle"/>
</list>
</field>
</record>
<record id="view_otk_translation_config_form" model="ir.ui.view">
<field name="name">otk.translation.config.form</field>
<field name="model">otk.translation.config</field>
<field name="arch" type="xml">
<form>
<sheet>
<div class="oe_button_box" name="button_box">
<button name="action_view_translatable_fields"
type="object"
class="oe_stat_button"
icon="fa-list"
invisible="not has_translatable_fields">
<field name="has_translatable_fields" invisible="1"/>
<span>Translatable Fields</span>
</button>
</div>
<widget name="web_ribbon" title="Archived" bg_color="text-bg-danger" invisible="active"/>
<group>
<group>
<field name="model_id" options="{'no_create': True}"/>
<field name="active" widget="boolean_toggle"/>
</group>
<group>
<field name="server_action_id" readonly="1"/>
</group>
</group>
<group string="Translatable Fields" invisible="not has_translatable_fields">
<field name="translatable_field_ids" widget="many2many_tags" nolabel="1" colspan="2" readonly="1"/>
</group>
<div class="alert alert-warning" role="alert" invisible="has_translatable_fields">
<strong>Warning:</strong> This model has no translatable fields. Bulk translation will not be useful.
</div>
</sheet>
</form>
</field>
</record>
<record id="view_otk_translation_config_search" model="ir.ui.view">
<field name="name">otk.translation.config.search</field>
<field name="model">otk.translation.config</field>
<field name="arch" type="xml">
<search>
<field name="model_id"/>
<filter name="active" string="Active" domain="[('active', '=', True)]"/>
<filter name="inactive" string="Archived" domain="[('active', '=', False)]"/>
<filter name="has_fields" string="With Translatable Fields" domain="[('has_translatable_fields', '=', True)]"/>
<group>
<filter string="Active" name="group_active" context="{'group_by': 'active'}"/>
</group>
</search>
</field>
</record>
<record id="action_otk_translation_config" model="ir.actions.act_window">
<field name="name">Translation Configuration</field>
<field name="res_model">otk.translation.config</field>
<field name="view_mode">list,form</field>
<field name="context">{'search_default_active': 1}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Configure models for bulk translation
</p>
<p>
Add models here to enable the "Bulk translate" action on their list views.
No code changes required - just select a model and it will automatically
get the bulk translation feature.
</p>
</field>
</record>
<!-- Wizard to enable all translatable models at once -->
<record id="action_enable_all_translatable_models" model="ir.actions.server">
<field name="name">Enable All Translatable Models</field>
<field name="model_id" ref="model_otk_translation_config"/>
<field name="state">code</field>
<field name="code">action = model.action_enable_all_translatable_models()</field>
</record>
<menuitem
id="otoolkit_translation_config_menu"
name="Translation Settings"
parent="otoolkit_menu"
action="action_otk_translation_config"
sequence="120"
/>
</odoo>
@@ -1,167 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_otoolkit_translation_action_list" model="ir.ui.view">
<field name="name">otk.translation.action.list</field>
<field name="model">otk.translation.action</field>
<field name="arch" type="xml">
<list>
<field name="id"/>
<field name="model_id" />
<field name="field_ids" widget="many2many_tags" />
<field name="translation_count" />
<field name="skipped_count" optional="show"/>
<field name="token_cost" />
<field name="status" />
<field name="progress" widget="progressbar"/>
</list>
</field>
</record>
<record id="view_otoolkit_translation_action_form" model="ir.ui.view">
<field name="name">otk.translation.action.form</field>
<field name="model">otk.translation.action</field>
<field name="arch" type="xml">
<form>
<header>
<button invisible="status!='error'" name="retry_error_translation" string="Retry translation" type="object" class="btn-primary" />
</header>
<sheet>
<!-- Skipped records alert -->
<div class="alert alert-warning" role="alert"
invisible="skipped_count == 0">
<strong>
<i class="fa fa-exclamation-triangle" title="Skipped records"/>
<field name="skipped_count" class="d-inline"/> records skipped
</strong>
— these records had no translatable content in the selected fields and were not sent to the API (no tokens consumed).
</div>
<!-- Server-side processing alert -->
<div class="alert alert-info" role="alert"
invisible="status != 'processing' or processing_count == 0">
<strong>
<i class="fa fa-spinner" title="Translating"/>
<field name="processing_count" class="d-inline"/> record(s) being translated on the O'Toolkit server.
</strong>
Large contents can take several minutes. Results are fetched automatically every few minutes —
last check: <field name="last_server_check" class="d-inline"/>
</div>
<group>
<group string="Configuration">
<field name="model_id" />
<field name="status" />
<field name="base_language_id" />
<field name="target_language_ids" widget="many2many_tags" />
<field name="field_ids" widget="many2many_tags" />
<field name="overwrite_existing" />
</group>
<group string="Progress">
<field name="translation_count" />
<field name="token_cost" />
<field name="progress" widget="progressbar" />
</group>
</group>
<notebook>
<page string="Translations" name="translations">
<field name="translation_ids">
<list create="0" delete="0">
<field name="record_id" />
<field name="cost" />
<field name="status" />
<field name="last_server_check" optional="show" />
</list>
</field>
</page>
<page string="Record IDs" name="record_ids">
<group>
<field name="pending_record_ids" widget="jsonb" />
<field name="processing_record_ids" widget="jsonb" />
<field name="done_record_ids" widget="jsonb" />
<field name="error_record_ids" widget="jsonb" />
<field name="skipped_record_ids" widget="jsonb" />
</group>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="view_otoolkit_translation_list" model="ir.ui.view">
<field name="name">otk.translation.list</field>
<field name="model">otk.translation</field>
<field name="arch" type="xml">
<list>
<field name="id"/>
<field name="model_id" />
<field name="record_id" />
<field name="action_id" />
<field name="status" />
<field name="last_server_check" optional="show" />
</list>
</field>
</record>
<record id="view_otoolkit_translation_form" model="ir.ui.view">
<field name="name">otk.translation.form</field>
<field name="model">otk.translation</field>
<field name="arch" type="xml">
<form>
<header>
<button invisible="status!='success'" name="revert_translation" string="Revert translation" type="object" class="btn-primary" />
<button name="open_record" string="Open record" type="object" />
</header>
<sheet>
<group>
<field name="model_id" />
<field name="record_id" />
<field name="status" />
<field name="last_server_check" invisible="status != 'pending'" />
</group>
<notebook>
<page string="Translations">
<field name="translations" widget="json_pretty" />
</page>
<page string="Initial value" invisible="status!='success' and status!='revert'">
<field name="initial_values" widget="json_pretty" />
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="action_otoolkit_translation" model="ir.actions.act_window">
<field name="name">Translation actions</field>
<field name="res_model">otk.translation</field>
<field name="view_mode">list,form</field>
</record>
<record id="action_otoolkit_translation_action" model="ir.actions.act_window">
<field name="name">Translation actions</field>
<field name="res_model">otk.translation.action</field>
<field name="view_mode">list,form</field>
</record>
<menuitem
id="otoolkit_menu"
name="Otoolkit"
parent="base.menu_custom"
sequence="100"
/>
<menuitem
id="otoolkit_translation_actions_menu"
name="Bulk translations"
parent="otoolkit_menu"
action="action_otoolkit_translation_action"
sequence="105"
/>
<menuitem
id="otoolkit_translation_menu"
name="Past translations"
parent="otoolkit_menu"
action="action_otoolkit_translation"
sequence="110"
/>
</odoo>
@@ -1 +0,0 @@
from . import bulk_translate
@@ -1,138 +0,0 @@
from odoo import fields, models, api, _
from odoo.exceptions import UserError
from ..models.translation import _is_valid_translatable_value
class BulkTranslate(models.TransientModel):
_name = 'otk.translation.bulk.translate'
_description = "Bulk translate Wizard"
model_id = fields.Many2one('ir.model', string="Model", ondelete='cascade')
base_language_id = fields.Many2one('res.lang', string="Base language", required=True, ondelete='cascade')
target_language_ids = fields.Many2many('res.lang', string="Target languages", required=True, ondelete='cascade')
field_ids = fields.Many2many(
'ir.model.fields',
string="Fields",
domain="[('translate', 'in', ['standard', 'html_translate', 'xml_translate']), ('model_id', '=', model_id), ('related', '=', False), ('store', '=', True)]",
required=True
)
overwrite_existing = fields.Boolean(
string="Overwrite existing translations",
default=False,
help="Re-translate every term, including terms that already have a "
"translation in the target languages.",
)
@api.depends('model_id')
def _compute_model_name(self):
for record in self:
record.model_name = record.model_id.model
@api.model
def default_get(self, fields_list):
defaults = super().default_get(fields_list)
active_model = self.env.context.get('active_model')
if active_model:
model = self.env['ir.model'].search([('model', '=', active_model)], limit=1)
defaults['model_id'] = model.id
defaults['base_language_id'] = self.env['res.lang'].search([('code', '=', self.env.user.lang)], limit=1)
defaults['target_language_ids'] = self.env['res.lang'].search([('code', '!=', self.env.user.lang)])
return defaults
def _resolve_field_value(self, model_name, record_id, field_name, base_lang_code):
"""Resolve a translatable field value with language fallback.
Tries base_lang first, then en_US. Handles html_translate fields that
may return False for a specific lang context.
"""
candidates = [base_lang_code]
if base_lang_code != 'en_US':
candidates.append('en_US')
for try_lang in candidates:
record = self.env[model_name].with_context(lang=try_lang).browse(record_id)
if not record.exists():
return False
value = getattr(record, field_name, False)
if _is_valid_translatable_value(value):
return value
return False
def _pre_filter_records(self, active_ids):
"""Pre-filter records to separate translatable from empty ones.
Uses language fallback (base_lang → en_US) to handle html_translate
fields that may not resolve for a specific lang context.
Returns:
tuple: (translatable_ids, skipped_ids)
"""
model_name = self.model_id.model
base_lang_code = self.base_language_id.code
translatable_ids = []
skipped_ids = []
for record_id in active_ids:
has_content = False
for field in self.field_ids:
value = self._resolve_field_value(
model_name, record_id, field.name, base_lang_code
)
if _is_valid_translatable_value(value):
has_content = True
break
if has_content:
translatable_ids.append(record_id)
else:
skipped_ids.append(record_id)
return translatable_ids, skipped_ids
def action_translate(self):
active_ids = self.env.context.get('active_ids', [])
if not active_ids:
raise UserError(_("No records selected for translation."))
# Pre-filter: separate records with content from empty ones
translatable_ids, skipped_ids = self._pre_filter_records(active_ids)
if not translatable_ids:
raise UserError(_(
"None of the %d selected records have translatable content "
"in the selected fields (%s).\n\n"
"Please check that the records have content in the base language (%s)."
) % (
len(active_ids),
', '.join(self.field_ids.mapped('field_description')),
self.base_language_id.name,
))
self.env['otk.translation.action'].sudo().create({
'model_id': self.model_id.id,
'base_language_id': self.base_language_id.id,
'target_language_ids': self.target_language_ids.ids,
'field_ids': self.field_ids.ids,
'overwrite_existing': self.overwrite_existing,
'pending_record_ids': translatable_ids,
'skipped_record_ids': skipped_ids,
'translation_count': len(translatable_ids),
})
# Show summary notification if some records were skipped
if skipped_ids:
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _("Translation Started"),
'message': _(
"%d records queued for translation.\n"
"%d records skipped (no translatable content in selected fields)."
) % (len(translatable_ids), len(skipped_ids)),
'type': 'warning',
'sticky': True,
}
}
@@ -1,24 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_bulk_translate_wizard_form" model="ir.ui.view">
<field name="name">translation.bulk.translate.wizard.form</field>
<field name="model">otk.translation.bulk.translate</field>
<field name="arch" type="xml">
<form string="Bulk translate">
<sheet>
<group>
<field invisible="1" name="model_id" />
<field options="{'no_create': True, 'no_open': True}" name="field_ids" widget="many2many_tags" />
<field options="{'no_create': True, 'no_open': True}" name="base_language_id" />
<field options="{'no_create': True, 'no_open': True}" name="target_language_ids" widget="many2many_tags" />
<field name="overwrite_existing" />
</group>
</sheet>
<footer>
<button string="Translate" name="action_translate" type="object" class="btn-primary"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
</odoo>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

@@ -46,7 +46,11 @@ Optional Dependencies
'website_blog',
],
'external_dependencies': {
'python': ['requests'],
'python': [
'requests',
'pyjwt',
'beautifulsoup4',
],
},
'data': [
# Data
@@ -5,6 +5,8 @@ import logging
import re
import requests
import time
import jwt
from bs4 import BeautifulSoup
from markupsafe import Markup
+3
View File
@@ -0,0 +1,3 @@
pyjwt
beautifulsoup4
requests
Internal Server Error - Gitea: Git with a cup of tea
500 Internal Server Error

Gitea Version: 1.28.0+dev-115-gb06002f449