diff --git a/base_exception/__manifest__.py b/base_exception/__manifest__.py index eaef9d7f9..0833fbf05 100644 --- a/base_exception/__manifest__.py +++ b/base_exception/__manifest__.py @@ -3,24 +3,23 @@ # Mourad EL HADJ MIMOUNE # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). { - 'name': 'Exception Rule', - 'version': '12.0.3.0.1', - 'category': 'Generic Modules', - 'summary': """ + "name": "Exception Rule", + "version": "12.0.3.0.1", + "category": "Generic Modules", + "summary": """ This module provide an abstract model to manage customizable exceptions to be applied on different models (sale order, invoice, ...)""", - 'author': - "Akretion, Sodexis, Camptocamp, Odoo Community Association (OCA)", - 'website': 'https://github.com/OCA/server-tools', - 'depends': [ - 'base_setup', + "author": "Akretion, Sodexis, Camptocamp, " + "Odoo Community Association (OCA)", + "website": "https://github.com/OCA/server-tools", + "depends": ["base_setup"], + "maintainers": ["hparfr", "sebastienbeau"], + "license": "AGPL-3", + "data": [ + "security/base_exception_security.xml", + "security/ir.model.access.csv", + "wizard/base_exception_confirm_view.xml", + "views/base_exception_view.xml", ], - 'license': 'AGPL-3', - 'data': [ - 'security/base_exception_security.xml', - 'security/ir.model.access.csv', - 'wizard/base_exception_confirm_view.xml', - 'views/base_exception_view.xml', - ], - 'installable': True, + "installable": True, } diff --git a/base_exception/models/__init__.py b/base_exception/models/__init__.py index ae49ca9d5..495e1fe24 100644 --- a/base_exception/models/__init__.py +++ b/base_exception/models/__init__.py @@ -1,2 +1 @@ - from . import base_exception diff --git a/base_exception/models/base_exception.py b/base_exception/models/base_exception.py index fee5f5674..acf8f667d 100644 --- a/base_exception/models/base_exception.py +++ b/base_exception/models/base_exception.py @@ -12,43 +12,55 @@ from odoo import osv class ExceptionRule(models.Model): - _name = 'exception.rule' - _description = 'Exception Rule' - _order = 'active desc, sequence asc' + _name = "exception.rule" + _description = "Exception Rule" + _order = "active desc, sequence asc" - name = fields.Char('Exception Name', required=True, translate=True) - description = fields.Text('Description', translate=True) + name = fields.Char("Exception Name", required=True, translate=True) + description = fields.Text("Description", translate=True) sequence = fields.Integer( - string='Sequence', + string="Sequence", help="Gives the sequence order when applying the test", ) - model = fields.Selection(selection=[], string='Apply on', required=True) + model = fields.Selection(selection=[], string="Apply on", required=True) exception_type = fields.Selection( - selection=[('by_domain', 'By domain'), - ('by_py_code', 'By python code')], - string='Exception Type', required=True, default='by_py_code', + selection=[ + ("by_domain", "By domain"), + ("by_py_code", "By python code"), + ("by_method", "By method"), + ], + string="Exception Type", + required=True, + default="by_py_code", help="By python code: allow to define any arbitrary check\n" - "By domain: limited to a selection by an odoo domain:\n" - " performance can be better when exceptions " - " are evaluated with several records") - domain = fields.Char('Domain') - - active = fields.Boolean('Active', default=True) + "By domain: limited to a selection by an odoo domain:\n" + " performance can be better when exceptions " + " are evaluated with several records", + ) + domain = fields.Char("Domain") + method = fields.Selection(selection=[], string="Method", readonly=True) + active = fields.Boolean("Active", default=True) code = fields.Text( - 'Python Code', + "Python Code", help="Python code executed to check if the exception apply or " - "not. Use failed = True to block the exception", - ) + "not. Use failed = True to block the exception", + ) - @api.constrains('exception_type', 'domain', 'code') + @api.constrains("exception_type", "domain", "code", "model") def check_exception_type_consistency(self): for rule in self: - if ((rule.exception_type == 'by_py_code' and not rule.code) or - (rule.exception_type == 'by_domain' and not rule.domain)): + if ( + (rule.exception_type == "by_py_code" and not rule.code) + or (rule.exception_type == "by_domain" and not rule.domain) + or (rule.exception_type == "by_method" and not rule.method) + ): raise ValidationError( - _("There is a problem of configuration, python code or " - "domain is missing to match the exception type.") + _( + "There is a problem of configuration, python code, " + "domain or method is missing to match the exception " + "type." + ) ) @api.multi @@ -59,8 +71,8 @@ class ExceptionRule(models.Model): class BaseExceptionMethod(models.AbstractModel): - _name = 'base.exception.method' - _description = 'Exception Rule Methods' + _name = "base.exception.method" + _description = "Exception Rule Methods" @api.multi def _get_main_records(self): @@ -81,15 +93,14 @@ class BaseExceptionMethod(models.AbstractModel): By default, only the rules with the correct model will be used. """ - return [('model', '=', self._name)] + return [("model", "=", self._name)] @api.multi def detect_exceptions(self): """List all exception_ids applied on self Exception ids are also written on records """ - rules = self.env['exception.rule'].sudo().search( - self._rule_domain()) + rules = self.env["exception.rule"].sudo().search(self._rule_domain()) all_exception_ids = [] rules_to_remove = {} rules_to_add = {} @@ -123,23 +134,23 @@ class BaseExceptionMethod(models.AbstractModel): # table # and the "to add" part generates one INSERT (with unnest) per rule. for rule_id, records in rules_to_remove.items(): - records.write({'exception_ids': [(3, rule_id,)]}) + records.write({"exception_ids": [(3, rule_id)]}) for rule_id, records in rules_to_add.items(): - records.write(({'exception_ids': [(4, rule_id,)]})) + records.write(({"exception_ids": [(4, rule_id)]})) return all_exception_ids @api.model def _exception_rule_eval_context(self, rec): return { - 'time': time, - 'self': rec, + "time": time, + "self": rec, # object, obj: deprecated. # should be removed in future migrations - 'object': rec, - 'obj': rec, + "object": rec, + "obj": rec, # copy context to prevent side-effects of eval # should be deprecated too, accesible through self. - 'context': self.env.context.copy() + "context": self.env.context.copy(), } @api.model @@ -147,26 +158,31 @@ class BaseExceptionMethod(models.AbstractModel): expr = rule.code space = self._exception_rule_eval_context(rec) try: - safe_eval(expr, - space, - mode='exec', - nocopy=True) # nocopy allows to return 'result' + safe_eval( + expr, space, mode="exec", nocopy=True + ) # nocopy allows to return 'result' except Exception as e: raise UserError( - _('Error when evaluating the exception.rule ' - 'rule:\n %s \n(%s)') % (rule.name, e)) - return space.get('failed', False) + _( + "Error when evaluating the exception.rule " + "rule:\n %s \n(%s)" + ) + % (rule.name, e) + ) + return space.get("failed", False) @api.multi def _detect_exceptions(self, rule): - if rule.exception_type == 'by_py_code': + if rule.exception_type == "by_py_code": return self._detect_exceptions_by_py_code(rule) - elif rule.exception_type == 'by_domain': + elif rule.exception_type == "by_domain": return self._detect_exceptions_by_domain(rule) + elif rule.exception_type == "by_method": + return self._detect_exceptions_by_method(rule) @api.multi def _get_base_domain(self): - return [('ignore_exception', '=', False), ('id', 'in', self.ids)] + return [("ignore_exception", "=", False), ("id", "in", self.ids)] @api.multi def _detect_exceptions_by_py_code(self, rule): @@ -191,36 +207,42 @@ class BaseExceptionMethod(models.AbstractModel): domain = osv.expression.AND([base_domain, rule_domain]) return self.search(domain) + @api.multi + def _detect_exceptions_by_method(self, rule): + """ + Find exceptions found on self. + """ + base_domain = self._get_base_domain() + records = self.search(base_domain) + return getattr(records, rule.method)() + class BaseException(models.AbstractModel): - _inherit = 'base.exception.method' - _name = 'base.exception' - _order = 'main_exception_id asc' - _description = 'Exception' + _inherit = "base.exception.method" + _name = "base.exception" + _order = "main_exception_id asc" + _description = "Exception" main_exception_id = fields.Many2one( - 'exception.rule', - compute='_compute_main_error', - string='Main Exception', + "exception.rule", + compute="_compute_main_error", + string="Main Exception", store=True, ) exceptions_summary = fields.Html( - 'Exceptions Summary', - compute='_compute_exceptions_summary', + "Exceptions Summary", compute="_compute_exceptions_summary" ) exception_ids = fields.Many2many( - 'exception.rule', - string='Exceptions', - copy=False, + "exception.rule", string="Exceptions", copy=False ) - ignore_exception = fields.Boolean('Ignore Exceptions', copy=False) + ignore_exception = fields.Boolean("Ignore Exceptions", copy=False) @api.multi def action_ignore_exceptions(self): - self.write({'ignore_exception': True}) + self.write({"ignore_exception": True}) return True - @api.depends('exception_ids', 'ignore_exception') + @api.depends("exception_ids", "ignore_exception") def _compute_main_error(self): for rec in self: if not rec.ignore_exception and rec.exception_ids: @@ -228,29 +250,35 @@ class BaseException(models.AbstractModel): else: rec.main_exception_id = False - @api.depends('exception_ids', 'ignore_exception') + @api.depends("exception_ids", "ignore_exception") def _compute_exceptions_summary(self): for rec in self: if rec.exception_ids and not rec.ignore_exception: - rec.exceptions_summary = '' % ''.join([ - '
  • %s: %s
  • ' % tuple(map(html.escape, ( - e.name, e.description))) for e in rec.exception_ids]) + rec.exceptions_summary = "" % "".join( + [ + "
  • %s: %s
  • " + % tuple(map(html.escape, (e.name, e.description))) + for e in rec.exception_ids + ] + ) @api.multi def _popup_exceptions(self): action = self._get_popup_action().read()[0] - action.update({ - 'context': { - 'active_id': self.ids[0], - 'active_ids': self.ids, - 'active_model': self._name, + action.update( + { + "context": { + "active_id": self.ids[0], + "active_ids": self.ids, + "active_model": self._name, + } } - }) + ) return action @api.model def _get_popup_action(self): - return self.env.ref('base_exception.action_exception_rule_confirm') + return self.env.ref("base_exception.action_exception_rule_confirm") @api.multi def _check_exception(self): @@ -266,5 +294,5 @@ class BaseException(models.AbstractModel): """ exception_ids = self.detect_exceptions() if exception_ids: - exceptions = self.env['exception.rule'].browse(exception_ids) - raise ValidationError('\n'.join(exceptions.mapped('name'))) + exceptions = self.env["exception.rule"].browse(exception_ids) + raise ValidationError("\n".join(exceptions.mapped("name"))) diff --git a/base_exception/tests/__init__.py b/base_exception/tests/__init__.py index 874f27874..9a972aeab 100644 --- a/base_exception/tests/__init__.py +++ b/base_exception/tests/__init__.py @@ -1,4 +1,3 @@ - from . import common from . import purchase_test from . import test_base_exception diff --git a/base_exception/tests/common.py b/base_exception/tests/common.py index a66b65238..5721e6353 100644 --- a/base_exception/tests/common.py +++ b/base_exception/tests/common.py @@ -8,6 +8,7 @@ def setup_test_model(env, model_clses): env.registry.setup_models(env.cr) env.registry.init_models( - env.cr, [model_cls._name for model_cls in model_clses], - dict(env.context, update_custom_fields=True) + env.cr, + [model_cls._name for model_cls in model_clses], + dict(env.context, update_custom_fields=True), ) diff --git a/base_exception/tests/purchase_test.py b/base_exception/tests/purchase_test.py index 5c2c7ad11..ace5342ce 100644 --- a/base_exception/tests/purchase_test.py +++ b/base_exception/tests/purchase_test.py @@ -3,59 +3,80 @@ from odoo import api, fields, models +class ExceptionRule(models.Model): + _inherit = "exception.rule" + _name = "exception.rule" + + method = fields.Selection( + selection_add=[('exception_method_no_zip', 'Purchase exception no zip')] + ) + + class PurchaseTest(models.Model): - _inherit = 'base.exception' + _inherit = "base.exception" _name = "base.exception.test.purchase" _description = "Base Ecxeption Test Model" name = fields.Char(required=True) - user_id = fields.Many2one('res.users', string='Responsible') + user_id = fields.Many2one("res.users", string="Responsible") state = fields.Selection( - [('draft', 'New'), ('cancel', 'Cancelled'), - ('purchase', 'Purchase'), - ('to approve', 'To approve'), ('done', 'Done')], - string="Status", readonly=True, default='draft') + [ + ("draft", "New"), + ("cancel", "Cancelled"), + ("purchase", "Purchase"), + ("to approve", "To approve"), + ("done", "Done"), + ], + string="Status", + readonly=True, + default="draft", + ) active = fields.Boolean(default=True) - partner_id = fields.Many2one('res.partner', string='Partner') - line_ids = fields.One2many( - 'base.exception.test.purchase.line', 'lead_id') - amount_total = fields.Float( - compute='_compute_amount_total', store=True) + partner_id = fields.Many2one("res.partner", string="Partner") + line_ids = fields.One2many("base.exception.test.purchase.line", "lead_id") + amount_total = fields.Float(compute="_compute_amount_total", store=True) - @api.depends('line_ids') + @api.depends("line_ids") def _compute_amount_total(cls): for record in cls: for line in record.line_ids: record.amount_total += line.amount * line.qty - @api.constrains('ignore_exception', 'line_ids', 'state') + @api.constrains("ignore_exception", "line_ids", "state") def test_purchase_check_exception(cls): - orders = cls.filtered(lambda s: s.state == 'purchase') + orders = cls.filtered(lambda s: s.state == "purchase") if orders: orders._check_exception() @api.multi def button_approve(cls, force=False): - cls.write({'state': 'to approve'}) + cls.write({"state": "to approve"}) return {} @api.multi def button_draft(cls): - cls.write({'state': 'draft'}) + cls.write({"state": "draft"}) return {} @api.multi def button_confirm(cls): - cls.write({'state': 'purchase'}) + cls.write({"state": "purchase"}) return True @api.multi def button_cancel(cls): - cls.write({'state': 'cancel'}) + cls.write({"state": "cancel"}) @api.multi def _reverse_field(self): - return 'test_purchase_ids' + return "test_purchase_ids" + + def exception_method_no_zip(self): + records_fail = self.env["base.exception.test.purchase"] + for rec in self: + if not rec.partner_id.zip: + records_fail += rec + return records_fail class LineTest(models.Model): @@ -63,7 +84,8 @@ class LineTest(models.Model): _description = "Base Exception Test Model Line" name = fields.Char() - lead_id = fields.Many2one('base.exception.test.purchase', - ondelete='cascade') + lead_id = fields.Many2one( + "base.exception.test.purchase", ondelete="cascade" + ) qty = fields.Float() amount = fields.Float() diff --git a/base_exception/tests/test_base_exception.py b/base_exception/tests/test_base_exception.py index a20e2a881..d56df7ed6 100644 --- a/base_exception/tests/test_base_exception.py +++ b/base_exception/tests/test_base_exception.py @@ -5,7 +5,7 @@ from odoo.tests import common from odoo.exceptions import ValidationError from odoo import fields from .common import setup_test_model -from .purchase_test import PurchaseTest, LineTest +from .purchase_test import PurchaseTest, LineTest, ExceptionRule import logging _logger = logging.getLogger(__name__) @@ -14,63 +14,100 @@ _logger = logging.getLogger(__name__) @common.at_install(False) @common.post_install(True) class TestBaseException(common.SavepointCase): - @classmethod def setUpClass(cls): super(TestBaseException, cls).setUpClass() - setup_test_model(cls.env, [PurchaseTest, LineTest]) + setup_test_model(cls.env, [PurchaseTest, LineTest, ExceptionRule]) + + cls.base_exception = cls.env["base.exception"] + cls.exception_rule = cls.env["exception.rule"] + if "test_purchase_ids" not in cls.exception_rule._fields: + field = fields.Many2many("base.exception.test.purchase") + cls.exception_rule._add_field("test_purchase_ids", field) + cls.exception_confirm = cls.env["exception.rule.confirm"] + cls.exception_rule._fields["model"].selection.append( + ("base.exception.test.purchase", "Purchase Order") + ) + + cls.exception_rule._fields["model"].selection.append( + ("base.exception.test.purchase.line", "Purchase Order Line") + ) - cls.base_exception = cls.env['base.exception'] - cls.exception_rule = cls.env['exception.rule'] - if 'test_purchase_ids' not in cls.exception_rule._fields: - field = fields.Many2many('base.exception.test.purchase') - cls.exception_rule._add_field('test_purchase_ids', field) - cls.exception_confirm = cls.env['exception.rule.confirm'] - cls.exception_rule._fields['model'].selection.append( - ('base.exception.test.purchase', 'Purchase Order')) + cls.partner = cls.env.ref("base.res_partner_1") + cls.partner.zip = False + cls.potest1 = cls.env["base.exception.test.purchase"].create( + { + "name": "Test base exception to basic purchase", + "partner_id": cls.partner.id, + "line_ids": [ + (0, 0, {"name": "line test", "amount": 120.0, "qty": 1.5}) + ], + } + ) - cls.exception_rule._fields['model'].selection.append( - ('base.exception.test.purchase.line', 'Purchase Order Line')) + def test_valid(self): + self.potest1.button_confirm() + self.assertFalse(self.potest1.exception_ids) - cls.exceptionnozip = cls.env['exception.rule'].create({ - 'name': "No ZIP code on destination", - 'sequence': 10, - 'model': "base.exception.test.purchase", - 'code': "if not obj.partner_id.zip: failed=True", - }) + def test_fail_by_py(self): + self.exception_amount_total = self.env["exception.rule"].create( + { + "name": "Min order except", + "sequence": 10, + "model": "base.exception.test.purchase", + "code": "if obj.amount_total <= 200.0: failed=True", + "exception_type": "by_py_code", + } + ) + with self.assertRaises(ValidationError): + self.potest1.button_confirm() + self.assertTrue(self.potest1.exception_ids) - cls.exceptionno_minorder = cls.env['exception.rule'].create({ - 'name': "Min order except", - 'sequence': 10, - 'model': "base.exception.test.purchase", - 'code': "if obj.amount_total <= 200.0: failed=True", - }) + def test_fail_by_domain(self): + self.exception_partner_no_zip = self.env["exception.rule"].create( + { + "name": "No ZIP code on destination", + "sequence": 10, + "model": "base.exception.test.purchase", + "domain": "[('partner_id.zip', '=', False)]", + "exception_type": "by_domain", + } + ) + with self.assertRaises(ValidationError): + self.potest1.button_confirm() + self.assertTrue(self.potest1.exception_ids) - cls.exceptionno_lineqty = cls.env['exception.rule'].create({ - 'name': "Qty > 0", - 'sequence': 10, - 'model': "base.exception.test.purchase.line", - 'code': "if obj.qty <= 0: failed=True" - }) + def test_fail_by_method(self): + self.exception_no_name = self.env["exception.rule"].create( + { + "name": "No name", + "sequence": 10, + "model": "base.exception.test.purchase", + "method": "exception_method_no_zip", + "exception_type": "by_method", + } + ) + with self.assertRaises(ValidationError): + self.potest1.button_confirm() + self.assertTrue(self.potest1.exception_ids) - def test_purchase_order_exception(self): - partner = self.env.ref('base.res_partner_1') - partner.zip = False - potest1 = self.env['base.exception.test.purchase'].create({ - 'name': 'Test base exception to basic purchase', - 'partner_id': partner.id, - 'line_ids': [(0, 0, { - 'name': "line test", - 'amount': 120.0, - 'qty': 1.5, - })], - }) + def test_ignore_exception(self): + # same as 1st test + self.exception_amount_total = self.env["exception.rule"].create( + { + "name": "Min order except", + "sequence": 10, + "model": "base.exception.test.purchase", + "code": "if obj.amount_total <= 200.0: failed=True", + "exception_type": "by_py_code", + } + ) # Block because of exception during validation with self.assertRaises(ValidationError): - potest1.button_confirm() + self.potest1.button_confirm() # Test that we have linked exceptions - self.assertTrue(potest1.exception_ids) + self.assertTrue(self.potest1.exception_ids) # Test ignore exeception make possible for the po to validate - potest1.ignore_exception = True - potest1.button_confirm() - self.assertTrue(potest1.state == 'purchase') + self.potest1.action_ignore_exceptions() + self.potest1.button_confirm() + self.assertTrue(self.potest1.state == "purchase") diff --git a/base_exception/wizard/__init__.py b/base_exception/wizard/__init__.py index 6987f7c00..a31f679e5 100644 --- a/base_exception/wizard/__init__.py +++ b/base_exception/wizard/__init__.py @@ -1,2 +1 @@ - from . import base_exception_confirm diff --git a/base_exception/wizard/base_exception_confirm.py b/base_exception/wizard/base_exception_confirm.py index ecdd63b49..b9bb533c9 100644 --- a/base_exception/wizard/base_exception_confirm.py +++ b/base_exception/wizard/base_exception_confirm.py @@ -7,34 +7,33 @@ from odoo.exceptions import ValidationError class ExceptionRuleConfirm(models.AbstractModel): - _name = 'exception.rule.confirm' - _description = 'Exception Rule Confirm Wizard' + _name = "exception.rule.confirm" + _description = "Exception Rule Confirm Wizard" - related_model_id = fields.Many2one('base.exception',) + related_model_id = fields.Many2one("base.exception") exception_ids = fields.Many2many( - 'exception.rule', - string='Exceptions to resolve', - readonly=True, + "exception.rule", string="Exceptions to resolve", readonly=True ) - ignore = fields.Boolean('Ignore Exceptions') + ignore = fields.Boolean("Ignore Exceptions") @api.model def default_get(self, field_list): res = super(ExceptionRuleConfirm, self).default_get(field_list) - current_model = self.env.context.get('active_model') + current_model = self.env.context.get("active_model") model_except_obj = self.env[current_model] - active_ids = self.env.context.get('active_ids') + active_ids = self.env.context.get("active_ids") if len(active_ids) > 1: raise ValidationError( - _('Only 1 ID accepted, got %r.') % active_ids) + _("Only 1 ID accepted, got %r.") % active_ids + ) active_id = active_ids[0] related_model_except = model_except_obj.browse(active_id) exception_ids = related_model_except.exception_ids.ids - res.update({'exception_ids': [(6, 0, exception_ids)]}) - res.update({'related_model_id': active_id}) + res.update({"exception_ids": [(6, 0, exception_ids)]}) + res.update({"related_model_id": active_id}) return res @api.multi def action_confirm(self): self.ensure_one() - return {'type': 'ir.actions.act_window_close'} + return {"type": "ir.actions.act_window_close"}