From 46ae211ea7b3f6d7b463dc5eeafac7eb1602a957 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien=20COURATIN?= Date: Wed, 28 Oct 2026 00:01:59 +0100 Subject: [PATCH] [ADD] web_portal_discuss: allow portal users to join discussions Allow internal users to add portal partners as native Discuss members. Expose only channels for which the portal partner is a member. Portal users can read and post messages but cannot manage channels or members. Reuse native Discuss membership instead of introducing a separate portal invitation or token system. --- web_portal_discuss/README.rst | 17 ++ web_portal_discuss/__init__.py | 2 + web_portal_discuss/__manifest__.py | 24 +++ web_portal_discuss/controllers/__init__.py | 3 + .../controllers/discuss_channel.py | 167 ++++++++++++++++++ .../controllers/portal_discuss.py | 90 ++++++++++ web_portal_discuss/controllers/thread.py | 29 +++ web_portal_discuss/data/mail_templates.xml | 2 + web_portal_discuss/models/__init__.py | 1 + web_portal_discuss/models/discuss_channel.py | 36 ++++ web_portal_discuss/pyproject.toml | 3 + .../security/ir.model.access.csv | 1 + .../static/src/img/discuss_portal.svg | 9 + .../static/src/js/portal_discuss_assets.js | 21 +++ .../js/portal_discuss_client_action.esm.js | 24 +++ .../static/src/scss/portal_discuss.scss | 100 +++++++++++ web_portal_discuss/tests/__init__.py | 3 + .../tests/test_controller_helpers.py | 121 +++++++++++++ .../tests/test_portal_discuss_http.py | 62 +++++++ .../tests/test_portal_discuss_messages.py | 88 +++++++++ .../views/discuss_channel_views.xml | 2 + .../views/portal_discuss_templates.xml | 73 ++++++++ 22 files changed, 878 insertions(+) create mode 100644 web_portal_discuss/README.rst create mode 100644 web_portal_discuss/__init__.py create mode 100644 web_portal_discuss/__manifest__.py create mode 100644 web_portal_discuss/controllers/__init__.py create mode 100644 web_portal_discuss/controllers/discuss_channel.py create mode 100644 web_portal_discuss/controllers/portal_discuss.py create mode 100644 web_portal_discuss/controllers/thread.py create mode 100644 web_portal_discuss/data/mail_templates.xml create mode 100644 web_portal_discuss/models/__init__.py create mode 100644 web_portal_discuss/models/discuss_channel.py create mode 100644 web_portal_discuss/pyproject.toml create mode 100644 web_portal_discuss/security/ir.model.access.csv create mode 100644 web_portal_discuss/static/src/img/discuss_portal.svg create mode 100644 web_portal_discuss/static/src/js/portal_discuss_assets.js create mode 100644 web_portal_discuss/static/src/js/portal_discuss_client_action.esm.js create mode 100644 web_portal_discuss/static/src/scss/portal_discuss.scss create mode 100644 web_portal_discuss/tests/__init__.py create mode 100644 web_portal_discuss/tests/test_controller_helpers.py create mode 100644 web_portal_discuss/tests/test_portal_discuss_http.py create mode 100644 web_portal_discuss/tests/test_portal_discuss_messages.py create mode 100644 web_portal_discuss/views/discuss_channel_views.xml create mode 100644 web_portal_discuss/views/portal_discuss_templates.xml diff --git a/web_portal_discuss/README.rst b/web_portal_discuss/README.rst new file mode 100644 index 000000000000..369cb8844999 --- /dev/null +++ b/web_portal_discuss/README.rst @@ -0,0 +1,17 @@ +Web Portal Discuss +================== + +This module lets portal users participate in native Discuss channels to which +an internal user has added their partner as a member. + +Security model +-------------- + +* Native ``discuss.channel.member`` membership is the only portal permission. +* Internal users manage channel membership with the standard Discuss interface. +* Portal users can read and participate in authorized channels only. +* Portal users cannot create channels, manage members, or invite users. +* No portal invitation, token, or signup workflow is provided by this module. + +The module reuses the Discuss client and applies the membership check server +side on portal requests. diff --git a/web_portal_discuss/__init__.py b/web_portal_discuss/__init__.py new file mode 100644 index 000000000000..91c5580fed36 --- /dev/null +++ b/web_portal_discuss/__init__.py @@ -0,0 +1,2 @@ +from . import controllers +from . import models diff --git a/web_portal_discuss/__manifest__.py b/web_portal_discuss/__manifest__.py new file mode 100644 index 000000000000..230129fd5062 --- /dev/null +++ b/web_portal_discuss/__manifest__.py @@ -0,0 +1,24 @@ +# Copyright 2026 Sébastien Couratin, Semper Connect +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Web Portal Discuss", + "summary": "Allow portal users to participate in authorized Discuss channels", + "version": "18.0.1.0.0", + "license": "AGPL-3", + "author": "Sébastien Couratin, Semper Connect, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/web", + "category": "Website/Portal", + "development_status": "Beta", + "depends": ["mail", "portal", "website"], + "data": ["views/portal_discuss_templates.xml"], + "assets": { + "mail.assets_public": [ + "web_portal_discuss/static/src/js/portal_discuss_assets.js", + "web_portal_discuss/static/src/js/portal_discuss_client_action.esm.js", + "web_portal_discuss/static/src/scss/portal_discuss.scss", + ], + }, + "installable": True, + "application": False, +} diff --git a/web_portal_discuss/controllers/__init__.py b/web_portal_discuss/controllers/__init__.py new file mode 100644 index 000000000000..a6d66f22571b --- /dev/null +++ b/web_portal_discuss/controllers/__init__.py @@ -0,0 +1,3 @@ +from . import discuss_channel +from . import portal_discuss +from . import thread diff --git a/web_portal_discuss/controllers/discuss_channel.py b/web_portal_discuss/controllers/discuss_channel.py new file mode 100644 index 000000000000..7e11fa9b0b64 --- /dev/null +++ b/web_portal_discuss/controllers/discuss_channel.py @@ -0,0 +1,167 @@ +from werkzeug.exceptions import NotFound + +from odoo import _, http +from odoo.exceptions import AccessError +from odoo.http import request + +from odoo.addons.mail.controllers.discuss.channel import ChannelController +from odoo.addons.mail.models.discuss.mail_guest import add_guest_to_context +from odoo.addons.mail.tools.discuss import Store + + +class PortalDiscussChannelController(ChannelController): + """Apply portal membership checks to native Discuss endpoints.""" + + def _get_portal_channel(self, channel_id): + channel = request.env["discuss.channel"].browse(channel_id).exists() + if not channel: + raise NotFound() + user = request.env.user + if user._is_portal(): + channel._portal_check_access(user) + elif not user._is_internal() and not user._is_public(): + raise AccessError(_("You are not allowed to access this channel.")) + return channel + + @http.route( + "/discuss/channel/messages", methods=["POST"], type="json", auth="public" + ) + @add_guest_to_context + def discuss_channel_messages( + self, + channel_id, + search_term=None, + before=None, + after=None, + limit=30, + around=None, + ): + if not request.env.user._is_portal(): + return super().discuss_channel_messages( + channel_id, + search_term=search_term, + before=before, + after=after, + limit=limit, + around=around, + ) + channel = self._get_portal_channel(channel_id) + try: + limit = min(max(int(limit or 30), 1), 100) + except (TypeError, ValueError): + limit = 30 + result = ( + request.env["mail.message"] + .sudo() + ._message_fetch( + [ + ("model", "=", "discuss.channel"), + ("res_id", "=", channel.id), + ("message_type", "!=", "user_notification"), + ], + search_term=search_term, + before=before, + after=after, + around=around, + limit=limit, + ) + ) + messages = result.pop("messages") + if not around: + messages.set_message_done() + return { + **result, + "data": Store(messages, for_current_user=True).get_result(), + "messages": Store.many_ids(messages), + } + + @http.route( + "/discuss/channel/info", + methods=["POST"], + type="json", + auth="public", + readonly=True, + ) + @add_guest_to_context + def discuss_channel_info(self, channel_id): + if not request.env.user._is_portal(): + return super().discuss_channel_info(channel_id) + return Store(self._get_portal_channel(channel_id)).get_result() + + @http.route( + "/discuss/channel/members", + methods=["POST"], + type="json", + auth="public", + readonly=True, + ) + @add_guest_to_context + def discuss_channel_members(self, channel_id, known_member_ids): + if not request.env.user._is_portal(): + return super().discuss_channel_members(channel_id, known_member_ids) + return self._get_portal_channel(channel_id)._load_more_members(known_member_ids) + + @http.route( + "/discuss/channel/pinned_messages", + methods=["POST"], + type="json", + auth="public", + readonly=True, + ) + @add_guest_to_context + def discuss_channel_pins(self, channel_id): + if not request.env.user._is_portal(): + return super().discuss_channel_pins(channel_id) + channel = self._get_portal_channel(channel_id) + messages = channel.pinned_message_ids.sorted(key="pinned_at", reverse=True) + return Store(messages, for_current_user=True).get_result() + + @http.route( + "/discuss/channel/attachments", + methods=["POST"], + type="json", + auth="public", + readonly=True, + ) + @add_guest_to_context + def load_attachments(self, channel_id, limit=30, before=None): + if not request.env.user._is_portal(): + return super().load_attachments(channel_id, limit=limit, before=before) + channel = self._get_portal_channel(channel_id) + domain = [ + ("res_id", "=", channel.id), + ("res_model", "=", "discuss.channel"), + ] + if before: + domain.append(("id", "<", before)) + return Store( + request.env["ir.attachment"] + .sudo() + .search(domain, limit=limit, order="id DESC") + ).get_result() + + @http.route( + "/discuss/channel/notify_typing", + methods=["POST"], + type="json", + auth="public", + ) + @add_guest_to_context + def discuss_channel_notify_typing(self, channel_id, is_typing): + if not request.env.user._is_portal(): + return super().discuss_channel_notify_typing(channel_id, is_typing) + channel = self._get_portal_channel(channel_id) + member = ( + request.env["discuss.channel.member"] + .sudo() + .search( + [ + ("channel_id", "=", channel.id), + ("partner_id", "=", request.env.user.partner_id.id), + ], + limit=1, + ) + ) + if not member: + raise NotFound() + member._notify_typing(is_typing) diff --git a/web_portal_discuss/controllers/portal_discuss.py b/web_portal_discuss/controllers/portal_discuss.py new file mode 100644 index 000000000000..8a69836adfb9 --- /dev/null +++ b/web_portal_discuss/controllers/portal_discuss.py @@ -0,0 +1,90 @@ +from werkzeug.exceptions import NotFound + +from odoo import http +from odoo.http import request + +from odoo.addons.mail.controllers.discuss.public_page import PublicPageController +from odoo.addons.mail.models.discuss.mail_guest import add_guest_to_context +from odoo.addons.mail.tools.discuss import Store + + +class PortalDiscussController(PublicPageController): + """Expose native Discuss channels to portal members.""" + + def _get_portal_channels(self, user=None): + user = user or request.env.user + if not user._is_portal(): + return request.env["discuss.channel"] + return ( + request.env["discuss.channel"] + .sudo() + .search( + [ + ("active", "=", True), + ("channel_member_ids.partner_id", "=", user.partner_id.id), + ] + ) + ) + + def _get_selected_channel(self, channels, channel_id): + if not channel_id: + return channels[:1] + try: + channel_id = int(channel_id) + except (TypeError, ValueError): + return channels[:1] + return channels.filtered(lambda channel: channel.id == channel_id)[:1] + + def _prepare_discuss_data(self, selected_channel): + """Build the native public Discuss store for the selected channel.""" + return Store( + { + "companyName": request.env.company.name, + "inPublicPage": True, + "discuss_public_thread": Store.one(selected_channel), + } + ).get_result() + + @http.route("/my/discuss", type="http", auth="user", website=True) + def portal_discuss(self, channel_id=None, **kwargs): + user = request.env.user + if user._is_internal(): + return request.redirect("/odoo/action-mail.action_discuss") + if not user._is_portal(): + raise NotFound() + + channels = self._get_portal_channels(user) + channels._portal_check_access(user) + selected_channel = self._get_selected_channel(channels, channel_id) + data = self._prepare_discuss_data(selected_channel) + return request.render( + "web_portal_discuss.portal_discuss", + { + "channels": channels, + "selected_channel": selected_channel, + "discuss_data": data, + "session_info": request.env["ir.http"].session_info(), + }, + ) + + @http.route( + "/discuss/channel/", + type="http", + auth="public", + website=True, + ) + @add_guest_to_context + def discuss_channel( + self, channel_id, *, highlight_message_id=None, portal_embed=False + ): + user = request.env.user + if user._is_portal(): + channel = request.env["discuss.channel"].browse(channel_id).exists() + if not channel: + raise NotFound() + channel._portal_check_access(user) + if not portal_embed: + return request.redirect(f"/my/discuss?channel_id={channel.id}") + return super().discuss_channel( + channel_id, highlight_message_id=highlight_message_id + ) diff --git a/web_portal_discuss/controllers/thread.py b/web_portal_discuss/controllers/thread.py new file mode 100644 index 000000000000..68856ecc3e47 --- /dev/null +++ b/web_portal_discuss/controllers/thread.py @@ -0,0 +1,29 @@ +from odoo import http +from odoo.http import request + +from odoo.addons.mail.controllers.thread import ThreadController +from odoo.addons.mail.models.discuss.mail_guest import add_guest_to_context + + +class PortalDiscussThreadController(ThreadController): + """Enforce channel membership for portal Discuss message posting.""" + + def _check_portal_channel_access(self, thread_model, thread_id): + if thread_model != "discuss.channel": + return + user = request.env.user + if not user._is_portal(): + return + channel = request.env[thread_model].browse(thread_id).exists() + if channel: + channel._portal_check_access(user) + + @http.route("/mail/message/post", methods=["POST"], type="json", auth="public") + @add_guest_to_context + def mail_message_post( + self, thread_model, thread_id, post_data, context=None, **kwargs + ): + self._check_portal_channel_access(thread_model, thread_id) + return super().mail_message_post( + thread_model, thread_id, post_data, context=context, **kwargs + ) diff --git a/web_portal_discuss/data/mail_templates.xml b/web_portal_discuss/data/mail_templates.xml new file mode 100644 index 000000000000..e0063c230aa3 --- /dev/null +++ b/web_portal_discuss/data/mail_templates.xml @@ -0,0 +1,2 @@ + + diff --git a/web_portal_discuss/models/__init__.py b/web_portal_discuss/models/__init__.py new file mode 100644 index 000000000000..534f6a9d3b9e --- /dev/null +++ b/web_portal_discuss/models/__init__.py @@ -0,0 +1 @@ +from . import discuss_channel diff --git a/web_portal_discuss/models/discuss_channel.py b/web_portal_discuss/models/discuss_channel.py new file mode 100644 index 000000000000..cff7ee3edd7a --- /dev/null +++ b/web_portal_discuss/models/discuss_channel.py @@ -0,0 +1,36 @@ +from odoo import _, models +from odoo.exceptions import AccessError + + +class DiscussChannel(models.Model): + _inherit = "discuss.channel" + + def _portal_user_has_access(self, user=None): + self.ensure_one() + user = user or self.env.user + if user._is_internal(): + return True + if not user._is_portal(): + return False + return bool( + self.env["discuss.channel.member"] + .sudo() + .search_count( + [ + ("channel_id", "=", self.id), + ("partner_id", "=", user.partner_id.id), + ] + ) + ) + + def _portal_check_access(self, user=None): + user = user or self.env.user + if user._is_internal(): + return self + if not user._is_portal() or not all( + channel._portal_user_has_access(user) for channel in self + ): + raise AccessError( + _("You are not allowed to access this discussion channel.") + ) + return self diff --git a/web_portal_discuss/pyproject.toml b/web_portal_discuss/pyproject.toml new file mode 100644 index 000000000000..4231d0cccb3d --- /dev/null +++ b/web_portal_discuss/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/web_portal_discuss/security/ir.model.access.csv b/web_portal_discuss/security/ir.model.access.csv new file mode 100644 index 000000000000..97dd8b917b8a --- /dev/null +++ b/web_portal_discuss/security/ir.model.access.csv @@ -0,0 +1 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink diff --git a/web_portal_discuss/static/src/img/discuss_portal.svg b/web_portal_discuss/static/src/img/discuss_portal.svg new file mode 100644 index 000000000000..c484af9c58aa --- /dev/null +++ b/web_portal_discuss/static/src/img/discuss_portal.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/web_portal_discuss/static/src/js/portal_discuss_assets.js b/web_portal_discuss/static/src/js/portal_discuss_assets.js new file mode 100644 index 000000000000..b84124369952 --- /dev/null +++ b/web_portal_discuss/static/src/js/portal_discuss_assets.js @@ -0,0 +1,21 @@ +(function () { + "use strict"; + + function markPortalDiscussFullscreen() { + if (window.location.pathname !== "/my/discuss") { + return; + } + document.documentElement.classList.add("o_portal_discuss_fullscreen"); + if (document.body) { + document.body.classList.add("o_portal_discuss_fullscreen"); + } + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", markPortalDiscussFullscreen, { + once: true, + }); + } else { + markPortalDiscussFullscreen(); + } +})(); diff --git a/web_portal_discuss/static/src/js/portal_discuss_client_action.esm.js b/web_portal_discuss/static/src/js/portal_discuss_client_action.esm.js new file mode 100644 index 000000000000..c04369d49e98 --- /dev/null +++ b/web_portal_discuss/static/src/js/portal_discuss_client_action.esm.js @@ -0,0 +1,24 @@ +/** @odoo-module **/ + +import {DiscussClientAction} from "@mail/core/public_web/discuss_client_action"; +import {browser} from "@web/core/browser/browser"; +import {patch} from "@web/core/utils/patch"; + +patch(DiscussClientAction.prototype, { + getActiveId(props) { + if (browser.location.pathname === "/my/discuss") { + const channelId = new URL(browser.location.href).searchParams.get( + "channel_id" + ); + if (channelId && /^\d+$/.test(channelId)) { + return `discuss.channel_${channelId}`; + } + return "mail.box_inbox"; + } + return super.getActiveId(props) || "mail.box_inbox"; + }, + + parseActiveId(rawActiveId) { + return super.parseActiveId(rawActiveId || "mail.box_inbox"); + }, +}); diff --git a/web_portal_discuss/static/src/scss/portal_discuss.scss b/web_portal_discuss/static/src/scss/portal_discuss.scss new file mode 100644 index 000000000000..679c2a458153 --- /dev/null +++ b/web_portal_discuss/static/src/scss/portal_discuss.scss @@ -0,0 +1,100 @@ +html.o_portal_discuss_fullscreen, +body.o_portal_discuss_fullscreen { + overflow: hidden; +} + +body.o_portal_discuss_fullscreen { + .o_portal_discuss_shell { + position: relative; + z-index: 20; + } + + .o_portal_discuss_breadcrumb { + width: 100%; + height: 56px; + display: flex; + align-items: center; + position: relative; + z-index: 21; + background: #fff; + border-bottom: 1px solid rgba(0, 0, 0, 0.06); + } + + .o_portal_discuss_breadcrumb_inner { + width: 100%; + display: flex; + align-items: center; + gap: 1rem; + padding: 0 1.5rem; + } + + .o_portal_discuss_brand { + display: inline-flex; + align-items: center; + flex: 0 0 auto; + height: 42px; + } + + .o_portal_discuss_brand img { + display: block; + max-height: 42px; + width: auto; + } + + .o_portal_discuss_breadcrumb .breadcrumb { + flex: 1 1 auto; + min-width: 0; + margin-bottom: 0; + } + + .o_portal_discuss_breadcrumb a { + color: var(--o-brand-odoo, #714b67); + } + + .o_portal_discuss_breadcrumb a:hover { + color: var(--o-brand-odoo, #714b67); + } + + .o-mail-Discuss { + position: fixed !important; + top: 56px !important; + right: 0 !important; + bottom: 0 !important; + left: 0 !important; + width: 100vw !important; + height: auto !important; + max-width: none !important; + max-height: none !important; + margin: 0 !important; + border-radius: 0 !important; + z-index: 1 !important; + background: var(--o-view-background-color, #fff); + } + + .o-mail-Discuss-content { + min-width: 0; + min-height: 0; + } + + @media (max-width: 991.98px) { + .o_portal_discuss_breadcrumb { + height: 52px; + } + + .o_portal_discuss_breadcrumb_inner { + padding: 0 1rem; + } + + .o_portal_discuss_brand { + height: 38px; + } + + .o_portal_discuss_brand img { + max-height: 38px; + } + + .o-mail-Discuss { + top: 52px !important; + } + } +} diff --git a/web_portal_discuss/tests/__init__.py b/web_portal_discuss/tests/__init__.py new file mode 100644 index 000000000000..b22cff3dadc5 --- /dev/null +++ b/web_portal_discuss/tests/__init__.py @@ -0,0 +1,3 @@ +from . import test_controller_helpers +from . import test_portal_discuss_http +from . import test_portal_discuss_messages diff --git a/web_portal_discuss/tests/test_controller_helpers.py b/web_portal_discuss/tests/test_controller_helpers.py new file mode 100644 index 000000000000..09ea8c269302 --- /dev/null +++ b/web_portal_discuss/tests/test_controller_helpers.py @@ -0,0 +1,121 @@ +from types import SimpleNamespace +from unittest.mock import patch + +from werkzeug.exceptions import NotFound + +from odoo.exceptions import AccessError +from odoo.tests.common import TransactionCase + +from ..controllers.discuss_channel import PortalDiscussChannelController +from ..controllers.portal_discuss import PortalDiscussController +from ..controllers.thread import PortalDiscussThreadController + + +class TestPortalDiscussControllerHelpers(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.portal_partner = cls.env["res.partner"].create( + { + "name": "Portal controller partner", + "email": "portal-controller@example.com", + } + ) + cls.portal_user = ( + cls.env["res.users"] + .with_context(no_reset_password=True) + .create( + { + "name": "Portal controller user", + "login": "portal-controller@example.com", + "partner_id": cls.portal_partner.id, + "share": True, + "groups_id": [(6, 0, [cls.env.ref("base.group_portal").id])], + } + ) + ) + cls.channel = cls.env["discuss.channel"].create( + { + "name": "Controller channel", + } + ) + cls.channel.sudo().add_members( + partner_ids=[cls.portal_partner.id], + post_joined_message=False, + ) + cls.other_channel = cls.env["discuss.channel"].create( + { + "name": "Controller private channel", + } + ) + + def _request(self, user): + return SimpleNamespace(env=self.env(user=user)) + + def test_channel_controller_checks_membership_and_not_found(self): + controller = PortalDiscussChannelController() + with patch( + "odoo.addons.web_portal_discuss.controllers.discuss_channel.request", + self._request(self.portal_user), + ): + self.assertEqual( + controller._get_portal_channel(self.channel.id), self.channel + ) + with self.assertRaises(AccessError): + controller._get_portal_channel(self.other_channel.id) + with self.assertRaises(NotFound): + controller._get_portal_channel(999999999) + + def test_channel_controller_allows_public_native_handling(self): + controller = PortalDiscussChannelController() + public_user = self.env.ref("base.public_user") + with patch( + "odoo.addons.web_portal_discuss.controllers.discuss_channel.request", + self._request(public_user), + ): + self.assertEqual( + controller._get_portal_channel(self.channel.id), self.channel + ) + + def test_portal_controller_filters_selected_channels(self): + controller = PortalDiscussController() + with patch( + "odoo.addons.web_portal_discuss.controllers.portal_discuss.request", + self._request(self.portal_user), + ): + channels = controller._get_portal_channels(self.portal_user) + self.assertIn(self.channel, channels) + self.assertNotIn(self.other_channel, channels) + self.assertEqual( + controller._get_selected_channel(channels, self.channel.id), + self.channel, + ) + self.assertEqual( + controller._get_selected_channel(channels, "invalid"), + channels[:1], + ) + self.assertFalse( + controller._get_selected_channel(channels, self.other_channel.id) + ) + + def test_portal_controller_returns_no_channels_for_non_portal(self): + controller = PortalDiscussController() + public_user = self.env.ref("base.public_user") + with patch( + "odoo.addons.web_portal_discuss.controllers.portal_discuss.request", + self._request(public_user), + ): + self.assertFalse(controller._get_portal_channels().exists()) + + def test_thread_controller_checks_only_discuss_channels(self): + controller = PortalDiscussThreadController() + with patch( + "odoo.addons.web_portal_discuss.controllers.thread.request", + self._request(self.portal_user), + ): + controller._check_portal_channel_access("res.partner", self.channel.id) + controller._check_portal_channel_access("discuss.channel", self.channel.id) + with self.assertRaises(AccessError): + controller._check_portal_channel_access( + "discuss.channel", self.other_channel.id + ) diff --git a/web_portal_discuss/tests/test_portal_discuss_http.py b/web_portal_discuss/tests/test_portal_discuss_http.py new file mode 100644 index 000000000000..d447da093056 --- /dev/null +++ b/web_portal_discuss/tests/test_portal_discuss_http.py @@ -0,0 +1,62 @@ +from odoo.tests import HttpCase, new_test_user, tagged + + +@tagged("post_install", "-at_install") +class TestPortalDiscussHttp(HttpCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.portal_user = new_test_user( + cls.env, + "portal_discuss_http_user", + groups="base.group_portal", + ) + cls.channel = cls.env["discuss.channel"].create( + { + "name": "Portal HTTP channel", + } + ) + cls.other_channel = cls.env["discuss.channel"].create( + { + "name": "Private HTTP channel", + } + ) + cls.channel.sudo().add_members( + partner_ids=[cls.portal_user.partner_id.id], + post_joined_message=False, + ) + cls.channel.message_post( + body="Message visible to the portal member", + message_type="comment", + ) + + def _authenticate_portal(self): + self.authenticate(self.portal_user.login, self.portal_user.login) + + def test_portal_member_can_open_discuss_page(self): + self._authenticate_portal() + response = self.url_open(f"/my/discuss?channel_id={self.channel.id}") + self.assertEqual(response.status_code, 200) + self.assertIn(b"Discussions", response.content) + + def test_portal_member_can_open_native_channel_route(self): + self._authenticate_portal() + response = self.url_open( + f"/discuss/channel/{self.channel.id}", + allow_redirects=False, + ) + self.assertEqual(response.status_code, 303) + self.assertIn( + f"/my/discuss?channel_id={self.channel.id}", + response.headers["Location"], + ) + + def test_portal_non_member_cannot_open_native_channel_route(self): + self._authenticate_portal() + response = self.url_open(f"/discuss/channel/{self.other_channel.id}") + self.assertIn(response.status_code, (403, 404)) + + def test_unknown_native_channel_returns_not_found(self): + self._authenticate_portal() + response = self.url_open("/discuss/channel/999999999") + self.assertEqual(response.status_code, 404) diff --git a/web_portal_discuss/tests/test_portal_discuss_messages.py b/web_portal_discuss/tests/test_portal_discuss_messages.py new file mode 100644 index 000000000000..a3e9378f4d87 --- /dev/null +++ b/web_portal_discuss/tests/test_portal_discuss_messages.py @@ -0,0 +1,88 @@ +from odoo.exceptions import AccessError +from odoo.tests.common import TransactionCase + + +class TestPortalDiscussMessages(TransactionCase): + """Test the portal Discuss membership policy.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.portal_partner = cls.env["res.partner"].create( + { + "name": "Portal Discuss Member", + "email": "portal-discuss-member@example.com", + } + ) + cls.portal_user = ( + cls.env["res.users"] + .with_context(no_reset_password=True) + .create( + { + "name": cls.portal_partner.name, + "login": cls.portal_partner.email, + "partner_id": cls.portal_partner.id, + "share": True, + "groups_id": [(6, 0, [cls.env.ref("base.group_portal").id])], + } + ) + ) + cls.channel = cls.env["discuss.channel"].create( + { + "name": "Portal test channel", + } + ) + cls.other_channel = cls.env["discuss.channel"].create( + { + "name": "Other portal channel", + } + ) + cls.channel.sudo().add_members( + partner_ids=[cls.portal_partner.id], post_joined_message=False + ) + + def test_portal_member_has_access(self): + self.assertTrue(self.channel._portal_user_has_access(self.portal_user)) + self.assertEqual( + self.channel._portal_check_access(self.portal_user), self.channel + ) + + def test_portal_non_member_has_no_access(self): + self.assertFalse(self.other_channel._portal_user_has_access(self.portal_user)) + with self.assertRaises(AccessError): + self.other_channel._portal_check_access(self.portal_user) + + def test_access_is_based_on_native_channel_membership(self): + self.assertEqual( + self.channel.channel_member_ids.filtered( + lambda member: member.partner_id == self.portal_partner + ).partner_id, + self.portal_partner, + ) + self.assertFalse( + self.other_channel.channel_member_ids.filtered( + lambda member: member.partner_id == self.portal_partner + ) + ) + + def test_messages_are_scoped_by_channel(self): + first_message = self.channel.message_post( + body="Message in the authorized channel", + message_type="comment", + ) + other_message = self.other_channel.message_post( + body="Message in another channel", + message_type="comment", + ) + messages = ( + self.env["mail.message"] + .sudo() + .search( + [ + ("model", "=", "discuss.channel"), + ("res_id", "=", self.channel.id), + ] + ) + ) + self.assertIn(first_message, messages) + self.assertNotIn(other_message, messages) diff --git a/web_portal_discuss/views/discuss_channel_views.xml b/web_portal_discuss/views/discuss_channel_views.xml new file mode 100644 index 000000000000..e0063c230aa3 --- /dev/null +++ b/web_portal_discuss/views/discuss_channel_views.xml @@ -0,0 +1,2 @@ + + diff --git a/web_portal_discuss/views/portal_discuss_templates.xml b/web_portal_discuss/views/portal_discuss_templates.xml new file mode 100644 index 000000000000..ece2a95b608f --- /dev/null +++ b/web_portal_discuss/views/portal_discuss_templates.xml @@ -0,0 +1,73 @@ + + + + + + +