From e5c35f988ba2ebeda95b48a965e04ae593fbb0f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Sat, 12 Apr 2025 16:54:16 +0200 Subject: [PATCH] Add chat --- trytond/CHANGELOG | 1 + trytond/trytond/ir/__init__.py | 9 +- trytond/trytond/ir/chat.py | 271 ++++++++++++++++++++++++++++++ trytond/trytond/ir/exceptions.py | 2 + trytond/trytond/ir/message.xml | 15 ++ trytond/trytond/model/__init__.py | 3 +- trytond/trytond/model/chat.py | 6 + 7 files changed, 303 insertions(+), 4 deletions(-) create mode 100644 trytond/trytond/ir/chat.py create mode 100644 trytond/trytond/model/chat.py diff --git a/trytond/CHANGELOG b/trytond/CHANGELOG index ff69750a8d..42a8013af6 100644 --- a/trytond/CHANGELOG +++ b/trytond/CHANGELOG @@ -1,3 +1,4 @@ +* Add chat backend * Transaction.check_warnings is now a directory mapping the key to the warnings * Store target of field as relation * Remove support for Python 3.8 diff --git a/trytond/trytond/ir/__init__.py b/trytond/trytond/ir/__init__.py index cf104fb2b5..179ef11154 100644 --- a/trytond/trytond/ir/__init__.py +++ b/trytond/trytond/ir/__init__.py @@ -3,9 +3,9 @@ from trytond.pool import Pool from . import ( - action, attachment, avatar, cache, calendar_, configuration, cron, date, - email_, error, export, lang, message, model, module, note, queue_, routes, - rule, sequence, session, translation, trigger, ui) + action, attachment, avatar, cache, calendar_, chat, configuration, cron, + date, email_, error, export, lang, message, model, module, note, queue_, + routes, rule, sequence, session, translation, trigger, ui) __all__ = ['register', 'routes'] @@ -96,6 +96,9 @@ def register(): email_.EmailTemplate, email_.EmailTemplate_Report, error.Error, + chat.Channel, + chat.Follower, + chat.Message, module='ir', type_='model') Pool.register( translation.TranslationSet, diff --git a/trytond/trytond/ir/chat.py b/trytond/trytond/ir/chat.py new file mode 100644 index 0000000000..6b896e3579 --- /dev/null +++ b/trytond/trytond/ir/chat.py @@ -0,0 +1,271 @@ +# 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 sql import Null +from sql.conditionals import NullIf + +from trytond.i18n import gettext +from trytond.model import ChatMixin, Check, ModelSQL, Unique, fields +from trytond.model.exceptions import ValidationError +from trytond.pool import Pool +from trytond.rpc import RPC +from trytond.tools.email_ import ( + EmailNotValidError, normalize_email, validate_email) +from trytond.transaction import Transaction + + +class InvalidEMailError(ValidationError): + pass + + +class Channel(ModelSQL): + "Chat Channel" + __name__ = 'ir.chat.channel' + + resource = fields.Reference( + "Resource", selection='get_models', required=True) + followers = fields.One2Many( + 'ir.chat.follower', 'channel', "Followers") + + @classmethod + def __setup__(cls): + super().__setup__() + t = cls.__table__() + cls._sql_constraints += [ + ('resource_unique', Unique(t, t.resource), + 'ir.msg_chat_channel_resource_unique'), + ] + cls.__rpc__.update( + subscribe=RPC(readonly=False), + unsubscribe=RPC(readonly=False), + subscribe_email=RPC(readonly=False), + unsubscribe_email=RPC(readonly=False), + post=RPC(readonly=False, result=int), + get_models=RPC(), + get=RPC(), + ) + + @classmethod + def get_models(cls): + pool = Pool() + Model = pool.get('ir.model') + return [ + (m, n) for m, n in Model.get_name_items() + if issubclass(pool.get(m), ChatMixin)] + + @classmethod + def check_access(cls, resource): + pool = Pool() + ModelAccess = pool.get('ir.model.access') + model, id_ = resource.split(',') + ModelAccess.check(model, mode='read') + # TODO check record rule + + @classmethod + def _get_channel(cls, resource): + cls.check_access(resource) + channels = cls.search([ + ('resource', '=', str(resource)), + ]) + if channels: + channel, = channels + else: + channel = cls(resource=str(resource)) + channel.save() + return channel + + @classmethod + def subscribe(cls, resource, username): + pool = Pool() + Follower = pool.get('ir.chat.follower') + User = pool.get('res.user') + user, = User.search([ + ('login', '=', username), + ]) + channel = cls._get_channel(resource) + Follower.add_user(channel, user) + + @classmethod + def unsubscribe(cls, resource): + pool = Pool() + Follower = pool.get('ir.chat.follower') + User = pool.get('res.user') + user = User(Transaction().user) + channel = cls._get_channel(resource) + Follower.remove_user(channel, user) + + @classmethod + def subscribe_email(cls, resource, email): + pool = Pool() + Follower = pool.get('ir.chat.follower') + channel = cls._get_channel(resource) + Follower.add_email(channel, email) + + @classmethod + def unsubscribe_email(cls, resource, email): + pool = Pool() + Follower = pool.get('ir.chat.follower') + channel = cls._get_channel(resource) + Follower.remove_email(channel, email) + + @classmethod + def post(cls, resource, content, audience='internal'): + pool = Pool() + Message = pool.get('ir.chat.message') + User = pool.get('res.user') + user = User(Transaction().user) + channel = cls._get_channel(resource) + message = Message( + channel=channel, + user=user, + content=content, + audience=audience) + message.save() + return message + + @classmethod + def get(cls, resource, before=None, after=None): + pool = Pool() + Message = pool.get('ir.chat.message') + cls.check_access(resource) + domain = [ + ('channel.resource', '=', resource), + ] + if before is not None: + domain.append(('id', '<', int(before))) + if after is not None: + domain.append(('id', '>', int(after))) + messages = Message.search(domain) + return [m.as_dict() for m in messages] + + +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") + + @classmethod + def __setup__(cls): + super().__setup__() + t = cls.__table__() + cls._sql_constraints += [ + ('check_user_or_email', + Check(t, + ((t.user == Null) & (NullIf(t.email, '') != Null)) + | ((t.user != Null) & (NullIf(t.email, '') == Null))), + 'ir.msg_chat_user_or_email'), + ] + + @classmethod + def validate_fields(cls, authors, field_names): + super().validate_fields(authors, field_names) + cls.check_valid_email(authors, field_names) + + @classmethod + def check_valid_email(cls, authors, field_names=None): + if field_names and 'email' not in field_names: + return + for author in authors: + if author.email: + try: + validate_email(author.email) + except EmailNotValidError as e: + raise InvalidEMailError(gettext( + 'ir.msg_chat_author_email_invalid', + email=author.email), + str(e)) from e + + @fields.depends('user', 'email') + def on_change_with_author(self, name=None): + if self.user: + return self.user.name + elif self.email: + return self.email + + @property + def avatar_url(self): + if self.user: + return self.user.avatar_url + + +class Follower(AuthorMixin, ModelSQL): + "Chat Follower" + __name__ = 'ir.chat.follower' + + channel = fields.Many2One( + 'ir.chat.channel', "Channel", required=True, ondelete='CASCADE') + + @classmethod + def __setup__(cls): + super().__setup__() + t = cls.__table__() + cls._sql_constraints += [ + ('channel_user_unique', + Unique(t, t.channel, t.user), + 'ir.msg_chat_follower_channel_user_unique'), + ('channel_email_unique', + Unique(t, t.channel, t.email), + 'ir.msg_chat_follower_channel_email_unique'), + ] + + @classmethod + def add_user(cls, channel, user): + if not cls.search([ + ('channel', '=', channel), + ('user', '=', user), + ]): + cls(channel=channel, user=user).save() + + @classmethod + def remove_user(cls, channel, user): + cls.delete(cls.search([ + ('channel', '=', channel), + ('user', '=', user), + ])) + + @classmethod + def add_email(cls, channel, email): + email = normalize_email(email) + if not cls.search([ + ('channel', '=', channel), + ('email', '=', email), + ]): + cls(channel=channel, email=email).save() + + @classmethod + def remove_email(cls, channel, email): + email = normalize_email(email) + cls.delete(cls.search([ + ('channel', '=', channel), + ('email', '=', email), + ])) + + +class Message(AuthorMixin, ModelSQL): + "Chat Message" + __name__ = 'ir.chat.message' + + channel = fields.Many2One( + 'ir.chat.channel', "Channel", required=True, ondelete='RESTRICT') + content = fields.Text("Content", required=True) + audience = fields.Selection([ + ('internal', "Internal"), + ('public', "Public"), + ], "Audience", required=True) + + @classmethod + def default_audience(cls): + return 'public' + + def as_dict(self): + return { + 'id': self.id, + 'timestamp': self.create_date, + 'user': self.user.id if self.user else None, + 'author': self.author, + 'avatar_url': self.avatar_url, + 'content': self.content, + 'audience': self.audience, + } diff --git a/trytond/trytond/ir/exceptions.py b/trytond/trytond/ir/exceptions.py index e73fe5bf92..5db5ed512c 100644 --- a/trytond/trytond/ir/exceptions.py +++ b/trytond/trytond/ir/exceptions.py @@ -1,6 +1,7 @@ # 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 .chat import InvalidEMailError as ChatInvalidEmailError from .lang import DateError as LanguageDateError from .lang import DeleteDefaultError as LanguageDeleteDefaultError from .lang import GroupingError as LanguageGroupingError @@ -13,6 +14,7 @@ from .translation import OverriddenError as TranslationOverriddenError from .trigger import ConditionError as TriggerConditionError __all__ = [ + ChatInvalidEmailError, DeactivateDependencyError, LanguageDateError, LanguageDeleteDefaultError, diff --git a/trytond/trytond/ir/message.xml b/trytond/trytond/ir/message.xml index 8c88fac4f2..13228c20d7 100644 --- a/trytond/trytond/ir/message.xml +++ b/trytond/trytond/ir/message.xml @@ -454,5 +454,20 @@ this repository contains the full copyright notices and license terms. --> The database ID must be unique per model. + + Only one channel per resource is allowed. + + + Only user or email can be filled. + + + A user can follow a channel only once. + + + An email can follow a channel only once. + + + The email address "%(email)s" is not valid. + diff --git a/trytond/trytond/model/__init__.py b/trytond/trytond/model/__init__.py index 708c659325..bc2fbed599 100644 --- a/trytond/trytond/model/__init__.py +++ b/trytond/trytond/model/__init__.py @@ -2,6 +2,7 @@ # this repository contains the full copyright notices and license terms. from .active import DeactivableMixin from .avatar import avatar_mixin +from .chat import ChatMixin from .descriptors import dualmethod from .dictschema import DictSchemaMixin from .digits import DigitsMixin @@ -23,4 +24,4 @@ __all__ = ['Model', 'ModelView', 'ModelStorage', 'ModelSingleton', 'ModelSQL', 'Workflow', 'DictSchemaMixin', 'MatchMixin', 'UnionMixin', 'dualmethod', 'MultiValueMixin', 'ValueMixin', 'SymbolMixin', 'DigitsMixin', 'EvalEnvironment', 'sequence_ordered', 'sort', 'DeactivableMixin', 'tree', - 'sum_tree', 'avatar_mixin'] + 'sum_tree', 'avatar_mixin', 'ChatMixin'] diff --git a/trytond/trytond/model/chat.py b/trytond/trytond/model/chat.py new file mode 100644 index 0000000000..069f2de7c6 --- /dev/null +++ b/trytond/trytond/model/chat.py @@ -0,0 +1,6 @@ +# This file is part of Tryton. The COPYRIGHT file at the top level of +# this repository contains the full copyright notices and license terms. + + +class ChatMixin: + __slots__ = ()