diff --git a/base_tier_validation_authentication_confirm/README.rst b/base_tier_validation_authentication_confirm/README.rst new file mode 100644 index 0000000000..9253e7afbc --- /dev/null +++ b/base_tier_validation_authentication_confirm/README.rst @@ -0,0 +1,87 @@ +===================================== +Base Tier Validation Password Confirm +===================================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:dc29a5a30d78467106dfe713b562de411e73d395f4ee15536d189548d1e43631 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fserver--ux-lightgray.png?logo=github + :target: https://github.com/OCA/server-ux/tree/18.0/base_tier_validation_authentication_confirm + :alt: OCA/server-ux +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/server-ux-18-0/server-ux-18-0-base_tier_validation_authentication_confirm + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/server-ux&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Allows to add a password confirmation to tier validation, where the user +must confirm his authentication in order to approve or reject the tier +review. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +To configure this module, you need to: + +1. Go to *Settings > Technical > Tier Validations > Tier Definition > + Select one tier definition*. +2. Mark confirm password required field. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* ForgeFlow + +Contributors +------------ + +- Arnau Cruz arnau.cruz@forgeflow.com + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/server-ux `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/base_tier_validation_authentication_confirm/__init__.py b/base_tier_validation_authentication_confirm/__init__.py new file mode 100644 index 0000000000..aee8895e7a --- /dev/null +++ b/base_tier_validation_authentication_confirm/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import wizards diff --git a/base_tier_validation_authentication_confirm/__manifest__.py b/base_tier_validation_authentication_confirm/__manifest__.py new file mode 100644 index 0000000000..2ae4882c02 --- /dev/null +++ b/base_tier_validation_authentication_confirm/__manifest__.py @@ -0,0 +1,19 @@ +# Copyright 2026 ForgeFlow S.L. (https://www.forgeflow.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +{ + "name": "Base Tier Validation Password Confirm", + "summary": "Authentication confirmation for base tiers.", + "version": "18.0.1.0.0", + "category": "Tools", + "website": "https://github.com/OCA/server-ux", + "author": "ForgeFlow, Odoo Community Association (OCA)", + "license": "AGPL-3", + "depends": ["base_tier_validation"], + "data": [ + "security/ir.model.access.csv", + "views/tier_definition_view.xml", + "wizards/authentication_wizard_view.xml", + ], + "application": False, + "installable": True, +} diff --git a/base_tier_validation_authentication_confirm/models/__init__.py b/base_tier_validation_authentication_confirm/models/__init__.py new file mode 100644 index 0000000000..c649fbfb3f --- /dev/null +++ b/base_tier_validation_authentication_confirm/models/__init__.py @@ -0,0 +1,3 @@ +from . import tier_definition +from . import tier_review +from . import tier_validation diff --git a/base_tier_validation_authentication_confirm/models/tier_definition.py b/base_tier_validation_authentication_confirm/models/tier_definition.py new file mode 100644 index 0000000000..67dc5334cf --- /dev/null +++ b/base_tier_validation_authentication_confirm/models/tier_definition.py @@ -0,0 +1,14 @@ +# Copyright 2026 ForgeFlow S.L. (https://www.forgeflow.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class TierDefinition(models.Model): + _inherit = "tier.definition" + + require_authentication = fields.Boolean( + help="If enabled, the user will be asked to authenticate " + "himself in order to validate or reject the tier.", + default=False, + ) diff --git a/base_tier_validation_authentication_confirm/models/tier_review.py b/base_tier_validation_authentication_confirm/models/tier_review.py new file mode 100644 index 0000000000..94d6ca1270 --- /dev/null +++ b/base_tier_validation_authentication_confirm/models/tier_review.py @@ -0,0 +1,13 @@ +# Copyright 2026 ForgeFlow S.L. (https://www.forgeflow.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class TierReview(models.Model): + _inherit = "tier.review" + + require_authentication = fields.Boolean( + related="definition_id.require_authentication", readonly=True + ) + authentication_confirmed = fields.Boolean() diff --git a/base_tier_validation_authentication_confirm/models/tier_validation.py b/base_tier_validation_authentication_confirm/models/tier_validation.py new file mode 100644 index 0000000000..c6118094f9 --- /dev/null +++ b/base_tier_validation_authentication_confirm/models/tier_validation.py @@ -0,0 +1,59 @@ +# Copyright 2026 ForgeFlow S.L. (https://www.forgeflow.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class TierValidation(models.AbstractModel): + _inherit = "tier.validation" + + require_authentication = fields.Boolean(compute="_compute_require_authentication") + + def _compute_require_authentication(self): + for rec in self: + require_authentication = rec.review_ids.filtered( + lambda r: r.status in ("waiting", "pending") + and (self.env.user in r.reviewer_ids) + ).mapped("require_authentication") + rec.require_authentication = True in require_authentication + + def validate_tier(self): + self.ensure_one() + sequences = self._get_sequences_to_approve(self.env.user) + reviews = self.review_ids.filtered( + lambda x: x.sequence in sequences or x.approve_sequence_bypass + ) + if not self.has_comment and self.require_authentication: + user_reviews = reviews.filtered( + lambda r: r.status == "pending" and (self.env.user in r.reviewer_ids) + ) + return self._confirm_authentication("validate", user_reviews) + return super().validate_tier() + + def reject_tier(self): + self.ensure_one() + sequences = self._get_sequences_to_approve(self.env.user) + reviews = self.review_ids.filtered(lambda x: x.sequence in sequences) + if not self.has_comment and self.require_authentication: + return self._confirm_authentication("reject", reviews) + return super().reject_tier() + + def _confirm_authentication(self, validate_reject, reviews): + wizard = self.env.ref( + "base_tier_validation_authentication_confirm.view_authentication_confirm_wizard" + ) + return { + "name": self.env._("Authentication Confirmation"), + "type": "ir.actions.act_window", + "view_mode": "form", + "res_model": "authentication.wizard", + "views": [(wizard.id, "form")], + "view_id": wizard.id, + "target": "new", + "context": { + "default_res_id": self.id, + "default_res_model": self._name, + "default_review_ids": reviews.ids, + "default_validate_reject": validate_reject, + }, + } diff --git a/base_tier_validation_authentication_confirm/pyproject.toml b/base_tier_validation_authentication_confirm/pyproject.toml new file mode 100644 index 0000000000..4231d0cccb --- /dev/null +++ b/base_tier_validation_authentication_confirm/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/base_tier_validation_authentication_confirm/readme/CONFIGURE.md b/base_tier_validation_authentication_confirm/readme/CONFIGURE.md new file mode 100644 index 0000000000..5b57d183d7 --- /dev/null +++ b/base_tier_validation_authentication_confirm/readme/CONFIGURE.md @@ -0,0 +1,5 @@ +To configure this module, you need to: + +1. Go to *Settings \> Technical \> Tier Validations \> Tier + Definition \> Select one tier definition*. +2. Mark confirm password required field. diff --git a/base_tier_validation_authentication_confirm/readme/CONTRIBUTORS.md b/base_tier_validation_authentication_confirm/readme/CONTRIBUTORS.md new file mode 100644 index 0000000000..629a347ae8 --- /dev/null +++ b/base_tier_validation_authentication_confirm/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Arnau Cruz diff --git a/base_tier_validation_authentication_confirm/readme/DESCRIPTION.md b/base_tier_validation_authentication_confirm/readme/DESCRIPTION.md new file mode 100644 index 0000000000..1d2aef1218 --- /dev/null +++ b/base_tier_validation_authentication_confirm/readme/DESCRIPTION.md @@ -0,0 +1,2 @@ +Allows to add a password confirmation to tier validation, +where the user must confirm his authentication in order to approve or reject the tier review. diff --git a/base_tier_validation_authentication_confirm/security/ir.model.access.csv b/base_tier_validation_authentication_confirm/security/ir.model.access.csv new file mode 100644 index 0000000000..4d28b09f6c --- /dev/null +++ b/base_tier_validation_authentication_confirm/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_authentication_wizard,access.authentication.wizard,model_authentication_wizard,base.group_user,1,1,1,1 diff --git a/base_tier_validation_authentication_confirm/static/description/index.html b/base_tier_validation_authentication_confirm/static/description/index.html new file mode 100644 index 0000000000..29a4d9c70e --- /dev/null +++ b/base_tier_validation_authentication_confirm/static/description/index.html @@ -0,0 +1,435 @@ + + + + + +Base Tier Validation Password Confirm + + + +
+

Base Tier Validation Password Confirm

+ + +

Beta License: AGPL-3 OCA/server-ux Translate me on Weblate Try me on Runboat

+

Allows to add a password confirmation to tier validation, where the user +must confirm his authentication in order to approve or reject the tier +review.

+

Table of contents

+ +
+

Configuration

+

To configure this module, you need to:

+
    +
  1. Go to Settings > Technical > Tier Validations > Tier Definition > +Select one tier definition.
  2. +
  3. Mark confirm password required field.
  4. +
+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • ForgeFlow
  • +
+
+ +
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/server-ux project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/base_tier_validation_authentication_confirm/tests/__init__.py b/base_tier_validation_authentication_confirm/tests/__init__.py new file mode 100644 index 0000000000..66b9c4e71d --- /dev/null +++ b/base_tier_validation_authentication_confirm/tests/__init__.py @@ -0,0 +1 @@ +from . import test_tier_validation_password_confirm diff --git a/base_tier_validation_authentication_confirm/tests/test_tier_validation_password_confirm.py b/base_tier_validation_authentication_confirm/tests/test_tier_validation_password_confirm.py new file mode 100644 index 0000000000..4f2c8dfc5a --- /dev/null +++ b/base_tier_validation_authentication_confirm/tests/test_tier_validation_password_confirm.py @@ -0,0 +1,210 @@ +# Copyright 2026 ForgeFlow S.L. +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +from odoo.exceptions import ValidationError +from odoo.tests import Form, tagged + +from odoo.addons.base_tier_validation.tests.common import CommonTierValidation + + +@tagged("post_install", "-at_install") +class TierTierValidationPasswordConfirm(CommonTierValidation): + @classmethod + def setUpClass(cls): + super().setUpClass() + + def test_01_password_confirmation_and_comment(self): + # Set user password for validation + self.test_user_1.password = "test_user_1" + + # Create new test record + test_record = self.test_model.create({"test_field": 2.5}) + + # Create tier definitions + 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)]", + "has_comment": True, + "require_authentication": True, + } + ) + # Request validation + review = test_record.with_user(self.test_user_2.id).request_validation() + self.assertTrue(review) + + # Let _compute_can_review assign status 'pending' instead of waiting + review.flush_recordset() + record = test_record.with_user(self.test_user_1.id) + res = record.validate_tier() + ctx = res.get("context") + wizard = Form(self.env["comment.wizard"].with_context(**ctx)) + wizard.comment = "Test Comment" + wiz = wizard.save() + res = wiz.add_comment() + + # Password confirmation wizard + pw_ctx = res["context"] + pw_wizard = Form( + self.env["authentication.wizard"] + .with_user(self.test_user_1) + .with_context(**pw_ctx) + ) + + # Wrong password should fail + pw_wizard.password = "wrong_password" + pw_wiz = pw_wizard.save() + with self.assertRaises(ValidationError): + pw_wiz.confirm_authentication() + + # Correct password should pass + pw_wizard.password = "test_user_1" + pw_wiz = pw_wizard.save() + pw_wiz.confirm_authentication() + + # Ensure review has comment + self.assertTrue(test_record.review_ids.filtered("comment")) + + # Check notifications + accepted_msg = test_record.with_user( + self.test_user_1 + )._notify_accepted_reviews_body() + rejected_msg = test_record.with_user( + self.test_user_1 + )._notify_rejected_review_body() + self.assertEqual(accepted_msg, "A review was accepted. (Test Comment)") + self.assertEqual(rejected_msg, "A review was rejected by John. (Test Comment)") + + def test_02_password_confirmation_without_comment(self): + # Set user password for validation + self.test_user_1.password = "test_user_1" + + # Create new test record + test_record = self.test_model.create({"test_field": 2.5}) + + # Create tier definition with no comment + password required + 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)]", + "has_comment": False, + "require_authentication": True, + } + ) + + # Request validation + review = test_record.with_user(self.test_user_2).request_validation() + self.assertTrue(review) + + # Password confirmation wizard + record = test_record.with_user(self.test_user_1) + res = record.validate_tier() + ctx = res["context"] + + pw_wizard = Form( + self.env["authentication.wizard"] + .with_user(self.test_user_1) + .with_context(**ctx) + ) + + # Wrong password should fail + pw_wizard.password = "wrong_password" + pw_wiz = pw_wizard.save() + with self.assertRaises(ValidationError): + pw_wiz.confirm_authentication() + + # Correct password should pass + pw_wizard.password = "test_user_1" + pw_wiz = pw_wizard.save() + pw_wiz.confirm_authentication() + + def test_03_password_confirmation_reject_with_comment(self): + # Set user password for validation + self.test_user_1.password = "test_user_1" + + # Create new test record + test_record = self.test_model.create({"test_field": 2.5}) + + # Create tier definitions + 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)]", + "has_comment": True, + "require_authentication": True, + } + ) + # Request validation + review = test_record.with_user(self.test_user_2.id).request_validation() + self.assertTrue(review) + + # Let _compute_can_review assign status 'pending' instead of waiting + review.flush_recordset() + record = test_record.with_user(self.test_user_1.id) + res = record.reject_tier() + ctx = res.get("context") + wizard = Form(self.env["comment.wizard"].with_context(**ctx)) + wizard.comment = "Rejection Comment" + wiz = wizard.save() + res = wiz.add_comment() + + # Password confirmation wizard + pw_ctx = res["context"] + pw_wizard = Form( + self.env["authentication.wizard"] + .with_user(self.test_user_1) + .with_context(**pw_ctx) + ) + + # Correct password should pass + pw_wizard.password = "test_user_1" + pw_wiz = pw_wizard.save() + pw_wiz.confirm_authentication() + + # Ensure review has comment + self.assertTrue(test_record.review_ids.filtered("comment")) + + def test_04_password_confirmation_reject_without_comment(self): + # Set user password for validation + self.test_user_1.password = "test_user_1" + + # Create new test record + test_record = self.test_model.create({"test_field": 2.5}) + + # Create tier definition with no comment + password required + 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)]", + "has_comment": False, + "require_authentication": True, + } + ) + + # Request validation + review = test_record.with_user(self.test_user_2).request_validation() + self.assertTrue(review) + + # Password confirmation wizard + record = test_record.with_user(self.test_user_1) + res = record.reject_tier() + ctx = res["context"] + + pw_wizard = Form( + self.env["authentication.wizard"] + .with_user(self.test_user_1) + .with_context(**ctx) + ) + + # Correct password should pass + pw_wizard.password = "test_user_1" + pw_wiz = pw_wizard.save() + pw_wiz.confirm_authentication() diff --git a/base_tier_validation_authentication_confirm/views/tier_definition_view.xml b/base_tier_validation_authentication_confirm/views/tier_definition_view.xml new file mode 100644 index 0000000000..24df48b423 --- /dev/null +++ b/base_tier_validation_authentication_confirm/views/tier_definition_view.xml @@ -0,0 +1,13 @@ + + + + tier.definition.form + tier.definition + + + + + + + + diff --git a/base_tier_validation_authentication_confirm/wizards/__init__.py b/base_tier_validation_authentication_confirm/wizards/__init__.py new file mode 100644 index 0000000000..4d082c4c22 --- /dev/null +++ b/base_tier_validation_authentication_confirm/wizards/__init__.py @@ -0,0 +1,2 @@ +from . import comment_wizard +from . import authentication_wizard diff --git a/base_tier_validation_authentication_confirm/wizards/authentication_wizard.py b/base_tier_validation_authentication_confirm/wizards/authentication_wizard.py new file mode 100644 index 0000000000..527975a9f2 --- /dev/null +++ b/base_tier_validation_authentication_confirm/wizards/authentication_wizard.py @@ -0,0 +1,55 @@ +# Copyright 2026 ForgeFlow S.L. (https://www.forgeflow.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import _, fields, models +from odoo.exceptions import AccessDenied, ValidationError + + +class AuthenticationWizard(models.TransientModel): + _name = "authentication.wizard" + _description = "Authentication Wizard" + + validate_reject = fields.Char() + res_model = fields.Char() + res_id = fields.Integer() + review_ids = fields.Many2many( + comodel_name="tier.review", + ) + password = fields.Char(required=True) + + def _check_user_password(self): + """Validate the password of current user.""" + self.ensure_one() + try: + credentials = { + "login": self.env.user.login, + "password": self.password, + "type": "password", + } + self.env.user._check_credentials(credentials, {"interactive": True}) + + except AccessDenied: + raise ValidationError( + _("The password is incorrect, please try again.") + ) from None + + def confirm_authentication(self): + self.ensure_one() + + self._check_user_password() + + record = self.env[self.res_model].browse(self.res_id) + comment = self.env.context.get("comment", False) + + vals = {"authentication_confirmed": True} + if comment: + vals["comment"] = comment + self.review_ids.write(vals) + + if self.validate_reject == "validate": + record._validate_tier(self.review_ids) + elif self.validate_reject == "reject": + record._rejected_tier(self.review_ids) + + record._update_counter({"review_deleted": True}) + return {"type": "ir.actions.act_window_close"} diff --git a/base_tier_validation_authentication_confirm/wizards/authentication_wizard_view.xml b/base_tier_validation_authentication_confirm/wizards/authentication_wizard_view.xml new file mode 100644 index 0000000000..154ade44e0 --- /dev/null +++ b/base_tier_validation_authentication_confirm/wizards/authentication_wizard_view.xml @@ -0,0 +1,24 @@ + + + + Authentication Wizard + authentication.wizard + form + +
+ + + + +
+
+
+
diff --git a/base_tier_validation_authentication_confirm/wizards/comment_wizard.py b/base_tier_validation_authentication_confirm/wizards/comment_wizard.py new file mode 100644 index 0000000000..523019000e --- /dev/null +++ b/base_tier_validation_authentication_confirm/wizards/comment_wizard.py @@ -0,0 +1,29 @@ +# Copyright 2026 ForgeFlow S.L. (https://www.forgeflow.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import models + + +class CommentWizard(models.TransientModel): + _inherit = "comment.wizard" + + def add_comment(self): + if any(review.require_authentication for review in self.review_ids): + return self._confirm_authentication() + return super().add_comment() + + def _confirm_authentication(self): + return { + "name": "Authentication Confirmation", + "type": "ir.actions.act_window", + "res_model": "authentication.wizard", + "view_mode": "form", + "target": "new", + "context": { + "default_validate_reject": self.validate_reject, + "default_res_model": self.res_model, + "default_res_id": self.res_id, + "default_review_ids": [(6, 0, self.review_ids.ids)], + "comment": self.comment, + }, + }