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(' | ').css('text-align', 'end').appendTo(row);
+ label = jQuery('', {
+ 'class': 'form-label',
+ 'text': Sao.i18n.gettext("Model:"),
+ }).appendTo(cell);
+ label.uniqueId();
+ cell = 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('', {
+ 'class': 'form-label',
+ 'text': Sao.i18n.gettext("ID:"),
+ }).appendTo(cell);
+ label.uniqueId();
+ cell = 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('
').appendTo(body);
+ cell = jQuery(' | ').css('text-align', 'end').appendTo(row);
+ label = jQuery('', {
+ 'class': 'form-label',
+ 'text': user_label,
+ }).appendTo(cell);
+ label.uniqueId();
+ cell = 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('', {
+ 'class': 'form-label',
+ 'text': date_label,
+ }).appendTo(cell);
+ label.uniqueId();
+ cell = 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 @@
+
+
+
diff --git a/trytond/trytond/ir/view/model_log_list.xml b/trytond/trytond/ir/view/model_log_list.xml
new file mode 100644
index 0000000000..fa27f8f85c
--- /dev/null
+++ b/trytond/trytond/ir/view/model_log_list.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/trytond/trytond/model/modelstorage.py b/trytond/trytond/model/modelstorage.py
index 718f264912..d746de1c64 100644
--- a/trytond/trytond/model/modelstorage.py
+++ b/trytond/trytond/model/modelstorage.py
@@ -119,6 +119,7 @@ class ModelStorage(Model):
searcher='search_rec_name')
_count_cache = Cache(
'modelstorage.count', duration=_cache_count_timeout, context=False)
+ _log = None
@classmethod
def __setup__(cls):
@@ -142,6 +143,8 @@ class ModelStorage(Model):
'export_data': RPC(instantiate=0, unique=False),
'import_data': RPC(readonly=False),
})
+ if cls._log is None:
+ cls._log = not cls.__name__.startswith('ir.')
@classmethod
def __post_setup__(cls):
@@ -167,6 +170,21 @@ class ModelStorage(Model):
"Default value for create_date field."
return datetime.datetime.today()
+ @dualmethod
+ def log(cls, records, event, target=None, user=None, **extra):
+ if not cls._log:
+ return
+ pool = Pool()
+ Log = pool.get('ir.model.log')
+ transaction = Transaction()
+ if user is None:
+ user = transaction.user
+ for record in records:
+ assert record.id >= 0
+ transaction.log_records.append(Log(
+ resource=record, event=event, target=target, user=user,
+ **extra))
+
@classmethod
def create(cls, vlist):
'''
@@ -230,6 +248,7 @@ class ModelStorage(Model):
pool = Pool()
ModelAccess = pool.get('ir.model.access')
ModelFieldAccess = pool.get('ir.model.field.access')
+ transaction = Transaction()
assert not len(args) % 2
actions = iter((records, values) + args)
@@ -239,6 +258,8 @@ class ModelStorage(Model):
cls.check_xml_record(records, values)
all_records += records
all_fields.update(values.keys())
+ if transaction.check_access and values:
+ cls.log(records, 'write', ','.join(sorted(values.keys())))
ModelAccess.check(cls.__name__, 'write')
ModelFieldAccess.check(cls.__name__, all_fields, 'write')
@@ -298,14 +319,18 @@ class ModelStorage(Model):
pool = Pool()
ModelAccess = pool.get('ir.model.access')
ModelData = pool.get('ir.model.data')
+ transaction = Transaction()
ModelAccess.check(cls.__name__, 'delete')
cls.check_xml_record(records, None)
if ModelData.has_model(cls.__name__):
ModelData.clean(records)
+ if transaction.check_access:
+ cls.log(records, 'delete')
+
# Increase transaction counter
- Transaction().counter += 1
+ transaction.counter += 1
# Clean local cache
for record in records:
diff --git a/trytond/trytond/model/modelview.py b/trytond/trytond/model/modelview.py
index 5087f77840..7f40a375f7 100644
--- a/trytond/trytond/model/modelview.py
+++ b/trytond/trytond/model/modelview.py
@@ -713,6 +713,9 @@ class ModelView(Model):
names = Button.get_reset(cls.__name__, func.__name__)
if names:
ButtonClick.reset(cls.__name__, names, records)
+
+ if check_access and issubclass(cls, ModelStorage):
+ cls.log(records, 'button', func.__name__)
with without_check_access():
return func(cls, records, *args, **kwargs)
return wrapper
diff --git a/trytond/trytond/model/workflow.py b/trytond/trytond/model/workflow.py
index ea1c666197..4e5104464a 100644
--- a/trytond/trytond/model/workflow.py
+++ b/trytond/trytond/model/workflow.py
@@ -43,6 +43,9 @@ class Workflow(object):
cls.write(list(to_update), {
cls._transition_state: state,
})
+ cls.log(
+ records, 'transition',
+ f'{cls._transition_state}:{state}')
return result
return wrapper
return check_transition
diff --git a/trytond/trytond/res/ir.xml b/trytond/trytond/res/ir.xml
index 458649ee08..1bdf6dcb26 100644
--- a/trytond/trytond/res/ir.xml
+++ b/trytond/trytond/res/ir.xml
@@ -101,6 +101,15 @@ this repository contains the full copyright notices and license terms. -->
+
+
+
+
+
+
+
+
+
diff --git a/trytond/trytond/tests/__init__.py b/trytond/trytond/tests/__init__.py
index 34b135f172..13e8ca5809 100644
--- a/trytond/trytond/tests/__init__.py
+++ b/trytond/trytond/tests/__init__.py
@@ -14,9 +14,9 @@ def register():
field_function, field_integer, field_many2many, field_many2one,
field_multiselection, field_numeric, field_one2many, field_one2one,
field_reference, field_selection, field_text, field_time,
- field_timedelta, history, import_data, mixin, model, modelsql,
- modelstorage, modelview, mptt, multivalue, path, resource, rule, tree,
- trigger, wizard, workflow)
+ field_timedelta, history, import_data, mixin, model, model_log,
+ modelsql, modelstorage, modelview, mptt, multivalue, path, resource,
+ rule, tree, trigger, wizard, workflow)
access.register('tests')
copy_.register('tests')
@@ -49,6 +49,7 @@ def register():
modelsql.register('tests')
modelstorage.register('tests')
modelview.register('tests')
+ model_log.register('tests')
mptt.register('tests')
multivalue.register('tests')
path.register('tests')
diff --git a/trytond/trytond/tests/model_log.py b/trytond/trytond/tests/model_log.py
new file mode 100644
index 0000000000..575684101f
--- /dev/null
+++ b/trytond/trytond/tests/model_log.py
@@ -0,0 +1,65 @@
+# This file is part of Tryton. The COPYRIGHT file at the top level of
+# this repository contains the full copyright notices and license terms.
+
+from trytond.model import ModelSQL, ModelView, Workflow, fields
+from trytond.pool import Pool
+from trytond.wizard import StateTransition, Wizard
+
+
+class Model(Workflow, ModelSQL, ModelView):
+ "Model"
+ __name__ = 'test.model_log.model'
+
+ name = fields.Char("Name")
+ state = fields.Selection([
+ ('start', "Start"),
+ ('end', "End"),
+ ], "State")
+
+ @classmethod
+ def __setup__(cls):
+ super().__setup__()
+ cls._transitions |= {('start', 'end')}
+
+ @classmethod
+ def default_state(cls):
+ return 'start'
+
+ @classmethod
+ @ModelView.button
+ def click(cls, records):
+ pass
+
+ @classmethod
+ @Workflow.transition('start')
+ def start(cls, records):
+ pass
+
+ @classmethod
+ @Workflow.transition('end')
+ def end(cls, records):
+ pass
+
+
+class Wizard(Wizard):
+ "Wizard"
+ __name__ = 'test.model_log.wizard'
+ no_modification = StateTransition()
+ modification = StateTransition()
+
+ def transition_no_modification(self):
+ return 'end'
+
+ def transition_modification(self):
+ self.record.name = "Bar"
+ self.record.save()
+ return 'end'
+
+
+def register(module):
+ Pool.register(
+ Model,
+ module=module, type_='model')
+ Pool.register(
+ Wizard,
+ module=module, type_='wizard')
diff --git a/trytond/trytond/tests/test_model_log.py b/trytond/trytond/tests/test_model_log.py
new file mode 100644
index 0000000000..18eb35a9b9
--- /dev/null
+++ b/trytond/trytond/tests/test_model_log.py
@@ -0,0 +1,250 @@
+# 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 unittest
+
+from trytond.pool import Pool
+from trytond.transaction import Transaction
+
+from .test_tryton import activate_module, with_transaction
+
+
+class ModelLogTestCase(unittest.TestCase):
+ "Test Model Log"
+
+ @classmethod
+ def setUpClass(cls):
+ activate_module('tests')
+
+ @with_transaction()
+ def test_log(self):
+ "Test model log"
+ pool = Pool()
+ Model = pool.get('test.model_log.model')
+ Log = pool.get('ir.model.log')
+ transaction = Transaction()
+
+ record, = Model.create([{}])
+ Model.log([record], 'delete', user=0)
+ transaction._store_log_records()
+
+ log, = Log.search([('resource', '=', str(record))])
+ self.assertEqual(log.user.id, 0)
+ self.assertEqual(log.event, 'delete')
+
+ @with_transaction()
+ def test_log_no_log(self):
+ "Test no model log without _log"
+ pool = Pool()
+ Model = pool.get('test.model_log.model')
+ Log = pool.get('ir.model.log')
+ transaction = Transaction()
+
+ self.addCleanup(setattr, Model, '_log', Model._log)
+ Model._log = False
+
+ record, = Model.create([{}])
+ Model.log([record], 'delete')
+ transaction._store_log_records()
+
+ logs = Log.search([('resource', '=', str(record))])
+ self.assertEqual(logs, [])
+
+ @with_transaction(context={'_check_access': True})
+ def test_create(self):
+ "Test no model log on create"
+ pool = Pool()
+ Model = pool.get('test.model_log.model')
+ Log = pool.get('ir.model.log')
+ transaction = Transaction()
+
+ record, = Model.create([{}])
+ transaction._store_log_records()
+
+ logs = Log.search([('resource', '=', str(record))])
+ self.assertEqual(logs, [])
+
+ @with_transaction(context={'_check_access': True})
+ def test_write(self):
+ "Test model log on write"
+ pool = Pool()
+ Model = pool.get('test.model_log.model')
+ Log = pool.get('ir.model.log')
+ transaction = Transaction()
+
+ record, = Model.create([{}])
+ Model.write([record], {'name': "Foo", 'state': 'end'})
+ transaction._store_log_records()
+
+ log, = Log.search([('resource', '=', str(record))])
+ self.assertEqual(log.user.id, 1)
+ self.assertEqual(log.event, 'write')
+ self.assertEqual(log.target, 'name,state')
+
+ @with_transaction(context={'_check_access': True})
+ def test_write_empty(self):
+ "Test no model log on empty write"
+ pool = Pool()
+ Model = pool.get('test.model_log.model')
+ Log = pool.get('ir.model.log')
+ transaction = Transaction()
+
+ record, = Model.create([{}])
+ Model.write([record], {})
+ transaction._store_log_records()
+
+ logs = Log.search([('resource', '=', str(record))])
+ self.assertEqual(logs, [])
+
+ @with_transaction(context={'_check_access': False})
+ def test_write_no_check_access(self):
+ "Test no model log on write without check access"
+ pool = Pool()
+ Model = pool.get('test.model_log.model')
+ Log = pool.get('ir.model.log')
+ transaction = Transaction()
+
+ record, = Model.create([{}])
+ Model.write([record], {'name': "Foo", 'state': 'end'})
+ transaction._store_log_records()
+
+ logs = Log.search([('resource', '=', str(record))])
+ self.assertEqual(logs, [])
+
+ @with_transaction(context={'_check_access': True})
+ def test_delete(self):
+ "Test model log on delete"
+ pool = Pool()
+ Model = pool.get('test.model_log.model')
+ Log = pool.get('ir.model.log')
+ transaction = Transaction()
+
+ record, = Model.create([{}])
+ Model.delete([record])
+ transaction._store_log_records()
+
+ log, = Log.search([('resource', '=', str(record))])
+ self.assertEqual(log.event, 'delete')
+
+ @with_transaction(context={'_check_access': False})
+ def test_delete_no_check_access(self):
+ "Test no model log on delete without check access"
+ pool = Pool()
+ Model = pool.get('test.model_log.model')
+ Log = pool.get('ir.model.log')
+ transaction = Transaction()
+
+ record, = Model.create([{}])
+ Model.delete([record])
+ transaction._store_log_records()
+
+ logs = Log.search([('resource', '=', str(record))])
+ self.assertEqual(logs, [])
+
+ @with_transaction(context={'_check_access': True})
+ def test_button(self):
+ "Test model log on button"
+ pool = Pool()
+ Model = pool.get('test.model_log.model')
+ Log = pool.get('ir.model.log')
+ transaction = Transaction()
+
+ record, = Model.create([{}])
+ Model.click([record])
+ transaction._store_log_records()
+
+ log, = Log.search([('resource', '=', str(record))])
+ self.assertEqual(log.user.id, 1)
+ self.assertEqual(log.event, 'button')
+ self.assertEqual(log.target, 'click')
+
+ @with_transaction(context={'_check_access': False})
+ def test_button_no_check_access(self):
+ "Test model log on button"
+ pool = Pool()
+ Model = pool.get('test.model_log.model')
+ Log = pool.get('ir.model.log')
+ transaction = Transaction()
+
+ record, = Model.create([{}])
+ Model.click([record])
+ transaction._store_log_records()
+
+ logs = Log.search([('resource', '=', str(record))])
+ self.assertEqual(logs, [])
+
+ @with_transaction()
+ def test_wizard(self):
+ "Test model log on wizard"
+ pool = Pool()
+ Model = pool.get('test.model_log.model')
+ Wizard = pool.get('test.model_log.wizard', type='wizard')
+ Log = pool.get('ir.model.log')
+ transaction = Transaction()
+
+ record, = Model.create([{}])
+ with transaction.set_context(
+ active_model=Model.__name__,
+ active_id=record.id,
+ active_ids=[record.id]):
+ session_id, _, _ = Wizard.create()
+ Wizard.execute(session_id, {}, 'modification')
+ transaction._store_log_records()
+
+ log, = Log.search([('resource', '=', str(record))])
+ self.assertEqual(log.user.id, 1)
+ self.assertEqual(log.event, 'wizard')
+ self.assertEqual(log.target, 'test.model_log.wizard:modification')
+
+ @with_transaction()
+ def test_wizard_no_modification(self):
+ "Test no model log on wizard without modification"
+ pool = Pool()
+ Model = pool.get('test.model_log.model')
+ Wizard = pool.get('test.model_log.wizard', type='wizard')
+ Log = pool.get('ir.model.log')
+ transaction = Transaction()
+
+ record, = Model.create([{}])
+ with transaction.set_context(
+ active_model=Model.__name__,
+ active_id=record.id,
+ active_ids=[record.id]):
+ session_id, _, _ = Wizard.create()
+ Wizard.execute(session_id, {}, 'no_modification')
+ transaction._store_log_records()
+
+ logs = Log.search([('resource', '=', str(record))])
+ self.assertEqual(logs, [])
+
+ @with_transaction()
+ def test_worflow(self):
+ "Test model log on workflow transition"
+ pool = Pool()
+ Model = pool.get('test.model_log.model')
+ Log = pool.get('ir.model.log')
+ transaction = Transaction()
+
+ record, = Model.create([{}])
+ Model.end([record])
+ transaction._store_log_records()
+
+ log, = Log.search([('resource', '=', str(record))])
+ self.assertEqual(log.user.id, 1)
+ self.assertEqual(log.event, 'transition')
+ self.assertEqual(log.target, 'state:end')
+
+ @with_transaction()
+ def test_worflow_no_transition(self):
+ "Test no model log on workflow without transition"
+ pool = Pool()
+ Model = pool.get('test.model_log.model')
+ Log = pool.get('ir.model.log')
+ transaction = Transaction()
+
+ record, = Model.create([{'state': 'end'}])
+ Model.end([record])
+ transaction._store_log_records()
+
+ logs = Log.search([('resource', '=', str(record))])
+ self.assertEqual(logs, [])
diff --git a/trytond/trytond/transaction.py b/trytond/trytond/transaction.py
index be9f6816a3..7c120efe4c 100644
--- a/trytond/trytond/transaction.py
+++ b/trytond/trytond/transaction.py
@@ -118,6 +118,7 @@ class Transaction(object):
instance.create_records = None
instance.delete_records = None
instance.trigger_records = None
+ instance.log_records = None
instance.check_warnings = None
instance.timestamp = None
instance.started_at = None
@@ -185,6 +186,7 @@ class Transaction(object):
self.create_records = defaultdict(set)
self.delete_records = defaultdict(set)
self.trigger_records = defaultdict(set)
+ self.log_records = []
self.check_warnings = set()
self.timestamp = {}
self.counter = 0
@@ -248,6 +250,7 @@ class Transaction(object):
self.create_records = None
self.delete_records = None
self.trigger_records = None
+ self.log_records = None
self.timestamp = None
self._datamanagers = []
@@ -305,9 +308,19 @@ class Transaction(object):
context=self.context, close=self.close, readonly=readonly,
autocommit=autocommit, **extras)
+ def _store_log_records(self):
+ from trytond.pool import Pool
+ if self.log_records:
+ pool = Pool()
+ Log = pool.get('ir.model.log')
+ with without_check_access():
+ Log.save(self.log_records)
+ self.log_records = []
+
def commit(self):
from trytond.cache import Cache
try:
+ self._store_log_records()
if self._datamanagers:
for datamanager in self._datamanagers:
datamanager.tpc_begin(self)
diff --git a/trytond/trytond/wizard/wizard.py b/trytond/trytond/wizard/wizard.py
index 8a667264a5..e454f68d94 100644
--- a/trytond/trytond/wizard/wizard.py
+++ b/trytond/trytond/wizard/wizard.py
@@ -327,6 +327,8 @@ class Wizard(URLMixin, PoolBase):
- ``values``: a dictionary with values
- ``buttons``: a list of buttons
'''
+ transaction = Transaction()
+ counter = transaction.counter
cls.check_access()
wizard = cls(session_id)
for key, values in data.items():
@@ -335,7 +337,14 @@ class Wizard(URLMixin, PoolBase):
if field == 'id':
continue
setattr(record, field, value)
- return wizard._execute(state_name)
+ result = wizard._execute(state_name)
+ # Log before _save increases the counter
+ if transaction.counter != counter and wizard.model:
+ wizard.model.log(
+ wizard.records, 'wizard',
+ f'{cls.__name__}:{state_name}')
+ wizard._save()
+ return result
def _execute(self, state_name):
if state_name == self.end_state:
@@ -371,7 +380,6 @@ class Wizard(URLMixin, PoolBase):
result = self._execute(transition())
if do_result:
result.setdefault('actions', []).append(do_result)
- self._save()
return result
def __init__(self, session_id):