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 @@
Subject:
+
+
+
Al mal tiempo, buena cara.
", + "src": "es", + "en": "To bad weather, good face.
", + "fr": "Au mauvais temps, bonne tĂȘte.
", + "nl": "Bij slecht weer, goed gezicht.
", + "lang": { + "fr": "espagnol", + "en": "Spanish", + }, +} + + +def mock_response(fun): + def wrapper(self, url, data=False, params=False, timeout=5): + response = requests.Response() + response.status_code = HTTPStatus.OK + content = {"data": fun(self, url=url, data=data, params=params)} + if not content["data"]: + response.status_code = HTTPStatus.BAD_REQUEST + content = {"error": {"message": "Mocked Error"}} + response._content = json.dumps(content).encode() + return response + + return wrapper + + +# Google Cloud Translation Documentation: https://cloud.google.com/translate/docs/reference/api-overview?hl=en +@tagged("post_install", "-at_install") +class TestTranslationController(HttpCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env["res.lang"]._activate_lang("fr_FR") + cls.env["res.lang"]._activate_lang("en_US") + cls.env.ref("base.user_admin").write({"lang": "fr_FR"}) + cls.api_key = "VALIDKEY" + cls.env["ir.config_parameter"].set_param("mail.google_translate_api_key", cls.api_key) + cls.message = cls.env["mail.message"].create( + { + "body": SAMPLE["text"], + "model": "res.partner", + "res_id": cls.env.ref("base.user_admin").partner_id.id, + "message_type": "comment", + } + ) + cls.request_count = 0 + + @mock_response + def _patched_post(self, url, data, params, timeout=5): + self.request_count += 1 + if f"/v2/detect?key={self.api_key}" in url: + result = { + "language": SAMPLE["src"], + "isReliable": True, + "confidence": 0.98, + } + return {"detections": [[result]]} + if f"/v2/?key={self.api_key}" in url: + return {"translations": [{"translatedText": SAMPLE[data.get("target")]}]} + + def _mock_translation_request(self, data): + with patch.object(requests, "post", self._patched_post): + return self.make_jsonrpc_request("/mail/message/translate", data) + + def test_update_message(self): + self.authenticate("admin", "admin") + result = self._mock_translation_request({"message_id": self.message.id}) + self.assertFalse(result.get("error")) + self.assertEqual(self.env["mail.message.translation"].search_count([]), 1) + # The translation records should not be discarded if the body did not change. + self.make_jsonrpc_request( + "/mail/message/update_content", {"message_id": self.message.id, "body": None, "attachment_ids": []} + ) + self.assertEqual(self.env["mail.message.translation"].search_count([]), 1) + self.make_jsonrpc_request( + "/mail/message/update_content", {"message_id": self.message.id, "body": "update", "attachment_ids": []} + ) + self.assertFalse(self.env["mail.message.translation"].search_count([])) + + def test_translation_multi_users(self): + new_test_user(self.env, "user_test_fr", groups="base.group_user", lang="fr_FR") + new_test_user(self.env, "user_test_en", groups="base.group_user", lang="en_US") + for login, target_lang in [("user_test_fr", "fr"), ("user_test_en", "en"), ("admin", "fr")]: + self.authenticate(login, login) + result = self._mock_translation_request({"message_id": self.message.id}) + self.assertFalse(result.get("error")) + self.assertEqual(result["body"], SAMPLE[target_lang]) + self.assertEqual(result["lang_name"], SAMPLE["lang"][target_lang]) + # There is one translation record per target language. + self.assertEqual(self.env["mail.message.translation"].search_count([]), 2) + # No API request should be sent if a translation value or source already exists. + self.assertEqual(self.request_count, 3) + + def test_invalid_api_key(self): + self.env["ir.config_parameter"].set_param("mail.google_translate_api_key", "INVALIDKEY") + self.authenticate("demo", "demo") + result = self._mock_translation_request({"message_id": self.message.id}) + self.assertNotIn("body", result) + self.assertNotIn("lang_name", result) + self.assertTrue(result["error"]) + + def test_html_sanitization(self): + self.env["res.lang"]._activate_lang("nl_NL") + new_test_user(self.env, "user_test_nl", groups="base.group_user", lang="nl_NL") + self.authenticate("user_test_nl", "user_test_nl") + result = self._mock_translation_request({"message_id": self.message.id}) + self.assertFalse(result.get("error")) + self.assertHTMLEqual(result["body"], "Bij slecht weer, goed gezicht.
") + translation = self.env["mail.message.translation"].search([]) + self.assertEqual(len(translation), 1) + self.assertHTMLEqual(translation.body, "Bij slecht weer, goed gezicht.
") + + def test_access_right(self): + with self.assertRaises(JsonRpcException, msg="odoo.http.SessionExpiredException"): + self._mock_translation_request({"message_id": self.message.id}) + new_test_user(self.env, "user_test_portal", groups="base.group_portal", lang="fr_FR") + self.authenticate("user_test_portal", "user_test_portal") + with self.assertRaises(JsonRpcException, msg="odoo.exceptions.AccessError"), mute_logger("odoo.http"): + self._mock_translation_request({"message_id": self.message.id}) diff --git a/addons/mail/views/res_config_settings_views.xml b/addons/mail/views/res_config_settings_views.xml index ddb40d0ae2a..8db6055d2dc 100644 --- a/addons/mail/views/res_config_settings_views.xml +++ b/addons/mail/views/res_config_settings_views.xml @@ -94,6 +94,9 @@