diff --git a/base_tier_validation/README.rst b/base_tier_validation/README.rst index 4ae10514dc..00fdea0601 100644 --- a/base_tier_validation/README.rst +++ b/base_tier_validation/README.rst @@ -42,6 +42,12 @@ development. See `purchase_tier_validation `_ as an example of implementation. +Additionally, if your state field is a (stored) computed field, you need +to set ``_tier_validation_state_field_is_computed`` to ``True`` in your +model Python file, and you will want to add the dependent fields of the +compute method in ``_get_after_validation_exceptions`` and +``_get_under_validation_exceptions``. + **Table of contents** .. contents:: @@ -233,6 +239,8 @@ Contributors * Houzéfa Abbasbhay +- Stefan Rijnhart + Maintainers ~~~~~~~~~~~ diff --git a/base_tier_validation/models/tier_validation.py b/base_tier_validation/models/tier_validation.py index 54b2a7de26..9f2f5102f4 100644 --- a/base_tier_validation/models/tier_validation.py +++ b/base_tier_validation/models/tier_validation.py @@ -6,6 +6,7 @@ from ast import literal_eval from lxml import etree +from psycopg2.extensions import AsIs from odoo import _, api, fields, models from odoo.exceptions import ValidationError @@ -21,6 +22,7 @@ class TierValidation(models.AbstractModel): _tier_validation_buttons_xpath = "/form/header/button[last()]" _tier_validation_manual_config = True + _tier_validation_state_field_is_computed = False _state_field = "state" _state_from = ["draft"] @@ -326,6 +328,39 @@ def _get_fields_to_write_validation(self, vals, records_exception_function): return allowed_field_names, not_allowed_field_names def write(self, vals): + self._tier_validation_check_state_on_write(vals) + self._tier_validation_check_write_allowed(vals) + self._tier_validation_check_write_remove_reviews(vals) + return super().write(vals) + + def _write(self, vals): + if self._tier_validation_state_field_is_computed: + self._tier_validation_check_state_on_write(vals) + self._tier_validation_check_write_remove_reviews(vals) + return super()._write(vals) + + def _tier_validation_get_current_state_value(self): + """Get the current value from the cache or the database. + + If the field is set in a computed method, the value in the cache will + already be the updated value, so we need to revert to the raw data. + """ + self.ensure_one() + if self._tier_validation_state_field_is_computed and isinstance(self.id, int): + self.env.cr.execute( + "select %(field)s from %(table)s where id = %(res_id)s", + { + "field": AsIs(self._state_field), + "table": AsIs(self._table), + "res_id": self.id, + }, + ) + rows = self.env.cr.fetchall() + if rows: + return rows[0][0] + return self[self._state_field] + + def _tier_validation_check_state_on_write(self, vals): for rec in self: if rec._check_state_conditions(vals): if rec.need_validation: @@ -346,6 +381,9 @@ def write(self, vals): "one record." ) ) + + def _tier_validation_check_write_allowed(self, vals): + for rec in self: # Write under validation if ( rec.review_ids @@ -377,7 +415,7 @@ def write(self, vals): if ( rec._get_validation_exceptions(add_base_exceptions=False) and rec.validation_status == "validated" - and getattr(rec, self._state_field) + and rec._tier_validation_get_current_state_value() in (self._state_to + [self._cancel_state]) and not rec._check_allow_write_after_validation(vals) and not rec._context.get("skip_validation_check") @@ -399,9 +437,11 @@ def write(self, vals): "allowed_fields": "\n- ".join(allowed_fields), } ) + + def _tier_validation_check_write_remove_reviews(self, vals): + for rec in self: if rec._allow_to_remove_reviews(vals): rec.mapped("review_ids").unlink() - return super(TierValidation, self).write(vals) def _allow_to_remove_reviews(self, values): """Method for deciding whether the elimination of revisions is necessary.""" @@ -409,7 +449,7 @@ def _allow_to_remove_reviews(self, values): state_to = values.get(self._state_field) if not state_to: return False - state_from = self[self._state_field] + state_from = self._tier_validation_get_current_state_value() # If you change to _cancel_state if state_to in (self._cancel_state): return True @@ -421,7 +461,7 @@ def _allow_to_remove_reviews(self, values): def _check_state_from_condition(self): return self.env.context.get("skip_check_state_condition") or ( self._state_field in self._fields - and getattr(self, self._state_field) in self._state_from + and self._tier_validation_get_current_state_value() in self._state_from ) def _check_state_conditions(self, vals): diff --git a/base_tier_validation/readme/CONTRIBUTORS.rst b/base_tier_validation/readme/CONTRIBUTORS.rst index 7723eafc69..63ed9ec528 100644 --- a/base_tier_validation/readme/CONTRIBUTORS.rst +++ b/base_tier_validation/readme/CONTRIBUTORS.rst @@ -11,3 +11,4 @@ * `XCG Consulting `_: * Houzéfa Abbasbhay +* Stefan Rijnhart diff --git a/base_tier_validation/readme/DESCRIPTION.rst b/base_tier_validation/readme/DESCRIPTION.rst index 5758782c26..b0bd8f1302 100644 --- a/base_tier_validation/readme/DESCRIPTION.rst +++ b/base_tier_validation/readme/DESCRIPTION.rst @@ -11,3 +11,8 @@ purchase orders, sales orders, budgets, expenses...). development. See `purchase_tier_validation `_ as an example of implementation. + +Additionally, if your state field is a (stored) computed field, you need to +set `_tier_validation_state_field_is_computed` to `True` in your model Python +file, and you will want to add the dependent fields of the compute method +in `_get_after_validation_exceptions` and `_get_under_validation_exceptions`. diff --git a/base_tier_validation/static/description/index.html b/base_tier_validation/static/description/index.html index 2c6f99ce09..764292e797 100644 --- a/base_tier_validation/static/description/index.html +++ b/base_tier_validation/static/description/index.html @@ -380,6 +380,11 @@

