Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions web_portal_discuss/README.rst
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions web_portal_discuss/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
from . import controllers
from . import models
24 changes: 24 additions & 0 deletions web_portal_discuss/__manifest__.py
Original file line number Diff line number Diff line change
@@ -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,
}
3 changes: 3 additions & 0 deletions web_portal_discuss/controllers/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from . import discuss_channel
from . import portal_discuss
from . import thread
167 changes: 167 additions & 0 deletions web_portal_discuss/controllers/discuss_channel.py
Original file line number Diff line number Diff line change
@@ -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)
90 changes: 90 additions & 0 deletions web_portal_discuss/controllers/portal_discuss.py
Original file line number Diff line number Diff line change
@@ -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/<int:channel_id>",
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
)
29 changes: 29 additions & 0 deletions web_portal_discuss/controllers/thread.py
Original file line number Diff line number Diff line change
@@ -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
)
2 changes: 2 additions & 0 deletions web_portal_discuss/data/mail_templates.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo />
1 change: 1 addition & 0 deletions web_portal_discuss/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import discuss_channel
36 changes: 36 additions & 0 deletions web_portal_discuss/models/discuss_channel.py
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions web_portal_discuss/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[build-system]
requires = ["whool"]
build-backend = "whool.buildapi"
1 change: 1 addition & 0 deletions web_portal_discuss/security/ir.model.access.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
9 changes: 9 additions & 0 deletions web_portal_discuss/static/src/img/discuss_portal.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading