From 82bd62a33b4d7e1bfdeb177fd57d7602837f7378 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 1eb07a86a..a19fcd0ac 100644
--- a/base_tier_validation/models/tier_definition.py
+++ b/base_tier_validation/models/tier_definition.py
@@ -120,6 +120,14 @@ def _get_tier_validation_model_names(self):
"Only meaningful for review_type='group'; the form hides this "
"field for the other review types.",
)
+ 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 782cca05d..44ec2c552 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(
@@ -660,6 +691,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 288709562..c837eedf8 100644
--- a/base_tier_validation/templates/tier_validation_templates.xml
+++ b/base_tier_validation/templates/tier_validation_templates.xml
@@ -42,7 +42,7 @@
+