Base Tier Validation

Note: To be able to use this module in a new model you will need some development.

See purchase_tier_validation as an example of implementation.

+

Additionally, if your state field is a (stored) computed field, you need +to set _tier_validation_state_field_is_computed to True in your +model Python file, and you will want to add the dependent fields of the +compute method in _get_after_validation_exceptions and +_get_under_validation_exceptions.

Table of contents

+
  • Stefan Rijnhart <stefan@opener.amsterdam>
  • diff --git a/base_tier_validation/tests/common.py b/base_tier_validation/tests/common.py index 7803c31036..a1418757d2 100644 --- a/base_tier_validation/tests/common.py +++ b/base_tier_validation/tests/common.py @@ -19,14 +19,21 @@ def setUpClass(cls): TierDefinition, TierValidationTester, TierValidationTester2, + TierValidationTesterComputed, ) cls.loader.update_registry( - (TierValidationTester, TierValidationTester2, TierDefinition) + ( + TierValidationTester, + TierValidationTester2, + TierValidationTesterComputed, + TierDefinition, + ) ) cls.test_model = cls.env[TierValidationTester._name] cls.test_model_2 = cls.env[TierValidationTester2._name] + cls.test_model_computed = cls.env[TierValidationTesterComputed._name] cls.tester_model = cls.env["ir.model"].search( [("model", "=", "tier.validation.tester")] @@ -34,34 +41,32 @@ def setUpClass(cls): cls.tester_model_2 = cls.env["ir.model"].search( [("model", "=", "tier.validation.tester2")] ) - - # Access record: - cls.env["ir.model.access"].create( - { - "name": "access.tester", - "model_id": cls.tester_model.id, - "perm_read": 1, - "perm_write": 1, - "perm_create": 1, - "perm_unlink": 1, - } + cls.tester_model_computed = cls.env["ir.model"].search( + [("model", "=", "tier.validation.tester.computed")] ) - cls.env["ir.model.access"].create( - { - "name": "access.tester2", - "model_id": cls.tester_model_2.id, - "perm_read": 1, - "perm_write": 1, - "perm_create": 1, - "perm_unlink": 1, - } + + models = ( + cls.tester_model, + cls.tester_model_2, + cls.tester_model_computed, ) + for model in models: + # Access record: + cls.env["ir.model.access"].create( + { + "name": f"access {model.name}", + "model_id": model.id, + "perm_read": 1, + "perm_write": 1, + "perm_create": 1, + "perm_unlink": 1, + } + ) - # Define views to avoid automatic views with all fields. - for model in cls.test_model._name, cls.test_model_2._name: + # Define views to avoid automatic views with all fields. cls.env["ir.ui.view"].create( { - "model": model, + "model": model.model, "name": f"Demo view for {model}", "arch": """
    @@ -103,6 +108,54 @@ def setUpClass(cls): cls.test_record = cls.test_model.create({"test_field": 2.5}) cls.test_record_2 = cls.test_model_2.create({"test_field": 2.5}) + cls.test_record_computed = cls.test_model_computed.create({"test_field": 2.5}) + + cls.tier_def_obj.create( + { + "model_id": cls.tester_model.id, + "review_type": "individual", + "reviewer_id": cls.test_user_1.id, + "definition_domain": "[('test_field', '>', 3.0)]", + "approve_sequence": True, + "sequence": 20, + "name": "Definition for test 19 - sequence - user 1", + } + ) + cls.tier_def_obj.create( + { + "model_id": cls.tester_model.id, + "review_type": "individual", + "reviewer_id": cls.test_user_2.id, + "definition_domain": "[('test_field', '>', 3.0)]", + "approve_sequence": True, + "sequence": 10, + "name": "Definition for test 19 - sequence - user 2", + } + ) + # Create definition for test 20 + cls.tier_def_obj.create( + { + "model_id": cls.tester_model.id, + "review_type": "individual", + "reviewer_id": cls.test_user_1.id, + "definition_domain": "[('test_field', '=', 0.9)]", + "approve_sequence": False, + "sequence": 10, + "name": "Definition for test 20 - no sequence - user 1 - no sequence", + } + ) + + cls.tier_def_obj.create( + { + "model_id": cls.tester_model_computed.id, + "review_type": "individual", + "reviewer_id": cls.test_user_1.id, + "definition_domain": "[]", + "approve_sequence": True, + "sequence": 20, + "name": "Definition for computed model", + } + ) @classmethod def tearDownClass(cls): diff --git a/base_tier_validation/tests/test_tier_validation.py b/base_tier_validation/tests/test_tier_validation.py index 40f278b688..ce3944590e 100644 --- a/base_tier_validation/tests/test_tier_validation.py +++ b/base_tier_validation/tests/test_tier_validation.py @@ -905,6 +905,35 @@ def test_25_change_field_exception_validation(self): ) self.assertEqual(self.test_record.test_validation_field, 4) + def test_26_computed_state_field(self): + """Test the regular flow on a model where state is a computed field""" + # The record cannot be confirmed without validation + with self.assertRaisesRegex( + ValidationError, + "This action needs to be validated", + ): + with self.env.cr.savepoint(): + self.test_record_computed.action_confirm() + # Flush manually to trigger the _write + self.test_record_computed.flush_recordset() + self.assertEqual(self.test_record_computed.state, "draft") + # The validation is performed + self.test_record_computed.request_validation() + self.test_record_computed.invalidate_recordset() + self.assertEqual(self.test_record_computed.review_ids.status, "waiting") + self.test_record_computed.with_user(self.test_user_1).validate_tier() + self.test_record_computed.invalidate_recordset() + self.assertEqual(self.test_record_computed.review_ids.status, "approved") + # After validation, the record can be confirmed + self.test_record_computed.action_confirm() + self.test_record_computed.flush_recordset() + self.assertEqual(self.test_record_computed.state, "confirmed") + # After cancelling, the reviews are removed + self.test_record_computed.action_cancel() + self.test_record_computed.flush_recordset() + self.assertFalse(self.test_record_computed.review_ids) + self.test_record_computed.invalidate_recordset() + @tagged("at_install") class TierTierValidationView(CommonTierValidation): diff --git a/base_tier_validation/tests/tier_validation_tester.py b/base_tier_validation/tests/tier_validation_tester.py index 882626ed55..bc0d8adae7 100644 --- a/base_tier_validation/tests/tier_validation_tester.py +++ b/base_tier_validation/tests/tier_validation_tester.py @@ -48,6 +48,59 @@ def action_confirm(self): self.write({"state": "confirmed"}) +class TierValidationTesterComputed(models.Model): + _name = "tier.validation.tester.computed" + _description = "Tier Validation Tester Computed" + _inherit = ["tier.validation"] + _tier_validation_manual_config = False + _tier_validation_state_field_is_computed = True + + confirmed = fields.Boolean() + cancelled = fields.Boolean() + state = fields.Selection( + selection=[ + ("draft", "Draft"), + ("confirmed", "Confirmed"), + ("cancel", "Cancel"), + ], + compute="_compute_state", + store=True, + ) + test_field = fields.Float() + test_validation_field = fields.Float() + user_id = fields.Many2one(string="Assigned to:", comodel_name="res.users") + + @api.model + def _get_after_validation_exceptions(self): + return super()._get_after_validation_exceptions() + [ + "confirmed", + "cancelled", + ] + + @api.model + def _get_under_validation_exceptions(self): + return super()._get_under_validation_exceptions() + [ + "confirmed", + "cancelled", + ] + + @api.depends("confirmed", "cancelled") + def _compute_state(self): + for rec in self: + if rec.cancelled: + rec.state = "cancel" + elif rec.confirmed: + rec.state = "confirmed" + else: + rec.state = "draft" + + def action_confirm(self): + self.write({"confirmed": True}) + + def action_cancel(self): + self.write({"cancelled": True}) + + class TierDefinition(models.Model): _inherit = "tier.definition" @@ -56,4 +109,5 @@ def _get_tier_validation_model_names(self): res = super()._get_tier_validation_model_names() res.append("tier.validation.tester") res.append("tier.validation.tester2") + res.append("tier.validation.tester.computed") return res