Skip to content

Commit

Permalink
[IMP] base_tier_validation: support computed state field
Browse files Browse the repository at this point in the history
Computed fields bypass `write`, so we need to override `_write` for that case.
Also, the current value before the update needs to be fetched from the database
because the new value is already set in the cache.
  • Loading branch information
StefanRijnhart authored and antonioburic committed Nov 13, 2024
1 parent 33f9425 commit 6e019ed
Show file tree
Hide file tree
Showing 8 changed files with 224 additions and 28 deletions.
8 changes: 8 additions & 0 deletions base_tier_validation/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ development.

See `purchase_tier_validation <https://github.com/OCA/purchase-workflow>`_ 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::
Expand Down Expand Up @@ -233,6 +239,8 @@ Contributors

* Houzéfa Abbasbhay

- Stefan Rijnhart <stefan@opener.amsterdam>

Maintainers
~~~~~~~~~~~

Expand Down
48 changes: 44 additions & 4 deletions base_tier_validation/models/tier_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"]
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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")
Expand All @@ -399,17 +437,19 @@ 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."""
self.ensure_one()
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
Expand All @@ -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):
Expand Down
1 change: 1 addition & 0 deletions base_tier_validation/readme/CONTRIBUTORS.rst
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@
* `XCG Consulting <https://xcg-consulting.fr>`_:

* Houzéfa Abbasbhay
* Stefan Rijnhart <stefan@opener.amsterdam>
5 changes: 5 additions & 0 deletions base_tier_validation/readme/DESCRIPTION.rst
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,8 @@ purchase orders, sales orders, budgets, expenses...).
development.

See `purchase_tier_validation <https://github.com/OCA/purchase-workflow>`_ 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`.
6 changes: 6 additions & 0 deletions base_tier_validation/static/description/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,11 @@ <h1 class="title">Base Tier Validation</h1>
<p><strong>Note:</strong> To be able to use this module in a new model you will need some
development.</p>
<p>See <a class="reference external" href="https://github.com/OCA/purchase-workflow">purchase_tier_validation</a> as an example of implementation.</p>
<p>Additionally, if your state field is a (stored) computed field, you need
to set <tt class="docutils literal">_tier_validation_state_field_is_computed</tt> to <tt class="docutils literal">True</tt> in your
model Python file, and you will want to add the dependent fields of the
compute method in <tt class="docutils literal">_get_after_validation_exceptions</tt> and
<tt class="docutils literal">_get_under_validation_exceptions</tt>.</p>
<p><strong>Table of contents</strong></p>
<div class="contents local topic" id="contents">
<ul class="simple">
Expand Down Expand Up @@ -586,6 +591,7 @@ <h2><a class="toc-backref" href="#toc-entry-21">Contributors</a></h2>
<li>Houzéfa Abbasbhay</li>
</ul>
</li>
<li>Stefan Rijnhart &lt;<a class="reference external" href="mailto:stefan&#64;opener.amsterdam">stefan&#64;opener.amsterdam</a>&gt;</li>
</ul>
</div>
<div class="section" id="maintainers">
Expand Down
101 changes: 77 additions & 24 deletions base_tier_validation/tests/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,49 +19,54 @@ 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")]
)
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": """<form>
<header>
Expand Down Expand Up @@ -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):
Expand Down
29 changes: 29 additions & 0 deletions base_tier_validation/tests/test_tier_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
54 changes: 54 additions & 0 deletions base_tier_validation/tests/tier_validation_tester.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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

0 comments on commit 6e019ed

Please sign in to comment.