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__ = ()