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}" /> +