From fe2f3ba680c88a1561271d6e03c9a3c6a5877273 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. The counter is updated once for the whole selection rather than once per record. ``_update_counter`` pushes a bus notification to which the systray answers with a full recount of its badge, so notifying per record turns one bulk approval into (documents x reviewers x open tabs) recounts -- precisely the pattern this feature is meant to make routine. Batched, a selection costs one notification per reviewer however large it is. --- base_tier_validation/README.rst | 3 + .../models/tier_definition.py | 6 ++ base_tier_validation/models/tier_review.py | 51 +++++++++++++ base_tier_validation/readme/CONFIGURE.md | 3 + .../static/description/index.html | 3 + .../tests/test_tier_validation.py | 72 +++++++++++++++++++ .../views/tier_definition_view.xml | 1 + .../views/tier_review_view.xml | 7 ++ 8 files changed, 146 insertions(+) diff --git a/base_tier_validation/README.rst b/base_tier_validation/README.rst index 64ed4c72..ca50c24d 100644 --- a/base_tier_validation/README.rst +++ b/base_tier_validation/README.rst @@ -80,6 +80,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/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..fdfcbcd6 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,53 @@ 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 + validated = {} + for (model, res_id), reviews in by_key.items(): + record = self.env[model].browse(res_id) + record._validate_tier(reviews) + validated[model] = validated.get(model, record.browse()) | record + # Update the counter once for the whole batch, not once per record. + # ``_update_counter`` re-promotes the remaining reviews and pushes a + # bus notification, and the systray answers every notification with a + # recount of its badge. Per record, approving a selection of N + # documents fans out to N notifications per reviewer -- multiplied by + # each reviewer's open tabs -- which is exactly the pattern bulk + # approve is meant to make routine. Batched, the selection costs one + # notification per reviewer however large it is. + for records in validated.values(): + records._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/static/description/index.html b/base_tier_validation/static/description/index.html index 3a8f165f..7a89a1be 100644 --- a/base_tier_validation/static/description/index.html +++ b/base_tier_validation/static/description/index.html @@ -446,6 +446,9 @@

Configuration

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..c693e74b 100644 --- a/base_tier_validation/tests/test_tier_validation.py +++ b/base_tier_validation/tests/test_tier_validation.py @@ -1457,6 +1457,78 @@ 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_notifies_once_for_the_whole_batch(self): + """Approving N documents must not push N counter notifications. + + Each notification makes every reviewer's systray recount its badge, + so a per-record notification turns one bulk approval into + (documents x reviewers x open tabs) recounts.""" + self.definition_1.allow_bulk_approve = True + records = self.test_model.create([{"test_field": 1.0} for _i in range(5)]) + records.with_user(self.test_user_2).request_validation() + reviews = records.review_ids.with_user(self.test_user_1) + with mock.patch.object( + type(self.test_model), "_update_counter", autospec=True + ) as update_counter: + reviews.action_bulk_validate() + self.assertEqual(set(reviews.mapped("status")), {"approved"}) + self.assertEqual( + update_counter.call_count, + 1, + "bulk approve notified once per document " + f"({update_counter.call_count} times for 5 documents) " + "instead of once for the batch", + ) + # ...and the single call covers every approved document, so no + # reviewer's badge is left stale. + self.assertEqual(update_counter.call_args.args[0], records) + + 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}" /> +