diff --git a/CHANGELOG b/CHANGELOG
index 9e1f1ca3b6..7e43c71628 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -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
diff --git a/doc/topics/configuration.rst b/doc/topics/configuration.rst
index 1eb3acf305..2ce46b23ec 100644
--- a/doc/topics/configuration.rst
+++ b/doc/topics/configuration.rst
@@ -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
-----
diff --git a/trytond/ir/__init__.py b/trytond/ir/__init__.py
index 4ea417156f..11099e8ff8 100644
--- a/trytond/ir/__init__.py
+++ b/trytond/ir/__init__.py
@@ -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,
diff --git a/trytond/ir/cron.py b/trytond/ir/cron.py
index f11c0a24f1..25d51896c4 100644
--- a/trytond/ir/cron.py
+++ b/trytond/ir/cron.py
@@ -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()
diff --git a/trytond/ir/error.py b/trytond/ir/error.py
new file mode 100644
index 0000000000..7d3e70dd55
--- /dev/null
+++ b/trytond/ir/error.py
@@ -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)
diff --git a/trytond/ir/error.xml b/trytond/ir/error.xml
new file mode 100644
index 0000000000..4e63df8d6f
--- /dev/null
+++ b/trytond/ir/error.xml
@@ -0,0 +1,85 @@
+
+
+