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, )