details: https://code.tryton.org/tryton/commit/481aed0501da branch: default user: Nicolas Évrard <[email protected]> date: Tue Dec 02 18:11:10 2025 +0100 description: Notify subscribed users of new message in chat diffstat:
trytond/doc/ref/models.rst | 18 +++++++++- trytond/trytond/ir/chat.py | 25 +++++++++++++- trytond/trytond/model/chat.py | 47 ++++++++++++++++++++++++++++ trytond/trytond/tests/__init__.py | 17 +++++---- trytond/trytond/tests/chat.py | 15 +++++++++ trytond/trytond/tests/message.xml | 3 + trytond/trytond/tests/test_chat.py | 62 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 174 insertions(+), 13 deletions(-) diffs (287 lines): diff -r 7e674916c6bc -r 481aed0501da trytond/doc/ref/models.rst --- a/trytond/doc/ref/models.rst Tue Dec 02 13:08:54 2025 +0100 +++ b/trytond/doc/ref/models.rst Tue Dec 02 18:11:10 2025 +0100 @@ -1286,9 +1286,23 @@ A mixin_ to activate a `model-ir.chat.channel` on any :class:`~trytond.model.ModelStorage` record. +Class methods: + +.. classmethod:: ChatMixin.chat_post(records, message_id[, audience[, n[, \*\*variables]]]) + + Posts a message to the `model-ir.chat.channel` of the ``records`` using the + XML ID of a `model-ir.message` formatted using + :func:`~trytond.i18n.gettext` or :func:`~trytond.i18n.ngettext` if ``n`` is + set. + +Instance methods: + +.. method:: ChatMixin.chat_language([audience]) + + Returns the language to use to translate the message posted by + :meth:`~ChatMixin.chat_post`. + .. _mixin: http://en.wikipedia.org/wiki/Mixin .. _JSON: http://en.wikipedia.org/wiki/Json .. _UNION: http://en.wikipedia.org/wiki/Union_(SQL)#UNION_operator - - diff -r 7e674916c6bc -r 481aed0501da trytond/trytond/ir/chat.py --- a/trytond/trytond/ir/chat.py Tue Dec 02 13:08:54 2025 +0100 +++ b/trytond/trytond/ir/chat.py Tue Dec 02 18:11:10 2025 +0100 @@ -1,6 +1,8 @@ # 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 json + from sql import Null from sql.conditionals import NullIf @@ -10,6 +12,7 @@ from trytond.model.exceptions import ValidationError from trytond.pool import Pool from trytond.rpc import RPC +from trytond.tools import firstline from trytond.tools.email_ import ( EmailNotValidError, normalize_email, validate_email) from trytond.transaction import Transaction @@ -114,7 +117,9 @@ pool = Pool() Message = pool.get('ir.chat.message') User = pool.get('res.user') - user = User(Transaction().user) + transaction = Transaction() + user = User(transaction.user) + ctx_user = User(transaction.context.get('user', user)) channel = cls._get_channel(resource) message = Message( channel=channel, @@ -129,7 +134,8 @@ 'message': message.as_dict(), }) for follower in channel.followers: - follower.notify(message) + if follower.user != ctx_user: + follower.notify(message) return message @@ -190,6 +196,8 @@ @fields.depends('user', 'email') def on_change_with_author(self, name=None): if self.user: + if not self.user.id: + return return self.user.name elif self.email: return self.email @@ -253,7 +261,18 @@ ])) def notify(self, message): - pass + pool = Pool() + Notification = pool.get('res.notification') + + if self.user: + Notification( + user=self.user, + label=message.author, + description=firstline(message.content), + icon='tryton-chat', + model=message.channel.resource.__name__, + records=json.dumps([message.channel.resource.id]) + ).save() class Message(AuthorMixin, ModelSQL): diff -r 7e674916c6bc -r 481aed0501da trytond/trytond/model/chat.py --- a/trytond/trytond/model/chat.py Tue Dec 02 13:08:54 2025 +0100 +++ b/trytond/trytond/model/chat.py Tue Dec 02 18:11:10 2025 +0100 @@ -1,6 +1,53 @@ # 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 collections import namedtuple +from itertools import groupby + +from trytond.i18n import gettext, ngettext +from trytond.pool import Pool +from trytond.transaction import Transaction + +from .descriptors import dualmethod + +ChatMessage = namedtuple( + 'ChatMessage', + ['msg_id', 'n', 'variables', 'audience']) + class ChatMixin: __slots__ = () + + def chat_language(self, audience='internal'): + return + + @dualmethod + def chat_post( + cls, records, message_id, + audience='internal', n=None, **variables): + transaction = Transaction() + for language, recs in groupby( + records, key=lambda r: r.chat_language(audience)): + with transaction.set_context(language=language): + cls.__queue__._chat_dispatch( + records, + ChatMessage( + msg_id=message_id, + n=n, + variables=variables, + audience=audience, + )) + + @classmethod + def _chat_dispatch(cls, records, message): + pool = Pool() + Channel = pool.get('ir.chat.channel') + transaction = Transaction() + message = ChatMessage(*message) + if message.n is None: + msg = gettext(message.msg_id, **message.variables) + else: + msg = ngettext(message.msg_id, message.n, **message.variables) + with transaction.set_user(0): + for record in records: + Channel.post(record, msg, audience=message.audience) diff -r 7e674916c6bc -r 481aed0501da trytond/trytond/tests/__init__.py --- a/trytond/trytond/tests/__init__.py Tue Dec 02 13:08:54 2025 +0100 +++ b/trytond/trytond/tests/__init__.py Tue Dec 02 18:11:10 2025 +0100 @@ -9,14 +9,14 @@ def register(): from . import ( - access, copy_, export_data, field_binary, field_boolean, field_char, - field_context, field_date, field_datetime, field_dict, field_float, - field_fmany2one, field_function, field_integer, field_many2many, - field_many2one, field_multiselection, field_numeric, field_one2many, - field_one2one, field_reference, field_selection, field_text, - field_time, field_timedelta, history, import_data, mixin, model, - model_log, modelsql, modelstorage, modelview, mptt, multivalue, path, - resource, rule, tree, trigger, wizard, workflow) + access, chat, copy_, export_data, field_binary, field_boolean, + field_char, field_context, field_date, field_datetime, field_dict, + field_float, field_fmany2one, field_function, field_integer, + field_many2many, field_many2one, field_multiselection, field_numeric, + field_one2many, field_one2one, field_reference, field_selection, + field_text, field_time, field_timedelta, history, import_data, mixin, + model, model_log, modelsql, modelstorage, modelview, mptt, multivalue, + path, resource, rule, tree, trigger, wizard, workflow) access.register('tests') copy_.register('tests') @@ -53,6 +53,7 @@ model_log.register('tests') mptt.register('tests') multivalue.register('tests') + chat.register('tests') path.register('tests') resource.register('tests') rule.register('tests') diff -r 7e674916c6bc -r 481aed0501da trytond/trytond/tests/chat.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/trytond/trytond/tests/chat.py Tue Dec 02 18:11:10 2025 +0100 @@ -0,0 +1,15 @@ +# 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 trytond.model import ChatMixin, ModelSQL +from trytond.pool import Pool + + +class ChatRoom(ChatMixin, ModelSQL): + __name__ = 'test.chat.room' + + +def register(module): + Pool.register( + ChatRoom, + module=module, type_='model') diff -r 7e674916c6bc -r 481aed0501da trytond/trytond/tests/message.xml --- a/trytond/trytond/tests/message.xml Tue Dec 02 13:08:54 2025 +0100 +++ b/trytond/trytond/tests/message.xml Tue Dec 02 18:11:10 2025 +0100 @@ -28,5 +28,8 @@ <record model="ir.message" id="msg_binary_required_sql_constraint"> <field name="text">Constraint must be checked.</field> </record> + <record model="ir.message" id="msg_chat"> + <field name="text">Chat Message: %(foo)s</field> + </record> </data> </tryton> diff -r 7e674916c6bc -r 481aed0501da trytond/trytond/tests/test_chat.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/trytond/trytond/tests/test_chat.py Tue Dec 02 18:11:10 2025 +0100 @@ -0,0 +1,62 @@ +# 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 trytond.pool import Pool +from trytond.transaction import Transaction + +from .test_tryton import TestCase, activate_module, with_transaction + + +class NotificationTestCase(TestCase): + "Test Notification" + + @classmethod + def setUpClass(cls): + super().setUpClass() + activate_module('tests') + + def run_tasks(self): + pool = Pool() + Queue = pool.get('ir.queue') + transaction = Transaction() + while transaction.tasks: + task = Queue(transaction.tasks.pop()) + task.run() + + @with_transaction() + def test_chat_post(self): + "Test posting on chat" + pool = Pool() + User = pool.get('res.user') + Room = pool.get('test.chat.room') + Notification = pool.get('res.notification') + 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) + + room.chat_post('tests.msg_chat', foo="Bar", audience='public') + self.run_tasks() + + message, = Message.search([('channel', '=', channel)]) + self.assertEqual(message.audience, 'public') + self.assertEqual(message.content, "Chat Message: Bar") + with Transaction().set_user(alice.id): + notification, = Notification.get() + self.assertEqual(notification, { + 'id': notification['id'], + 'label': None, + 'description': "Chat Message: Bar", + 'icon': 'tryton-chat', + 'model': Room.__name__, + 'records': [room.id], + 'action': None, + 'unread': True, + })
