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(