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:
Cédric Krier
2023-02-08 19:03:29 +01:00
parent 68fdcb3f90
commit 17bbce3919
25 changed files with 775 additions and 80 deletions
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+7
View File
@@ -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)
+3 -33
View File
@@ -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():
+95
View File
@@ -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)
+3 -2
View File
@@ -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
View File
@@ -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
+4
View File
@@ -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.
+1
View File
@@ -50,6 +50,7 @@ def register():
model.ModelButtonClick,
model.ModelButtonReset,
model.ModelData,
model.Log,
model.PrintModelGraphStart,
attachment.Attachment,
note.Note,
+12
View File
@@ -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"
+80
View File
@@ -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'
+32
View File
@@ -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>
+26 -1
View File
@@ -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:
+3
View File
@@ -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
+3
View File
@@ -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
+9
View File
@@ -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"/>
+4 -3
View File
@@ -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')
+65
View File
@@ -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')
+250
View File
@@ -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, [])
+13
View File
@@ -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)
+10 -2
View File
@@ -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):