From 5742162bde473f443179a5a94bac137cd4eff0f2 Mon Sep 17 00:00:00 2001 From: bosd <5e2fd43-d292-4c90-9d1f-74ff3436329a@anonaddy.me> Date: Thu, 14 May 2026 01:16:38 +0200 Subject: [PATCH] [IMP] base_tier_validation: validation progress indicator Make it possible to tell at a glance how close a document under validation is to being fully approved. Server-side: - New `validation_progress` Integer (0-100) on the `tier.validation` abstract mixin, computed as `approved_reviews / total_reviews * 100`. Returns 0 when there are no reviews. The computation reuses `_validated_states()` so subclasses that override the approved state semantics keep working. Client-side (embedded reviews widget): - Render a Bootstrap progress bar in the widget header next to the "Reviews" collapse title. The percentage is computed live in JS from the already-embedded review_ids data (no extra RPC), via a new `getValidationProgress()` helper on the OWL component. - SCSS scopes the new layout under `.o_tier_validation_progress_header` so the title + bar sit on one line with a sensible max width. The server-side field is what bridge modules will use to add a `widget="progressbar"` column to their list views (account.move, sale.order, ...). That work lands as separate follow-up PRs per bridge. Test: create two tier definitions, request validation, validate one tier (expect 50%), validate the other (expect 100%). --- .../models/tier_validation.py | 18 ++++++++++ .../tier_review_widget.esm.js | 10 ++++++ .../tier_review_widget.scss | 17 ++++++++++ .../tier_review_widget/tier_review_widget.xml | 17 +++++++++- .../tests/test_tier_validation.py | 33 +++++++++++++++++++ 5 files changed, 94 insertions(+), 1 deletion(-) diff --git a/base_tier_validation/models/tier_validation.py b/base_tier_validation/models/tier_validation.py index 29896f80..6df15d52 100644 --- a/base_tier_validation/models/tier_validation.py +++ b/base_tier_validation/models/tier_validation.py @@ -73,6 +73,24 @@ class TierValidation(models.AbstractModel): ) next_review = fields.Char(compute="_compute_next_review") hide_reviews = fields.Boolean(compute="_compute_hide_reviews") + validation_progress = fields.Integer( + compute="_compute_validation_progress", + string="Validation progress", + help="Percentage of approved tier reviews out of the total assigned " + "for this record. 0 when no reviews exist; useful as a progressbar " + "widget on list views to spot near-done validations.", + ) + + @api.depends("review_ids.status") + def _compute_validation_progress(self): + approved = self._validated_states() + for rec in self: + total = len(rec.review_ids) + if not total: + rec.validation_progress = 0 + continue + done = len(rec.review_ids.filtered(lambda r: r.status in approved)) + rec.validation_progress = int(round(done * 100.0 / total)) @api.depends_context("uid") @api.depends( diff --git a/base_tier_validation/static/src/components/tier_review_widget/tier_review_widget.esm.js b/base_tier_validation/static/src/components/tier_review_widget/tier_review_widget.esm.js index b9b87135..69d2f4dc 100644 --- a/base_tier_validation/static/src/components/tier_review_widget/tier_review_widget.esm.js +++ b/base_tier_validation/static/src/components/tier_review_widget/tier_review_widget.esm.js @@ -16,6 +16,16 @@ export class ReviewsTable extends Component { return records.map((record) => record.data); } + getValidationProgress() { + // Compute live from the embedded review data so the bar updates + // without re-reading the parent record. The server-side + // validation_progress field exists for list-view contexts. + const reviews = this._getReviewData(); + if (!reviews.length) return 0; + const done = reviews.filter((r) => r.status === "approved").length; + return Math.round((done * 100) / reviews.length); + } + onToggleCollapse(ev) { const panelHeading = ev.currentTarget.closest(".panel-heading"); const collapseDiv = panelHeading.nextElementSibling.matches("div#collapse1") diff --git a/base_tier_validation/static/src/components/tier_review_widget/tier_review_widget.scss b/base_tier_validation/static/src/components/tier_review_widget/tier_review_widget.scss index a42c166d..04a5a17c 100644 --- a/base_tier_validation/static/src/components/tier_review_widget/tier_review_widget.scss +++ b/base_tier_validation/static/src/components/tier_review_widget/tier_review_widget.scss @@ -27,6 +27,23 @@ ul.o_review { display: block; } +.o_tier_validation_progress_header { + display: flex; + align-items: center; + gap: 16px; + + .panel-title { + flex: 0 0 auto; + } +} + +.o_tier_validation_progress { + flex: 1 1 auto; + max-width: 320px; + height: 1rem; + margin-right: 24px; // leave room for the rotating caret arrow +} + .panel-heading a:before { font-family: FontAwesome; content: "\f0d7"; diff --git a/base_tier_validation/static/src/components/tier_review_widget/tier_review_widget.xml b/base_tier_validation/static/src/components/tier_review_widget/tier_review_widget.xml index 92735f4d..3ef9f6a0 100644 --- a/base_tier_validation/static/src/components/tier_review_widget/tier_review_widget.xml +++ b/base_tier_validation/static/src/components/tier_review_widget/tier_review_widget.xml @@ -3,7 +3,7 @@
-
+
diff --git a/base_tier_validation/tests/test_tier_validation.py b/base_tier_validation/tests/test_tier_validation.py index 3f2ab80c..d552852b 100644 --- a/base_tier_validation/tests/test_tier_validation.py +++ b/base_tier_validation/tests/test_tier_validation.py @@ -536,6 +536,39 @@ def test_16b_review_user_count_no_model_access(self): result = self.test_user_2.with_user(self.test_user_2).review_user_count() self.assertEqual(result, []) + def test_validation_progress(self): + """validation_progress is approved / total reviews * 100, + rounded to an integer.""" + self.tier_def_obj.create( + { + "model_id": self.tester_model.id, + "review_type": "individual", + "reviewer_id": self.test_user_1.id, + "definition_domain": "[('test_field', '>', 1.0)]", + } + ) + self.tier_def_obj.create( + { + "model_id": self.tester_model.id, + "review_type": "individual", + "reviewer_id": self.test_user_2.id, + "definition_domain": "[('test_field', '>', 1.0)]", + } + ) + record = self.test_model.create({"test_field": 2.5}) + # No reviews yet -> 0%. + self.assertEqual(record.validation_progress, 0) + record.with_user(self.test_user_1).request_validation() + # Two reviews assigned, none approved -> 0%. + self.assertEqual(record.validation_progress, 0) + # User 1 approves their tier. + record.with_user(self.test_user_1).validate_tier() + # One of two -> 50%. + self.assertEqual(record.validation_progress, 50) + # User 2 approves -> 100%. + record.with_user(self.test_user_2).validate_tier() + self.assertEqual(record.validation_progress, 100) + def test_17_search_records_no_validation(self): """Search for records that have no validation process started""" records = self.env["tier.validation.tester"].search(