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>


Reply via email to