[IMP] im_livechat, mail: allow livechat visitors to upload attachments
Since [1], live chat visitors are using the mail guest system for authentication. With this change, visitors are allowed to reach the attachment upload routes (even if nothing allows it in the frontend for now). This commit restricts attachment upload for guest and portal users with the `allow_visitor_upload` field that can be toggled. At the same time, this commit enables file upload when authorized on the frontend and for cross origin live chats. [1]: odoo#129770 task-3332628 closes odoo/odoo#137574 Signed-off-by: Sébastien Theys (seb) <seb@odoo.com>
This commit is contained in:
@@ -124,6 +124,7 @@ Help your customers with this chat, and analyse their feedback.
|
||||
'web/static/src/libs/pdfjs.js',
|
||||
'web/static/src/views/fields/formatters.js',
|
||||
'web/static/src/views/fields/file_handler.*',
|
||||
'web/static/src/scss/mimetypes.scss',
|
||||
'bus/static/src/*.js',
|
||||
'bus/static/src/services/**/*.js',
|
||||
'bus/static/src/workers/websocket_worker.js',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import attachment
|
||||
from . import chatbot
|
||||
from . import main
|
||||
from . import webclient
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import _
|
||||
from odoo.http import route, request
|
||||
from odoo.addons.mail.controllers.attachment import AttachmentController
|
||||
from odoo.exceptions import AccessError
|
||||
from odoo.addons.mail.models.discuss.mail_guest import add_guest_to_context
|
||||
|
||||
|
||||
class LivechatAttachmentController(AttachmentController):
|
||||
@route()
|
||||
@add_guest_to_context
|
||||
def mail_attachment_upload(self, ufile, thread_id, thread_model, is_pending=False, **kwargs):
|
||||
thread = request.env[thread_model]._get_from_context_or_raise(int(thread_id))
|
||||
if (
|
||||
thread_model == "discuss.channel"
|
||||
and thread.channel_type == "livechat"
|
||||
and not thread.livechat_active
|
||||
and not request.env.user._is_internal()
|
||||
):
|
||||
raise AccessError(_("You are not allowed to upload attachments on this channel."))
|
||||
return super().mail_attachment_upload(ufile, thread_id, thread_model, is_pending, **kwargs)
|
||||
@@ -1,5 +1,7 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import attachment
|
||||
from . import binary
|
||||
from . import channel
|
||||
from . import link_preview
|
||||
from . import main
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
from odoo.http import route
|
||||
from odoo.addons.mail.controllers.attachment import AttachmentController
|
||||
from odoo.addons.im_livechat.tools.misc import force_guest_env
|
||||
|
||||
|
||||
class LivechatAttachmentController(AttachmentController):
|
||||
@route("/im_livechat/cors/attachment/upload", auth="public", cors="*", csrf=False)
|
||||
def im_livechat_attachment_upload(self, guest_token, ufile, thread_id, thread_model, is_pending=False, **kwargs):
|
||||
force_guest_env(guest_token)
|
||||
return self.mail_attachment_upload(ufile, thread_id, thread_model, is_pending, **kwargs)
|
||||
|
||||
@route("/im_livechat/cors/attachment/delete", methods=["POST"], type="json", auth="public", cors="*")
|
||||
def im_livechat_attachment_delete(self, guest_token, attachment_id, access_token=None):
|
||||
force_guest_env(guest_token)
|
||||
return self.mail_attachment_delete(attachment_id, access_token)
|
||||
@@ -0,0 +1,33 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
|
||||
from odoo.http import route
|
||||
from odoo.addons.mail.controllers.discuss.binary import BinaryController
|
||||
from odoo.addons.im_livechat.tools.misc import force_guest_env
|
||||
|
||||
|
||||
class LivechatBinaryController(BinaryController):
|
||||
@route(
|
||||
"/im_livechat/cors/channel/<int:channel_id>/attachment/<int:attachment_id>",
|
||||
methods=["GET"],
|
||||
type="http",
|
||||
auth="public",
|
||||
cors="*",
|
||||
)
|
||||
def livechat_channel_attachment(self, guest_token, channel_id, attachment_id, download=None, **kwargs):
|
||||
force_guest_env(guest_token)
|
||||
return self.discuss_channel_attachment(channel_id, attachment_id, download, **kwargs)
|
||||
|
||||
@route(
|
||||
[
|
||||
"/im_livechat/cors/channel/<int:channel_id>/image/<int:attachment_id>",
|
||||
"/im_livechat/cors/channel/<int:channel_id>/image/<int:attachment_id>/<int:width>x<int:height>",
|
||||
],
|
||||
methods=["GET"],
|
||||
type="http",
|
||||
auth="public",
|
||||
cors="*"
|
||||
)
|
||||
def livechat_fetch_image(self, guest_token, channel_id, attachment_id, width=0, height=0, **kwargs):
|
||||
force_guest_env(guest_token)
|
||||
return self.fetch_image(channel_id, attachment_id, width, height, **kwargs)
|
||||
@@ -9,4 +9,4 @@ class WebClient(WebclientController):
|
||||
@route("/im_livechat/cors/init_messaging", methods=["POST"], type="json", auth="public", cors="*")
|
||||
def livechat_init_messaging(self, guest_token):
|
||||
force_guest_env(guest_token)
|
||||
return super().mail_init_messaging()
|
||||
return self.mail_init_messaging()
|
||||
|
||||
@@ -5,10 +5,6 @@ import { Composer } from "@mail/core/common/composer";
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
|
||||
patch(Composer.prototype, {
|
||||
get allowUpload() {
|
||||
return this.thread?.type !== "livechat" && super.allowUpload;
|
||||
},
|
||||
|
||||
onKeydown(ev) {
|
||||
super.onKeydown(ev);
|
||||
if (
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
/* @odoo-module */
|
||||
|
||||
import { Composer } from "@mail/core/common/composer";
|
||||
import { Store } from "@mail/core/common/store_service";
|
||||
import { messageActionsRegistry } from "@mail/core/common/message_actions";
|
||||
import { threadActionsRegistry } from "@mail/core/common/thread_actions";
|
||||
import { Thread } from "@mail/core/common/thread_model";
|
||||
import { ThreadService } from "@mail/core/common/thread_service";
|
||||
@@ -9,9 +8,10 @@ import { ThreadService } from "@mail/core/common/thread_service";
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
import { SESSION_STATE } from "./livechat_service";
|
||||
|
||||
patch(Composer.prototype, {
|
||||
get allowUpload() {
|
||||
return false;
|
||||
const downloadFilesAction = messageActionsRegistry.get("download_files");
|
||||
patch(downloadFilesAction, {
|
||||
condition(component) {
|
||||
return component.message.originThread.type !== "livechat" && super.condition(component);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -32,13 +32,6 @@ patch(ThreadService.prototype, {
|
||||
},
|
||||
});
|
||||
|
||||
patch(Store.prototype, {
|
||||
setup() {
|
||||
super.setup(...arguments);
|
||||
this.hasLinkPreviewFeature = false;
|
||||
},
|
||||
});
|
||||
|
||||
const allowedThreadActions = new Set(["fold-chat-window", "close", "restart", "settings"]);
|
||||
for (const [actionName] of threadActionsRegistry.getEntries()) {
|
||||
if (!allowedThreadActions.has(actionName)) {
|
||||
|
||||
@@ -16,15 +16,22 @@ patch(Thread, {
|
||||
const chatbotService = this.env.services["im_livechat.chatbot"];
|
||||
const messageService = this.env.services["mail.message"];
|
||||
if (thread.type === "livechat" && isUnknown) {
|
||||
onChange(thread, ["state", "seen_message_id", "message_unread_counter"], () => {
|
||||
if (![SESSION_STATE.CLOSED, SESSION_STATE.NONE].includes(livechatService.state)) {
|
||||
livechatService.updateSession({
|
||||
state: thread.state,
|
||||
seen_message_id: thread.seen_message_id,
|
||||
channel: thread.channel,
|
||||
});
|
||||
onChange(
|
||||
thread,
|
||||
["state", "seen_message_id", "message_unread_counter", "allow_public_upload"],
|
||||
() => {
|
||||
if (
|
||||
![SESSION_STATE.CLOSED, SESSION_STATE.NONE].includes(livechatService.state)
|
||||
) {
|
||||
livechatService.updateSession({
|
||||
state: thread.state,
|
||||
seen_message_id: thread.seen_message_id,
|
||||
channel: thread.channel,
|
||||
allow_public_upload: thread.allow_public_upload,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
if (chatbotService.isChatbotThread(thread)) {
|
||||
thread.chatbotTypingMessage = {
|
||||
id: messageService.getNextTemporaryId(),
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/* @odoo-module */
|
||||
|
||||
import { Attachment } from "@mail/core/common/attachment_model";
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
|
||||
patch(Attachment.prototype, {
|
||||
get urlQueryParams() {
|
||||
return {
|
||||
...super.urlQueryParams,
|
||||
guest_token: this._store.env.services["im_livechat.livechat"].guestToken,
|
||||
};
|
||||
},
|
||||
get urlRoute() {
|
||||
if (!this.accessToken && this.originThread?.model === "discuss.channel") {
|
||||
return this.isImage
|
||||
? `/im_livechat/cors/channel/${this.originThread.id}/image/${this.id}`
|
||||
: `/im_livechat/cors/channel/${this.originThread.id}/attachment/${this.id}`;
|
||||
}
|
||||
return super.urlRoute;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
/* @odoo-module */
|
||||
|
||||
import { AttachmentUploadService } from "@mail/core/common/attachment_upload_service";
|
||||
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
import { session } from "@web/session";
|
||||
|
||||
patch(AttachmentUploadService.prototype, {
|
||||
get uploadURL() {
|
||||
return `${session.origin}/im_livechat/cors/attachment/upload`;
|
||||
},
|
||||
|
||||
_makeFormData() {
|
||||
const formData = super._makeFormData(...arguments);
|
||||
formData.append("guest_token", this.env.services["im_livechat.livechat"].guestToken);
|
||||
},
|
||||
});
|
||||
@@ -18,6 +18,7 @@ livechatRoutingMap
|
||||
"/discuss/channel/set_last_seen_message",
|
||||
"/im_livechat/cors/channel/set_last_seen_message"
|
||||
)
|
||||
.add("/mail/attachment/delete", "/im_livechat/cors/attachment/delete")
|
||||
.add("/discuss/channel/ping", "/im_livechat/cors/channel/ping")
|
||||
.add("/mail/init_messaging", "/im_livechat/cors/init_messaging")
|
||||
.add("/mail/link_preview", "/im_livechat/cors/link_preview")
|
||||
|
||||
@@ -5,43 +5,10 @@ import { startServer } from "@bus/../tests/helpers/mock_python_environment";
|
||||
import { Command } from "@mail/../tests/helpers/command";
|
||||
import { start } from "@mail/../tests/helpers/test_utils";
|
||||
|
||||
import { click, contains, createFile, dragenterFiles, insertText } from "@web/../tests/utils";
|
||||
import { click, contains, insertText } from "@web/../tests/utils";
|
||||
|
||||
QUnit.module("composer (patch)");
|
||||
|
||||
QUnit.test("No add attachments button", async () => {
|
||||
const pyEnv = await startServer();
|
||||
const channelId = pyEnv["discuss.channel"].create({
|
||||
name: "Livechat 1",
|
||||
channel_type: "livechat",
|
||||
});
|
||||
const { openDiscuss } = await start();
|
||||
await openDiscuss(channelId);
|
||||
await contains(".o-mail-Composer");
|
||||
await contains("button[title='Attach files']", { count: 0 });
|
||||
});
|
||||
|
||||
QUnit.test("Attachment upload via drag and drop disabled", async () => {
|
||||
const pyEnv = await startServer();
|
||||
const channelId = pyEnv["discuss.channel"].create({
|
||||
name: "Livechat 1",
|
||||
channel_type: "livechat",
|
||||
});
|
||||
const { openDiscuss } = await start();
|
||||
await openDiscuss(channelId);
|
||||
await contains(".o-mail-Composer");
|
||||
const files = [
|
||||
await createFile({
|
||||
content: "hello, world",
|
||||
contentType: "text/plain",
|
||||
name: "text3.txt",
|
||||
}),
|
||||
];
|
||||
await dragenterFiles(".o-mail-Composer-input", files);
|
||||
// weak test: no guarantee that we waited long enough for the potential dropzone to show
|
||||
await contains(".o-mail-Dropzone", { count: 0 });
|
||||
});
|
||||
|
||||
QUnit.test("Can execute help command on livechat channels", async (assert) => {
|
||||
const pyEnv = await startServer();
|
||||
const guestId = pyEnv["mail.guest"].create({ name: "Visitor 11" });
|
||||
|
||||
@@ -12,3 +12,4 @@ from . import test_im_livechat_report
|
||||
from . import test_im_livechat_support_page
|
||||
from . import test_js
|
||||
from . import test_message
|
||||
from . import test_upload_attachment
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import http
|
||||
from odoo.tests.common import tagged, HttpCase
|
||||
from odoo.tools import mute_logger, file_open
|
||||
|
||||
|
||||
@tagged("post_install", "-at_install")
|
||||
class TestUploadAttachment(HttpCase):
|
||||
def test_visitor_cannot_upload_on_closed_livechat(self):
|
||||
self.authenticate(None, None)
|
||||
operator = self.env["res.users"].create({"name": "Operator", "login": "operator"})
|
||||
self.env["bus.presence"].create({"user_id": operator.id, "status": "online"})
|
||||
livechat_channel = self.env["im_livechat.channel"].create(
|
||||
{"name": "Test Livechat Channel", "user_ids": [operator.id]}
|
||||
)
|
||||
channel_info = self.make_jsonrpc_request(
|
||||
"/im_livechat/get_session",
|
||||
{
|
||||
"anonymous_name": "Visitor",
|
||||
"channel_id": livechat_channel.id,
|
||||
"persisted": True,
|
||||
},
|
||||
)
|
||||
self.make_jsonrpc_request("/im_livechat/visitor_leave_session", {"uuid": channel_info["uuid"]})
|
||||
with mute_logger("odoo.http"), file_open("addons/web/__init__.py") as file:
|
||||
response = self.url_open(
|
||||
"/mail/attachment/upload",
|
||||
{
|
||||
"csrf_token": http.Request.csrf_token(self),
|
||||
"thread_id": channel_info["id"],
|
||||
"thread_model": "discuss.channel",
|
||||
},
|
||||
files={"ufile": file},
|
||||
)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
@@ -89,7 +89,9 @@
|
||||
<template id="loader" name="Livechat : Javascript appending the livechat button">
|
||||
<t t-translation="off">
|
||||
if (!window.odoo) {
|
||||
window.odoo = {};
|
||||
window.odoo = {
|
||||
csrf_token: "<t t-out="request.csrf_token(None)"/>",
|
||||
};
|
||||
}
|
||||
odoo.__session_info__ = Object.assign(odoo.__session_info__ || {}, {
|
||||
livechatData: {
|
||||
|
||||
@@ -47,7 +47,9 @@ class AttachmentController(http.Controller):
|
||||
@http.route("/mail/attachment/upload", methods=["POST"], type="http", auth="public")
|
||||
@add_guest_to_context
|
||||
def mail_attachment_upload(self, ufile, thread_id, thread_model, is_pending=False, **kwargs):
|
||||
env = request.env["ir.attachment"]._get_upload_env(thread_model, thread_id)
|
||||
thread = request.env[thread_model]._get_from_context_or_raise(int(thread_id))
|
||||
if thread_model == "discuss.channel" and not thread.allow_public_upload and not request.env.user._is_internal():
|
||||
raise AccessError(_("You are not allowed to upload attachments on this channel."))
|
||||
vals = {
|
||||
"name": ufile.filename,
|
||||
"raw": ufile.read(),
|
||||
@@ -63,11 +65,11 @@ class AttachmentController(http.Controller):
|
||||
"res_model": "mail.compose.message",
|
||||
}
|
||||
)
|
||||
if env.user.share:
|
||||
if thread.env.user.share:
|
||||
# Only generate the access token if absolutely necessary (= not for internal user).
|
||||
vals["access_token"] = env["ir.attachment"]._generate_access_token()
|
||||
vals["access_token"] = thread.env["ir.attachment"]._generate_access_token()
|
||||
try:
|
||||
attachment = env["ir.attachment"].create(vals)
|
||||
attachment = thread.env["ir.attachment"].create(vals)
|
||||
attachment._post_add_create(**kwargs)
|
||||
attachmentData = attachment._attachment_format()[0]
|
||||
if attachment.access_token:
|
||||
|
||||
@@ -81,6 +81,7 @@ class Channel(models.Model):
|
||||
uuid = fields.Char('UUID', size=50, default=_generate_random_token, copy=False)
|
||||
group_public_id = fields.Many2one('res.groups', string='Authorized Group', compute='_compute_group_public_id', readonly=False, store=True)
|
||||
invitation_url = fields.Char('Invitation URL', compute='_compute_invitation_url')
|
||||
allow_public_upload = fields.Boolean(default=False)
|
||||
|
||||
_sql_constraints = [
|
||||
('channel_type_not_null', 'CHECK(channel_type IS NOT NULL)', 'The channel type cannot be empty'),
|
||||
@@ -860,6 +861,7 @@ class Channel(models.Model):
|
||||
'group_based_subscription': bool(channel.group_ids),
|
||||
'create_uid': channel.create_uid.id,
|
||||
'authorizedGroupFullName': channel.group_public_id.full_name,
|
||||
'allow_public_upload': channel.allow_public_upload,
|
||||
}
|
||||
# add last message preview (only used in mobile)
|
||||
info['last_message_id'] = channel_last_message_ids.get(channel.id, False)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, models, fields
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class IrAttachment(models.Model):
|
||||
@@ -17,19 +17,6 @@ class IrAttachment(models.Model):
|
||||
return guest
|
||||
return super()._bus_notification_target()
|
||||
|
||||
@api.model
|
||||
def _get_upload_env(self, thread_model, thread_id):
|
||||
"""Overriden to allow guests and (portal) users to upload attachments to channels they have
|
||||
access to. The base method returns the env of the current request, which is not sudo and
|
||||
relies on access rights. Guests or (portal) users need sudo to upload attachments."""
|
||||
if thread_model == "discuss.channel":
|
||||
return (
|
||||
self.env["discuss.channel.member"]
|
||||
._get_as_sudo_from_context_or_raise(channel_id=int(thread_id))
|
||||
.env
|
||||
)
|
||||
return super()._get_upload_env(thread_model, thread_id)
|
||||
|
||||
def _attachment_format(self):
|
||||
attachment_format = super()._attachment_format()
|
||||
for a in attachment_format:
|
||||
|
||||
@@ -88,7 +88,3 @@ class IrAttachment(models.Model):
|
||||
'model': attachment.res_model,
|
||||
})],
|
||||
} for attachment in self]
|
||||
|
||||
@api.model
|
||||
def _get_upload_env(self, thread_model, thread_id):
|
||||
return request.env
|
||||
|
||||
@@ -22,6 +22,7 @@ from email.message import EmailMessage
|
||||
from email import message_from_string
|
||||
from lxml import etree
|
||||
from werkzeug import urls
|
||||
from werkzeug.exceptions import NotFound
|
||||
from xmlrpc import client as xmlrpclib
|
||||
from markupsafe import Markup, escape
|
||||
|
||||
@@ -480,7 +481,10 @@ class MailThread(models.AbstractModel):
|
||||
|
||||
@api.model
|
||||
def _get_from_context_or_raise(self, thread_id):
|
||||
return self.browse(thread_id).exists()
|
||||
thread = self.search([["id", "=", thread_id]])
|
||||
if not thread:
|
||||
raise NotFound()
|
||||
return thread
|
||||
|
||||
# ------------------------------------------------------
|
||||
# TRACKING / LOG
|
||||
|
||||
@@ -121,6 +121,10 @@ export class AttachmentUploadService {
|
||||
);
|
||||
}
|
||||
|
||||
get uploadURL() {
|
||||
return "/mail/attachment/upload";
|
||||
}
|
||||
|
||||
async unlink(attachment) {
|
||||
const abort = this.abortByAttachmentId.get(attachment.id);
|
||||
const def = this.deferredByAttachmentId.get(attachment.id);
|
||||
@@ -138,7 +142,7 @@ export class AttachmentUploadService {
|
||||
this.hookersByTmpId.set(tmpId, hooker);
|
||||
this.uploadingAttachmentIds.add(tmpId);
|
||||
await this.fileUploadService
|
||||
.upload("/mail/attachment/upload", [file], {
|
||||
.upload(this.uploadURL, [file], {
|
||||
buildFormData: (formData) => {
|
||||
this._makeFormData(formData, file, hooker, tmpId, options);
|
||||
},
|
||||
|
||||
@@ -81,12 +81,10 @@ export class Composer extends Component {
|
||||
_t("<samp>%(send_keybind)s</samp><i> to send</i>", { send_keybind: this.sendKeybind })
|
||||
);
|
||||
this.store = useState(useService("mail.store"));
|
||||
if (this.allowUpload) {
|
||||
this.attachmentUploader = useAttachmentUploader(
|
||||
this.thread ?? this.props.composer.message.originThread,
|
||||
{ composer: this.props.composer }
|
||||
);
|
||||
}
|
||||
this.attachmentUploader = useAttachmentUploader(
|
||||
this.thread ?? this.props.composer.message.originThread,
|
||||
{ composer: this.props.composer }
|
||||
);
|
||||
this.messageService = useState(useService("mail.message"));
|
||||
this.threadService = useService("mail.thread");
|
||||
this.ui = useState(useService("ui"));
|
||||
@@ -117,8 +115,13 @@ export class Composer extends Component {
|
||||
this.suggestion = this.store.user ? useSuggestion() : undefined;
|
||||
this.markEventHandled = markEventHandled;
|
||||
this.onDropFile = this.onDropFile.bind(this);
|
||||
if (this.props.dropzoneRef && this.allowUpload) {
|
||||
useDropzone(this.props.dropzoneRef, this.onDropFile, "o-mail-Composer-dropzone");
|
||||
if (this.props.dropzoneRef) {
|
||||
useDropzone(
|
||||
this.props.dropzoneRef,
|
||||
this.onDropFile,
|
||||
"o-mail-Composer-dropzone",
|
||||
() => this.allowUpload
|
||||
);
|
||||
}
|
||||
if (this.props.messageEdition) {
|
||||
this.props.messageEdition.composerOfThread = this;
|
||||
|
||||
@@ -9,7 +9,7 @@ import { registry } from "@web/core/registry";
|
||||
const componentRegistry = registry.category("main_components");
|
||||
|
||||
let id = 1;
|
||||
export function useDropzone(targetRef, onDrop, extraClass) {
|
||||
export function useDropzone(targetRef, onDrop, extraClass, isDropzoneEnabled = () => true) {
|
||||
const dropzoneId = `mail.dropzone_${id++}`;
|
||||
let dragCount = 0;
|
||||
let hasTarget = false;
|
||||
@@ -26,7 +26,7 @@ export function useDropzone(targetRef, onDrop, extraClass) {
|
||||
});
|
||||
|
||||
function updateDropzone() {
|
||||
const shouldDisplayDropzone = dragCount && hasTarget;
|
||||
const shouldDisplayDropzone = dragCount && hasTarget && isDropzoneEnabled();
|
||||
const hasDropzone = componentRegistry.contains(dropzoneId);
|
||||
if (shouldDisplayDropzone && !hasDropzone) {
|
||||
componentRegistry.add(dropzoneId, {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { AttachmentList } from "@mail/core/common/attachment_list";
|
||||
|
||||
import { Component, onWillStart, onWillUpdateProps } from "@odoo/owl";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { useVisible } from "@mail/utils/common/hooks";
|
||||
import { useSequential, useVisible } from "@mail/utils/common/hooks";
|
||||
|
||||
/**
|
||||
* @typedef {Object} Props
|
||||
@@ -18,6 +18,9 @@ export class AttachmentPanel extends Component {
|
||||
static template = "mail.AttachmentPanel";
|
||||
|
||||
setup() {
|
||||
this.sequential = useSequential();
|
||||
this.store = useService("mail.store");
|
||||
this.ormService = useService("orm");
|
||||
this.threadService = useService("mail.thread");
|
||||
this.attachmentUploadService = useService("mail.attachment_upload");
|
||||
onWillStart(() => {
|
||||
@@ -47,4 +50,20 @@ export class AttachmentPanel extends Component {
|
||||
}
|
||||
return attachmentsByDate;
|
||||
}
|
||||
|
||||
get hasToggleAllowPublicUpload() {
|
||||
return (
|
||||
this.props.thread.model !== "mail.box" &&
|
||||
this.props.thread.type !== "chat" &&
|
||||
this.store.user?.user?.isInternalUser
|
||||
);
|
||||
}
|
||||
|
||||
toggleAllowPublicUpload() {
|
||||
this.sequential(() =>
|
||||
this.ormService.write("discuss.channel", [this.props.thread.id], {
|
||||
allow_public_upload: !this.props.thread.allow_public_upload,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,13 @@
|
||||
<t t-name="mail.AttachmentPanel">
|
||||
<t t-set="title">Attachments</t>
|
||||
<ActionPanel title="title">
|
||||
<div t-if="hasToggleAllowPublicUpload" class="form-check form-switch">
|
||||
<label class="form-check-label">
|
||||
<input t-on-change="toggleAllowPublicUpload" class="form-check-input" type="checkbox" role="switch" t-att-checked="props.thread.allow_public_upload"/>
|
||||
<t t-if="props.thread.allow_public_upload">File upload is enabled for external users</t>
|
||||
<t t-else="">File upload is disabled for external users</t>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex-grow-1" t-att-class="{
|
||||
'd-flex justify-content-center align-items-center': props.thread.attachments.length === 0,
|
||||
}">
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
/* @odoo-module */
|
||||
|
||||
import { AttachmentUploadService } from "@mail/core/common/attachment_upload_service";
|
||||
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
|
||||
patch(AttachmentUploadService.prototype, {
|
||||
setup() {
|
||||
super.setup(...arguments);
|
||||
this.env.services["bus_service"].subscribe("mail.record/insert", ({ Thread }) => {
|
||||
if (
|
||||
Thread &&
|
||||
"allow_public_upload" in Thread &&
|
||||
!Thread.allow_public_upload &&
|
||||
!this.store.user?.user?.isInternalUser
|
||||
) {
|
||||
const attachments = [...this.store.Thread.insert(Thread).composer.attachments];
|
||||
for (const attachment of attachments) {
|
||||
this.unlink(attachment);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
/* @odoo-module */
|
||||
|
||||
import { Composer } from "@mail/core/common/composer";
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
|
||||
patch(Composer.prototype, {
|
||||
get allowUpload() {
|
||||
const thread = this.thread ?? this.message.originThread;
|
||||
return (
|
||||
super.allowUpload &&
|
||||
(thread.model !== "discuss.channel" ||
|
||||
thread?.allow_public_upload ||
|
||||
this.store.user?.user?.isInternalUser)
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -19,4 +19,8 @@ patch(Thread.prototype, {
|
||||
}
|
||||
return super.imgUrl;
|
||||
},
|
||||
update(data) {
|
||||
super.update(data);
|
||||
assignDefined(this, data, ["allow_public_upload"]);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -49,3 +49,23 @@ QUnit.test("Attachment panel sort by date", async () => {
|
||||
after: [".o-mail-DateSection", { text: "August, 2023" }],
|
||||
});
|
||||
});
|
||||
|
||||
QUnit.test("Can toggle allow public upload", async () => {
|
||||
const pyEnv = await startServer();
|
||||
const channelId = await pyEnv["discuss.channel"].create({ name: "General" });
|
||||
const tab1 = await start({ asTab: true });
|
||||
await tab1.openDiscuss(channelId);
|
||||
await click(".o-mail-Discuss-header button[title='Show Attachments']", { target: tab1.target });
|
||||
const tab2 = await start({ asTab: true });
|
||||
await tab2.openDiscuss(channelId);
|
||||
await click(".o-mail-Discuss-header button[title='Show Attachments']", { target: tab2.target });
|
||||
await contains(".o-mail-ActionPanel", {
|
||||
contains: ["label", { text: "File upload is disabled for external users" }],
|
||||
target: tab2.target,
|
||||
});
|
||||
await click(".o-mail-ActionPanel input[type='checkbox']", { target: tab1.target });
|
||||
await contains(".o-mail-ActionPanel", {
|
||||
contains: ["label", { text: "File upload is enabled for external users" }],
|
||||
target: tab2.target,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -735,6 +735,7 @@ patch(MockServer.prototype, {
|
||||
],
|
||||
];
|
||||
res.channel = channelData;
|
||||
res.allow_public_upload = channel.allow_public_upload;
|
||||
return res;
|
||||
});
|
||||
},
|
||||
|
||||
@@ -220,7 +220,7 @@ patch(MockServer.prototype, {
|
||||
type: "partner",
|
||||
};
|
||||
if (user) {
|
||||
author["user"] = { id: user.id };
|
||||
author["user"] = { id: user.id, isInternalUser: !user.share };
|
||||
}
|
||||
} else {
|
||||
author = false;
|
||||
|
||||
@@ -279,6 +279,7 @@ patch(MockServer.prototype, {
|
||||
user: mainUser
|
||||
? {
|
||||
id: mainUser.id,
|
||||
isInternalUser: !mainUser.share,
|
||||
}
|
||||
: false,
|
||||
},
|
||||
|
||||
@@ -4,5 +4,6 @@ from . import test_discuss_channel
|
||||
from . import test_discuss_channel_as_guest
|
||||
from . import test_discuss_channel_member
|
||||
from . import test_message_controller
|
||||
from . import test_toggle_upload
|
||||
from . import test_load_messages
|
||||
from . import test_rtc
|
||||
|
||||
@@ -29,12 +29,14 @@ class TestMailPublicPage(HttpCase):
|
||||
guest = self.env['mail.guest'].create({'name': 'Guest Mario'})
|
||||
|
||||
self.channel = self.env['discuss.channel'].browse(self.env['discuss.channel'].channel_create(group_id=None, name='Test channel')['id'])
|
||||
self.channel.allow_public_upload = True
|
||||
self.channel.add_members(portal_user.partner_id.ids)
|
||||
self.channel.add_members(internal_user.partner_id.ids)
|
||||
self.channel.add_members(guest_ids=[guest.id])
|
||||
|
||||
self.group = self.env['discuss.channel'].browse(self.env['discuss.channel'].create_group(partners_to=(internal_user + portal_user).partner_id.ids, name="Test group")['id'])
|
||||
self.group.add_members(guest_ids=[guest.id])
|
||||
self.group.allow_public_upload = True
|
||||
|
||||
self.tour = "mail/static/tests/tours/discuss_public_tour.js"
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from requests.exceptions import HTTPError
|
||||
|
||||
from odoo import Command, http
|
||||
from odoo.tests import tagged, HttpCase
|
||||
from odoo.tools import file_open, mute_logger
|
||||
|
||||
|
||||
@tagged("post_install", "-at_install")
|
||||
class TestToggleUpload(HttpCase):
|
||||
def test_upload_allowed(self):
|
||||
self.authenticate(None, None)
|
||||
channel = self.env["discuss.channel"].create({"name": "General"})
|
||||
guest = self.env["mail.guest"].create({"name": "Guest"})
|
||||
channel.write({"channel_member_ids": [Command.create({"guest_id": guest.id})]})
|
||||
self.assertFalse(channel.allow_public_upload)
|
||||
channel.write({'allow_public_upload': True})
|
||||
with file_open("addons/web/__init__.py") as file:
|
||||
response = self.url_open(
|
||||
"/mail/attachment/upload",
|
||||
{
|
||||
"csrf_token": http.Request.csrf_token(self),
|
||||
"thread_id": channel.id,
|
||||
"thread_model": "discuss.channel",
|
||||
},
|
||||
files={"ufile": file},
|
||||
headers={"Cookie": f"session_id={self.session.sid};{guest._cookie_name}={guest._format_auth_cookie()};"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_upload_denied(self):
|
||||
self.authenticate(None, None)
|
||||
channel = self.env["discuss.channel"].create({"name": "General"})
|
||||
guest = self.env["mail.guest"].create({"name": "Guest"})
|
||||
channel.write({"channel_member_ids": [Command.create({"guest_id": guest.id})]})
|
||||
self.assertFalse(channel.allow_public_upload)
|
||||
with mute_logger("odoo.http"), file_open("addons/web/__init__.py") as file:
|
||||
response = self.url_open(
|
||||
"/mail/attachment/upload",
|
||||
{
|
||||
"csrf_token": http.Request.csrf_token(self),
|
||||
"thread_id": channel.id,
|
||||
"thread_model": "discuss.channel",
|
||||
},
|
||||
files={"ufile": file},
|
||||
headers={"Cookie": f"session_id={self.session.sid};{guest._cookie_name}={guest._format_auth_cookie()};"},
|
||||
)
|
||||
with self.assertRaises(HTTPError) as error_catcher:
|
||||
response.raise_for_status()
|
||||
self.assertEqual(error_catcher.exception.response.status_code, 403)
|
||||
@@ -131,6 +131,7 @@ class TestDiscussFullPerformance(HttpCase):
|
||||
'odoobotOnboarding': False,
|
||||
'channels': [
|
||||
{
|
||||
'allow_public_upload': False,
|
||||
'authorizedGroupFullName': self.group_user.full_name,
|
||||
'channel': {
|
||||
'anonymous_country': False,
|
||||
@@ -181,6 +182,7 @@ class TestDiscussFullPerformance(HttpCase):
|
||||
'uuid': self.channel_general.uuid,
|
||||
},
|
||||
{
|
||||
'allow_public_upload': False,
|
||||
'authorizedGroupFullName': False,
|
||||
'channel': {
|
||||
'anonymous_country': False,
|
||||
@@ -231,6 +233,7 @@ class TestDiscussFullPerformance(HttpCase):
|
||||
'uuid': self.channel_channel_public_1.uuid,
|
||||
},
|
||||
{
|
||||
'allow_public_upload': False,
|
||||
'authorizedGroupFullName': False,
|
||||
'channel': {
|
||||
'anonymous_country': False,
|
||||
@@ -281,6 +284,7 @@ class TestDiscussFullPerformance(HttpCase):
|
||||
'uuid': self.channel_channel_public_2.uuid,
|
||||
},
|
||||
{
|
||||
'allow_public_upload': False,
|
||||
'authorizedGroupFullName': self.group_user.full_name,
|
||||
'channel': {
|
||||
'anonymous_country': False,
|
||||
@@ -331,6 +335,7 @@ class TestDiscussFullPerformance(HttpCase):
|
||||
'uuid': self.channel_channel_group_1.uuid,
|
||||
},
|
||||
{
|
||||
'allow_public_upload': False,
|
||||
'authorizedGroupFullName': self.group_user.full_name,
|
||||
'channel': {
|
||||
'anonymous_country': False,
|
||||
@@ -381,6 +386,7 @@ class TestDiscussFullPerformance(HttpCase):
|
||||
'uuid': self.channel_channel_group_2.uuid,
|
||||
},
|
||||
{
|
||||
'allow_public_upload': False,
|
||||
'authorizedGroupFullName': False,
|
||||
'channel': {
|
||||
'anonymous_country': False,
|
||||
@@ -467,6 +473,7 @@ class TestDiscussFullPerformance(HttpCase):
|
||||
'uuid': self.channel_group_1.uuid,
|
||||
},
|
||||
{
|
||||
'allow_public_upload': False,
|
||||
'authorizedGroupFullName': False,
|
||||
'channel': {
|
||||
'anonymous_country': False,
|
||||
@@ -553,6 +560,7 @@ class TestDiscussFullPerformance(HttpCase):
|
||||
'uuid': self.channel_chat_1.uuid,
|
||||
},
|
||||
{
|
||||
'allow_public_upload': False,
|
||||
'authorizedGroupFullName': False,
|
||||
'channel': {
|
||||
'anonymous_country': False,
|
||||
@@ -639,6 +647,7 @@ class TestDiscussFullPerformance(HttpCase):
|
||||
'uuid': self.channel_chat_2.uuid,
|
||||
},
|
||||
{
|
||||
'allow_public_upload': False,
|
||||
'authorizedGroupFullName': False,
|
||||
'channel': {
|
||||
'anonymous_country': False,
|
||||
@@ -725,6 +734,7 @@ class TestDiscussFullPerformance(HttpCase):
|
||||
'uuid': self.channel_chat_3.uuid,
|
||||
},
|
||||
{
|
||||
'allow_public_upload': False,
|
||||
'authorizedGroupFullName': False,
|
||||
'channel': {
|
||||
'anonymous_country': False,
|
||||
@@ -811,6 +821,7 @@ class TestDiscussFullPerformance(HttpCase):
|
||||
'uuid': self.channel_chat_4.uuid,
|
||||
},
|
||||
{
|
||||
'allow_public_upload': False,
|
||||
'authorizedGroupFullName': False,
|
||||
'channel': {
|
||||
'anonymous_country': {
|
||||
@@ -896,6 +907,7 @@ class TestDiscussFullPerformance(HttpCase):
|
||||
'uuid': self.channel_livechat_1.uuid,
|
||||
},
|
||||
{
|
||||
'allow_public_upload': False,
|
||||
'authorizedGroupFullName': False,
|
||||
'channel': {
|
||||
'anonymous_country': {
|
||||
|
||||
@@ -23,6 +23,7 @@ export class FileUploader extends Component {
|
||||
if (!ev.target.files.length) {
|
||||
return;
|
||||
}
|
||||
const { target } = ev;
|
||||
for (const file of ev.target.files) {
|
||||
if (!checkFileSize(file.size, this.notification)) {
|
||||
return null;
|
||||
@@ -47,7 +48,7 @@ export class FileUploader extends Component {
|
||||
this.state.isUploading = false;
|
||||
}
|
||||
}
|
||||
ev.target.value = null;
|
||||
target.value = null;
|
||||
if (this.props.multiUpload && this.props.onUploadComplete) {
|
||||
this.props.onUploadComplete({});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user