[IMP] mail: translate message from chatter

This commit introduces a new optional feature in the chatter.

Users can request translation from the Google Cloud Translation API.
Documentation: <https://cloud.google.com/translate/docs#docs>

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) <seb@odoo.com>
This commit is contained in:
Killian Frappart
2023-10-25 11:37:17 +00:00
committed by Sébastien Theys
parent f64c9f27f1
commit a3bab45dbe
22 changed files with 352 additions and 11 deletions
+1
View File
@@ -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
@@ -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
+1
View File
@@ -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
+1
View File
@@ -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,
+2
View File
@@ -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(),
}
@@ -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()
+7 -3
View File
@@ -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
@@ -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)
+1
View File
@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
62 access_discuss_voice_metadata_user discuss.voice.metadata.user model_discuss_voice_metadata base.group_system 1 1 1 1
63 access_mail_partner_device access_mail_partner_device mail.model_mail_partner_device base.group_system 1 1 1 1
64 access_mail_notification_web_push access_mail_notification_web_push mail.model_mail_notification_web_push base.group_system 1 1 1 1
65 access_mail_message_translation_system mail.message.translation model_mail_message_translation base.group_system 1 1 1 1
@@ -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);
}
}
@@ -74,3 +74,7 @@
.o-mail-Message-starred {
color: $o-main-favorite-color;
}
.o-mail-Message-translated {
color: $o-enterprise-action-color;
}
@@ -93,7 +93,15 @@
<button t-if="message.type === 'email'" class="o-mail-Message-originalEmailText d-block float-end p-1 mb-1 btn btn-link opacity-75 fst-italic" t-att-class="env.inChatWindow and isAlignedRight ? 'me-3' : 'ms-3'" t-esc="originalEmailText" t-on-click="() => this.state.originalEmail = !this.state.originalEmail"/>
<em t-if="message.subject and !message.isSubjectSimilarToOriginThreadName and !message.isSubjectDefault" class="mb-1 me-2">Subject: <t t-out="props.messageSearch?.highlight(message.subject) ?? message.subject"/></em>
<div t-if="message.type === 'email'" t-ref="shadowBody"/>
<t t-elif="state.showTranslation" t-out="message.translationValue"/>
<t t-elif="message.body" t-out="props.messageSearch?.highlight(message.body) ?? message.body"/>
<p class="fst-italic text-muted small" t-if="state.showTranslation">
<t t-if="message.translationSource" t-esc="translatedFromText"/>
</p>
<p class="fst-italic text-muted small" t-if="message.translationErrors">
<i class="text-danger fa fa-warning" role="img" aria-label="Translation Failure"/>
<t t-if="message.translationErrors" t-esc="translationFailureText"/>
</p>
<!-- <small t-if="message.editDate" class="o-mail-Message-edited fst-italic text-muted" t-att-class="{ 'ms-2': !message.isBodyEmpty }" t-att-title="message.editDatetimeHuge">(edited)</small> --> <!-- DISABLED because new messages sent by email are wrongly flagged as (edited) -->
<t t-if="showSubtypeDescription" t-out="props.messageSearch?.highlight(message.subtype_description) ?? message.subtype_description"/>
</t>
@@ -90,6 +90,14 @@ messageActionsRegistry
url: "mail/attachment/zip",
}),
sequence: 55,
})
.add("toggle-translation", {
condition: (component) => component.translatable,
icon: (component) =>
`fa-language ${component.state.showTranslation ? "o-mail-Message-translated" : ""}`,
title: (component) => (component.state.showTranslation ? _t("Revert") : _t("Translate")),
onClick: (component) => component.onClickToggleTranslation(),
sequence: 100,
});
function transformAction(component, id, action) {
@@ -127,6 +127,12 @@ export class Message extends Record {
subtype_description;
/** @type {Object[]} */
trackingValues = [];
/** @type {string|undefined} */
translationValue;
/** @type {string|undefined} */
translationSource;
/** @type {string|undefined} */
translationErrors;
/** @type {string} */
type;
/** @type {string} */
@@ -92,6 +92,7 @@ export class Messaging {
this.store.odoobotOnboarding = data.odoobotOnboarding;
this.isReady.resolve(data);
this.store.isMessagingReady = true;
this.store.hasMessageTranslationFeature = data.hasMessageTranslationFeature;
}
updateImStatusRegistration() {
@@ -35,6 +35,7 @@ patch(MockServer.prototype, {
userSettings.id
),
hasGifPickerFeature: true,
hasMessageTranslationFeature: true,
initBusId: this.lastBusNotificationId,
menu_id: false, // not useful in QUnit tests
needaction_inbox_counter: this._mockResPartner_GetNeedactionCount(user.partner_id),
@@ -0,0 +1,48 @@
/* @odoo-module */
import { startServer } from "@bus/../tests/helpers/mock_python_environment";
import { start } from "@mail/../tests/helpers/test_utils";
import { click, contains } from "@web/../tests/utils";
QUnit.module("Google Cloud Translation");
QUnit.test("Toggle display of original/translated version of chatter message", async (assert) => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
pyEnv["mail.message"].create({
model: "res.partner",
body: "Al mal tiempo, buena cara.",
author_id: pyEnv.odoobotId,
res_id: partnerId,
});
const { openFormView } = await start({
mockRPC(route) {
if (route === "/mail/message/translate") {
assert.step("Request");
return { body: "To bad weather, good face.", lang_name: "Spanish", error: null };
}
},
});
await openFormView("res.partner", partnerId);
await click("button[title='Expand']");
await contains("span[title='Translate']");
await contains("span[title='Revert']", { count: 0 });
// Click acts as a toogle affecting its appearence and the actual message content displayed.
await click("span[title='Translate']");
await click("button[title='Expand']");
await contains(".o-mail-Message-body", {
text: "To bad weather, good face.(Translated from: Spanish)",
});
await contains("span[title='Translate']", { count: 0 });
await contains("span[title='Revert']");
await click("span[title='Revert']");
await click("button[title='Expand']");
await contains(".o-mail-Message", {
text: "Al mal tiempo, buena cara.",
});
await click("span[title='Translate']");
// The translation button should not trigger more than one external request for a single message.
assert.verifySteps(["Request"]);
});
@@ -6,7 +6,7 @@ import { DELAY_FOR_SPINNER } from "@mail/core/web/chatter";
import { patchUiSize, SIZES } from "@mail/../tests/helpers/patch_ui_size";
import { start } from "@mail/../tests/helpers/test_utils";
import {makeDeferred, patchWithCleanup, triggerHotkey} from "@web/../tests/helpers/utils";
import { makeDeferred, patchWithCleanup, triggerHotkey } from "@web/../tests/helpers/utils";
import {
click,
contains,
@@ -315,7 +315,6 @@ QUnit.test("base rendering when chatter has no record", async () => {
await contains(".o-mail-Message-body", { text: "Creating a new record..." });
await contains("button", { count: 0, text: "Load More" });
await contains(".o-mail-Message-actions");
await contains(".o-mail-Message-actions i", { count: 0 });
});
QUnit.test("base rendering when chatter has attachments", async () => {
@@ -755,11 +754,11 @@ QUnit.test(
const wizardOpened = makeDeferred();
patchWithCleanup(env.services.action, {
doAction(action, options) {
if (action.res_model === 'res.partner') {
if (action.res_model === "res.partner") {
return super.doAction(action, options);
} else if (action.res_model === 'mail.activity.schedule') {
} else if (action.res_model === "mail.activity.schedule") {
assert.step("mail.activity.schedule");
assert.equal(action.context.active_model, 'res.partner');
assert.equal(action.context.active_model, "res.partner");
assert.ok(Number(action.context.active_id));
options.onClose();
wizardOpened.resolve();
@@ -771,9 +770,7 @@ QUnit.test(
await openFormView("res.partner");
await click("button", { text: "Activities" });
await wizardOpened;
assert.verifySteps([
"mail.activity.schedule",
]);
assert.verifySteps(["mail.activity.schedule"]);
}
);
+1
View File
@@ -11,6 +11,7 @@ from . import test_res_company
from . import test_res_partner
from . import test_res_users
from . import test_res_users_settings
from . import test_translation_controller
from . import test_uninstall
from . import test_update_notification
@@ -0,0 +1,130 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import json
import requests
from http import HTTPStatus
from unittest.mock import patch
from odoo.tests.common import HttpCase, JsonRpcException, new_test_user, tagged
from odoo.tools import mute_logger
SAMPLE = {
"text": "<p>Al mal tiempo, buena cara.</p>",
"src": "es",
"en": "<p>To bad weather, good face.</p>",
"fr": "<p>Au mauvais temps, bonne tête.</p>",
"nl": "<script src='xss-min.js'/><p onclick='XSS()'>Bij slecht weer, goed gezicht.</p>",
"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"], "<p>Bij slecht weer, goed gezicht.</p>")
translation = self.env["mail.message.translation"].search([])
self.assertEqual(len(translation), 1)
self.assertHTMLEqual(translation.body, "<p>Bij slecht weer, goed gezicht.</p>")
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})
@@ -94,6 +94,9 @@
<setting id="tenor_gif_limit" string="Tenor GIF limits" help="Fetch up to the specified number of GIF.">
<field name="tenor_gif_limit"/>
</setting>
<setting string="Message Translation" help="Google Translate Integration" id="message_translation_setting" documentation="https://cloud.google.com/translate/docs/setup">
<field name="google_translate_api_key" placeholder="Paste your API key"/>
</setting>
</block>
</div>
<setting id="document_layout_setting" position="after">
@@ -126,6 +126,7 @@ class TestDiscussFullPerformance(HttpCase):
'initBusId': self.env['bus.bus'].sudo()._bus_last_id(),
'hasGifPickerFeature': False,
'hasLinkPreviewFeature': True,
'hasMessageTranslationFeature': False,
'needaction_inbox_counter': 1,
'starred_counter': 1,
'odoobotOnboarding': False,