translate

This commit is contained in:
2026-07-11 00:27:43 +02:00
parent 9ebd070c65
commit df94c53a83
27 changed files with 3069 additions and 0 deletions
@@ -0,0 +1,4 @@
from . import translation_mixin
from . import translation_dialog
from . import translation
from . import translation_config
@@ -0,0 +1,825 @@
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
}
@@ -0,0 +1,186 @@
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',
}
@@ -0,0 +1,231 @@
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,
)
@@ -0,0 +1,49 @@
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