[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:
committed by
Sébastien Theys
parent
f64c9f27f1
commit
a3bab45dbe
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
@@ -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"]);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user