232 lines
10 KiB
Python
232 lines
10 KiB
Python
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,
|
|
)
|