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