Add support for emails on the chat channels
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
* Add an action to handle chat replies
|
||||
* Add support for Python 3.14
|
||||
* Remove support for Python 3.9
|
||||
|
||||
|
||||
@@ -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``
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}'
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0"?>
|
||||
<!-- This file is part of Tryton. The COPYRIGHT file at the top level of
|
||||
this repository contains the full copyright notices and license terms. -->
|
||||
<tryton>
|
||||
<data>
|
||||
<record model="ir.message" id="msg_reply_above">
|
||||
<field name="text">Please reply above the line.</field>
|
||||
</record>
|
||||
</data>
|
||||
</tryton>
|
||||
@@ -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 <john.doe@example.com>"
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
+54
-1
@@ -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}
|
||||
|
||||
@@ -34,6 +34,7 @@ model:
|
||||
configuration.ConfigurationLang
|
||||
ir.Email
|
||||
ir.EmailTemplate
|
||||
ir.Channel
|
||||
wizard:
|
||||
party.CheckVIES
|
||||
party.Replace
|
||||
|
||||
+232
-59
@@ -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('<div/>', {
|
||||
'class': 'chat',
|
||||
});
|
||||
|
||||
let btn_group = jQuery('<div/>', {
|
||||
'class': 'btn-group',
|
||||
'role': 'group',
|
||||
let toolbar = jQuery('<div/>', {
|
||||
'class': 'btn-toolbar',
|
||||
'role': 'toolbar',
|
||||
}).appendTo(el);
|
||||
let input_group = jQuery('<div/>', {
|
||||
'class': 'input-group',
|
||||
'role': 'group',
|
||||
}).appendTo(toolbar);
|
||||
this.subscribe_input = jQuery('<input/>', {
|
||||
'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('<div/>', {
|
||||
'title': val.key,
|
||||
})
|
||||
.append(jQuery('<img/>', {
|
||||
'src': add_size(val.avatar_url, avatar_size),
|
||||
'class': 'img-circle',
|
||||
'style': `width: ${avatar_size}px; height: ${avatar_size}px;`,
|
||||
}))
|
||||
.append(jQuery('<span/>', {
|
||||
})
|
||||
.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('<div/>', {
|
||||
'class': 'btn-group dropdown',
|
||||
'role': 'group',
|
||||
}).appendTo(toolbar);
|
||||
let subscribe_btn = jQuery('<button/>', {
|
||||
'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('<ul/>', {
|
||||
'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('<a/>', {
|
||||
'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('<a/>', {
|
||||
'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('<li/>', {
|
||||
'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('<li/>', {
|
||||
'class': 'divider',
|
||||
}).appendTo(menu);
|
||||
}
|
||||
let add_follower = (follower) => {
|
||||
jQuery('<li/>', {
|
||||
'role': 'presentation',
|
||||
})
|
||||
.append(jQuery('<div/>', {
|
||||
'title': follower.key,
|
||||
})
|
||||
.append(jQuery('<img/>', {
|
||||
'class': 'img-circle chat-avatar',
|
||||
'src': add_size(follower.avatar_url, avatar_size),
|
||||
'style': `width: ${avatar_size}px; height: ${avatar_size}px;`,
|
||||
}))
|
||||
.append(jQuery('<span/>')
|
||||
.text(follower.name))
|
||||
.append(jQuery('<button/>', {
|
||||
'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('<div/>', {
|
||||
'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('<div/>', {
|
||||
'class': 'media chat-message',
|
||||
}).append(jQuery('<div/>', {
|
||||
'class': 'media-left',
|
||||
}).append(jQuery('<img/>', {
|
||||
'class': 'media-object img-circle chat-avatar',
|
||||
'src': avatar_url,
|
||||
'alt': message.author,
|
||||
'style': `width: ${avatar_size}px; height: ${avatar_size}px;`,
|
||||
}))).append(jQuery('<div/>', {
|
||||
'class': 'media-body well well-sm',
|
||||
}).append(jQuery('<h6/>', {
|
||||
'class': 'media-heading',
|
||||
}).text(message.author)
|
||||
.append(jQuery('<small/>', {
|
||||
'class': 'text-muted pull-right',
|
||||
}).text(timestamp)))
|
||||
.append(jQuery('<div/>', {
|
||||
'class': `chat-content chat-content-${message.audience}`,
|
||||
}).text(message.content)));
|
||||
} else {
|
||||
return jQuery('<div/>', {
|
||||
'class': 'media chat-message system-message',
|
||||
}).append(jQuery('<div/>', {
|
||||
'class': 'chat-content',
|
||||
'title': timestamp,
|
||||
}).text(message.content));
|
||||
}
|
||||
return jQuery('<div/>', {
|
||||
'class': 'media chat-message',
|
||||
}).append(jQuery('<div/>', {
|
||||
'class': 'media-left',
|
||||
}).append(jQuery('<img/>', {
|
||||
'class': 'media-object img-circle chat-avatar',
|
||||
'src': avatar_url,
|
||||
'alt': message.author,
|
||||
'style': `width: ${avatar_size}px; height: ${avatar_size}px;`,
|
||||
}))).append(jQuery('<div/>', {
|
||||
'class': 'media-body well well-sm',
|
||||
}).append(jQuery('<h6/>', {
|
||||
'class': 'media-heading',
|
||||
}).text(message.author)
|
||||
.append(jQuery('<small/>', {
|
||||
'class': 'text-muted pull-right',
|
||||
}).text(timestamp)))
|
||||
.append(jQuery('<div/>', {
|
||||
'class': `chat-content chat-content-${message.audience}`,
|
||||
}).text(message.content)));
|
||||
}
|
||||
}
|
||||
Sao.Chat = _Chat;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+221
-50
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+253
-28
@@ -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):
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
<?xml version="1.0"?>
|
||||
<!-- This file is part of Tryton. The COPYRIGHT file at the top level of
|
||||
this repository contains the full copyright notices and license terms. -->
|
||||
<tryton>
|
||||
<data>
|
||||
<record model="ir.ui.view" id="chat_channel_view_list">
|
||||
<field name="model">ir.chat.channel</field>
|
||||
<field name="type">tree</field>
|
||||
<field name="name">chat_channel_list</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.ui.view" id="chat_channel_view_form">
|
||||
<field name="model">ir.chat.channel</field>
|
||||
<field name="type">form</field>
|
||||
<field name="name">chat_channel_form</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.action.act_window" id="act_chat_channel">
|
||||
<field name="name">Channels</field>
|
||||
<field name="res_model">ir.chat.channel</field>
|
||||
</record>
|
||||
<record model="ir.action.act_window.view" id="act_chat_channel_view1">
|
||||
<field name="sequence" eval="10"/>
|
||||
<field name="view" ref="chat_channel_view_list"/>
|
||||
<field name="act_window" ref="act_chat_channel"/>
|
||||
</record>
|
||||
<record model="ir.action.act_window.view" id="act_chat_channel_view2">
|
||||
<field name="sequence" eval="20"/>
|
||||
<field name="view" ref="chat_channel_view_form"/>
|
||||
<field name="act_window" ref="act_chat_channel"/>
|
||||
</record>
|
||||
<menuitem parent="ir.menu_models" action="act_chat_channel" id="menu_chat_channel_form"/>
|
||||
|
||||
<record model="ir.model.button" id="chat_channel_reset_identifier_button">
|
||||
<field name="name">reset_identifier</field>
|
||||
<field name="string">Reset Identifier</field>
|
||||
<field name="model">ir.chat.channel</field>
|
||||
<field name="confirm">This action will make previous entry point unusable. Do you want to continue?</field>
|
||||
</record>
|
||||
<record model="ir.model.button-res.group" id="chat_channel_reset_identifier_button_group_admin">
|
||||
<field name="button" ref="chat_channel_reset_identifier_button"/>
|
||||
<field name="group" ref="res.group_admin"/>
|
||||
</record>
|
||||
|
||||
<record model="ir.model.access" id="access_chat_channel">
|
||||
<field name="model">ir.chat.channel</field>
|
||||
<field name="perm_read" eval="True"/>
|
||||
<field name="perm_write" eval="False"/>
|
||||
<field name="perm_create" eval="False"/>
|
||||
<field name="perm_delete" eval="False"/>
|
||||
</record>
|
||||
<record model="ir.model.access" id="access_chat_channel_admin">
|
||||
<field name="model">ir.chat.channel</field>
|
||||
<field name="group" ref="res.group_admin"/>
|
||||
<field name="perm_read" eval="True"/>
|
||||
<field name="perm_write" eval="True"/>
|
||||
<field name="perm_create" eval="True"/>
|
||||
<field name="perm_delete" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record model="ir.ui.view" id="chat_follower_view_list">
|
||||
<field name="model">ir.chat.follower</field>
|
||||
<field name="type">tree</field>
|
||||
<field name="name">chat_follower_list</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.ui.view" id="chat_follower_view_form">
|
||||
<field name="model">ir.chat.follower</field>
|
||||
<field name="type">form</field>
|
||||
<field name="name">chat_follower_form</field>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</tryton>
|
||||
@@ -478,6 +478,18 @@ this repository contains the full copyright notices and license terms. -->
|
||||
<record model="ir.message" id="msg_chat_channel_resource_unique">
|
||||
<field name="text">Only one channel per resource is allowed.</field>
|
||||
</record>
|
||||
<record model="ir.message" id="msg_chat_channel_identifier_unique">
|
||||
<field name="text">Identifier must be unique per channel.</field>
|
||||
</record>
|
||||
<record model="ir.message" id="msg_chat_follower_joined">
|
||||
<field name="text">"%(name)s" joined</field>
|
||||
</record>
|
||||
<record model="ir.message" id="msg_chat_follower_left">
|
||||
<field name="text">"%(name)s" left</field>
|
||||
</record>
|
||||
<record model="ir.message" id="msg_subject">
|
||||
<field name="text">%(resource)s: %(author)s left a message</field>
|
||||
</record>
|
||||
<record model="ir.message" id="msg_chat_user_or_email">
|
||||
<field name="text">Only user or email can be filled.</field>
|
||||
</record>
|
||||
|
||||
@@ -22,6 +22,7 @@ xml:
|
||||
queue.xml
|
||||
email.xml
|
||||
error.xml
|
||||
chat.xml
|
||||
filestore.xml
|
||||
|
||||
[register]
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0"?>
|
||||
<!-- This file is part of Tryton. The COPYRIGHT file at the top level of
|
||||
this repository contains the full copyright notices and license terms. -->
|
||||
<form col="2">
|
||||
<label name="resource"/>
|
||||
<field name="resource"/>
|
||||
<label name="identifier"/>
|
||||
<group col="2" colspan="1" id="identifier">
|
||||
<field name="identifier"/>
|
||||
<button name="reset_identifier"/>
|
||||
</group>
|
||||
<field name="followers" colspan="2"/>
|
||||
</form>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0"?>
|
||||
<!-- This file is part of Tryton. The COPYRIGHT file at the top level of
|
||||
this repository contains the full copyright notices and license terms. -->
|
||||
<tree>
|
||||
<field name="resource" expand="2"/>
|
||||
<field name="identifier" optional="1" expand="1"/>
|
||||
</tree>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0"?>
|
||||
<!-- This file is part of Tryton. The COPYRIGHT file at the top level of
|
||||
this repository contains the full copyright notices and license terms. -->
|
||||
<form col="2">
|
||||
<label name="user"/>
|
||||
<field name="user"/>
|
||||
|
||||
<label name="email"/>
|
||||
<field name="email"/>
|
||||
</form>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0"?>
|
||||
<!-- This file is part of Tryton. The COPYRIGHT file at the top level of
|
||||
this repository contains the full copyright notices and license terms. -->
|
||||
<tree>
|
||||
<field name="author"/>
|
||||
</tree>
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user