diff --git a/sao/CHANGELOG b/sao/CHANGELOG index 17b53c88f2..00f1c8b3da 100644 --- a/sao/CHANGELOG +++ b/sao/CHANGELOG @@ -1,3 +1,4 @@ +* Include events to logs * Support scroll time on calendar view * Save and Restore tree state on List Form * Do record (pre)validation and saving on selection change for list-form views diff --git a/sao/src/tab.js b/sao/src/tab.js index 9bd9be49a8..b652074bbd 100644 --- a/sao/src/tab.js +++ b/sao/src/tab.js @@ -952,43 +952,7 @@ 'info'); return jQuery.when(); } - var fields = [ - ['id', Sao.i18n.gettext('ID:')], - ['create_uid.rec_name', - Sao.i18n.gettext('Created by:')], - ['create_date', Sao.i18n.gettext('Created at:')], - ['write_uid.rec_name', - Sao.i18n.gettext('Edited by:')], - ['write_date', Sao.i18n.gettext('Edited at:')] - ]; - - return this.screen.model.execute('read', [[record.id], - fields.map(function(field) { - return field[0]; - })], this.screen.context) - .then(data => { - data = data[0]; - var message = ''; - for (const field of fields) { - const key = field[0]; - const label = field[1]; - let value = data; - const keys = key.split('.'); - const name = keys.splice(-1); - for (const key of keys) { - value = value[key + '.'] || {}; - } - value = (value || {})[name] || '/'; - if (value && value.isDateTime) { - value = Sao.common.format_datetime( - Sao.common.date_format() + ' %H:%M:%S', - value); - } - message += label + ' ' + value + '\n'; - } - message += Sao.i18n.gettext('Model: ') + this.screen.model.name; - Sao.common.message.run(message); - }); + new Sao.Window.Log(record); }, revision: function() { var current_id = null; diff --git a/sao/src/window.js b/sao/src/window.js index 7f101470a2..e0b53f3c26 100644 --- a/sao/src/window.js +++ b/sao/src/window.js @@ -170,6 +170,7 @@ } }); var dialog = new Sao.Dialog('', 'window-form', 'lg', false); + this.dialog = dialog; this.el = dialog.modal; this.el.on('keydown', e => { if (e.which == Sao.common.ESC_KEYCODE) { @@ -544,7 +545,9 @@ } } else { result = true; - this.callback(result); + if (this.callback) { + this.callback(result); + } this.destroy(); } }); @@ -575,7 +578,9 @@ result = response_id != 'RESPONSE_CANCEL'; } (cancel_prm || jQuery.when()).then(() => { - this.callback(result); + if (this.callback) { + this.callback(result); + } this.destroy(); }); }, @@ -739,6 +744,123 @@ } }); + Sao.Window.Log = Sao.class_(Sao.Window.Form, { + init: function(record) { + this.resource = record.model.name + ',' + record.id; + var title = record.rec_name().then(function(rec_name) { + return Sao.i18n.gettext("Logs (%1)", rec_name); + }); + var context = jQuery.extend({}, record.get_context()); + + var form = jQuery('
', { + 'class': 'form', + }); + var table = jQuery('', { + 'class': 'form-container responsive responsive-noheader', + }).appendTo(form); + var body = jQuery('').appendTo(table); + + record.model.execute( + 'read', [[record.id], + ['create_uid.rec_name', 'create_date', + 'write_uid.rec_name', 'write_date']], context + ).then(([log]) => { + var row, cell, label, input; + + row = jQuery('').appendTo(body); + cell = jQuery('').appendTo(body); + cell = jQuery('
').css('text-align', 'end').appendTo(row); + label = jQuery('').appendTo(row); + input = jQuery('', { + 'readonly': true, + 'class': 'form-control input-sm', + 'aria-labelledby': label.attr('id'), + }).val(record.model.name).appendTo(cell); + input.uniqueId(); + + cell = jQuery('').css('text-align', 'end').appendTo(row); + label = jQuery('').appendTo(row); + input = jQuery('', { + 'type': 'integer', + 'readonly': true, + 'class': 'form-control input-sm', + 'aria-labelledby': label.attr('id'), + }).val(record.id).appendTo(cell); + input.css('text-align', 'end'); + input.uniqueId(); + + [['create_uid.', Sao.i18n.gettext("Created by:"), + 'create_date', Sao.i18n.gettext("Created at:")], + ['write_uid.', Sao.i18n.gettext("Last Modified by:"), + 'write_date', Sao.i18n.gettext("Last Modified at:")], + ].forEach(([user, user_label, date, date_label]) => { + + row = jQuery('
').css('text-align', 'end').appendTo(row); + label = jQuery('').appendTo(row); + user = log[user]; + if (user) { + user = user.rec_name; + } + input = jQuery('', { + 'readonly': true, + 'class': 'form-control input-sm', + 'aria-labelledby': label.attr('id'), + }).val(user || '').appendTo(cell); + input.css('width', '50ch'); + input.uniqueId(); + + cell = jQuery('').css('text-align', 'end').appendTo(row); + label = jQuery('').appendTo(row); + date = log[date]; + if (date) { + date = Sao.common.format_datetime( + Sao.common.date_format() + ' %H:%M:%S', date); + } + input = jQuery('', { + 'readonly': true, + 'class': 'form-control input-sm', + 'aria-labelledby': label.attr('id'), + }).val(date || '').appendTo(cell); + input.css('width', date.length + 'ch'); + input.uniqueId(); + }); + }); + + var screen = new Sao.Screen('ir.model.log', { + domain: [['resource', '=', this.resource]], + mode: ['tree', 'form'], + context: context, + }); + Sao.Window.Log._super.init.call( + this, screen, null, {view_type: 'tree', title: title}); + this.switch_prm = this.switch_prm.then(function() { + return screen.search_filter(); + }); + + this.dialog.body.prepend(form); + }, + }); + Sao.Window.Search = Sao.class_(Object, { init: function(model, callback, kwargs) { kwargs = kwargs || {}; diff --git a/tryton/CHANGELOG b/tryton/CHANGELOG index 70da3cdc81..6bc0aac3c3 100644 --- a/tryton/CHANGELOG +++ b/tryton/CHANGELOG @@ -1,3 +1,4 @@ +* Include events to logs * Add option to disable thread * Remove support for Python 3.7 * Add support for Python 3.11 diff --git a/tryton/tryton/common/popup_menu.py b/tryton/tryton/common/popup_menu.py index 0878cfec66..78cac488e9 100644 --- a/tryton/tryton/common/popup_menu.py +++ b/tryton/tryton/common/popup_menu.py @@ -10,6 +10,7 @@ from tryton.common.common import selection from tryton.gui.window import Window from tryton.gui.window.attachment import Attachment from tryton.gui.window.email_ import Email +from tryton.gui.window.log import Log from tryton.gui.window.note import Note from tryton.gui.window.view_form.screen import Screen @@ -56,6 +57,9 @@ def populate(menu, model, record, title='', field=None, context=None): with Window(hide_current=True, allow_similar=allow_similar): Action.execute(action, data, context=rec.get_context()) + def log(menuitem): + Log(load(record)) + def attachment(menuitem): Attachment(load(record), None) @@ -103,6 +107,9 @@ def populate(menu, model, record, title='', field=None, context=None): edit_item.connect('activate', edit) action_menu.append(edit_item) action_menu.append(Gtk.SeparatorMenuItem()) + log_item = Gtk.MenuItem(label=_("View Logs...")) + action_menu.append(log_item) + log_item.connect('activate', log) attachment_item = Gtk.MenuItem(label=_('Attachments...')) action_menu.append(attachment_item) attachment_item.connect('activate', attachment) diff --git a/tryton/tryton/gui/window/form.py b/tryton/tryton/gui/window/form.py index 9880b14d33..9f53d613f5 100644 --- a/tryton/tryton/gui/window/form.py +++ b/tryton/tryton/gui/window/form.py @@ -2,7 +2,6 @@ # this repository contains the full copyright notices and license terms. "Form" import csv -import datetime import gettext import locale import os @@ -14,8 +13,7 @@ from gi.repository import Gdk, GLib, Gtk import tryton.common as common from tryton import plugins from tryton.action import Action -from tryton.common import ( - RPCException, RPCExecute, message, sur, sur_3b, timezoned_date) +from tryton.common import RPCException, RPCExecute, sur, sur_3b from tryton.common.common import selection as selection_ from tryton.common.popup_menu import popup from tryton.common.underline import set_underline @@ -23,6 +21,7 @@ from tryton.gui import Main from tryton.gui.window import Window from tryton.gui.window.attachment import Attachment from tryton.gui.window.email_ import Email +from tryton.gui.window.log import Log from tryton.gui.window.note import Note from tryton.gui.window.revision import Revision from tryton.gui.window.view_form.screen import Screen @@ -295,37 +294,8 @@ class Form(TabContent): if not current_record or current_record.id < 0: self.info_bar_add( _('You have to select one record.'), Gtk.MessageType.INFO) - return False - - fields = [ - ('id', _('ID:')), - ('create_uid.rec_name', _('Created by:')), - ('create_date', _('Created at:')), - ('write_uid.rec_name', _('Edited by:')), - ('write_date', _('Edited at:')), - ] - - try: - data = RPCExecute('model', self.model, 'read', [current_record.id], - [x[0] for x in fields], context=self.screen.context)[0] - except RPCException: return - date_format = self.screen.context.get('date_format', '%x') - datetime_format = date_format + ' %H:%M:%S.%f' - message_str = '' - for (key, label) in fields: - value = data - keys = key.split('.') - name = keys.pop(-1) - for key in keys: - value = value.get(key + '.', {}) - value = (value or {}).get(name, '/') - if isinstance(value, datetime.datetime): - value = timezoned_date(value).strftime(datetime_format) - message_str += '%s %s\n' % (label, value) - message_str += _('Model:') + ' ' + self.model - message(message_str) - return True + Log(current_record) def sig_revision(self, widget=None): if not self.modified_save(): diff --git a/tryton/tryton/gui/window/log.py b/tryton/tryton/gui/window/log.py new file mode 100644 index 0000000000..04bdb4c2fe --- /dev/null +++ b/tryton/tryton/gui/window/log.py @@ -0,0 +1,95 @@ +# 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 + +from tryton.common import RPCException, RPCExecute, timezoned_date +from tryton.common.underline import set_underline +from tryton.gui.window.view_form.screen import Screen +from tryton.gui.window.win_form import WinForm + +_ = gettext.gettext + + +class Log(WinForm): + + def __init__(self, record): + self.resource = '%s,%s' % (record.model_name, record.id) + title = _("Logs (%s)") % record.rec_name() + + context = record.get_context() + try: + log, = RPCExecute( + 'model', record.model_name, 'read', [record.id], + ['create_uid.rec_name', 'create_date', + 'write_uid.rec_name', 'write_date'], context=context) + except RPCException: + return + + date_format = context.get('date_format', '%x') + datetime_format = date_format + ' %H:%M:%S.%f' + + grid = Gtk.Grid( + column_spacing=3, row_spacing=3, border_width=3) + + entry_model = Gtk.Entry(editable=False) + entry_model.set_text(record.model_name) + grid.attach(entry_model, 1, 1, 1, 1) + label_model = Gtk.Label( + label=set_underline(_("Model:")), + use_underline=True, halign=Gtk.Align.END) + label_model.set_mnemonic_widget(entry_model) + grid.attach(label_model, 0, 1, 1, 1) + + entry_id = Gtk.Entry(editable=False) + entry_id.set_alignment(1) + entry_id.set_text(str(record.id)) + grid.attach(entry_id, 3, 1, 1, 1) + label_id = Gtk.Label( + label=set_underline(_("ID:")), + use_underline=True, halign=Gtk.Align.END) + label_id.set_mnemonic_widget(entry_id) + grid.attach(label_id, 2, 1, 1, 1) + + for i, (user, user_label, date, date_label) in enumerate([ + ('create_uid.', _("Created by:"), + 'create_date', _("Created at:")), + ('write_uid.', _("Last Modified by:"), + 'write_date', _("Last Modified at:"))], 2): + entry_user = Gtk.Entry(editable=False, width_chars=50) + user = log.get(user) + if user: + user = user.get('rec_name', '') + entry_user.set_text(user or '') + grid.attach(entry_user, 1, i, 1, 1) + label_user = Gtk.Label( + label=set_underline(user_label), + use_underline=True, halign=Gtk.Align.END) + label_user.set_mnemonic_widget(entry_user) + grid.attach(label_user, 0, i, 1, 1) + + entry_date = Gtk.Entry(editable=False) + date = log.get(date) + if date: + date = timezoned_date(date).strftime(datetime_format) + entry_date.set_width_chars(len(date or '')) + entry_date.set_text(date or '') + grid.attach(entry_date, 3, i, 1, 1) + label_date = Gtk.Label( + label=set_underline(date_label), + use_underline=True, halign=Gtk.Align.END) + label_date.set_mnemonic_widget(entry_date) + grid.attach(label_date, 2, i, 1, 1) + + grid.show_all() + + screen = Screen('ir.model.log', domain=[ + ('resource', '=', self.resource), + ], mode=['tree', 'form']) + super().__init__(screen, view_type='tree', title=title) + screen.search_filter() + + self.win.vbox.pack_start(grid, expand=False, fill=True, padding=0) + self.win.vbox.reorder_child(grid, 2) diff --git a/tryton/tryton/gui/window/win_form.py b/tryton/tryton/gui/window/win_form.py index 9500eecf17..4a8cfa64e2 100644 --- a/tryton/tryton/gui/window/win_form.py +++ b/tryton/tryton/gui/window/win_form.py @@ -21,7 +21,7 @@ _ = gettext.gettext class WinForm(NoModal, InfoBar): "Form window" - def __init__(self, screen, callback, view_type='form', + def __init__(self, screen, callback=None, view_type='form', new=False, many=0, domain=None, context=None, save_current=False, title='', rec_name=None): tooltips = common.Tooltips() @@ -448,7 +448,8 @@ class WinForm(NoModal, InfoBar): result = False else: result = response_id not in cancel_responses - self.callback(result) + if self.callback: + self.callback(result) self.destroy() def new(self): diff --git a/trytond/CHANGELOG b/trytond/CHANGELOG index a782f008b4..cb64b660cc 100644 --- a/trytond/CHANGELOG +++ b/trytond/CHANGELOG @@ -1,3 +1,4 @@ +* Log events for ModelStorage * Use IDENTITY column for id columns in the postgresql backend * Use no breaking space to format number and date * Fallback to code to format currency without symbol diff --git a/trytond/doc/ref/models.rst b/trytond/doc/ref/models.rst index 84ab81df88..6937c6de0d 100644 --- a/trytond/doc/ref/models.rst +++ b/trytond/doc/ref/models.rst @@ -273,6 +273,10 @@ Static methods: Class methods: +.. classmethod:: ModelStorage.log(records, event[, target[, user[, \**extra]]]) + + Log event for records. + .. classmethod:: ModelStorage.create(vlist) Create records. diff --git a/trytond/trytond/ir/__init__.py b/trytond/trytond/ir/__init__.py index ba5e0f4f09..77afb9c839 100644 --- a/trytond/trytond/ir/__init__.py +++ b/trytond/trytond/ir/__init__.py @@ -50,6 +50,7 @@ def register(): model.ModelButtonClick, model.ModelButtonReset, model.ModelData, + model.Log, model.PrintModelGraphStart, attachment.Attachment, note.Note, diff --git a/trytond/trytond/ir/action.py b/trytond/trytond/ir/action.py index e69a144eac..e0cd5b89f3 100644 --- a/trytond/trytond/ir/action.py +++ b/trytond/trytond/ir/action.py @@ -1123,6 +1123,18 @@ class ActionWizard(ActionMixin, ModelSQL, ModelView): actions = cls.search(domain) return {a.model for a in actions if a.model} + @classmethod + def get_name(cls, name, model): + # TODO add cache + actions = cls.search([ + (cls._action_name, '=', name), + ('model', '=', model), + ], limit=1) + if actions: + action, = actions + return action.name + return name + class ActionURL(ActionMixin, ModelSQL, ModelView): "Action URL" diff --git a/trytond/trytond/ir/model.py b/trytond/trytond/ir/model.py index 066010c7d0..0ee5ee376e 100644 --- a/trytond/trytond/ir/model.py +++ b/trytond/trytond/ir/model.py @@ -28,6 +28,8 @@ from trytond.tools.string_ import StringMatcher from trytond.transaction import Transaction, without_check_access from trytond.wizard import Button, StateAction, StateView, Wizard +from .resource import ResourceAccessMixin + logger = logging.getLogger(__name__) @@ -1419,6 +1421,84 @@ class ModelData(ModelSQL, ModelView): cls.write(*to_write) +class Log(ResourceAccessMixin, ModelSQL, ModelView): + "Log" + __name__ = 'ir.model.log' + + user = fields.Many2One( + 'res.user', "User", + states={ + 'required': Eval('event') != 'transition', + }) + event = fields.Selection([ + ('write', "Modified"), + ('delete', "Deleted"), + ('button', "Clicked on"), + ('wizard', "Launched"), + ('transition', "Transitioned to"), + ], "Event", required=True) + event_string = event.translated('event') + target = fields.Char( + "Target", + states={ + 'required': Eval('event').in_( + ['write', 'button', 'wizard', 'transition']), + 'invisible': ( + ~Eval('event').in_( + ['write', 'button', 'wizard', 'transition'])), + }) + action = fields.Function( + fields.Char("Action"), 'get_action', searcher='search_action') + + @classmethod + def __setup__(cls): + super().__setup__() + cls.resource.required = False # store deleted record + cls._order = [ + ('create_date', 'DESC'), + ('id', 'DESC'), + ] + + @classmethod + def get_models(cls): + return super().get_models() + [(None, '')] # store deleted record + + def get_action(self, name): + pool = Pool() + Field = pool.get('ir.model.field') + Button = pool.get('ir.model.button') + Wizard = pool.get('ir.action.wizard') + if self.resource: + Model = self.resource.__class__ + model = self.resource.__name__ + if self.event == 'write': + fields = self.target.split(',') + get_name = Field.get_name + fields = [get_name(model, f) for f in fields] + return ', '.join(fields) + elif self.event == 'button': + return Button.get_view_attributes(model, self.target).get( + 'string', self.target) + elif self.event == 'wizard': + wiz_name, state_name = self.target.split(':') + wiz_name = Wizard.get_name(wiz_name, model) + return f'{state_name} @ {wiz_name}' + elif self.event == 'transition': + field_name, state = self.target.split(':') + field = getattr(Model, field_name, None) + field_name = Field.get_name(model, field_name) + if field: + selection = field.get_selection( + Model, field.name, self.resource) + state = field.get_selection_string(selection, state) + return f'{field_name} : {state}' + return self.target + + @classmethod + def search_action(cls, name, clause): + return [('target', *clause[1:])] + + class PrintModelGraphStart(ModelView): 'Print Model Graph' __name__ = 'ir.model.print_model_graph.start' diff --git a/trytond/trytond/ir/model.xml b/trytond/trytond/ir/model.xml index fe825cd890..a8736357f1 100644 --- a/trytond/trytond/ir/model.xml +++ b/trytond/trytond/ir/model.xml @@ -325,5 +325,37 @@ this repository contains the full copyright notices and license terms. --> + + ir.model.log + form + model_log_form + + + + ir.model.log + tree + model_log_list + + + + Logs + ir.model.log + + + + + + + + + + + + + diff --git a/trytond/trytond/ir/view/model_log_form.xml b/trytond/trytond/ir/view/model_log_form.xml new file mode 100644 index 0000000000..f404813e06 --- /dev/null +++ b/trytond/trytond/ir/view/model_log_form.xml @@ -0,0 +1,16 @@ + + +
+