Log events for ModelStorage
It logs by default: - the write and delete done directly by the users - the execution of a button - the execution of wizard state that has modified the database - the workflow transition to a new state
This commit is contained in:
@@ -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
|
||||
|
||||
+1
-37
@@ -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;
|
||||
|
||||
+124
-2
@@ -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('<div/>', {
|
||||
'class': 'form',
|
||||
});
|
||||
var table = jQuery('<table/>', {
|
||||
'class': 'form-container responsive responsive-noheader',
|
||||
}).appendTo(form);
|
||||
var body = jQuery('<tbody/>').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('<tr/>').appendTo(body);
|
||||
cell = jQuery('<td/>').css('text-align', 'end').appendTo(row);
|
||||
label = jQuery('<label/>', {
|
||||
'class': 'form-label',
|
||||
'text': Sao.i18n.gettext("Model:"),
|
||||
}).appendTo(cell);
|
||||
label.uniqueId();
|
||||
cell = jQuery('<td/>').appendTo(row);
|
||||
input = jQuery('<input/>', {
|
||||
'readonly': true,
|
||||
'class': 'form-control input-sm',
|
||||
'aria-labelledby': label.attr('id'),
|
||||
}).val(record.model.name).appendTo(cell);
|
||||
input.uniqueId();
|
||||
|
||||
cell = jQuery('<td/>').css('text-align', 'end').appendTo(row);
|
||||
label = jQuery('<label/>', {
|
||||
'class': 'form-label',
|
||||
'text': Sao.i18n.gettext("ID:"),
|
||||
}).appendTo(cell);
|
||||
label.uniqueId();
|
||||
cell = jQuery('<td/>').appendTo(row);
|
||||
input = jQuery('<input/>', {
|
||||
'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('<tr/>').appendTo(body);
|
||||
cell = jQuery('<td/>').css('text-align', 'end').appendTo(row);
|
||||
label = jQuery('<label/>', {
|
||||
'class': 'form-label',
|
||||
'text': user_label,
|
||||
}).appendTo(cell);
|
||||
label.uniqueId();
|
||||
cell = jQuery('<td/>').appendTo(row);
|
||||
user = log[user];
|
||||
if (user) {
|
||||
user = user.rec_name;
|
||||
}
|
||||
input = jQuery('<input/>', {
|
||||
'readonly': true,
|
||||
'class': 'form-control input-sm',
|
||||
'aria-labelledby': label.attr('id'),
|
||||
}).val(user || '').appendTo(cell);
|
||||
input.css('width', '50ch');
|
||||
input.uniqueId();
|
||||
|
||||
cell = jQuery('<td/>').css('text-align', 'end').appendTo(row);
|
||||
label = jQuery('<label/>', {
|
||||
'class': 'form-label',
|
||||
'text': date_label,
|
||||
}).appendTo(cell);
|
||||
label.uniqueId();
|
||||
cell = jQuery('<td/>').appendTo(row);
|
||||
date = log[date];
|
||||
if (date) {
|
||||
date = Sao.common.format_datetime(
|
||||
Sao.common.date_format() + ' %H:%M:%S', date);
|
||||
}
|
||||
input = jQuery('<input/>', {
|
||||
'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 || {};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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)
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -50,6 +50,7 @@ def register():
|
||||
model.ModelButtonClick,
|
||||
model.ModelButtonReset,
|
||||
model.ModelData,
|
||||
model.Log,
|
||||
model.PrintModelGraphStart,
|
||||
attachment.Attachment,
|
||||
note.Note,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -325,5 +325,37 @@ this repository contains the full copyright notices and license terms. -->
|
||||
<field name="model" search="[('model', '=', 'ir.model.data')]"/>
|
||||
</record>
|
||||
|
||||
<record model="ir.ui.view" id="model_log_view_form">
|
||||
<field name="model">ir.model.log</field>
|
||||
<field name="type">form</field>
|
||||
<field name="name">model_log_form</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.ui.view" id="model_log_view_list">
|
||||
<field name="model">ir.model.log</field>
|
||||
<field name="type">tree</field>
|
||||
<field name="name">model_log_list</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.action.act_window" id="act_model_log_form">
|
||||
<field name="name">Logs</field>
|
||||
<field name="res_model">ir.model.log</field>
|
||||
</record>
|
||||
<record model="ir.action.act_window.view" id="act_model_log_form_view1">
|
||||
<field name="sequence" eval="10"/>
|
||||
<field name="view" ref="model_log_view_list"/>
|
||||
<field name="act_window" ref="act_model_log_form"/>
|
||||
</record>
|
||||
<record model="ir.action.act_window.view" id="act_model_log_form_view2">
|
||||
<field name="sequence" eval="20"/>
|
||||
<field name="view" ref="model_log_view_form"/>
|
||||
<field name="act_window" ref="act_model_log_form"/>
|
||||
</record>
|
||||
|
||||
<menuitem
|
||||
parent="menu_model_form"
|
||||
action="act_model_log_form"
|
||||
sequence="50"
|
||||
id="menu_model_log_form"/>
|
||||
</data>
|
||||
</tryton>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<?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="user"/>
|
||||
<field name="user"/>
|
||||
<label name="create_date" string="at"/>
|
||||
<field name="create_date"/>
|
||||
|
||||
<label name="resource"/>
|
||||
<field name="resource"/>
|
||||
<group id="event_action" col="-1" colspan="2">
|
||||
<field name="event"/>
|
||||
<field name="action"/>
|
||||
</group>
|
||||
</form>
|
||||
@@ -0,0 +1,11 @@
|
||||
<?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="resource" expand="2"/>
|
||||
<field name="create_date" widget="date" string="Date"/>
|
||||
<field name="create_date" widget="time" string="Hour"/>
|
||||
<field name="user" expand="1"/>
|
||||
<field name="event"/>
|
||||
<field name="action" expand="1"/>
|
||||
</tree>
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -101,6 +101,15 @@ this repository contains the full copyright notices and license terms. -->
|
||||
<field name="perm_create" eval="False"/>
|
||||
<field name="perm_delete" eval="False"/>
|
||||
</record>
|
||||
|
||||
<record model="ir.model.access" id="access_ir_model_log">
|
||||
<field name="model" search="[('model', '=', 'ir.model.log')]"/>
|
||||
<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_ir_cron">
|
||||
<field name="model" search="[('model', '=', 'ir.cron')]"/>
|
||||
<field name="perm_read" eval="True"/>
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
@@ -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, [])
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user