Manage errors during non-interactive operations

issue10667
review346781002
This commit is contained in:
Cédric Krier committed 2021-09-13 23:40:28 +02:00
1 parent 216a7a9f50
commit b843e4e5bf
12 files changed
+333 -1

No files matched your search

+1
View File
@@ -1,3 +1,4 @@
* Manage errors during non-interactive operations
* Add estimation count to ModelStorage
* Support empty digits in format_number of Report
* Add digits mixin
+10
View File
@@ -284,6 +284,16 @@ The number of days after which processed tasks are removed.
Default: ``30``
error
-----
clean_days
~~~~~~~~~~
The number of days after which reported errors are removed.
Default: ``90``
table
-----
+2
View File
@@ -24,6 +24,7 @@ from . import calendar_
from . import message
from . import email_
from . import routes
from . import error
__all__ = ['register', 'routes']
@@ -102,6 +103,7 @@ def register():
email_.EmailAddress,
email_.EmailTemplate,
email_.EmailTemplate_Report,
error.Error,
module='ir', type_='model')
Pool.register(
translation.TranslationSet,
+9 -1
View File
@@ -7,6 +7,7 @@ import logging
from trytond import backend
from trytond.config import config
from trytond.exceptions import UserError, UserWarning
from trytond.model import (
ModelView, ModelSQL, DeactivableMixin, fields, dualmethod)
from trytond.pool import Pool
@@ -69,6 +70,7 @@ class Cron(DeactivableMixin, ModelSQL, ModelView):
method = fields.Selection([
('ir.trigger|trigger_time', "Run On Time Triggers"),
('ir.queue|clean', "Clean Task Queue"),
('ir.error|clean', "Clean Errors"),
], "Method", required=True)
@classmethod
@@ -147,6 +149,8 @@ class Cron(DeactivableMixin, ModelSQL, ModelView):
now = datetime.datetime.now()
retry = config.getint('database', 'retry')
with transaction.start(db_name, 0, context={'_skip_warnings': True}):
pool = Pool()
Error = pool.get('ir.error')
transaction.database.lock(transaction.connection, cls._table)
crons = cls.search(['OR',
('next_call', '<=', now),
@@ -168,7 +172,11 @@ class Cron(DeactivableMixin, ModelSQL, ModelView):
if (isinstance(e, backend.DatabaseOperationalError)
and count):
continue
logger.error('%s failed', name, exc_info=True)
if isinstance(e, (UserError, UserWarning)):
Error.log(cron, e)
logger.info('%s failed', name)
else:
logger.critical('%s failed', name, exc_info=True)
cron.next_call = cron.compute_next_call(now)
cron.save()
transaction.commit()
+169
View File
@@ -0,0 +1,169 @@
# 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 datetime as dt
import functools
import logging
from trytond.config import config
from trytond.exceptions import UserError, UserWarning
from trytond.model import ModelSQL, ModelView, Workflow, fields
from trytond.tools import firstline
from trytond.transaction import Transaction
from trytond.pool import Pool
from trytond.pyson import Eval
logger = logging.getLogger(__name__)
clean_days = config.getint('error', 'clean_days', default=90)
def set_user(field):
def decorator(func):
@functools.wraps(func)
def wrapper(cls, records, *args, **kwargs):
result = func(cls, records, *args, **kwargs)
cls.write(
[r for r in records
if not getattr(r, field)], {
field: Transaction().user,
})
return result
return wrapper
return decorator
def reset_user(*fields):
def decorator(func):
@functools.wraps(func)
def wrapper(cls, records, *args, **kwargs):
result = func(cls, records, *args, **kwargs)
cls.write(records, {f: None for f in fields})
return result
return wrapper
return decorator
class Error(Workflow, ModelView, ModelSQL):
"Error"
__name__ = 'ir.error'
origin = fields.Reference("Origin", [
('ir.cron', "Action"),
('ir.queue', "Task"),
], readonly=True)
origin_string = origin.translated('origin')
message = fields.Text("Message", readonly=True)
description = fields.Text("Description", readonly=True)
summary = fields.Function(fields.Char("Summary"), 'on_change_with_summary')
processed_by = fields.Many2One(
'res.user', "Processed by",
states={
'readonly': Eval('state').in_(['processing', 'solved']),
},
depends=['state'])
solved_by = fields.Many2One(
'res.user', "Solved by",
states={
'readonly': Eval('state').in_(['solved']),
},
depends=['state'])
state = fields.Selection([
('open', "Open"),
('processing', "Processing"),
('solved', "Solved"),
], "State", readonly=True, select=True)
@classmethod
def __setup__(cls):
super().__setup__()
cls._transitions |= {
('open', 'processing'),
('processing', 'solved'),
('processing', 'open'),
}
cls._buttons.update({
'open': {
'invisible': Eval('state') != 'processing',
'depends': ['state'],
},
'process': {
'invisible': Eval('state') != 'open',
'depends': ['state'],
},
'solve': {
'invisible': Eval('state') != 'processing',
'depends': ['state'],
},
})
@classmethod
def default_state(cls):
return 'open'
@fields.depends('message')
def on_change_with_summary(self, name=None):
return firstline(self.message or '')
def get_rec_name(self, name):
if self.origin:
return "%s - %s" % (self.origin_string, self.origin.rec_name)
return super().get_rec_name(name)
@classmethod
def log(cls, origin, exception):
try:
assert isinstance(exception, (UserError, UserWarning))
with Transaction().new_transaction(autocommit=True):
if not cls.search([
('origin', '=', str(origin)),
('message', '=', exception.message),
('description', '=', exception.description),
('state', '!=', 'solved'),
]):
cls.create([{
'origin': str(origin),
'message': exception.message,
'description': exception.description,
}])
except Exception:
logger.critical(
"failed to store exception %s of %s", exception, origin,
exc_info=True)
@classmethod
def clean(cls, date=None):
if date is None:
date = (
dt.datetime.now() - dt.timedelta(days=clean_days))
errors = cls.search([('create_date', '<', date)])
cls.delete(errors)
@classmethod
@ModelView.button
@Workflow.transition('open')
@reset_user('processed_by')
def open(cls, errors):
pass
@classmethod
@ModelView.button
@Workflow.transition('processing')
@set_user('processed_by')
def process(cls, errors):
pass
@classmethod
@ModelView.button
@Workflow.transition('solved')
@set_user('solved_by')
def solve(cls, errors):
pool = Pool()
Cron = pool.get('ir.cron')
Queue = pool.get('ir.queue')
for error in errors:
if isinstance(error.origin, Cron):
Cron.__queue__.run_once([error.origin])
elif isinstance(error.origin, Queue):
task = error.origin
Queue.push(task.name, task.data)
+85
View File
@@ -0,0 +1,85 @@
<?xml version="1.0"?>
<!-- This file is part of Tryton. The COPYRIGHT file at the top level of
this repository contains the full copyright notices and license terms. -->
<tryton>
<data>
<record model="ir.ui.view" id="error_view_list">
<field name="model">ir.error</field>
<field name="type">tree</field>
<field name="name">error_list</field>
</record>
<record model="ir.ui.view" id="error_view_form">
<field name="model">ir.error</field>
<field name="type">form</field>
<field name="name">error_form</field>
</record>
<record model="ir.action.act_window" id="act_error_form">
<field name="name">Errors</field>
<field name="res_model">ir.error</field>
</record>
<record model="ir.action.act_window.view" id="act_error_form_view1">
<field name="sequence" eval="10"/>
<field name="view" ref="error_view_list"/>
<field name="act_window" ref="act_error_form"/>
</record>
<record model="ir.action.act_window.view" id="act_error_form_view2">
<field name="sequence" eval="20"/>
<field name="view" ref="error_view_form"/>
<field name="act_window" ref="act_error_form"/>
</record>
<record model="ir.action.act_window.domain" id="act_error_form_domain_open">
<field name="name">Open</field>
<field name="sequence" eval="10"/>
<field name="domain" eval="[('state', '=', 'open')]" pyson="1"/>
<field name="count" eval="True"/>
<field name="act_window" ref="act_error_form"/>
</record>
<record model="ir.action.act_window.domain" id="act_error_form_domain_processing">
<field name="name">Processing</field>
<field name="sequence" eval="20"/>
<field name="domain" eval="[('state', '=', 'processing')]" pyson="1"/>
<field name="count" eval="True"/>
<field name="act_window" ref="act_error_form"/>
</record>
<record model="ir.action.act_window.domain" id="act_error_form_domain_all">
<field name="name">All</field>
<field name="sequence" eval="9999"/>
<field name="domain"></field>
<field name="act_window" ref="act_error_form"/>
</record>
<menuitem
parent="ir.menu_scheduler"
action="act_error_form"
sequence="50"
id="menu_error_form"/>
<record model="ir.model.button" id="error_open_button">
<field name="name">open</field>
<field name="string">Open</field>
<field name="model" search="[('model', '=', 'ir.error')]"/>
</record>
<record model="ir.model.button" id="error_process_button">
<field name="name">process</field>
<field name="string">Process</field>
<field name="model" search="[('model', '=', 'ir.error')]"/>
</record>
<record model="ir.model.button" id="error_solve_button">
<field name="name">solve</field>
<field name="string">Solve</field>
<field name="model" search="[('model', '=', 'ir.error')]"/>
</record>
</data>
<data noupdate="1">
<record model="ir.cron" id="cron_error_clean">
<field name="method">ir.error|clean</field>
<field name="interval_number" eval="1"/>
<field name="interval_type">months</field>
</record>
</data>
</tryton>
+1
View File
@@ -222,6 +222,7 @@ class _Method(object):
scheduled_at = now + scheduled_at
expected_at = context.pop('queue_expected_at', None)
context.pop('_check_access', None)
context.pop('language', None)
if expected_at is not None:
expected_at = now + expected_at
try:
+1
View File
@@ -21,3 +21,4 @@ xml:
message.xml
queue.xml
email.xml
error.xml
+21
View File
@@ -0,0 +1,21 @@
<?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="origin"/>
<field name="rec_name" colspan="3"/>
<separator name="message" colspan="4"/>
<field name="message" colspan="4"/>
<separator name="description" colspan="4"/>
<field name="description" colspan="4"/>
<label name="state"/>
<field name="state"/>
<group col="-1" colspan="2" id="buttons">
<button name="open" icon="tryton-back"/>
<button name="process" icon="tryton-forward"/>
<button name="solve" icon="tryton-close"/>
</group>
</form>
+11
View File
@@ -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="rec_name" string="Origin" expand="1"/>
<field name="summary" expand="2"/>
<field name="state"/>
<button name="open"/>
<button name="process"/>
<button name="solve"/>
</tree>
+16
View File
@@ -1067,5 +1067,21 @@ this repository contains the full copyright notices and license terms. -->
<field name="perm_create" eval="True"/>
<field name="perm_delete" eval="True"/>
</record>
<record model="ir.model.access" id="access_ir_error">
<field name="model" search="[('model', '=', 'ir.error')]"/>
<field name="perm_read" eval="False"/>
<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_error_admin">
<field name="model" search="[('model', '=', 'ir.error')]"/>
<field name="group" ref="group_admin"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="False"/>
<field name="perm_delete" eval="True"/>
</record>
</data>
</tryton>
+7
View File
@@ -12,6 +12,7 @@ from sql import Flavor
from trytond import backend
from trytond.config import config
from trytond.exceptions import UserError, UserWarning
from trytond.pool import Pool
from trytond.status import processing
from trytond.transaction import Transaction
@@ -99,6 +100,7 @@ def run_task(pool, task_id):
if not isinstance(pool, Pool):
pool = Pool(pool)
Queue = pool.get('ir.queue')
Error = pool.get('ir.error')
name = '<Task %s@%s>' % (task_id, pool.database_name)
logger.info('%s started', name)
retry = config.getint('database', 'retry')
@@ -121,6 +123,9 @@ def run_task(pool, task_id):
transaction.rollback()
continue
raise
except (UserError, UserWarning) as e:
Error.log(task, e)
raise
logger.info('%s done', name)
except backend.DatabaseOperationalError:
logger.info('%s failed, retrying', name, exc_info=True)
@@ -139,5 +144,7 @@ def run_task(pool, task_id):
except Exception:
logger.critical(
'rescheduling %s failed', name, exc_info=True)
except (UserError, UserWarning):
logger.info('%s failed', name)
except Exception:
logger.critical('%s failed', name, exc_info=True)