Add user notification
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -24,6 +24,7 @@ module.exports = function(grunt) {
|
||||
'src/board.js',
|
||||
'src/bus.js',
|
||||
'src/chat.js',
|
||||
'src/notification.js',
|
||||
'src/plugins.js',
|
||||
'src/html_sanitizer.js'
|
||||
];
|
||||
|
||||
@@ -0,0 +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>
|
||||
|
After Width: | Height: | Size: 387 B |
+5
-4
@@ -39,8 +39,8 @@ this repository contains the full copyright notices and license terms. -->
|
||||
<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 @@ this repository contains the full copyright notices and license terms. -->
|
||||
<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 @@ this repository contains the full copyright notices and license terms. -->
|
||||
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 @@ this repository contains the full copyright notices and license terms. -->
|
||||
}
|
||||
}
|
||||
});
|
||||
jQuery('#main_navbar').on('click', 'a', function() {
|
||||
jQuery('#main_navbar').on('click', 'a:not(.dropdown-toggle)', function() {
|
||||
jQuery('#main_navbar').collapse('hide');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3154,6 +3154,7 @@
|
||||
'tryton-log',
|
||||
'tryton-menu',
|
||||
'tryton-note',
|
||||
'tryton-notification',
|
||||
'tryton-ok',
|
||||
'tryton-open',
|
||||
'tryton-print',
|
||||
|
||||
@@ -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();
|
||||
|
||||
}());
|
||||
+25
-6
@@ -611,7 +611,14 @@ var Sao = {
|
||||
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 @@ var Sao = {
|
||||
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 @@ var Sao = {
|
||||
};
|
||||
|
||||
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 @@ var Sao = {
|
||||
'class': 'img-circle',
|
||||
}));
|
||||
}
|
||||
user.prepend(Sao.NotificationMenu.indicator);
|
||||
var title = Sao.i18n.gettext("Logout");
|
||||
jQuery('#user-logout > a')
|
||||
.attr('title', title)
|
||||
|
||||
+88
-13
@@ -414,24 +414,99 @@ html[theme="default"] {
|
||||
}
|
||||
}
|
||||
|
||||
#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;
|
||||
|
||||
> img {
|
||||
margin: -5px 5px;
|
||||
width: 30px;
|
||||
.navbar-toggle > & {
|
||||
inset-block-start: -5px;
|
||||
inset-inline-start: -15px;
|
||||
}
|
||||
}
|
||||
|
||||
&[src=''] {
|
||||
width: 0;
|
||||
|
||||
#user-preferences {
|
||||
> a {
|
||||
max-width: 30em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
> img {
|
||||
margin: -5px 5px;
|
||||
width: 30px;
|
||||
|
||||
&[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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ class Action(object):
|
||||
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.
|
||||
|
||||
@@ -48,6 +48,14 @@ def main():
|
||||
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()
|
||||
|
||||
@@ -0,0 +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>
|
||||
|
After Width: | Height: | Size: 387 B |
@@ -19,11 +19,13 @@ import tryton.plugins
|
||||
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 @@ class Main(Gtk.Application):
|
||||
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 @@ class Main(Gtk.Application):
|
||||
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()
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -60,6 +60,7 @@ CLIENT_ICONS = [(x, x) for x in [
|
||||
'tryton-log',
|
||||
'tryton-menu',
|
||||
'tryton-note',
|
||||
'tryton-notification',
|
||||
'tryton-ok',
|
||||
'tryton-open',
|
||||
'tryton-print',
|
||||
|
||||
@@ -11,13 +11,21 @@ class Rule(metaclass=PoolMeta):
|
||||
@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
|
||||
|
||||
@@ -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})
|
||||
@@ -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>
|
||||
@@ -5,6 +5,7 @@ xml:
|
||||
res.xml
|
||||
group.xml
|
||||
user.xml
|
||||
notification.xml
|
||||
message.xml
|
||||
|
||||
[register]
|
||||
@@ -19,6 +20,7 @@ model:
|
||||
user.Warning_
|
||||
user.UserApplication
|
||||
user.UserConfigStart
|
||||
notification.Notification
|
||||
wizard:
|
||||
user.UserConfig
|
||||
report:
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user