diff --git a/addons/mail/controllers/__init__.py b/addons/mail/controllers/__init__.py index 17a0f967a9d..833021cecc2 100644 --- a/addons/mail/controllers/__init__.py +++ b/addons/mail/controllers/__init__.py @@ -1,6 +1,7 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from . import attachment +from . import google_translate from . import guest from . import link_preview from . import mail diff --git a/addons/mail/controllers/google_translate.py b/addons/mail/controllers/google_translate.py new file mode 100644 index 00000000000..5f8b65ba158 --- /dev/null +++ b/addons/mail/controllers/google_translate.py @@ -0,0 +1,55 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import babel +import requests + +from odoo.http import request, route, Controller + + +class GoogleTranslateController(Controller): + @route("/mail/message/translate", type="json", auth="user") + def translate(self, message_id): + message = request.env["mail.message"].search([("id", "=", message_id)]) + if not message: + raise request.not_found() + domain = [("message_id", "=", message.id), ("target_lang", "=", request.env.user.lang.split("_")[0])] + # sudo: mail.message.translation - searching translations of a message that can be read with standard ACL + translation = request.env["mail.message.translation"].sudo().search(domain) + if not translation: + try: + source_lang = self._detect_source_lang(message) + target_lang = request.env.user.lang.split("_")[0] + # sudo: mail.message.translation - create translation of a message that can be read with standard ACL + vals = { + "body": self._get_translation(str(message.body), source_lang, target_lang), + "message_id": message.id, + "source_lang": source_lang, + "target_lang": target_lang, + } + translation = request.env["mail.message.translation"].sudo().create(vals) + except requests.exceptions.HTTPError as err: + return {"error": err.response.json()["error"]["message"]} + return { + "body": translation.body, + "lang_name": babel.Locale(translation.source_lang).get_display_name(request.env.user.lang), + } + + def _detect_source_lang(self, message): + # sudo: mail.message.translation - searching translations of a message that can be read with standard ACL + translation = request.env["mail.message.translation"].sudo().search([("message_id", "=", message.id)], limit=1) + if translation: + return translation.source_lang + response = self._post(endpoint="detect", data={"q": str(message.body)}) + return response.json()["data"]["detections"][0][0]["language"] + + def _get_translation(self, body, source_lang, target_lang): + response = self._post(data={"q": body, "target": target_lang, "source": source_lang}) + return response.json()["data"]["translations"][0]["translatedText"] + + def _post(self, endpoint="", data=None): + # sudo: ir.config_parameter - reading google translate api key, using it to make the request + api_key = request.env["ir.config_parameter"].sudo().get_param("mail.google_translate_api_key") + url = f"https://translation.googleapis.com/language/translate/v2/{endpoint}?key={api_key}" + response = requests.post(url, data=data, timeout=3) + response.raise_for_status() + return response diff --git a/addons/mail/models/__init__.py b/addons/mail/models/__init__.py index e5ec79f2273..b95ab6967d6 100644 --- a/addons/mail/models/__init__.py +++ b/addons/mail/models/__init__.py @@ -33,6 +33,7 @@ from . import mail_link_preview from . import mail_message_reaction from . import mail_message_schedule from . import mail_message_subtype +from . import mail_message_translation from . import mail_message from . import mail_mail from . import mail_tracking_value diff --git a/addons/mail/models/discuss/mail_guest.py b/addons/mail/models/discuss/mail_guest.py index 74b776d782c..172c34a13b1 100644 --- a/addons/mail/models/discuss/mail_guest.py +++ b/addons/mail/models/discuss/mail_guest.py @@ -147,6 +147,7 @@ class MailGuest(models.Model): # sudo: ir.config_parameter: safe to check for existence of tenor api key 'hasGifPickerFeature': bool(self.env["ir.config_parameter"].sudo().get_param("discuss.tenor_api_key")), 'hasLinkPreviewFeature': self.env['mail.link.preview']._is_link_preview_enabled(), + 'hasMessageTranslationFeature': False, # sudo: bus.bus: reading non-sensitive last id 'initBusId': self.env['bus.bus'].sudo()._bus_last_id(), 'menu_id': False, diff --git a/addons/mail/models/discuss/res_users.py b/addons/mail/models/discuss/res_users.py index 05e617b1831..00befd533e1 100644 --- a/addons/mail/models/discuss/res_users.py +++ b/addons/mail/models/discuss/res_users.py @@ -61,5 +61,7 @@ class ResUsers(models.Model): "channels": channels._channel_info(), # sudo: ir.config_parameter - reading hard-coded key to check its existence, safe to return if the feature is enabled "hasGifPickerFeature": bool(self.env["ir.config_parameter"].sudo().get_param("discuss.tenor_api_key")), + # sudo: ir.config_parameter - reading hard-coded key to check its existence, safe to return if the feature is enabled + 'hasMessageTranslationFeature': bool(self.env["ir.config_parameter"].sudo().get_param("mail.google_translate_api_key")), **super()._init_messaging(), } diff --git a/addons/mail/models/mail_message_translation.py b/addons/mail/models/mail_message_translation.py new file mode 100644 index 00000000000..f20ffc41f68 --- /dev/null +++ b/addons/mail/models/mail_message_translation.py @@ -0,0 +1,32 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from dateutil.relativedelta import relativedelta + +from odoo import api, fields, models + + +class MessageTranslation(models.Model): + _name = "mail.message.translation" + _description = "Message Translation" + + message_id = fields.Many2one("mail.message", "Message", required=True, ondelete="cascade") + source_lang = fields.Char( + "Source Language", required=True, help="Result of the language detection based on its content." + ) + target_lang = fields.Char( + "Target Language", required=True, help="Shortened language code used as the target for the translation request." + ) + body = fields.Html( + "Translation Body", required=True, sanitize_style=True, help="String received from the translation request." + ) + create_date = fields.Datetime(index=True) + + def init(self): + self.env.cr.execute( + f"CREATE UNIQUE INDEX IF NOT EXISTS mail_message_translation_unique ON {self._table} (message_id, target_lang)" + ) + + @api.autovacuum + def _gc_translations(self): + treshold = fields.Datetime().now() - relativedelta(weeks=2) + self.search([("create_date", "<", treshold)]).unlink() diff --git a/addons/mail/models/mail_thread.py b/addons/mail/models/mail_thread.py index 5b7116607ea..75dcbf1539b 100644 --- a/addons/mail/models/mail_thread.py +++ b/addons/mail/models/mail_thread.py @@ -4197,8 +4197,7 @@ class MailThread(models.AbstractModel): empty_messages = message.sudo()._filter_empty() empty_messages._cleanup_side_records() empty_messages.write({'pinned_at': None}) - - self.env['bus.bus']._sendone(message._bus_notification_target(), 'mail.record/insert', { + payload = { 'Message': { 'id': message.id, 'body': message.body, @@ -4207,7 +4206,12 @@ class MailThread(models.AbstractModel): 'recipients': [{'id': p.id, 'name': p.name, 'type': "partner"} for p in message.partner_ids], 'write_date': message.write_date, } - }) + } + if "body" in msg_values: + # sudo: mail.message.translation - discarding translations of message after editing it + self.env["mail.message.translation"].sudo().search([("message_id", "=", message.id)]).unlink() + payload["Message"]["translationValue"] = False + self.env["bus.bus"]._sendone(message._bus_notification_target(), "mail.record/insert", payload) # ------------------------------------------------------ # CONTROLLERS diff --git a/addons/mail/models/res_config_settings.py b/addons/mail/models/res_config_settings.py index c57b86d15e3..5193dc83bad 100644 --- a/addons/mail/models/res_config_settings.py +++ b/addons/mail/models/res_config_settings.py @@ -65,6 +65,11 @@ class ResConfigSettings(models.TransientModel): config_parameter='discuss.tenor_gif_limit', help="Fetch up to the specified number of GIF.", ) + google_translate_api_key = fields.Char( + "Message Translation API Key", + help="A valid Google API key is required to enable message translation. https://cloud.google.com/translate/docs/setup", + config_parameter="mail.google_translate_api_key", + ) def _compute_fail_counter(self): previous_date = fields.Datetime.now() - datetime.timedelta(days=30) diff --git a/addons/mail/security/ir.model.access.csv b/addons/mail/security/ir.model.access.csv index 2c3d3d31e51..bdbcb57e0cf 100644 --- a/addons/mail/security/ir.model.access.csv +++ b/addons/mail/security/ir.model.access.csv @@ -62,3 +62,4 @@ access_discuss_gif_favorite,discuss.gif.favorite,model_discuss_gif_favorite,base access_discuss_voice_metadata_user,discuss.voice.metadata.user,model_discuss_voice_metadata,base.group_system,1,1,1,1 access_mail_partner_device,access_mail_partner_device,mail.model_mail_partner_device,base.group_system,1,1,1,1 access_mail_notification_web_push,access_mail_notification_web_push,mail.model_mail_notification_web_push,base.group_system,1,1,1,1 +access_mail_message_translation_system,mail.message.translation,model_mail_message_translation,base.group_system,1,1,1,1 diff --git a/addons/mail/static/src/core/common/message.js b/addons/mail/static/src/core/common/message.js index e95f2822e5c..54b3722a351 100644 --- a/addons/mail/static/src/core/common/message.js +++ b/addons/mail/static/src/core/common/message.js @@ -16,6 +16,7 @@ import { isEventHandled, markEventHandled } from "@web/core/utils/misc"; import { Component, + markup, onMounted, onPatched, useChildSubEnv, @@ -103,6 +104,7 @@ export class Message extends Component { expandOptions: false, originalEmail: false, emailHeaderOpen: false, + showTranslation: false, }); this.root = useRef("root"); this.hasTouch = hasTouch; @@ -314,6 +316,22 @@ export class Message extends Component { return this.props.thread.id === "inbox"; } + get translatable() { + return ( + this.store.hasMessageTranslationFeature && + this.env.inChatter && + !this.message.isSelfAuthored + ); + } + + get translatedFromText() { + return _t("(Translated from: %(language)s)", { language: this.message.translationSource }); + } + + get translationFailureText() { + return _t("(Translation Failure: %(error)s)", { error: this.message.translationErrors }); + } + onMouseenter() { this.state.isHovered = true; } @@ -477,4 +495,17 @@ export class Message extends Component { message: this.props.message, }); } + + async onClickToggleTranslation() { + if (!this.message.translationValue) { + const { error, lang_name, body } = await this.rpc("/mail/message/translate", { + message_id: this.message.id, + }); + this.message.translationValue = body && markup(body); + this.message.translationSource = lang_name; + this.message.translationErrors = error; + } + this.state.showTranslation = + !this.state.showTranslation && Boolean(this.message.translationValue); + } } diff --git a/addons/mail/static/src/core/common/message.scss b/addons/mail/static/src/core/common/message.scss index 5e2dc979210..62b3e207383 100644 --- a/addons/mail/static/src/core/common/message.scss +++ b/addons/mail/static/src/core/common/message.scss @@ -74,3 +74,7 @@ .o-mail-Message-starred { color: $o-main-favorite-color; } + +.o-mail-Message-translated { + color: $o-enterprise-action-color; +} diff --git a/addons/mail/static/src/core/common/message.xml b/addons/mail/static/src/core/common/message.xml index 16837a22d1f..2095c5a4c3f 100644 --- a/addons/mail/static/src/core/common/message.xml +++ b/addons/mail/static/src/core/common/message.xml @@ -93,7 +93,15 @@