From 0d6eb70a7d3ebc29bdefcf985b34113d722ba051 Mon Sep 17 00:00:00 2001 From: bosd <5e2fd43-d292-4c90-9d1f-74ff3436329a@anonaddy.me> Date: Thu, 14 May 2026 18:00:35 +0200 Subject: [PATCH] [ADD] base_tier_validation: tier.definition.allow_reject for sign-off tiers Model some tier reviews as "informational" / "sign-off" only -- the reviewer is expected to acknowledge but cannot block the workflow with a Reject. Implemented as a per-definition Boolean so the tier author controls the policy, rather than a per-user gate. - New ``tier.definition.allow_reject`` Boolean (default ``True``, i.e. backwards compatible -- nothing changes for existing definitions). When unchecked, reviewers of that tier see Validate but no Reject on the validation banner. - New computed ``tier.validation.can_reject`` Boolean that drives the Reject button's visibility in the form template. It is True only if the user can act on the record *and* at least one of their actionable tier reviews has ``allow_reject = True``. - ``reject_tier`` now filters out reviews whose definition forbids rejection. A reviewer assigned to a mix of sign-off and regular tiers can still reject the regular ones; the sign-off ones stay pending. - ``allow_reject`` exposed on the tier definition form view under the approve-sequence block. Tests: - ``test_allow_reject_false_hides_reject_button`` -- a sign-off definition leaves ``can_review`` True but flips ``can_reject`` False, so the template hides the button. - ``test_reject_tier_skips_definitions_with_allow_reject_false`` -- the action itself only rejects the reviews whose definition allows it; sign-off reviews stay pending. Future work (deferred): an optional ``reject_user_ids`` Many2many on the definition would let a single tier be rejectable by some reviewers (e.g. seniors) but only sign-off-able by others. This PR ships the simpler per-definition gate first. --- .../models/tier_definition.py | 8 ++++ .../models/tier_validation.py | 38 ++++++++++++++++ .../templates/tier_validation_templates.xml | 2 +- .../tests/test_tier_validation.py | 45 +++++++++++++++++++ .../views/tier_definition_view.xml | 1 + 5 files changed, 93 insertions(+), 1 deletion(-) diff --git a/base_tier_validation/models/tier_definition.py b/base_tier_validation/models/tier_definition.py index b36a82f6..8e43a6c7 100644 --- a/base_tier_validation/models/tier_definition.py +++ b/base_tier_validation/models/tier_definition.py @@ -109,6 +109,14 @@ def _get_tier_validation_model_names(self): help="Bypassed (auto validated), if previous tier was validated " "by same reviewer", ) + allow_reject = fields.Boolean( + string="Allow Rejection", + default=True, + help="When unchecked, reviewers of this tier can only validate " + "the record, not reject it. Use this to model 'sign-off' / " + "informational tiers where the reviewer is expected to " + "acknowledge but not block the workflow.", + ) @api.onchange("review_type") def onchange_review_type(self): diff --git a/base_tier_validation/models/tier_validation.py b/base_tier_validation/models/tier_validation.py index 29896f80..2b60d074 100644 --- a/base_tier_validation/models/tier_validation.py +++ b/base_tier_validation/models/tier_validation.py @@ -67,6 +67,14 @@ class TierValidation(models.AbstractModel): can_review = fields.Boolean( compute="_compute_can_review", search="_search_can_review" ) + can_reject = fields.Boolean( + compute="_compute_can_reject", + help="True when the current user can act on this record AND at " + "least one of their actionable tier reviews has " + "``tier.definition.allow_reject`` set. Drives the visibility " + "of the Reject button on the validation banner so that " + "sign-off / informational tiers don't expose a Reject action.", + ) has_comment = fields.Boolean( compute="_compute_has_comment", help="If set, Allow the reviewer to leave a comment on the review.", @@ -115,6 +123,29 @@ def _compute_can_review(self): for rec in self: rec.can_review = rec._get_sequences_to_approve(self.env.user) + @api.depends_context("uid") + @api.depends( + "review_ids.approve_sequence", + "review_ids.reviewer_ids", + "review_ids.sequence", + "review_ids.status", + "review_ids.definition_id.allow_reject", + ) + def _compute_can_reject(self): + user = self.env.user + for rec in self: + sequences = rec._get_sequences_to_approve(user) + if not sequences: + rec.can_reject = False + continue + # The user has actionable tiers; check whether at least one + # of them is configured to allow rejection. + rec.can_reject = any( + r.definition_id.allow_reject + for r in rec.review_ids + if r.sequence in sequences + ) + @api.model def _search_can_review(self, operator, value): domain = Domain( @@ -639,6 +670,13 @@ def reject_tier(self): self.ensure_one() sequences = self._get_sequences_to_approve(self.env.user) reviews = self.review_ids.filtered(lambda x: x.sequence in sequences) + # Honor per-definition ``allow_reject``: tiers flagged as + # sign-off / informational only let the reviewer validate, not + # reject. Filter them out so calling reject_tier on a mixed + # batch only rejects the tiers that actually permit it. + reviews = reviews.filtered(lambda r: r.definition_id.allow_reject) + if not reviews: + return if self.has_comment: return self._add_comment("reject", reviews) self._rejected_tier(reviews) diff --git a/base_tier_validation/templates/tier_validation_templates.xml b/base_tier_validation/templates/tier_validation_templates.xml index b63d94dd..9c0e0797 100644 --- a/base_tier_validation/templates/tier_validation_templates.xml +++ b/base_tier_validation/templates/tier_validation_templates.xml @@ -39,7 +39,7 @@