translate
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user