diff --git a/modules/inbound_email/CHANGELOG b/modules/inbound_email/CHANGELOG
index 6a1ec5474f..19e9d25129 100644
--- a/modules/inbound_email/CHANGELOG
+++ b/modules/inbound_email/CHANGELOG
@@ -1,3 +1,4 @@
+* Add an action to handle chat replies
* Add support for Python 3.14
* Remove support for Python 3.9
diff --git a/modules/inbound_email/doc/configuration.rst b/modules/inbound_email/doc/configuration.rst
index e8ddfe7c36..97c19993e5 100644
--- a/modules/inbound_email/doc/configuration.rst
+++ b/modules/inbound_email/doc/configuration.rst
@@ -5,6 +5,19 @@ Configuration
The *Inbound Email Module* uses values from settings in the ``[inbound_email]``
section of the :ref:`trytond:topics-configuration`.
+.. _config-inbound_email.chat_reply_to:
+
+``chat_reply_to``
+=================
+
+``chat_reply_to`` defines the email address used in the ``Reply-To`` header
+when sending a message from a chat channel.
+Tryton uses `sub-addressing`_ to discriminate between channels.
+
+.. _`sub-addressing`: https://en.wikipedia.org/wiki/Email_address#Sub-addressing
+
+Default: To the value of :ref:`trytond:config-email.from`.
+
.. _config-inboud_email.filestore:
``filestore``
diff --git a/modules/inbound_email/inbound_email.py b/modules/inbound_email/inbound_email.py
index 844041db70..e5f001d64c 100644
--- a/modules/inbound_email/inbound_email.py
+++ b/modules/inbound_email/inbound_email.py
@@ -255,6 +255,7 @@ class Rule(sequence_ordered(), ModelSQL, ModelView):
action = fields.Selection([
(None, ""),
+ ('ir.chat.channel|post_inbound_email', "Post to Channel"),
], "Action")
@classmethod
diff --git a/modules/inbound_email/ir.py b/modules/inbound_email/ir.py
new file mode 100644
index 0000000000..f116aa9a1d
--- /dev/null
+++ b/modules/inbound_email/ir.py
@@ -0,0 +1,79 @@
+# This file is part of Tryton. The COPYRIGHT file at the top level of
+# this repository contains the full copyright notices and license terms.
+
+import email.parser
+import email.policy
+import itertools
+import re
+from email.utils import getaddresses
+
+import trytond.config as config
+from trytond.pool import Pool, PoolMeta
+from trytond.transaction import Transaction
+
+REPLY_LINE = '\N{EM DASH}' * 80
+DESTINATION_RE = re.compile(r"^[^@]*\+([0-9a-f]{32})@", re.IGNORECASE)
+
+
+def _get_channel_identifier(email):
+ base = config.get(
+ 'inbound_email', 'chat_reply_to',
+ default=config.get('email', 'from'))
+ _, our_domain = base.split('@', 1)
+
+ for key in ('To', 'Cc'):
+ if key not in email:
+ continue
+ addresses = [a for _, a in getaddresses(email.get_all(key))]
+ for address in addresses:
+ _, domain = address.split('@', 1)
+ if domain != our_domain:
+ continue
+ if (m := DESTINATION_RE.match(address)):
+ channel_id, = m.groups()
+ return channel_id
+ return None
+
+
+class Channel(metaclass=PoolMeta):
+ __name__ = 'ir.chat.channel'
+
+ @classmethod
+ def _email_channel(cls, email):
+ channels = []
+ if (channel_id := _get_channel_identifier(email)):
+ channels = cls.search([('identifier', '=', channel_id)])
+ return None if len(channels) != 1 else channels[0]
+
+ @classmethod
+ def _email_content(cls, body):
+ return '\n'.join(itertools.takewhile(
+ lambda l: REPLY_LINE not in l,
+ body.splitlines()))
+
+ @classmethod
+ def post_inbound_email(cls, inbound_email):
+ parser = email.parser.BytesParser(policy=email.policy.default)
+ message = parser.parsebytes(inbound_email.data)
+ return cls.post_from_email(message)
+
+ @classmethod
+ def _email_reply_to(cls, message):
+ base = config.get(
+ 'inbound_email', 'chat_reply_to',
+ default=config.get('email', 'from'))
+ local_part, domain_part = base.split('@', 1)
+ if '+' in local_part:
+ local_part, _ = local_part.split('+', 1)
+ local_part += f'+{message.channel.identifier}'
+ return '@'.join((local_part, domain_part))
+
+ @classmethod
+ def _email_body(cls, message):
+ pool = Pool()
+ Message = pool.get('ir.message')
+ ModelData = pool.get('ir.model.data')
+
+ with Transaction().set_context(language=message.channel.language):
+ above_msg = Message(ModelData.get_id('ir', 'msg_reply_above'))
+ return f'{REPLY_LINE}\n{above_msg.text}\n\n{message.content}'
diff --git a/modules/inbound_email/message.xml b/modules/inbound_email/message.xml
new file mode 100644
index 0000000000..1eadd0743b
--- /dev/null
+++ b/modules/inbound_email/message.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+ Please reply above the line.
+
+
+
diff --git a/modules/inbound_email/tests/test_module.py b/modules/inbound_email/tests/test_module.py
index e184a82a3a..7767495fb1 100644
--- a/modules/inbound_email/tests/test_module.py
+++ b/modules/inbound_email/tests/test_module.py
@@ -6,6 +6,9 @@ import uuid
from email.message import EmailMessage
from unittest.mock import patch
+import trytond.config as config
+from trytond.modules.inbound_email.ir import (
+ REPLY_LINE, _get_channel_identifier)
from trytond.pool import Pool
from trytond.protocols.wrappers import HTTPStatus
from trytond.tests.test_tryton import (
@@ -17,6 +20,16 @@ class InboundEmailTestCase(ModuleTestCase):
"Test Inbound Email module"
module = 'inbound_email'
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ email_from = config.get('email', 'from', default='')
+ config.set('email', 'from', 'tryton@example.org')
+ cls.addClassCleanup(config.set, 'email', 'from', email_from)
+ config.add_section('inbound_email')
+ config.set('inbound_email', 'chat_reply_to', 'chat@example.org')
+ cls.addClassCleanup(config.remove_section, 'inbound_email')
+
def get_message(self):
message = EmailMessage()
message['From'] = "John Doe "
@@ -303,6 +316,40 @@ class InboundEmailTestCase(ModuleTestCase):
func.assert_called_once_with(email, rule)
self.assertEqual(email.result, result)
+ def test_get_channel_identifier(self):
+ "Test parsing the recipients to find the email channel"
+ channel_id = uuid.uuid4().hex
+
+ for value, expected in [
+ (f'chat+{channel_id}@example.org', channel_id),
+ (['chat@example.org', f'chat+{channel_id}@example.org'],
+ channel_id),
+ ('chat@example.org', None),
+ ('chat+foo@example.org', None),
+ (f'{channel_id}@example.org', None),
+ (f'chat+{channel_id}@example.com', None),
+ ]:
+ for recipient_hdr in ('To', 'Cc'):
+ with self.subTest(f"{recipient_hdr}: {value}"):
+ msg = EmailMessage()
+ msg[recipient_hdr] = value
+ self.assertEqual(_get_channel_identifier(msg), expected)
+
+ @with_transaction()
+ def test_find_email_content(self):
+ "Test finding the content of the reply"
+ pool = Pool()
+ Channel = pool.get('ir.chat.channel')
+
+ for (body, expected) in [
+ ("Foo", "Foo"),
+ ("Foo\n-----\nBar", "Foo\n-----\nBar"),
+ (f"Foo\n{REPLY_LINE}\nLorem Ipsum", "Foo"),
+ (f"Foo\n> {REPLY_LINE}\n> Lorem Ipsum", "Foo"),
+ ]:
+ with self.subTest(body):
+ self.assertEqual(Channel._email_content(body), expected)
+
class InboundEmailRouteTestCase(RouteTestCase):
"Test Inbound Email route"
diff --git a/modules/inbound_email/tryton.cfg b/modules/inbound_email/tryton.cfg
index 3214276fca..6531d41063 100644
--- a/modules/inbound_email/tryton.cfg
+++ b/modules/inbound_email/tryton.cfg
@@ -5,6 +5,7 @@ depends:
res
xml:
inbound_email.xml
+ message.xml
[register]
model:
@@ -12,3 +13,4 @@ model:
inbound_email.Email
inbound_email.Rule
inbound_email.RuleHeader
+ ir.Channel
diff --git a/modules/party/ir.py b/modules/party/ir.py
index c0b1614f5f..5ff9ddcd1b 100644
--- a/modules/party/ir.py
+++ b/modules/party/ir.py
@@ -2,7 +2,7 @@
# this repository contains the full copyright notices and license terms.
from trytond.model import fields
from trytond.pool import Pool, PoolMeta
-from trytond.tools import escape_wildcard
+from trytond.tools import escape_wildcard, grouped_slice
from trytond.transaction import Transaction
@@ -101,3 +101,56 @@ class EmailTemplate(metaclass=PoolMeta):
elif record.party.lang:
language = record.party.lang
return language
+
+
+class Channel(metaclass=PoolMeta):
+ __name__ = 'ir.chat.channel'
+
+ @classmethod
+ def _get_followers(cls, resource):
+ pool = Pool()
+ Party = pool.get('party.party')
+ ContactMechanism = pool.get('party.contact_mechanism')
+
+ followers = super()._get_followers(resource)
+ has_avatar = 'avatar_url' in Party._fields
+
+ for emails in grouped_slice([e for t, e in followers if t == 'email']):
+ contacts = ContactMechanism.search([
+ ('type', '=', 'email'),
+ ('value', 'in', list(emails)),
+ ])
+ followers |= {('email', c.value): {
+ 'type': 'email',
+ 'key': c.value,
+ 'name': f'{c.name}',
+ 'avatar_url': c.party.avatar_url if has_avatar else None,
+ } for c in contacts}
+
+ return followers
+
+ @classmethod
+ def _search_followers(cls, resource, text):
+ pool = Pool()
+ Party = pool.get('party.party')
+ ContactMechanism = pool.get('party.contact_mechanism')
+
+ followers = super()._search_followers(resource, text)
+ has_avatar = 'avatar_url' in Party._fields
+
+ contacts = ContactMechanism.search([
+ 'OR',
+ [
+ ('rec_name', 'ilike', '{text}%'),
+ ('type', '=', 'email'),
+ ],
+ ('name', 'ilike', f'%{text}%'),
+ ])
+ return followers | {
+ ('email', c.value): {
+ 'type': 'email',
+ 'key': c.value,
+ 'name': f'{c.name}',
+ 'avatar_url': c.party.avatar_url if has_avatar else None,
+ }
+ for c in contacts}
diff --git a/modules/party/tryton.cfg b/modules/party/tryton.cfg
index 8e2461168a..ee9606a394 100644
--- a/modules/party/tryton.cfg
+++ b/modules/party/tryton.cfg
@@ -34,6 +34,7 @@ model:
configuration.ConfigurationLang
ir.Email
ir.EmailTemplate
+ ir.Channel
wizard:
party.CheckVIES
party.Replace
diff --git a/sao/src/chat.js b/sao/src/chat.js
index 461a85fccd..7e0b57d1f3 100644
--- a/sao/src/chat.js
+++ b/sao/src/chat.js
@@ -3,6 +3,16 @@
(function() {
'use strict';
+ function add_size(url, size=32) {
+ if (url) {
+ url = new URL(url, window.location);
+ url.searchParams.set('s', size);
+ return url.href;
+ } else {
+ return '';
+ }
+ }
+
class _Chat {
constructor(record) {
this.notify = this.notify.bind(this);
@@ -46,63 +56,221 @@
});
}
+ _get_followers() {
+ return Sao.rpc({
+ 'method': 'model.ir.chat.channel.get_followers',
+ 'params': [this.record, {}],
+ }, Sao.Session.current_session);
+ }
+
+ _search_followers(text) {
+ if (!text) {
+ return jQuery.when();
+ }
+
+ return Sao.rpc({
+ 'method': 'model.ir.chat.channel.search_followers',
+ 'params': [this.record, text, {}],
+ }, Sao.Session.current_session).then(followers => {
+ return followers.filter(e => {
+ return !((e.type == 'user') &&
+ (e.key == Sao.Session.current_session.login));
+ });
+ });
+ }
+
+ _add_follower(follower) {
+ let method;
+ switch (follower.type) {
+ case 'user':
+ method = 'subscribe';
+ break;
+ case 'email':
+ method = 'subscribe_email';
+ break;
+ }
+ Sao.rpc({
+ 'method': `model.ir.chat.channel.${method}`,
+ 'params': [this.record, follower.key, {}],
+ }, Sao.Session.current_session);
+ }
+
+ _remove_follower(follower) {
+ let method;
+ switch (follower.type) {
+ case 'user':
+ method = 'unsubscribe';
+ break;
+ case 'email':
+ method = 'unsubscribe_email';
+ break;
+ }
+ Sao.rpc({
+ 'method': `model.ir.chat.channel.${method}`,
+ 'params': [this.record, follower.key, {}],
+ }, Sao.Session.current_session);
+ }
+
__build() {
let el = jQuery('', {
'class': 'chat',
});
- let btn_group = jQuery('', {
- 'class': 'btn-group',
- 'role': 'group',
+ let toolbar = jQuery('', {
+ 'class': 'btn-toolbar',
+ 'role': 'toolbar',
}).appendTo(el);
+ let input_group = jQuery('', {
+ 'class': 'input-group',
+ 'role': 'group',
+ }).appendTo(toolbar);
+ this.subscribe_input = jQuery('', {
+ 'class': 'form-control input-sm',
+ 'placeholder': Sao.i18n.gettext("Add a follower"),
+ 'title': Sao.i18n.gettext("Subscribe a follower to this channel"),
+ }).appendTo(input_group);
+ let avatar_size = 32;
+
+ let format = function(val) {
+ return jQuery('', {
+ 'title': val.key,
+ })
+ .append(jQuery('
', {
+ 'src': add_size(val.avatar_url, avatar_size),
+ 'class': 'img-circle',
+ 'style': `width: ${avatar_size}px; height: ${avatar_size}px;`,
+ }))
+ .append(jQuery('', {
+ })
+ .text(val.name));
+ };
+ new Sao.common.InputCompletion(
+ input_group, this._search_followers.bind(this),
+ (follower) => {
+ this._add_follower(follower);
+ this.subscribe_input.val('');
+ this.subscribe_input.focus();
+ }, format);
+
+ let dropdown = jQuery('', {
+ 'class': 'btn-group dropdown',
+ 'role': 'group',
+ }).appendTo(toolbar);
let subscribe_btn = jQuery('', {
- 'class': 'btn btn-default pull-right',
'type': 'button',
- 'title': Sao.i18n.gettext("Toggle notification"),
+ 'class': 'btn btn-default pull-right dropdown-toggle',
+ 'data-toggle': 'dropdown',
+ 'aria-expanded': false,
+ 'aria-haspopup': true,
+ 'title': Sao.i18n.gettext("Show followers"),
}).append(Sao.common.ICONFACTORY.get_icon_img(
'tryton-notification'))
- .appendTo(btn_group);
+ .appendTo(dropdown);
+ subscribe_btn.uniqueId();
- Sao.rpc({
- 'method': 'model.ir.chat.channel.get_followers',
- 'params': [this.record, {}],
- }, Sao.Session.current_session).then((followers) => {
- set_subscribe_state(~followers.users.indexOf(
- Sao.Session.current_session.login));
- })
+ this._get_followers().then((followers) => {
+ let subscribed = followers.some((e) => (
+ (e.type == 'user') &&
+ (e.key == Sao.Session.current_session.login)));
+ set_subscribe_state(subscribed);
+ });;
let set_subscribe_state = (subscribed) => {
let img;
if (subscribed) {
img = 'tryton-notification-on';
- subscribe_btn.addClass('active').off().click(unsubscribe);
+ subscribe_btn.addClass('active');
} else {
img = 'tryton-notification-off';
- subscribe_btn.removeClass('active').off().click(subscribe);
+ subscribe_btn.removeClass('active');
}
subscribe_btn.html(Sao.common.ICONFACTORY.get_icon_img(img));
}
- let subscribe = () => {
- let session = Sao.Session.current_session;
- Sao.rpc({
- 'method': 'model.ir.chat.channel.subscribe',
- 'params': [this.record, {}],
- }, session).then(() => {
- set_subscribe_state(true);
- })
- };
+ let menu = jQuery('', {
+ 'class': 'dropdown-menu dropdown-menu-right',
+ 'role': 'menu',
+ 'aria-labelledby': subscribe_btn.id,
+ }).appendTo(dropdown);
- let unsubscribe = () => {
- let session = Sao.Session.current_session;
- Sao.rpc({
- 'method': 'model.ir.chat.channel.unsubscribe',
- 'params': [this.record, {}],
- }, session).then(() => {
- set_subscribe_state(false);
- })
- };
+ dropdown.on('show.bs.dropdown', () => {
+ menu.empty();
+
+ let user_action;
+ if (subscribe_btn.hasClass('active')) {
+ user_action = jQuery('', {
+ 'href': '#',
+ 'class': 'text-danger text-uppercase',
+ }).text(Sao.i18n.gettext("Unsubscribe"))
+ .click(() => {
+ Sao.rpc({
+ 'method': 'model.ir.chat.channel.unsubscribe',
+ 'params': [this.record, {}],
+ }, Sao.Session.current_session).then(() => {
+ set_subscribe_state(false);
+ });
+ });
+ } else {
+ user_action = jQuery('', {
+ 'href': '#',
+ 'class': 'text-primary text-uppercase',
+ }).text(Sao.i18n.gettext("Subscribe"))
+ .click(() => {
+ Sao.rpc({
+ 'method': 'model.ir.chat.channel.subscribe',
+ 'params': [this.record, {}],
+ }, Sao.Session.current_session).then(() => {
+ set_subscribe_state(true);
+ });
+ });
+ }
+ jQuery('', {
+ 'role': 'presentation',
+ }).append(user_action).appendTo(menu);
+
+ this._get_followers().then((followers) => {
+ followers = followers.filter(e => {
+ return !((e.type == 'user') &&
+ (e.key == Sao.Session.current_session.login));
+ });
+ if (followers.length) {
+ jQuery('', {
+ 'class': 'divider',
+ }).appendTo(menu);
+ }
+ let add_follower = (follower) => {
+ jQuery('', {
+ 'role': 'presentation',
+ })
+ .append(jQuery('', {
+ 'title': follower.key,
+ })
+ .append(jQuery('
', {
+ 'class': 'img-circle chat-avatar',
+ 'src': add_size(follower.avatar_url, avatar_size),
+ 'style': `width: ${avatar_size}px; height: ${avatar_size}px;`,
+ }))
+ .append(jQuery('')
+ .text(follower.name))
+ .append(jQuery('', {
+ 'class': 'btn btn-link',
+ 'title': Sao.i18n.gettext("Unsubscribe"),
+ 'type': 'button',
+ }).append("×").click((evt) => {
+ Sao.common.sur.run(
+ Sao.i18n.gettext(
+ 'Are you sure to unsubscribe "%1" from this channel?',
+ follower.name)
+ ).then(() => {
+ this._remove_follower(follower);
+ });
+ }))
+ ).appendTo(menu);
+ };
+ followers.forEach(add_follower);
+ });
+ });
this._messages = jQuery('', {
'class': 'chat-messages',
@@ -167,35 +335,40 @@
}
create_message(message) {
- let avatar_size = 32;
let timestamp = Sao.common.format_datetime(
Sao.common.date_format() + ' %X', message.timestamp);
- let avatar_url = '';
- if (message.avatar_url) {
- let url = new URL(message.avatar_url, window.location);
- url.searchParams.set('s', avatar_size);
- avatar_url = url.href;
+ if (message.user) {
+ let avatar_size = 32;
+ let avatar_url = add_size(message.avatar_url, avatar_size);
+
+ return jQuery('', {
+ 'class': 'media chat-message',
+ }).append(jQuery('', {
+ 'class': 'media-left',
+ }).append(jQuery('
', {
+ 'class': 'media-object img-circle chat-avatar',
+ 'src': avatar_url,
+ 'alt': message.author,
+ 'style': `width: ${avatar_size}px; height: ${avatar_size}px;`,
+ }))).append(jQuery('', {
+ 'class': 'media-body well well-sm',
+ }).append(jQuery('', {
+ 'class': 'media-heading',
+ }).text(message.author)
+ .append(jQuery('', {
+ 'class': 'text-muted pull-right',
+ }).text(timestamp)))
+ .append(jQuery('', {
+ 'class': `chat-content chat-content-${message.audience}`,
+ }).text(message.content)));
+ } else {
+ return jQuery('', {
+ 'class': 'media chat-message system-message',
+ }).append(jQuery('', {
+ 'class': 'chat-content',
+ 'title': timestamp,
+ }).text(message.content));
}
- return jQuery('', {
- 'class': 'media chat-message',
- }).append(jQuery('', {
- 'class': 'media-left',
- }).append(jQuery('
', {
- 'class': 'media-object img-circle chat-avatar',
- 'src': avatar_url,
- 'alt': message.author,
- 'style': `width: ${avatar_size}px; height: ${avatar_size}px;`,
- }))).append(jQuery('', {
- 'class': 'media-body well well-sm',
- }).append(jQuery('', {
- 'class': 'media-heading',
- }).text(message.author)
- .append(jQuery('', {
- 'class': 'text-muted pull-right',
- }).text(timestamp)))
- .append(jQuery('', {
- 'class': `chat-content chat-content-${message.audience}`,
- }).text(message.content)));
}
}
Sao.Chat = _Chat;
diff --git a/sao/src/sao.less b/sao/src/sao.less
index f102348cf8..15150b5c11 100644
--- a/sao/src/sao.less
+++ b/sao/src/sao.less
@@ -520,6 +520,40 @@ html[theme="default"] {
padding-left: 5px;
padding-right: 5px;
+ .btn-toolbar {
+ display: flex;
+
+ .input-group {
+ flex: 1;
+
+ .img-circle {
+ height: 2em;
+ margin-inline-end: 1ex;
+ }
+ }
+
+ ul.dropdown-menu {
+ width: 20em;
+
+ div {
+ display: flex;
+ margin: 1ex;
+ gap: 5px;
+
+ img {
+ align-self: center;
+ width: 32px;
+ height: 32px;
+ }
+
+ span {
+ flex: 1;
+ align-self: center;
+ }
+ }
+ }
+ }
+
.chat-messages-outer {
display: flex;
flex-direction: column;
@@ -535,6 +569,15 @@ html[theme="default"] {
.chat-message {
margin-top: 5px;
+
+ &.system-message {
+ align-items: center;
+ color: @text-muted;
+ display: flex;
+ font-size: small;
+ justify-content: center;
+ padding: 0 2ex;
+ }
}
.chat-content {
diff --git a/tryton/tryton/chat.py b/tryton/tryton/chat.py
index 86e7ea1f92..23fa43db40 100644
--- a/tryton/tryton/chat.py
+++ b/tryton/tryton/chat.py
@@ -2,10 +2,11 @@
# this repository contains the full copyright notices and license terms.
import gettext
-from gi.repository import Gdk, Gio, GObject, Gtk
+from gi.repository import Gdk, Gio, GLib, GObject, Gtk
from tryton import common, rpc
from tryton.bus import Bus
+from tryton.common import sur
_ = gettext.gettext
@@ -48,6 +49,12 @@ class MessageList(GObject.GObject, Gio.ListModel):
self.emit('items-changed', len(self._messages) - 1, 0, 1)
+def _follower_self(follower):
+ return (
+ follower['type'] == 'user'
+ and follower['key'] == rpc._LOGIN)
+
+
class Chat:
def __init__(self, record):
@@ -89,24 +96,21 @@ class Chat:
widget = Gtk.VBox()
widget.set_spacing(3)
- hbuttonbox = Gtk.HButtonBox()
- hbuttonbox.set_layout(Gtk.ButtonBoxStyle.END)
- widget.pack_start(hbuttonbox, expand=False, fill=True, padding=0)
+ hbox = Gtk.HBox()
+ widget.pack_start(hbox, expand=False, fill=True, padding=0)
- subscribe_btn = Gtk.ToggleButton()
+ subscribe_entry = self.__build_follower_entry()
+ hbox.pack_start(subscribe_entry, expand=True, fill=True, padding=0)
+
+ subscribe_btn = Gtk.MenuButton()
subscribe_btn.set_image(common.IconFactory.get_image(
'tryton-notification', Gtk.IconSize.SMALL_TOOLBAR))
- tooltips.set_tip(subscribe_btn, _("Toggle notification"))
+ tooltips.set_tip(subscribe_btn, _("Show followers"))
subscribe_btn.set_relief(Gtk.ReliefStyle.NONE)
- hbuttonbox.pack_start(
+ hbox.pack_start(
subscribe_btn, expand=False, fill=True, padding=0)
- hbuttonbox.set_child_non_homogeneous(subscribe_btn, True)
- followers = rpc.execute(
- 'model', 'ir.chat.channel', 'get_followers', self.record,
- rpc.CONTEXT)
-
- def set_subscribe_state(subscribed):
+ def set_subscribe_state():
if subscribed:
img = 'tryton-notification-on'
else:
@@ -114,22 +118,86 @@ class Chat:
subscribe_btn.set_image(common.IconFactory.get_image(
img, Gtk.IconSize.SMALL_TOOLBAR))
- subscribed = rpc._LOGIN in followers['users']
- set_subscribe_state(subscribed)
- subscribe_btn.set_active(subscribed)
+ subscribed = any(_follower_self(f) for f in self._get_followers())
+ set_subscribe_state()
+
+ followers_grid = Gtk.Grid()
+ followers_grid.set_column_spacing(2)
+ followers_grid.set_row_spacing(2)
+
+ subscribe_popover = Gtk.Popover()
+ subscribe_btn.set_popover(subscribe_popover)
+ subscribe_popover.add(followers_grid)
+
+ def unsubscribe(follower):
+ def do_unsubscribe(btn):
+ confirmation_text = _('Are you sure to unsubscribe "%(name)s"'
+ ' from this channel?') % follower
+ if sur(confirmation_text):
+ if follower['type'] == 'user':
+ method = 'unsubscribe'
+ elif follower['type'] == 'email':
+ method = 'unsubscribe_email'
+ rpc.execute(
+ 'model', 'ir.chat.channel', method,
+ self.record, follower['key'], rpc.CONTEXT)
+ subscribe_popover.popdown()
+ return do_unsubscribe
+
+ def fill_followers(btn):
+ nonlocal subscribed
+ for child in followers_grid.get_children():
+ followers_grid.remove(child)
+
+ followers = self._get_followers()
+ subscribed = any(_follower_self(f) for f in self._get_followers())
+ if subscribed:
+ action_txt = _("Unsubscribe")
+ else:
+ action_txt = _("Subscribe")
+
+ followers_grid.attach(
+ (b := Gtk.Button(action_txt)), 0, 0, 5, 1)
+ b.set_relief(Gtk.ReliefStyle.NONE)
+ b.connect('clicked', toggle_subscribe)
+
+ followers = filter(lambda f: not _follower_self(f), followers)
+ for idx, follower in enumerate(followers, start=1):
+ if follower['avatar_url']:
+ image = Gtk.Image()
+ pixbuf = common.IconFactory.get_pixbuf_url(
+ follower['avatar_url'], size=16, size_param='s',
+ callback=image.set_from_pixbuf)
+ image.set_from_pixbuf(pixbuf)
+ followers_grid.attach(image, 0, idx, 1, 1)
+ followers_grid.attach(
+ Gtk.Label(follower['name'], halign=Gtk.Align.START),
+ 1, idx, 3, 1)
+ followers_grid.attach((b := Gtk.Button('×')), 4, idx, 1, 1)
+ b.set_relief(Gtk.ReliefStyle.NONE)
+ b.connect('clicked', unsubscribe(follower))
+
+ subscribe_popover.show_all()
def toggle_subscribe(button):
- if button.props.active:
- rpc.execute(
- 'model', 'ir.chat.channel', 'subscribe', self.record,
- rpc.CONTEXT)
- else:
+ nonlocal subscribed
+ if subscribed:
rpc.execute(
'model', 'ir.chat.channel', 'unsubscribe', self.record,
rpc.CONTEXT)
- set_subscribe_state(button.props.active)
+ else:
+ rpc.execute(
+ 'model', 'ir.chat.channel', 'subscribe', self.record,
+ rpc.CONTEXT)
+ subscribed = not subscribed
+ set_subscribe_state()
+ if subscribed:
+ action_txt = _("Unsubscribe")
+ else:
+ action_txt = _("Subscribe")
+ button.set_label(action_txt)
- subscribe_btn.connect('toggled', toggle_subscribe)
+ subscribe_btn.connect('clicked', fill_followers)
def _submit(button):
buffer = input_.get_buffer()
@@ -177,7 +245,97 @@ class Chat:
return widget
+ def _get_followers(self):
+ return rpc.execute(
+ 'model', 'ir.chat.channel', 'get_followers', self.record,
+ rpc.CONTEXT)
+
+ def _search_followers(self, text):
+ followers = rpc.execute(
+ 'model', 'ir.chat.channel', 'search_followers',
+ str(self.record), text, rpc.CONTEXT)
+ return filter(lambda f: not _follower_self(f), followers)
+
+ def _add_follower(self, follower):
+ if follower['type'] == 'user':
+ rpc.execute(
+ 'model', 'ir.chat.channel', 'subscribe',
+ self.record, follower['key'], rpc.CONTEXT)
+ elif follower['type'] == 'email':
+ rpc.execute(
+ 'model', 'ir.chat.channel', 'subscribe_email',
+ self.record, follower['key'], rpc.CONTEXT)
+
+ def __build_follower_entry(self):
+ tooltips = common.Tooltips()
+ subscribe_entry = Gtk.Entry()
+ subscribe_entry.set_placeholder_text(_("Add a follower"))
+ tooltips.set_tip(
+ subscribe_entry, _("Subscribe a follower to this channel"))
+
+ def text_setter(layout, cell, store, iter_):
+ cell.set_property('text', store[iter_][0]['name'])
+
+ def pixbuf_setter(layout, cell, store, iter_):
+ avatar_url = store[iter_][0]['avatar_url']
+ if avatar_url:
+ pixbuf = common.IconFactory.get_pixbuf_url(
+ avatar_url, size=16, size_param='s')
+ else:
+ pixbuf = None
+ cell.set_property('pixbuf', pixbuf)
+
+ model = Gtk.ListStore(GObject.TYPE_PYOBJECT)
+ completion = Gtk.EntryCompletion()
+ completion.set_match_func(lambda *a: True)
+ completion.set_property('popup_set_width', True)
+ completion.set_model(model)
+ completion.pack_start(
+ (pixbuf_rdr := Gtk.CellRendererPixbuf()), expand=False)
+ completion.set_cell_data_func(pixbuf_rdr, pixbuf_setter)
+ completion.pack_start(
+ text_rdr := (Gtk.CellRendererText()), expand=True)
+ completion.set_cell_data_func(text_rdr, text_setter)
+ subscribe_entry.set_completion(completion)
+
+ def update(entry, search_text):
+ if search_text != entry.get_text():
+ return
+
+ if not search_text or not model:
+ model.clear()
+ model.search_text = search_text
+ return
+
+ if getattr(model, 'search_text', None) == search_text:
+ return
+
+ followers = self._search_followers(search_text)
+
+ model.clear()
+ for follower in followers:
+ model.append([follower])
+
+ model.search_text = search_text
+ entry.emit('changed')
+
+ def changed(entry):
+ search_text = entry.get_text()
+ GLib.timeout_add(300, update, entry, search_text)
+
+ subscribe_entry.connect('changed', changed)
+
+ def match_selected(completion, model, iter_):
+ self._add_follower(model[iter_][0])
+ subscribe_entry.set_text('')
+ subscribe_entry.grab_focus()
+
+ completion.connect('match-selected', match_selected)
+
+ return subscribe_entry
+
def create_message(self, item):
+ tooltips = common.Tooltips()
message = item.message
row = Gtk.ListBoxRow()
@@ -187,37 +345,50 @@ class Chat:
hbox = Gtk.HBox(spacing=10)
row.add(hbox)
- if avatar_url := message.get('avatar_url'):
- image = Gtk.Image()
- pixbuf = common.IconFactory.get_pixbuf_url(
- avatar_url, size=32, size_param='s',
- callback=image.set_from_pixbuf)
- image.set_from_pixbuf(pixbuf)
- image.set_valign(Gtk.Align.START)
- hbox.pack_start(image, False, False, 0)
+ timestamp = message['timestamp'].strftime('%x %X')
- bubble = Gtk.VBox()
- hbox.pack_end(bubble, True, True, 0)
+ if message.get('user'):
+ if avatar_url := message.get('avatar_url'):
+ image = Gtk.Image()
+ pixbuf = common.IconFactory.get_pixbuf_url(
+ avatar_url, size=32, size_param='s',
+ callback=image.set_from_pixbuf)
+ image.set_from_pixbuf(pixbuf)
+ image.set_valign(Gtk.Align.START)
+ hbox.pack_start(image, False, False, 0)
- author = Gtk.Label(label=message['author'])
- author.set_xalign(0)
- author.get_style_context().add_class('dim')
- timestamp = Gtk.Label(label=message['timestamp'].strftime('%x %X'))
- timestamp.set_xalign(1)
- timestamp.get_style_context().add_class('dim')
+ bubble = Gtk.VBox()
+ hbox.pack_end(bubble, True, True, 0)
- meta = Gtk.HBox()
- meta.pack_start(author, True, True, 0)
- meta.pack_end(timestamp, False, False, 0)
+ author = Gtk.Label(label=message['author'])
+ author.set_xalign(0)
+ author.get_style_context().add_class('dim')
- content = Gtk.Label(label=message['content'])
- content.set_xalign(0)
- content.set_line_wrap(True)
- content.set_selectable(True)
- content.get_style_context().add_class(
- f"chat-content-{message['audience']}")
+ timestamp = Gtk.Label(label=timestamp)
+ timestamp.set_xalign(1)
+ timestamp.get_style_context().add_class('dim')
- bubble.pack_start(meta, False, False, 0)
- bubble.pack_start(content, False, False, 0)
+ meta = Gtk.HBox()
+ meta.pack_start(author, True, True, 0)
+ meta.pack_end(timestamp, False, False, 0)
+
+ content = Gtk.Label(label=message['content'])
+ content.set_xalign(0)
+ content.set_line_wrap(True)
+ content.set_selectable(True)
+ content.get_style_context().add_class(
+ f"chat-content-{message['audience']}")
+
+ bubble.pack_start(meta, False, False, 0)
+ bubble.pack_start(content, False, False, 0)
+ else:
+ content = Gtk.Label(label=message['content'])
+ content.set_xalign(.5)
+ content.set_line_wrap(True)
+ content.set_selectable(True)
+ content.get_style_context().add_class('dim')
+
+ hbox.pack_start(content, True, True, 0)
+ tooltips.set_tip(content, timestamp)
return row
diff --git a/trytond/CHANGELOG b/trytond/CHANGELOG
index a63e33f94f..5d7f71efe0 100644
--- a/trytond/CHANGELOG
+++ b/trytond/CHANGELOG
@@ -1,3 +1,4 @@
+* Send emails on chat messages
* Add path attribute on XML fields to specfiy a relative path value
* Allow to include subdirectories in tryton.cfg
* Add route for login / logout with cookie
diff --git a/trytond/trytond/ir/chat.py b/trytond/trytond/ir/chat.py
index c56782fafb..c6f00488d1 100644
--- a/trytond/trytond/ir/chat.py
+++ b/trytond/trytond/ir/chat.py
@@ -2,27 +2,37 @@
# this repository contains the full copyright notices and license terms.
import json
+import uuid
+from email.message import EmailMessage
+from email.utils import getaddresses, make_msgid
+from operator import itemgetter
from sql import Null
from sql.conditionals import NullIf
+import trytond.config as config
from trytond.bus import Bus
from trytond.i18n import gettext
-from trytond.model import ChatMixin, Check, ModelSQL, Unique, fields
+from trytond.model import ChatMixin, Check, ModelSQL, ModelView, Unique, fields
from trytond.model.exceptions import ValidationError
from trytond.pool import Pool
+from trytond.pyson import Bool, Eval
from trytond.rpc import RPC
-from trytond.tools import firstline
+from trytond.sendmail import send_message_transactional
+from trytond.tools import cached_property, firstline
from trytond.tools.email_ import (
- EmailNotValidError, normalize_email, validate_email)
+ EmailNotValidError, normalize_email, set_from_header, validate_email)
from trytond.transaction import Transaction
+from trytond.url import host
+
+FOLLOWER_SEARCH_LIMIT = 20
class InvalidEMailError(ValidationError):
pass
-class Channel(ModelSQL):
+class Channel(ModelSQL, ModelView):
"Chat Channel"
__name__ = 'ir.chat.channel'
@@ -30,6 +40,7 @@ class Channel(ModelSQL):
"Resource", selection='get_models', required=True)
followers = fields.One2Many(
'ir.chat.follower', 'channel', "Followers")
+ identifier = fields.Char("Identifier", readonly=True)
@classmethod
def __setup__(cls):
@@ -38,6 +49,8 @@ class Channel(ModelSQL):
cls._sql_constraints += [
('resource_unique', Unique(t, t.resource),
'ir.msg_chat_channel_resource_unique'),
+ ('identifier_unique', Unique(t, t.identifier),
+ 'ir.msg_chat_channel_identifier_unique'),
]
cls.__rpc__.update(
subscribe=RPC(readonly=False),
@@ -45,10 +58,33 @@ class Channel(ModelSQL):
subscribe_email=RPC(readonly=False),
unsubscribe_email=RPC(readonly=False),
get_followers=RPC(),
+ search_followers=RPC(),
post=RPC(readonly=False, result=int),
get_models=RPC(),
get=RPC(),
)
+ cls._buttons.update({
+ 'reset_identifier': {
+ 'icon': 'tryton-refresh',
+ },
+ })
+
+ @classmethod
+ def preprocess_values(cls, mode, values):
+ values = super().preprocess_values(mode, values)
+ if mode == 'create' and 'identifier' not in values:
+ values['identifier'] = uuid.uuid4().hex
+ return values
+
+ @cached_property
+ def language(self):
+ pool = Pool()
+ Configuration = pool.get('ir.configuration')
+
+ language = self.resource.chat_language(audience='public')
+ if language is None:
+ language = Configuration.get_language()
+ return language
@classmethod
def get_models(cls):
@@ -83,9 +119,14 @@ class Channel(ModelSQL):
@classmethod
def subscribe(cls, resource, username=None):
+ cls.check_access(resource)
+
pool = Pool()
Follower = pool.get('ir.chat.follower')
User = pool.get('res.user')
+ Message = pool.get('ir.message')
+ ModelData = pool.get('ir.model.data')
+
if username is not None:
user, = User.search([
('login', '=', username),
@@ -95,48 +136,126 @@ class Channel(ModelSQL):
channel = cls._get_channel(resource)
Follower.add_user(channel, user)
+ with Transaction().set_context(
+ language=channel.language, user=0, _notify=False):
+ msg = Message(ModelData.get_id('ir', 'msg_chat_follower_joined'))
+ cls.post(resource, msg.text % {'name': user.login})
+
@classmethod
- def unsubscribe(cls, resource):
+ def unsubscribe(cls, resource, username=None):
pool = Pool()
Follower = pool.get('ir.chat.follower')
User = pool.get('res.user')
- user = User(Transaction().user)
+ Message = pool.get('ir.message')
+ ModelData = pool.get('ir.model.data')
+
+ if username:
+ cls.check_access(resource)
+ try:
+ user, = User.search([('login', '=', username)], limit=1)
+ except ValueError:
+ return None
+ else:
+ user = User(Transaction().user)
+
channel = cls._get_channel(resource)
Follower.remove_user(channel, user)
+ with Transaction().set_context(
+ language=channel.language, user=0, _notify=False):
+ msg = Message(ModelData.get_id('ir', 'msg_chat_follower_left'))
+ cls.post(resource, msg.text % {'name': user.login})
+
@classmethod
def subscribe_email(cls, resource, email):
+ cls.check_access(resource)
+
pool = Pool()
Follower = pool.get('ir.chat.follower')
+ Message = pool.get('ir.message')
+ ModelData = pool.get('ir.model.data')
+
channel = cls._get_channel(resource)
Follower.add_email(channel, email)
+ with Transaction().set_context(
+ language=channel.language, user=0, _notify=False):
+ msg = Message(ModelData.get_id('ir', 'msg_chat_follower_joined'))
+ cls.post(resource, msg.text % {'name': email})
+
@classmethod
def unsubscribe_email(cls, resource, email):
+ cls.check_access(resource)
+
pool = Pool()
Follower = pool.get('ir.chat.follower')
+ Message = pool.get('ir.message')
+ ModelData = pool.get('ir.model.data')
+
channel = cls._get_channel(resource)
Follower.remove_email(channel, email)
+ with Transaction().set_context(
+ language=channel.language, user=0, _notify=False):
+ msg = Message(ModelData.get_id('ir', 'msg_chat_follower_left'))
+ cls.post(resource, msg.text % {'name': email})
+
@classmethod
- def get_followers(cls, resource):
+ def _get_followers(cls, resource):
pool = Pool()
Follower = pool.get('ir.chat.follower')
- users, emails = [], []
+ followers = {}
channel = cls._get_channel(resource)
if channel:
- followers = Follower.search([
+ chan_followers = Follower.search([
('channel', '=', channel),
])
- for follower in followers:
- if follower.user:
- users.append(follower.user.login)
- elif follower.email:
- emails.append(follower.email)
+ for f in chan_followers:
+ if f.user:
+ followers['user', f.user.login] = {
+ 'type': 'user',
+ 'key': f.user.login,
+ 'name': f'{f.user.name or f.user.login}',
+ 'avatar_url': f.user.avatar_url,
+ }
+ elif f.email:
+ followers['email', f.email] = {
+ 'type': 'email',
+ 'key': f.email,
+ 'name': f.email,
+ 'avatar_url': None,
+ }
+ return followers
+
+ @classmethod
+ def get_followers(cls, resource):
+ return sorted(
+ cls._get_followers(resource).values(), key=itemgetter('name'))
+
+ @classmethod
+ def _search_followers(cls, resource, text):
+ pool = Pool()
+ User = pool.get('res.user')
+
+ users = User.search([('rec_name', 'ilike', f'%{text}%')])
return {
- 'users': users,
- 'emails': emails,
- }
+ ('user', u.login): {
+ 'type': 'user',
+ 'key': u.login,
+ 'name': f'{u.name or u.login}',
+ 'avatar_url': u.avatar_url,
+ }
+ for u in users}
+
+ @classmethod
+ def search_followers(cls, resource, text, limit=FOLLOWER_SEARCH_LIMIT):
+ potential_followers = cls._search_followers(resource, text)
+ current_followers = cls.get_followers(resource)
+
+ for follower in current_followers:
+ potential_followers.pop((follower['type'], follower['key']), None)
+
+ return list(potential_followers.values())[:limit]
@classmethod
def post(cls, resource, content, audience='internal'):
@@ -144,26 +263,115 @@ class Channel(ModelSQL):
Message = pool.get('ir.chat.message')
User = pool.get('res.user')
transaction = Transaction()
- user = User(transaction.user)
- ctx_user = User(transaction.context.get('user', user))
+ ctx_user = User(transaction.context.get('user', transaction.user))
channel = cls._get_channel(resource)
message = Message(
channel=channel,
- user=user,
+ user=ctx_user,
content=content,
audience=audience)
message.save()
+ cls.dispatch_message(message, lambda u: u == ctx_user)
+
+ return message
+
+ @classmethod
+ def _email_channel(cls, email):
+ return None
+
+ @classmethod
+ def _email_content(cls, body):
+ return body
+
+ @classmethod
+ def post_from_email(cls, email):
+ pool = Pool()
+ Message = pool.get('ir.chat.message')
+
+ if 'From' in email:
+ from_ = getaddresses([email.get('From')])[0][1]
+ else:
+ from_ = None
+ channel = cls._email_channel(email)
+ if channel is None:
+ return
+
+ if email.get_content_maintype() != 'multipart':
+ content = email.get_payload()
+ for part in email.walk():
+ if part.get_content_type() == 'text/plain':
+ content = part.get_payload()
+ break
+
+ message = Message(
+ channel=channel,
+ email=from_,
+ audience='public',
+ content=cls._email_content(content))
+ message.save()
+
+ cls.dispatch_message(message, lambda u: u == from_)
+
+ return message
+
+ @classmethod
+ def _email_from(cls, message):
+ return config.get('email', 'from')
+
+ @classmethod
+ def _email_reply_to(cls, message):
+ return None
+
+ @classmethod
+ def _email_body(cls, message):
+ return message.content
+
+ @classmethod
+ def dispatch_message(cls, message, is_sender):
+ pool = Pool()
+ Message = pool.get('ir.message')
+ ModelData = pool.get('ir.model.data')
+
Bus.publish(
- f'chat:{str(resource)}', {
+ f'chat:{str(message.channel.resource)}', {
'type': 'message',
'message': message.as_dict(),
})
- for follower in channel.followers:
- if follower.user != ctx_user:
- follower.notify(message)
- return message
+ if not Transaction().context.get('_notify', True):
+ return
+
+ to_email = []
+ for follower in message.channel.followers:
+ if follower.user is not None and not is_sender(follower.user):
+ follower.notify(message)
+ if (message.audience != 'internal'
+ and follower.email is not None
+ and not is_sender(follower.email)):
+ to_email.append(follower.email)
+
+ if to_email:
+ with Transaction().set_context(language=message.channel.language):
+ subject_msg = Message(ModelData.get_id('ir', 'msg_subject'))
+ subject = subject_msg.text
+
+ from_ = cls._email_from(message)
+ msg = EmailMessage()
+ set_from_header(msg, from_, from_)
+ if (reply_to := cls._email_reply_to(message)):
+ msg['Reply-To'] = reply_to
+ msg['Bcc'] = to_email
+ msg['Auto-Submitted'] = 'auto-generated'
+ msg['Message-ID'] = message.reference = make_msgid(domain=host())
+ msg['Subject'] = subject % {
+ 'author': message.author,
+ 'resource': message.channel.resource.rec_name,
+ }
+ msg.set_content(cls._email_body(message))
+ send_message_transactional(msg)
+
+ message.save()
@classmethod
def get(cls, resource, before=None, after=None):
@@ -180,13 +388,28 @@ class Channel(ModelSQL):
messages = Message.search(domain)
return [m.as_dict() for m in messages]
+ @classmethod
+ @ModelView.button
+ def reset_identifier(cls, channels):
+ for channel in channels:
+ channel.identifier = uuid.uuid4().hex
+ cls.save(channels)
+
class AuthorMixin:
__slots__ = ()
author = fields.Function(fields.Char("Author"), 'on_change_with_author')
- user = fields.Many2One('res.user', "User", ondelete='CASCADE')
- email = fields.Char("Email")
+ user = fields.Many2One(
+ 'res.user', "User", ondelete='CASCADE',
+ states={
+ 'invisible': Bool(Eval('email')),
+ })
+ email = fields.Char(
+ "Email",
+ states={
+ 'invisible': Bool(Eval('user')),
+ })
@classmethod
def __setup__(cls):
@@ -234,7 +457,7 @@ class AuthorMixin:
return self.user.avatar_url
-class Follower(AuthorMixin, ModelSQL):
+class Follower(AuthorMixin, ModelView, ModelSQL):
"Chat Follower"
__name__ = 'ir.chat.follower'
@@ -253,6 +476,7 @@ class Follower(AuthorMixin, ModelSQL):
Unique(t, t.channel, t.email),
'ir.msg_chat_follower_channel_email_unique'),
]
+ cls.__access__.add('channel')
@classmethod
def add_user(cls, channel, user):
@@ -312,6 +536,7 @@ class Message(AuthorMixin, ModelSQL):
('internal', "Internal"),
('public', "Public"),
], "Audience", required=True)
+ reference = fields.Char("Reference", readonly=True)
@classmethod
def default_audience(cls):
diff --git a/trytond/trytond/ir/chat.xml b/trytond/trytond/ir/chat.xml
new file mode 100644
index 0000000000..1643c9b705
--- /dev/null
+++ b/trytond/trytond/ir/chat.xml
@@ -0,0 +1,74 @@
+
+
+
+
+
+ ir.chat.channel
+ tree
+ chat_channel_list
+
+
+
+ ir.chat.channel
+ form
+ chat_channel_form
+
+
+
+ Channels
+ ir.chat.channel
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ reset_identifier
+ Reset Identifier
+ ir.chat.channel
+ This action will make previous entry point unusable. Do you want to continue?
+
+
+
+
+
+
+
+ ir.chat.channel
+
+
+
+
+
+
+ ir.chat.channel
+
+
+
+
+
+
+
+
+ ir.chat.follower
+ tree
+ chat_follower_list
+
+
+
+ ir.chat.follower
+ form
+ chat_follower_form
+
+
+
+
diff --git a/trytond/trytond/ir/message.xml b/trytond/trytond/ir/message.xml
index 6b79de940d..cddb520add 100644
--- a/trytond/trytond/ir/message.xml
+++ b/trytond/trytond/ir/message.xml
@@ -478,6 +478,18 @@ this repository contains the full copyright notices and license terms. -->
Only one channel per resource is allowed.
+
+ Identifier must be unique per channel.
+
+
+ "%(name)s" joined
+
+
+ "%(name)s" left
+
+
+ %(resource)s: %(author)s left a message
+
Only user or email can be filled.
diff --git a/trytond/trytond/ir/tryton.cfg b/trytond/trytond/ir/tryton.cfg
index 447464501b..cae99f9ff7 100644
--- a/trytond/trytond/ir/tryton.cfg
+++ b/trytond/trytond/ir/tryton.cfg
@@ -22,6 +22,7 @@ xml:
queue.xml
email.xml
error.xml
+ chat.xml
filestore.xml
[register]
diff --git a/trytond/trytond/ir/view/chat_channel_form.xml b/trytond/trytond/ir/view/chat_channel_form.xml
new file mode 100644
index 0000000000..5d7c336378
--- /dev/null
+++ b/trytond/trytond/ir/view/chat_channel_form.xml
@@ -0,0 +1,13 @@
+
+
+
diff --git a/trytond/trytond/ir/view/chat_channel_list.xml b/trytond/trytond/ir/view/chat_channel_list.xml
new file mode 100644
index 0000000000..cb26d5294e
--- /dev/null
+++ b/trytond/trytond/ir/view/chat_channel_list.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
diff --git a/trytond/trytond/ir/view/chat_follower_form.xml b/trytond/trytond/ir/view/chat_follower_form.xml
new file mode 100644
index 0000000000..a6f97f1931
--- /dev/null
+++ b/trytond/trytond/ir/view/chat_follower_form.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/trytond/trytond/ir/view/chat_follower_list.xml b/trytond/trytond/ir/view/chat_follower_list.xml
new file mode 100644
index 0000000000..f719c33a0a
--- /dev/null
+++ b/trytond/trytond/ir/view/chat_follower_list.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/trytond/trytond/tests/test_chat.py b/trytond/trytond/tests/test_chat.py
index 25f34b6c28..f352408dd5 100644
--- a/trytond/trytond/tests/test_chat.py
+++ b/trytond/trytond/tests/test_chat.py
@@ -1,6 +1,10 @@
# This file is part of Tryton. The COPYRIGHT file at the top level of
# this repository contains the full copyright notices and license terms.
+from email.message import EmailMessage
+from email.utils import getaddresses
+from unittest.mock import patch
+
from trytond.pool import Pool
from trytond.transaction import Transaction
@@ -45,7 +49,10 @@ class NotificationTestCase(TestCase):
room.chat_post('tests.msg_chat', foo="Bar", audience='public')
self.run_tasks()
- message, = Message.search([('channel', '=', channel)])
+ message, = Message.search([
+ ('channel', '=', channel),
+ ('audience', '=', 'public'),
+ ])
self.assertEqual(message.audience, 'public')
self.assertEqual(message.content, "Chat Message: Bar")
with Transaction().set_user(alice.id):
@@ -61,3 +68,60 @@ class NotificationTestCase(TestCase):
'action': None,
'unread': True,
})
+
+ @with_transaction()
+ def test_chat_post_email(self):
+ "Test posting on chat send email"
+ pool = Pool()
+ Room = pool.get('test.chat.room')
+ Channel = pool.get('ir.chat.channel')
+
+ room = Room()
+ room.save()
+ Channel.subscribe_email(room, "foo@example.org")
+ Channel.subscribe_email(room, "bar@example.com")
+
+ with patch('trytond.ir.chat.send_message_transactional') as p:
+ room.chat_post('tests.msg_chat', foo="Bar", audience='public')
+ self.run_tasks()
+
+ (msg,), _ = p.call_args
+
+ self.assertEqual(
+ list(a for n, a in getaddresses([msg['Bcc']])),
+ ['foo@example.org', 'bar@example.com'])
+ self.assertEqual(msg.get_content().strip(), "Chat Message: Bar")
+
+ @with_transaction()
+ def test_chat_post_from_email(self):
+ "Test posting on chat from an email"
+ pool = Pool()
+ User = pool.get('res.user')
+ Room = pool.get('test.chat.room')
+ Channel = pool.get('ir.chat.channel')
+ Message = pool.get('ir.chat.message')
+
+ alice, = User.create([{
+ 'name': "Alice",
+ 'login': 'alice',
+ }])
+
+ room = Room()
+ room.save()
+ Channel.subscribe(room, alice.login)
+ channel = Channel._get_channel(room)
+
+ msg = EmailMessage()
+ msg['From'] = 'foo@example.org'
+ msg['Subject'] = 'New message!'
+ msg.set_content("Test Content")
+
+ with patch.object(Channel, '_email_channel') as find_channel:
+ find_channel.return_value = channel
+ Channel.post_from_email(msg)
+
+ message, = Message.search([
+ ('channel', '=', channel),
+ ('audience', '=', 'public'),
+ ])
+ self.assertEqual(message.content, "Test Content\n")