From b843e4e5bf8df1a3a4ca0c326797d5a5f227e8d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Mon, 13 Sep 2021 23:40:28 +0200 Subject: [PATCH] Manage errors during non-interactive operations issue10667 review346781002 --- CHANGELOG | 1 + doc/topics/configuration.rst | 10 ++ trytond/ir/__init__.py | 2 + trytond/ir/cron.py | 10 +- trytond/ir/error.py | 169 +++++++++++++++++++++++++++++++++ trytond/ir/error.xml | 85 +++++++++++++++++ trytond/ir/queue.py | 1 + trytond/ir/tryton.cfg | 1 + trytond/ir/view/error_form.xml | 21 ++++ trytond/ir/view/error_list.xml | 11 +++ trytond/res/ir.xml | 16 ++++ trytond/worker.py | 7 ++ 12 files changed, 333 insertions(+), 1 deletion(-) create mode 100644 trytond/ir/error.py create mode 100644 trytond/ir/error.xml create mode 100644 trytond/ir/view/error_form.xml create mode 100644 trytond/ir/view/error_list.xml 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 @@ + + + + + + ir.error + tree + error_list + + + + ir.error + form + error_form + + + + Errors + ir.error + + + + + + + + + + + + + Open + + + + + + + Processing + + + + + + + All + + + + + + + + + open + Open + + + + + process + Process + + + + + solve + Solve + + + + + + + ir.error|clean + + months + + + diff --git a/trytond/ir/queue.py b/trytond/ir/queue.py index b6115b77c4..09c76fc2af 100644 --- a/trytond/ir/queue.py +++ b/trytond/ir/queue.py @@ -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: diff --git a/trytond/ir/tryton.cfg b/trytond/ir/tryton.cfg index acadb72d70..9db68b4c9a 100644 --- a/trytond/ir/tryton.cfg +++ b/trytond/ir/tryton.cfg @@ -21,3 +21,4 @@ xml: message.xml queue.xml email.xml + error.xml diff --git a/trytond/ir/view/error_form.xml b/trytond/ir/view/error_form.xml new file mode 100644 index 0000000000..87037017be --- /dev/null +++ b/trytond/ir/view/error_form.xml @@ -0,0 +1,21 @@ + + +
+