details: https://code.tryton.org/tryton/commit/7e674916c6bc
branch: default
user: Nicolas Évrard <[email protected]>
date: Tue Dec 02 13:08:54 2025 +0100
description:
Add user notification
diffstat:
sao/CHANGELOG | 1 +
sao/Gruntfile.js | 1 +
sao/images/tryton-notification.svg | 1 +
sao/index.html | 9 +-
sao/src/common.js | 1 +
sao/src/notification.js | 197 ++++++++++++++
sao/src/sao.js | 31 +-
sao/src/sao.less | 101 ++++++-
tryton/CHANGELOG | 1 +
tryton/tryton/action/main.py | 2 +-
tryton/tryton/client.py | 8 +
tryton/tryton/data/pixmaps/tryton/tryton-notification.svg | 1 +
tryton/tryton/gui/main.py | 8 +
tryton/tryton/gui/notification.py | 124 ++++++++
trytond/CHANGELOG | 1 +
trytond/trytond/ir/ui/menu.py | 1 +
trytond/trytond/res/ir.py | 12 +-
trytond/trytond/res/notification.py | 161 +++++++++++
trytond/trytond/res/notification.xml | 85 ++++++
trytond/trytond/res/tryton.cfg | 2 +
trytond/trytond/res/view/notification_form.xml | 13 +
trytond/trytond/res/view/notification_form_admin.xml | 29 ++
trytond/trytond/res/view/notification_list.xml | 9 +
23 files changed, 773 insertions(+), 26 deletions(-)
diffs (1051 lines):
diff -r cf9db3d44577 -r 7e674916c6bc sao/CHANGELOG
--- a/sao/CHANGELOG Tue Dec 02 14:38:58 2025 +0100
+++ b/sao/CHANGELOG Tue Dec 02 13:08:54 2025 +0100
@@ -1,3 +1,4 @@
+* Manage user notification
* Add search on empty relation field to domain parser
* Escape completion content with custom format (issue14363)
* Use sandboxed iframe to display document (issue14290)
diff -r cf9db3d44577 -r 7e674916c6bc sao/Gruntfile.js
--- a/sao/Gruntfile.js Tue Dec 02 14:38:58 2025 +0100
+++ b/sao/Gruntfile.js Tue Dec 02 13:08:54 2025 +0100
@@ -24,6 +24,7 @@
'src/board.js',
'src/bus.js',
'src/chat.js',
+ 'src/notification.js',
'src/plugins.js',
'src/html_sanitizer.js'
];
diff -r cf9db3d44577 -r 7e674916c6bc sao/images/tryton-notification.svg
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/sao/images/tryton-notification.svg Tue Dec 02 13:08:54 2025 +0100
@@ -0,0 +1,1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960"
width="24px" fill="#e3e3e3"><path d="M160-200v-80h80v-280q0-83
50-147.5T420-792v-28q0-25 17.5-42.5T480-880q25 0 42.5 17.5T540-820v28q80 20 130
84.5T720-560v280h80v80H160Zm320-300Zm0 420q-33 0-56.5-23.5T400-160h160q0
33-23.5 56.5T480-80ZM320-280h320v-280q0-66-47-113t-113-47q-66 0-113 47t-47
113v280Z"/></svg>
\ No newline at end of file
diff -r cf9db3d44577 -r 7e674916c6bc sao/index.html
--- a/sao/index.html Tue Dec 02 14:38:58 2025 +0100
+++ b/sao/index.html Tue Dec 02 13:08:54 2025 +0100
@@ -39,8 +39,8 @@
<nav class="navbar navbar-inverse navbar-static-top"
role="navigation">
<div class="container-fluid">
<div class="navbar-header">
- <button type="button" class="navbar-toggle collapsed"
- data-toggle="collapse"
data-target="#main_navbar">
+ <button type="button" class="navbar-toggle collapsed"
data-toggle="collapse" data-target="#main_navbar">
+ <span class="notification-badge"></span>
<span class="caret"></span>
</button>
<a class="navbar-brand" href="javascript:void(0)"
data-toggle="menu">
@@ -57,7 +57,7 @@
<form class="navbar-form navbar-left flip"
role="search" id="global-search" style="border-style: none;">
</form>
<ul class="nav navbar-nav navbar-right flip">
- <li id="user-preferences"></li>
+ <li id="user-preferences" class="dropdown"></li>
<li id="user-logout">
<a href="#">
<span class="icon hidden-xs">
@@ -92,6 +92,7 @@
jQuery('[data-toggle="menu"]').click(function() {
jQuery('#menu').toggleClass('hidden');
jQuery('#tabs').toggleClass('hidden-xs');
+ jQuery('#main_navbar').collapse('hide');
});
jQuery('#tabs').on('ready', function() {
var mq = window.matchMedia('(max-width: 991px)');
@@ -105,7 +106,7 @@
}
}
});
- jQuery('#main_navbar').on('click', 'a', function() {
+ jQuery('#main_navbar').on('click',
'a:not(.dropdown-toggle)', function() {
jQuery('#main_navbar').collapse('hide');
});
});
diff -r cf9db3d44577 -r 7e674916c6bc sao/src/common.js
--- a/sao/src/common.js Tue Dec 02 14:38:58 2025 +0100
+++ b/sao/src/common.js Tue Dec 02 13:08:54 2025 +0100
@@ -3154,6 +3154,7 @@
'tryton-log',
'tryton-menu',
'tryton-note',
+ 'tryton-notification',
'tryton-ok',
'tryton-open',
'tryton-print',
diff -r cf9db3d44577 -r 7e674916c6bc sao/src/notification.js
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/sao/src/notification.js Tue Dec 02 13:08:54 2025 +0100
@@ -0,0 +1,197 @@
+/* This file is part of Tryton. The COPYRIGHT file at the top level of
+ this repository contains the full copyright notices and license terms. */
+
+(function() {
+ 'use strict';
+
+ class _NotificationMenu {
+ constructor() {
+ this.el = jQuery('<ul/>', {
+ 'class': 'notification-menu dropdown-menu',
+ 'role': 'menu',
+ });
+ this.el.on('show.bs.dropdown', () => {
+ this.fill();
+ this.indicator.hide();
+ });
+ this.indicator = jQuery('<span/>', {
+ 'class': 'notification-badge',
+ });
+ let indicator_observer = new MutationObserver(() => {
+ let indicators =
jQuery('.notification-badge').not(this.indicator);
+ indicators.text(this.indicator.text())
+ indicators.toggle(this.indicator.css('display') !== 'none');
+ });
+ indicator_observer.observe(
+ this.indicator.get(0), {
+ characterData: true,
+ attributes: true,
+ attributeFilter: ['style'],
+ });
+ this.indicator.hide();
+ }
+
+ fill() {
+ let Notification = new Sao.Model('res.notification');
+ Notification.execute('get', []).done((notifications) => {
+ this.el.empty();
+ for (let notification of notifications) {
+ let notification_item = jQuery('<div/>', {
+ }).append(jQuery('<span/>', {
+ 'class': 'notification-label',
+ 'text': notification.label,
+ })).append(jQuery('<span/>', {
+ 'class': 'notification-description',
+ 'text': notification.description
+ }));
+ let link = jQuery('<a/>', {
+ 'role': 'menuitem',
+ 'href': '#',
+ }).click((evt) => {
+ evt.preventDefault();
+ this.open(notification)
+ }).append(notification_item);
+ let li = jQuery('<li/>', {
+ 'class': 'notification-item',
+ 'role': 'presentation',
+ });
+ let img = jQuery('<img/>', {
+ 'class': 'icon',
+ });
+ link.prepend(img);
+ Sao.common.ICONFACTORY.get_icon_url(
+ notification.icon || 'tryton-notification')
+ .then(url => {
+ img.attr('src', url);
+ // Append only when the url is known to prevent
+ // layout shifts
+ li.append(link);
+ });
+ if (notification.unread) {
+ li.addClass('notification-unread');
+ }
+ this.el.append(li)
+ }
+ this.el.append(
+ jQuery('<li/>', {
+ 'role': 'presentation',
+ 'class': 'notification-item notification-action',
+ }).append(jQuery('<a/>', {
+ 'role': 'menuitem',
+ 'href': '#',
+ 'title': Sao.i18n.gettext("All Notifications..."),
+ }).append(jQuery('<span/>', {
+ 'class': 'caret',
+ })).click((evt) => {
+ evt.preventDefault();
+ let params = {
+ context: jQuery.extend({},
Sao.Session.current_session.context),
+ domain: [['user', '=',
Sao.Session.current_session.user_id]],
+ };
+ params.model = 'res.notification';
+ Sao.Tab.create(params).done(() => {
+ this.indicator.hide();
+ });
+ }))
+ );
+ if (notifications.length > 0) {
+ this.el.append(
+ jQuery('<li/>', {
+ 'role': 'separator',
+ 'class': 'divider',
+ }));
+ }
+ let preferences_img = jQuery('<img/>', {
+ 'class': 'icon',
+ });
+ let preferences = jQuery('<li/>', {
+ 'class': 'notification-item',
+ 'role': 'presentation',
+ }).append(
+ jQuery('<a/>', {
+ 'role': 'menuitem',
+ 'href': '#',
+ 'text': Sao.i18n.gettext("Preferences..."),
+ }).prepend(preferences_img
+ ).click((evt) => {
+ evt.preventDefault();
+ Sao.preferences();
+ }));
+ Sao.common.ICONFACTORY.get_icon_url('tryton-launch')
+ .then(url => {
+ preferences_img.attr('src', url);
+ });
+ this.el.append(preferences);
+ });
+
+ }
+
+ open(notification) {
+ let prms = [];
+ if (notification.model && notification.records) {
+ let params = {
+ context: jQuery.extend({},
Sao.Session.current_session.context),
+ domain: [['id', 'in', notification.records]],
+ };
+ if (notification.records.length == 1) {
+ params['res_id'] = notification.records[0];
+ params['mode'] = ['form', 'tree'];
+ }
+ params.model = notification.model;
+ prms.push(Sao.Tab.create(params));
+ }
+ if (notification.action) {
+ prms.push(Sao.Action.execute(notification.action));
+ }
+ jQuery.when.apply(jQuery, prms).done(() => {
+ if (notification.unread) {
+ let Notification = new Sao.Model('res.notification');
+ Notification.execute('mark_read', [[notification.id]]);
+ }
+ });
+ }
+
+ _update(count) {
+ if (count > 0) {
+ if (count < 10) {
+ this.indicator.text(count);
+ } else {
+ // Let's keep the text short
+ this.indicator.text('9+');
+ }
+ this.indicator.show();
+ } else {
+ this.indicator.hide();
+ }
+ }
+
+ notify(message) {
+ if (message.type == 'user-notification') {
+ this._update(message.count);
+ try {
+ if (Notification.permission == "granted") {
+ message.content.forEach((body) => {
+ new Notification(
+ Sao.config.title, {
+ 'body': body,
+ });
+ });
+ }
+ } catch (e) {
+ Sao.Logger.error(e.message, e.stack);
+ }
+ }
+ }
+
+ count() {
+ let Notification = new Sao.Model('res.notification');
+ Notification.execute('get_count', [])
+ .done((count) => {
+ this._update(count);
+ });
+ }
+ }
+
+ Sao.NotificationMenu = new _NotificationMenu();
+
+}());
diff -r cf9db3d44577 -r 7e674916c6bc sao/src/sao.js
--- a/sao/src/sao.js Tue Dec 02 14:38:58 2025 +0100
+++ b/sao/src/sao.js Tue Dec 02 13:08:54 2025 +0100
@@ -611,7 +611,14 @@
Sao.menu(preferences);
Sao.user_menu(preferences);
Sao.open_url(url);
+ let user_id =
Sao.Session.current_session.user_id;
+ Sao.Bus.register(
+ `notification:${user_id}`,
+ Sao.NotificationMenu.notify
+ .bind(Sao.NotificationMenu));
+ Sao.NotificationMenu.count();
Sao.Bus.listen();
+
});
}
}, function() {
@@ -633,6 +640,9 @@
jQuery('#user-preferences').empty();
jQuery('#global-search').empty();
jQuery('#menu').empty();
+ let user_id = Sao.Session.current_session.user_id;
+ Sao.Bus.unregister(
+ `notification:${user_id}`, Sao.NotificationMenu.update);
session.do_logout().always(Sao.login);
Sao.set_title();
});
@@ -702,16 +712,24 @@
};
Sao.user_menu = function(preferences) {
- jQuery('#user-preferences').empty();
+ let user_preferences = jQuery('#user-preferences');
+ user_preferences.empty();
var user = jQuery('<a/>', {
'href': '#',
+ 'class': 'dropdown-toggle',
+ 'data-toggle': 'dropdown',
+ 'role': 'button',
+ 'aria-expanded': false,
+ 'aria-haspopup': true,
'title': preferences.status_bar,
- }).click(function(evt) {
- evt.preventDefault();
- user.prop('disabled', true);
- Sao.preferences().then(() => user.prop('disabled', false));
}).text(preferences.status_bar);
- jQuery('#user-preferences').append(user);
+ user_preferences
+ .off('show.bs.dropdown')
+ .on('show.bs.dropdown', () => {
+ Sao.NotificationMenu.fill();
+ Sao.NotificationMenu.indicator.hide();
+ })
+ .append(user).append(Sao.NotificationMenu.el);
if (preferences.avatar_badge_url) {
user.prepend(jQuery('<img/>', {
'src': preferences.avatar_badge_url + '?s=15',
@@ -724,6 +742,7 @@
'class': 'img-circle',
}));
}
+ user.prepend(Sao.NotificationMenu.indicator);
var title = Sao.i18n.gettext("Logout");
jQuery('#user-logout > a')
.attr('title', title)
diff -r cf9db3d44577 -r 7e674916c6bc sao/src/sao.less
--- a/sao/src/sao.less Tue Dec 02 14:38:58 2025 +0100
+++ b/sao/src/sao.less Tue Dec 02 13:08:54 2025 +0100
@@ -414,24 +414,99 @@
}
}
-#user-preferences > a {
- max-width: 30em;
- overflow: hidden;
- text-overflow: ellipsis;
+.notification-badge {
+ z-index: 100;
+ position: absolute;
+ inset-block-start: 5px;
+ inset-inline-start: 5px;
+ color: @navbar-default-link-hover-color;
+ background-color: @brand-info;
+ min-width: 20px;
+ min-height: 20px;
+ padding: 0.2em;
+ border-radius: 10px;
white-space: nowrap;
+ text-align: center;
+ font-size: smaller;
+
+ .navbar-toggle > & {
+ inset-block-start: -5px;
+ inset-inline-start: -15px;
+ }
+}
+
- > img {
- margin: -5px 5px;
- width: 30px;
+#user-preferences {
+ > a {
+ max-width: 30em;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+
+ > img {
+ margin: -5px 5px;
+ width: 30px;
- &[src=''] {
- width: 0;
+ &[src=''] {
+ width: 0;
+ }
+
+ &.img-badge {
+ margin-left: -15px;
+ margin-bottom: -25px;
+ width: 15px;
+ }
+ }
+ }
+
+ ul.notification-menu {
+ @media (min-width: @grid-float-breakpoint) {
+ width: 320px;
}
- &.img-badge {
- margin-left: -15px;
- margin-bottom: -25px;
- width: 15px;
+ > li.notification-item {
+ padding: 2px 0;
+
+ &.notification-unread {
+ background-color: @state-selected-bg;
+ }
+
+ > a {
+ display: flex;
+ padding: 3px 10px;
+
+ img.icon {
+ padding-inline-end: 3px;
+ }
+
+ > div {
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+
+ span {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ span.notification-label {
+ font-weight: bold;
+ }
+
+ span.notification-description {
+ }
+ }
+ }
+
+ &.notification-action {
+ margin: 0.3em 0;
+
+ > a {
+ display: block;
+ text-align: center;
+ background-color: @dropdown-link-hover-bg;
+ }
+ }
}
}
}
diff -r cf9db3d44577 -r 7e674916c6bc tryton/CHANGELOG
--- a/tryton/CHANGELOG Tue Dec 02 14:38:58 2025 +0100
+++ b/tryton/CHANGELOG Tue Dec 02 13:08:54 2025 +0100
@@ -1,3 +1,4 @@
+* Manage user notification
* Add search on empty relation field to domain parser
* Add support for multiple button in the tree view
diff -r cf9db3d44577 -r 7e674916c6bc tryton/tryton/action/main.py
--- a/tryton/tryton/action/main.py Tue Dec 02 14:38:58 2025 +0100
+++ b/tryton/tryton/action/main.py Tue Dec 02 13:08:54 2025 +0100
@@ -37,7 +37,7 @@
callback=callback)
@staticmethod
- def execute(action, data, context=None, keyword=False):
+ def execute(action, data=None, context=None, keyword=False):
if isinstance(action, int):
# Must be executed synchronously to avoid double execution
# on double click.
diff -r cf9db3d44577 -r 7e674916c6bc tryton/tryton/client.py
--- a/tryton/tryton/client.py Tue Dec 02 14:38:58 2025 +0100
+++ b/tryton/tryton/client.py Tue Dec 02 13:08:54 2025 +0100
@@ -48,6 +48,14 @@
font-size: medium;
font-weight: normal;
}
+ .unread-notification {
+ color: @theme_selected_fg_color;
+ background-color: lighter(@theme_selected_bg_color);
+ }
+ .unread-notification:hover {
+ color: @theme_selected_fg_color;
+ background-color: @theme_selected_bg_color;
+ }
"""
screen = Gdk.Screen.get_default()
diff -r cf9db3d44577 -r 7e674916c6bc
tryton/tryton/data/pixmaps/tryton/tryton-notification.svg
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/tryton/tryton/data/pixmaps/tryton/tryton-notification.svg Tue Dec 02
13:08:54 2025 +0100
@@ -0,0 +1,1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960"
width="24px" fill="#e3e3e3"><path d="M160-200v-80h80v-280q0-83
50-147.5T420-792v-28q0-25 17.5-42.5T480-880q25 0 42.5 17.5T540-820v28q80 20 130
84.5T720-560v280h80v80H160Zm320-300Zm0 420q-33 0-56.5-23.5T400-160h160q0
33-23.5 56.5T480-80ZM320-280h320v-280q0-66-47-113t-113-47q-66 0-113 47t-47
113v280Z"/></svg>
\ No newline at end of file
diff -r cf9db3d44577 -r 7e674916c6bc tryton/tryton/gui/main.py
--- a/tryton/tryton/gui/main.py Tue Dec 02 14:38:58 2025 +0100
+++ b/tryton/tryton/gui/main.py Tue Dec 02 13:08:54 2025 +0100
@@ -19,11 +19,13 @@
import tryton.rpc as rpc
import tryton.translate as translate
from tryton.action import Action
+from tryton.bus import Bus
from tryton.common import RPCContextReload, RPCException, RPCExecute
from tryton.common.cellrendererclickablepixbuf import (
CellRendererClickablePixbuf)
from tryton.config import CONFIG, TRYTON_ICON, get_config_dir
from tryton.exceptions import TrytonError, TrytonServerUnavailable
+from tryton.gui.notification import NotificationMenu
from tryton.gui.window import Window
from tryton.jsonrpc import object_hook
from tryton.pyson import PYSONDecoder
@@ -164,6 +166,9 @@
self.set_global_search()
self.header.pack_start(self.global_search_entry)
+ self.notification_menu = NotificationMenu(self)
+ self.header.pack_end(self.notification_menu.button)
+
self.accel_group = Gtk.AccelGroup()
self.window.add_accel_group(self.accel_group)
@@ -263,6 +268,9 @@
msg_type=Gtk.MessageType.ERROR)
return self.quit()
self.get_preferences()
+ Bus.register(
+ f'notification:{rpc._USER}', self.notification_menu.notify)
+ self.notification_menu.count()
def do_command_line(self, cmd):
self.do_activate()
diff -r cf9db3d44577 -r 7e674916c6bc tryton/tryton/gui/notification.py
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/tryton/tryton/gui/notification.py Tue Dec 02 13:08:54 2025 +0100
@@ -0,0 +1,124 @@
+# 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 gettext
+
+from gi.repository import Gtk, Pango
+
+import tryton.common as common
+import tryton.rpc as rpc
+from tryton.action import Action
+from tryton.config import CONFIG
+from tryton.gui.window import Window
+
+_ = gettext.gettext
+
+
+class NotificationMenu:
+
+ def __init__(self, app):
+ self.app = app
+ self.button = Gtk.MenuButton()
+ img = common.IconFactory.get_image(
+ 'tryton-notification', Gtk.IconSize.BUTTON)
+ self.button.set_image(img)
+ self.menu = Gtk.Menu()
+ self.menu.connect('show', self.fill)
+ self.button.set_popup(self.menu)
+
+ def fill(self, menu):
+ for child in menu.get_children():
+ menu.remove(child)
+
+ notifications = rpc.execute(
+ 'model', 'res.notification', 'get', rpc.CONTEXT)
+
+ for notification in notifications:
+ item = Gtk.MenuItem()
+
+ hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
+ hbox.set_margin_top(4)
+ hbox.set_margin_bottom(4)
+ hbox.set_margin_start(6)
+ hbox.set_margin_end(6)
+
+ img = common.IconFactory.get_image(
+ notification['icon'] or 'tryton-notification',
+ Gtk.IconSize.MENU)
+ hbox.pack_start(img, False, False, 0)
+
+ vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
+
+ label = Gtk.Label(notification['label'])
+ label.set_xalign(0)
+ vbox.pack_start(label, False, False, 0)
+
+ description = Gtk.Label(notification['description'])
+ description.set_xalign(0)
+ description.get_style_context().add_class("dim-label")
+ description.set_max_width_chars(30)
+ description.set_line_wrap(True)
+ description.set_line_wrap_mode(Pango.WrapMode.WORD_CHAR)
+ description.set_ellipsize(Pango.EllipsizeMode.END)
+ description.set_lines(2)
+ vbox.pack_start(description, False, False, 0)
+
+ hbox.pack_start(vbox, True, True, 0)
+ item.add(hbox)
+
+ item.connect('activate', self.open, notification)
+
+ if notification['unread']:
+ style = item.get_style_context()
+ style.add_class('unread-notification')
+
+ menu.append(item)
+
+ if notifications:
+ menu.append(Gtk.SeparatorMenuItem())
+
+ def open_all_notifications(item):
+ params = {
+ 'domain': [['user', '=', rpc._USER]],
+ }
+ Window.create('res.notification', **params)
+
+ all_notifications = Gtk.MenuItem(label=_("All Notifications..."))
+ all_notifications.connect('activate', open_all_notifications)
+ menu.append(all_notifications)
+
+ menu.show_all()
+ self._update(0)
+
+ def open(self, menuitem, notification):
+ if notification.get('model') and notification.get('records'):
+ params = {
+ 'domain': [['id', 'in', notification['records']]],
+ }
+ if len(notification['records']) == 1:
+ params['res_id'] = notification['records'][0]
+ params['mode'] = ['form', 'tree']
+ Window.create(notification['model'], **params)
+ if notification.get('action'):
+ Action.execute(notification['action'])
+ if notification['unread']:
+ rpc.execute(
+ 'model', 'res.notification', 'mark_read',
+ [notification['id']], rpc.CONTEXT)
+
+ def _update(self, count):
+ img = common.IconFactory.get_image(
+ 'tryton-notification', Gtk.IconSize.BUTTON,
+ badge=2 if count else None)
+ self.button.set_image(img)
+
+ def notify(self, message):
+ if message['type'] == 'user-notification':
+ self._update(message['count'])
+ for msg in message['content']:
+ self.app.show_notification(CONFIG['client.title'], msg)
+
+ def count(self):
+ count = rpc.execute(
+ 'model', 'res.notification', 'get_count', rpc.CONTEXT)
+ self._update(count)
diff -r cf9db3d44577 -r 7e674916c6bc trytond/CHANGELOG
--- a/trytond/CHANGELOG Tue Dec 02 14:38:58 2025 +0100
+++ b/trytond/CHANGELOG Tue Dec 02 13:08:54 2025 +0100
@@ -1,3 +1,4 @@
+* Add user notification
* Add support for materializing ModelSQL based on a table query
* Add an option to trytond-console to run a script file
* Store only immutable structure in MemoryCache
diff -r cf9db3d44577 -r 7e674916c6bc trytond/trytond/ir/ui/menu.py
--- a/trytond/trytond/ir/ui/menu.py Tue Dec 02 14:38:58 2025 +0100
+++ b/trytond/trytond/ir/ui/menu.py Tue Dec 02 13:08:54 2025 +0100
@@ -60,6 +60,7 @@
'tryton-log',
'tryton-menu',
'tryton-note',
+ 'tryton-notification',
'tryton-ok',
'tryton-open',
'tryton-print',
diff -r cf9db3d44577 -r 7e674916c6bc trytond/trytond/res/ir.py
--- a/trytond/trytond/res/ir.py Tue Dec 02 14:38:58 2025 +0100
+++ b/trytond/trytond/res/ir.py Tue Dec 02 13:08:54 2025 +0100
@@ -11,13 +11,21 @@
@classmethod
def _get_context(cls, model_name):
context = super()._get_context(model_name)
- if model_name in {'res.user.warning', 'res.user.application'}:
+ if model_name in {
+ 'res.user.warning',
+ 'res.user.application',
+ 'res.notification',
+ }:
context['user_id'] = Transaction().user
return context
@classmethod
def _get_cache_key(cls, model_names):
key = super()._get_cache_key(model_names)
- if model_names & {'res.user.warning', 'res.user.application'}:
+ if model_names & {
+ 'res.user.warning',
+ 'res.user.application',
+ 'res.notification',
+ }:
key = (*key, Transaction().user)
return key
diff -r cf9db3d44577 -r 7e674916c6bc trytond/trytond/res/notification.py
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/trytond/trytond/res/notification.py Tue Dec 02 13:08:54 2025 +0100
@@ -0,0 +1,161 @@
+# 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 collections import defaultdict
+
+from sql import Literal
+from sql.aggregate import Count
+
+from trytond.bus import Bus
+from trytond.ir.ui.menu import CLIENT_ICONS
+from trytond.model import Index, ModelSQL, ModelView, fields
+from trytond.pool import Pool
+from trytond.pyson import Bool, Eval
+from trytond.rpc import RPC
+from trytond.transaction import Transaction
+
+
+class Notification(
+ fields.fmany2one(
+ 'model_ref', 'model', 'ir.model,name', "Model",
+ ondelete='CASCADE'),
+ ModelSQL, ModelView):
+ __name__ = 'res.notification'
+
+ user = fields.Many2One(
+ 'res.user', "User", required=True, ondelete='CASCADE',
+ states={
+ 'readonly': Eval('id', 0) > 0,
+ })
+ label = fields.Char("Label")
+ description = fields.Char("Description")
+ icon = fields.Selection('list_icons', 'Icon', translate=False)
+ unread = fields.Boolean("Unread")
+ model = fields.Char(
+ "Model",
+ states={
+ 'required': Bool(Eval('records')),
+ })
+ records = fields.Char(
+ "Records",
+ states={
+ 'required': Bool(Eval('model')),
+ })
+ action = fields.Many2One(
+ 'ir.action', "Action", ondelete='CASCADE',
+ states={
+ 'required': Bool(Eval('action_value')),
+ })
+ action_value = fields.Char("Action Value")
+
+ @classmethod
+ def __setup__(cls):
+ super().__setup__()
+
+ cls.__rpc__.update({
+ 'get': RPC(),
+ 'get_count': RPC(),
+ 'mark_read': RPC(
+ readonly=False, instantiate=0, check_access=False),
+ })
+
+ table = cls.__table__()
+ cls._sql_indexes.update({
+ Index(
+ table,
+ (table.user, Index.Equality()),
+ where=table.unread),
+ Index(table, (table.user, Index.Equality())),
+ })
+
+ @classmethod
+ def default_unread(cls):
+ return True
+
+ @classmethod
+ def copy(cls, notifications, default=None):
+ default = default.copy() if default is not None else {}
+ default.setdefault('unread', True)
+ return super().copy(notifications, default=default)
+
+ @classmethod
+ def create(cls, vlist):
+ notifications = super().create(vlist)
+
+ notifications_by_user = defaultdict(list)
+ for notification in notifications:
+ notifications_by_user[notification.user.id].append(
+ notification)
+
+ notification = cls.__table__()
+ cursor = Transaction().connection.cursor()
+ cursor.execute(*notification.select(
+ notification.user, Count(Literal('*')),
+ where=((notification.user.in_(list(notifications_by_user)))
+ & notification.unread),
+ group_by=[notification.user]))
+ for user, count in cursor.fetchall():
+ messages = [
+ '\n'.join(filter(None, (n.label, n.description)))
+ for n in notifications_by_user[user]]
+ Bus.publish(
+ f'notification:{user}', {
+ 'type': 'user-notification',
+ 'count': count,
+ 'content': messages,
+ })
+
+ return notifications
+
+ @classmethod
+ def list_icons(cls):
+ pool = Pool()
+ Icon = pool.get('ir.ui.icon')
+ return sorted(CLIENT_ICONS
+ + [(name, name) for _, name in Icon.list_icons()]
+ + [(None, "")],
+ key=lambda e: e[1])
+
+ @property
+ def _action_value(self):
+ if self.action_value:
+ action_value = self.action.get_action_value()
+ action_value.update(json.loads(self.action_value))
+ return action_value
+ elif self.action:
+ return self.action.id
+
+ @classmethod
+ def get(cls, count=10):
+ "Get the last count notifications"
+ notifications = cls.search([
+ ('user', '=', Transaction().user),
+ ],
+ limit=count, order=[('create_date', 'DESC'), ('id', 'DESC')])
+ return [{
+ 'id': n.id,
+ 'label': n.label,
+ 'description': n.description,
+ 'icon': n.icon,
+ 'model': n.model,
+ 'records': json.loads(n.records) if n.records else None,
+ 'action': n._action_value,
+ 'unread': bool(n.unread),
+ } for n in notifications]
+
+ @classmethod
+ def get_count(cls):
+ notification = cls.__table__()
+ cursor = Transaction().connection.cursor()
+ cursor.execute(*notification.select(
+ Count(Literal('*')),
+ where=((notification.user == Transaction().user)
+ & notification.unread)))
+ return cursor.fetchone()[0]
+
+ @classmethod
+ def mark_read(cls, notifications):
+ current_user = Transaction().user
+ notifications = [n for n in notifications if n.user.id == current_user]
+ cls.write(notifications, {'unread': False})
diff -r cf9db3d44577 -r 7e674916c6bc trytond/trytond/res/notification.xml
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/trytond/trytond/res/notification.xml Tue Dec 02 13:08:54 2025 +0100
@@ -0,0 +1,85 @@
+<?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="notification_view_list">
+ <field name="model">res.notification</field>
+ <field name="type">tree</field>
+ <field name="name">notification_list</field>
+ </record>
+
+ <record model="ir.ui.view" id="notification_view_form">
+ <field name="model">res.notification</field>
+ <field name="type">form</field>
+ <field name="priority" eval="10"/>
+ <field name="name">notification_form</field>
+ </record>
+
+ <record model="ir.ui.view" id="notification_view_form_admin">
+ <field name="model">res.notification</field>
+ <field name="type">form</field>
+ <field name="priority" eval="20"/>
+ <field name="name">notification_form_admin</field>
+ </record>
+
+ <record model="ir.action.act_window" id="act_notification">
+ <field name="name">Notifications</field>
+ <field name="res_model">res.notification</field>
+ </record>
+ <record model="ir.action.act_window.view" id="act_notification_view1">
+ <field name="sequence" eval="10"/>
+ <field name="view" ref="notification_view_list"/>
+ <field name="act_window" ref="act_notification"/>
+ </record>
+ <record model="ir.action.act_window.view" id="act_notification_view2">
+ <field name="sequence" eval="20"/>
+ <field name="view" ref="notification_view_form_admin"/>
+ <field name="act_window" ref="act_notification"/>
+ </record>
+ <menuitem parent="res.menu_res" action="act_notification"
id="menu_notification_form"/>
+
+ <record model="ir.model.access" id="access_notification">
+ <field name="model">res.notification</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_notification_admin">
+ <field name="model">res.notification</field>
+ <field name="group" ref="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.rule.group" id="rule_group_notification">
+ <field name="name">Own notification</field>
+ <field name="model">res.notification</field>
+ <field name="global_p" eval="False"/>
+ <field name="default_p" eval="True"/>
+ </record>
+ <record model="ir.rule" id="rule_user_notification1">
+ <field name="domain" eval="[('user', '=', Eval('user_id', -1))]"
pyson="1"/>
+ <field name="rule_group" ref="rule_group_notification"/>
+ </record>
+
+ <record model="ir.rule.group" id="rule_group_notification_admin">
+ <field name="name">Any notification</field>
+ <field name="model">res.notification</field>
+ <field name="global_p" eval="False"/>
+ <field name="default_p" eval="False"/>
+ </record>
+ <record model="ir.rule" id="rule_user_notification_admin1">
+ <field name="domain" eval="[]" pyson="1"/>
+ <field name="rule_group" ref="rule_group_notification_admin"/>
+ </record>
+ <record model="ir.rule.group-res.group"
id="rule_group_notification_admin_admin">
+ <field name="rule_group" ref="rule_group_notification_admin"/>
+ <field name="group" ref="group_admin"/>
+ </record>
+ </data>
+</tryton>
diff -r cf9db3d44577 -r 7e674916c6bc trytond/trytond/res/tryton.cfg
--- a/trytond/trytond/res/tryton.cfg Tue Dec 02 14:38:58 2025 +0100
+++ b/trytond/trytond/res/tryton.cfg Tue Dec 02 13:08:54 2025 +0100
@@ -5,6 +5,7 @@
res.xml
group.xml
user.xml
+ notification.xml
message.xml
[register]
@@ -19,6 +20,7 @@
user.Warning_
user.UserApplication
user.UserConfigStart
+ notification.Notification
wizard:
user.UserConfig
report:
diff -r cf9db3d44577 -r 7e674916c6bc
trytond/trytond/res/view/notification_form.xml
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/trytond/trytond/res/view/notification_form.xml Tue Dec 02 13:08:54
2025 +0100
@@ -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="user"/>
+ <field name="user"/>
+ <label name="label"/>
+ <field name="label"/>
+ <label name="description"/>
+ <field name="description"/>
+ <label name="unread"/>
+ <field name="unread"/>
+</form>
diff -r cf9db3d44577 -r 7e674916c6bc
trytond/trytond/res/view/notification_form_admin.xml
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/trytond/trytond/res/view/notification_form_admin.xml Tue Dec 02
13:08:54 2025 +0100
@@ -0,0 +1,29 @@
+<?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>
+ <label name="icon"/>
+ <field name="icon"/>
+ <label name="user"/>
+ <field name="user"/>
+
+ <label name="label"/>
+ <field name="label" colspan="3"/>
+
+ <label name="description"/>
+ <field name="description" colspan="3"/>
+
+ <label name="unread"/>
+ <field name="unread"/>
+ <newline/>
+
+ <label name="model_ref"/>
+ <field name="model_ref"/>
+ <label name="records"/>
+ <field name="records" widget="pyson"/>
+
+ <label name="action"/>
+ <field name="action"/>
+ <label name="action_value"/>
+ <field name="action_value" widget="pyson"/>
+</form>
diff -r cf9db3d44577 -r 7e674916c6bc
trytond/trytond/res/view/notification_list.xml
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/trytond/trytond/res/view/notification_list.xml Tue Dec 02 13:08:54
2025 +0100
@@ -0,0 +1,9 @@
+<?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="user" optional="0"/>
+ <field name="label" icon="icon" expand="1"/>
+ <field name="description" expand="2"/>
+ <field name="unread" optional="0"/>
+</tree>