From 3196429b378fa104bdfe4e780562bb8cf53bf447 Mon Sep 17 00:00:00 2001 From: bosd <5e2fd43-d292-4c90-9d1f-74ff3436329a@anonaddy.me> Date: Thu, 14 May 2026 21:28:58 +0200 Subject: [PATCH] [ADD] base_tier_validation: allow_bulk_approve on tier.definition Reviewers often need to validate many reviews at once (e.g. month-end batch approval of expense lines). Add an opt-in Boolean on tier.definition; when set, the tier.review list view exposes a Validate header button that approves every selected eligible review. Eligibility (AND semantics): the review is pending and assigned to the current user, it is the reviewer-of-the-moment (can_review), the definition opts in via allow_bulk_approve, and the definition does not require a comment (which cannot be collected in a bulk action). Reviews that fail any of these are skipped silently; each underlying record's own _validate_tier is invoked so notifications and approve-sequence promotion keep working. --- .../models/tier_definition.py | 6 +++ base_tier_validation/models/tier_review.py | 40 ++++++++++++++++ base_tier_validation/readme/CONFIGURE.md | 3 ++ .../tests/test_tier_validation.py | 46 +++++++++++++++++++ .../views/tier_definition_view.xml | 1 + .../views/tier_review_view.xml | 7 +++ 6 files changed, 103 insertions(+) diff --git a/base_tier_validation/models/tier_definition.py b/base_tier_validation/models/tier_definition.py index b36a82f6..886a3d44 100644 --- a/base_tier_validation/models/tier_definition.py +++ b/base_tier_validation/models/tier_definition.py @@ -105,6 +105,12 @@ def _get_tier_validation_model_names(self): default=False, help="Approval order by the specified sequence number", ) + allow_bulk_approve = fields.Boolean( + default=False, + help="When set, reviewers can approve several reviews at once from the " + "tier review list view. Reviews whose definition does not have this " + "flag are skipped from a bulk selection.", + ) approve_sequence_bypass = fields.Boolean( help="Bypassed (auto validated), if previous tier was validated " "by same reviewer", diff --git a/base_tier_validation/models/tier_review.py b/base_tier_validation/models/tier_review.py index bed4caaf..1e53b5c4 100644 --- a/base_tier_validation/models/tier_review.py +++ b/base_tier_validation/models/tier_review.py @@ -67,6 +67,7 @@ class TierReview(models.Model): approve_sequence_bypass = fields.Boolean( related="definition_id.approve_sequence_bypass" ) + allow_bulk_approve = fields.Boolean(related="definition_id.allow_bulk_approve") last_reminder_date = fields.Datetime(readonly=True) @api.depends("status") @@ -212,3 +213,42 @@ def _schedule_review_reminder_activity(self, record): note=self._notify_review_reminder_body(), user_id=self.reviewer_ids.id, ) + + def _filter_bulk_validatable(self): + """Return the subset of self that the current user may bulk-approve. + + A review is eligible when its definition opts in via + ``allow_bulk_approve``, the review is pending and assigned to the + current user, the user is the reviewer-of-the-moment (``can_review``), + and the definition does not require a per-review comment (which + cannot be collected in a bulk action). + """ + return self.filtered( + lambda r: r.allow_bulk_approve + and r.status == "pending" + and r.can_review + and self.env.user in r.reviewer_ids + and not r.has_comment + ) + + def action_bulk_validate(self): + """Approve every eligible review in ``self`` in a single call. + + Reviews are grouped by their underlying record so that each record's + own ``_validate_tier`` runs once -- preserving notifications, the + approve-sequence promotion logic, and the review counter update. + Ineligible reviews are skipped silently; the action is meant to be + wired to the list view header. + """ + eligible = self._filter_bulk_validatable() + if not eligible: + return False + by_key = {} + for review in eligible: + by_key.setdefault((review.model, review.res_id), self.browse()) + by_key[(review.model, review.res_id)] |= review + for (model, res_id), reviews in by_key.items(): + record = self.env[model].browse(res_id) + record._validate_tier(reviews) + record._update_counter({"review_deleted": True}) + return True diff --git a/base_tier_validation/readme/CONFIGURE.md b/base_tier_validation/readme/CONFIGURE.md index 4bd817e6..944fc755 100644 --- a/base_tier_validation/readme/CONFIGURE.md +++ b/base_tier_validation/readme/CONFIGURE.md @@ -15,6 +15,9 @@ To configure this module, you need to: Reject. - If check *Approve by sequence*, reviewers is forced to review by specified sequence. +- If check *Allow Bulk Approve*, reviewers can validate several reviews at + once from the Tier Review list. Reviews on definitions without this flag + are skipped from a bulk selection. To configure Tier Validation Exceptions, you need to: diff --git a/base_tier_validation/tests/test_tier_validation.py b/base_tier_validation/tests/test_tier_validation.py index 344004fd..5b425edd 100644 --- a/base_tier_validation/tests/test_tier_validation.py +++ b/base_tier_validation/tests/test_tier_validation.py @@ -1457,6 +1457,52 @@ def test_34_test_duplicate_new_user_should_not_have_review_ids(self): @tagged("at_install") class TierTierValidationView(CommonTierValidation): + def test_bulk_validate_approves_eligible_reviews(self): + """Reviews on definitions with allow_bulk_approve=True get approved; + those without are left untouched.""" + self.definition_1.allow_bulk_approve = True + rec_a = self.test_model.create({"test_field": 1.0}) + rec_b = self.test_model.create({"test_field": 1.0}) + rec_a.with_user(self.test_user_2).request_validation() + rec_b.with_user(self.test_user_2).request_validation() + reviews = (rec_a.review_ids | rec_b.review_ids).with_user(self.test_user_1) + self.assertEqual(set(reviews.mapped("status")), {"pending"}) + reviews.action_bulk_validate() + self.assertEqual(set(reviews.mapped("status")), {"approved"}) + self.assertEqual(rec_a.validation_status, "validated") + self.assertEqual(rec_b.validation_status, "validated") + + def test_bulk_validate_skips_definitions_without_flag(self): + """A review on a definition without allow_bulk_approve stays pending + even when included in a bulk selection.""" + self.assertFalse(self.definition_1.allow_bulk_approve) + rec = self.test_model.create({"test_field": 1.0}) + rec.with_user(self.test_user_2).request_validation() + review = rec.review_ids.with_user(self.test_user_1) + review.action_bulk_validate() + self.assertEqual(review.status, "pending") + + def test_bulk_validate_skips_other_users_reviews(self): + """The action only touches reviews assigned to the current user.""" + self.definition_1.allow_bulk_approve = True + rec = self.test_model.create({"test_field": 1.0}) + rec.with_user(self.test_user_2).request_validation() + review = rec.review_ids.with_user(self.test_user_2) + self.assertFalse(review._filter_bulk_validatable()) + review.action_bulk_validate() + self.assertEqual(review.status, "pending") + + def test_bulk_validate_skips_reviews_requiring_comment(self): + """A comment-required definition cannot be approved in bulk because + no comment can be collected; such reviews are skipped.""" + self.definition_1.allow_bulk_approve = True + self.definition_1.has_comment = True + rec = self.test_model.create({"test_field": 1.0}) + rec.with_user(self.test_user_2).request_validation() + review = rec.review_ids.with_user(self.test_user_1) + review.action_bulk_validate() + self.assertEqual(review.status, "pending") + def test_view_manual(self): view = self.env[self.test_record._name].get_view(False, "form") with Form(self.test_record) as f: diff --git a/base_tier_validation/views/tier_definition_view.xml b/base_tier_validation/views/tier_definition_view.xml index 922727ed..e5622516 100644 --- a/base_tier_validation/views/tier_definition_view.xml +++ b/base_tier_validation/views/tier_definition_view.xml @@ -76,6 +76,7 @@ options="{'no_create': True}" /> +