From a3bab45dbec269dc6a67cb9534ab9102c8aa9129 Mon Sep 17 00:00:00 2001 From: Killian Frappart Date: Tue, 24 Oct 2023 18:20:43 +0200 Subject: [PATCH] [IMP] mail: translate message from chatter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit introduces a new optional feature in the chatter. Users can request translation from the Google Cloud Translation API. Documentation: Adding the key in settings will instantly enable the feature for all users of the database and consumption is not measured nor limited by Odoo as it is natively supported using the Google Cloud Console. Translation values are stored in the database to reduce cost as much as possible. Therefore, translation values are automatically garbage collected every 2 weeks to prevent database pollution. Task-3338165 closes odoo/odoo#126555 Signed-off-by: Sébastien Theys (seb) --- addons/mail/controllers/__init__.py | 1 + addons/mail/controllers/google_translate.py | 55 ++++++++ addons/mail/models/__init__.py | 1 + addons/mail/models/discuss/mail_guest.py | 1 + addons/mail/models/discuss/res_users.py | 2 + .../mail/models/mail_message_translation.py | 32 +++++ addons/mail/models/mail_thread.py | 10 +- addons/mail/models/res_config_settings.py | 5 + addons/mail/security/ir.model.access.csv | 1 + addons/mail/static/src/core/common/message.js | 31 +++++ .../mail/static/src/core/common/message.scss | 4 + .../mail/static/src/core/common/message.xml | 8 ++ .../static/src/core/common/message_actions.js | 8 ++ .../static/src/core/common/message_model.js | 6 + .../src/core/common/messaging_service.js | 1 + .../helpers/mock_server/models/res_users.js | 1 + .../tests/translation/translation_test.js | 48 +++++++ addons/mail/static/tests/web/chatter_tests.js | 13 +- addons/mail/tests/__init__.py | 1 + .../mail/tests/test_translation_controller.py | 130 ++++++++++++++++++ .../mail/views/res_config_settings_views.xml | 3 + .../tests/test_performance.py | 1 + 22 files changed, 352 insertions(+), 11 deletions(-) create mode 100644 addons/mail/controllers/google_translate.py create mode 100644 addons/mail/models/mail_message_translation.py create mode 100644 addons/mail/static/tests/translation/translation_test.js create mode 100644 addons/mail/tests/test_translation_controller.py 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 @@