diff --git a/base_tier_validation/README.rst b/base_tier_validation/README.rst new file mode 100644 index 0000000..977eb44 --- /dev/null +++ b/base_tier_validation/README.rst @@ -0,0 +1,73 @@ +.. image:: https://img.shields.io/badge/license-AGPL--3-blue.png + :target: https://www.gnu.org/licenses/agpl + :alt: License: AGPL-3 + +==================== +Base Tier Validation +==================== + +This module does not provide a functionality by itself but an abstract model +to implement a validation process based on tiers on other models (e.g. +purchase orders, sales orders...). + +**Note:** To be able to use this module in a new model you will need some +development. + +See `purchase_tier_validation `_ as an example of implementation. + +Configuration +============= + +To configure this module, you need to: + +#. Go to *Settings > Technical > Tier Validations > Tier Definition*. +#. Create as many tiers as you want for any model having tier validation + functionality. + +.. image:: https://odoo-community.org/website/image/ir.attachment/5784_f2813bd/datas + :alt: Try me on Runbot + :target: https://runbot.odoo-community.org/runbot/250/11.0 + +Known issues / Roadmap +====================== + +* In odoo v11 it would be interesting to try to take advantage of ``mail.activity.mixin``. + +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 smash it by providing detailed and welcomed feedback. + +Credits +======= + +Images +------ + +* Odoo Community Association: `Icon `_. + +Contributors +------------ + +* Lois Rilo + +Do not contact contributors directly about support or help with technical issues. + +Maintainer +---------- + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +This module is maintained by the OCA. + +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. + +To contribute to this module, please visit https://odoo-community.org. diff --git a/base_tier_validation/__init__.py b/base_tier_validation/__init__.py new file mode 100644 index 0000000..31660d6 --- /dev/null +++ b/base_tier_validation/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import models diff --git a/base_tier_validation/__manifest__.py b/base_tier_validation/__manifest__.py new file mode 100644 index 0000000..febb73f --- /dev/null +++ b/base_tier_validation/__manifest__.py @@ -0,0 +1,21 @@ +# Copyright 2017 Eficent Business and IT Consulting Services S.L. +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +{ + "name": "Base Tier Validation", + "summary": "Implement a validation process based on tiers.", + "version": "11.0.1.0.0", + "category": "Tools", + "website": "https://github.com/OCA/server-ux", + "author": "Eficent, Odoo Community Association (OCA)", + "license": "AGPL-3", + "application": False, + "installable": True, + "depends": [ + "base", + ], + "data": [ + "security/ir.model.access.csv", + "views/tier_definition_view.xml", + "views/tier_review_view.xml", + ], +} diff --git a/base_tier_validation/models/__init__.py b/base_tier_validation/models/__init__.py new file mode 100644 index 0000000..2c43513 --- /dev/null +++ b/base_tier_validation/models/__init__.py @@ -0,0 +1,5 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import tier_definition +from . import tier_review +from . import tier_validation diff --git a/base_tier_validation/models/tier_definition.py b/base_tier_validation/models/tier_definition.py new file mode 100644 index 0000000..1a027c0 --- /dev/null +++ b/base_tier_validation/models/tier_definition.py @@ -0,0 +1,53 @@ +# Copyright 2017 Eficent Business and IT Consulting Services S.L. +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class TierDefinition(models.Model): + _name = "tier.definition" + _rec_name = "model_id" + + @api.model + def _get_tier_validation_model_names(self): + res = [] + return res + + model_id = fields.Many2one( + comodel_name="ir.model", + string="Referenced Model", + ) + model = fields.Char( + related='model_id.model', index=True, store=True, + ) + review_type = fields.Selection( + string="Validated by", default="individual", + selection=[("individual", "Specific user"), + ("group", "Any user in a specific group.")] + ) + reviewer_id = fields.Many2one( + comodel_name="res.users", string="Reviewer", + ) + reviewer_group_id = fields.Many2one( + comodel_name="res.groups", string="Reviewer group", + ) + python_code = fields.Text( + string='Tier Definition Expression', + help="Write Python code that defines when this tier confirmation " + "will be needed. The result of executing the expresion must be " + "a boolean.", + default="""# Available locals:\n# - rec: current record""", + ) + active = fields.Boolean(default=True) + sequence = fields.Integer(default=30) + company_id = fields.Many2one( + comodel_name="res.company", string="Company", + default=lambda self: self.env["res.company"]._company_default_get( + "tier.definition"), + ) + + @api.onchange('model_id') + def onchange_model_id(self): + return {'domain': { + 'model_id': [ + ('model', 'in', self._get_tier_validation_model_names())]}} diff --git a/base_tier_validation/models/tier_review.py b/base_tier_validation/models/tier_review.py new file mode 100644 index 0000000..c5f46dc --- /dev/null +++ b/base_tier_validation/models/tier_review.py @@ -0,0 +1,40 @@ +# Copyright 2017 Eficent Business and IT Consulting Services S.L. +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class TierReview(models.Model): + _name = "tier.review" + + status = fields.Selection( + selection=[("pending", "Pending"), + ("rejected", "Rejected"), + ("approved", "Approved")], + default="pending", + ) + model = fields.Char(string='Related Document Model', index=True) + res_id = fields.Integer(string='Related Document ID', index=True) + definition_id = fields.Many2one( + comodel_name="tier.definition", + ) + review_type = fields.Selection( + related="definition_id.review_type", readonly=True, + ) + reviewer_id = fields.Many2one( + related="definition_id.reviewer_id", readonly=True, + ) + reviewer_group_id = fields.Many2one( + related="definition_id.reviewer_group_id", readonly=True, + ) + reviewer_ids = fields.Many2many( + string="Reviewers", comodel_name="res.users", + compute="_compute_reviewer_ids", store=True, + ) + sequence = fields.Integer(string="Tier") + + @api.multi + @api.depends('reviewer_id', 'reviewer_group_id', 'reviewer_group_id.users') + def _compute_reviewer_ids(self): + for rec in self: + rec.reviewer_ids = rec.reviewer_id + rec.reviewer_group_id.users diff --git a/base_tier_validation/models/tier_validation.py b/base_tier_validation/models/tier_validation.py new file mode 100644 index 0000000..5b6ec5f --- /dev/null +++ b/base_tier_validation/models/tier_validation.py @@ -0,0 +1,187 @@ +# Copyright 2017 Eficent Business and IT Consulting Services S.L. +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models, _ +from odoo.exceptions import ValidationError, UserError +from odoo.tools.safe_eval import safe_eval + + +class TierValidation(models.AbstractModel): + _name = "tier.validation" + + _state_field = 'state' + _state_from = ['draft'] + _state_to = ['confirmed'] + _cancel_state = 'cancel' + + # TODO: step by step validation? + + review_ids = fields.One2many( + comodel_name='tier.review', inverse_name='res_id', + string='Validations', + domain=lambda self: [('model', '=', self._name)], + auto_join=True, + ) + validated = fields.Boolean( + compute="_compute_validated_rejected", + search="_search_validated", + ) + need_validation = fields.Boolean(compute="_compute_need_validation") + rejected = fields.Boolean(compute="_compute_validated_rejected") + reviewer_ids = fields.Many2many( + string="Reviewers", comodel_name="res.users", + compute="_compute_reviewer_ids", + search="_search_reviewer_ids", + ) + + @api.multi + @api.depends('review_ids') + def _compute_reviewer_ids(self): + for rec in self: + rec.reviewer_ids = rec.review_ids.filtered( + lambda r: r.status == 'pending').mapped('reviewer_ids') + + @api.model + def _search_validated(self, operator, value): + assert operator in ('=', '!='), 'Invalid domain operator' + assert value in (True, False), 'Invalid domain value' + pos = self.search([ + (self._state_field, 'in', self._state_from)]).filtered( + lambda r: r.review_ids and r.validated == value) + return [('id', 'in', pos.ids)] + + @api.model + def _search_reviewer_ids(self, operator, value): + reviews = self.env['tier.review'].search([ + ('model', '=', self._name), + ('reviewer_ids', operator, value), + ('status', '=', 'pending')]) + return [('id', 'in', list(set(reviews.mapped('res_id'))))] + + @api.multi + def _compute_validated_rejected(self): + for rec in self: + rec.validated = self._calc_reviews_validated(rec.review_ids) + rec.rejected = self._calc_reviews_rejected(rec.review_ids) + + @api.model + def _calc_reviews_validated(self, reviews): + """Override for different validation policy.""" + if not reviews: + return False + return not any([s != 'approved' for s in reviews.mapped('status')]) + + @api.model + def _calc_reviews_rejected(self, reviews): + """Override for different rejection policy.""" + return any([s == 'rejected' for s in reviews.mapped('status')]) + + @api.multi + def _compute_need_validation(self): + for rec in self: + tiers = self.env[ + 'tier.definition'].search([('model', '=', self._name)]) + valid_tiers = any([self.evaluate_tier(tier) for tier in tiers]) + rec.need_validation = not self.review_ids and valid_tiers and \ + getattr(rec, self._state_field) in self._state_from + + @api.multi + def evaluate_tier(self, tier): + try: + res = safe_eval(tier.python_code, globals_dict={'rec': self}) + except Exception as error: + raise UserError(_( + "Error evaluating tier validation conditions.\n %s") % error) + return res + + @api.model + def _get_under_validation_exceptions(self): + """Extend for more field exceptions.""" + return ['message_follower_ids'] + + @api.multi + def _check_allow_write_under_validation(self, vals): + """Allow to add exceptions for fields that are allowed to be written + even when the record is under validation.""" + exceptions = self._get_under_validation_exceptions() + for val in vals: + if val not in exceptions: + return False + return True + + @api.multi + def write(self, vals): + for rec in self: + if (getattr(rec, self._state_field) in self._state_from and + vals.get(self._state_field) in self._state_to): + if rec.need_validation: + # try to validate operation + reviews = rec.request_validation() + rec._validate_tier(reviews) + if not self._calc_reviews_validated(reviews): + raise ValidationError(_( + "This action needs to be validated for at least " + "one record. \nPlease request a validation.")) + if rec.review_ids and not rec.validated: + raise ValidationError(_( + "A validation process is still open for at least " + "one record.")) + if (rec.review_ids and getattr(rec, self._state_field) in + self._state_from and not vals.get(self._state_field) in + (self._state_to + [self._cancel_state]) and not + self._check_allow_write_under_validation(vals)): + raise ValidationError(_("The operation is under validation.")) + if vals.get(self._state_field) in self._state_from: + self.mapped('review_ids').unlink() + return super(TierValidation, self).write(vals) + + def _validate_tier(self, tiers=False): + self.ensure_one() + tier_reviews = tiers or self.review_ids + user_reviews = tier_reviews.filtered( + lambda r: r.status in ('pending', 'rejected') and + (r.reviewer_id == self.env.user or + r.reviewer_group_id in self.env.user.groups_id)) + user_reviews.write({'status': 'approved'}) + + @api.multi + def validate_tier(self): + for rec in self: + rec._validate_tier() + + @api.multi + def reject_tier(self): + for rec in self: + user_reviews = rec.review_ids.filtered( + lambda r: r.status in ('pending', 'approved') and + (r.reviewer_id == self.env.user or + r.reviewer_group_id in self.env.user.groups_id)) + user_reviews.write({'status': 'rejected'}) + + @api.multi + def request_validation(self): + td_obj = self.env['tier.definition'] + tr_obj = created_trs = self.env['tier.review'] + for rec in self: + if getattr(rec, self._state_field) in self._state_from: + if rec.need_validation: + tier_definitions = td_obj.search([ + ('model', '=', self._name)], order="sequence desc") + sequence = 0 + for td in tier_definitions: + if self.evaluate_tier(td): + sequence += 1 + created_trs += tr_obj.create({ + 'model': self._name, + 'res_id': rec.id, + 'definition_id': td.id, + 'sequence': sequence, + }) + # TODO: notify? post some msg in chatter? + return created_trs + + @api.multi + def restart_validation(self): + for rec in self: + if getattr(rec, self._state_field) in self._state_from: + rec.mapped('review_ids').unlink() diff --git a/base_tier_validation/security/ir.model.access.csv b/base_tier_validation/security/ir.model.access.csv new file mode 100644 index 0000000..8491017 --- /dev/null +++ b/base_tier_validation/security/ir.model.access.csv @@ -0,0 +1,4 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_tier_review,access.tier.review,model_tier_review,,1,1,1,1 +access_tier_definition_all,tier.definition.all,model_tier_definition,,1,0,0,0 +access_tier_definition_settings,tier.definition.settings,model_tier_definition,base.group_system,1,1,1,1 diff --git a/base_tier_validation/tests/__init__.py b/base_tier_validation/tests/__init__.py new file mode 100644 index 0000000..c5d19b1 --- /dev/null +++ b/base_tier_validation/tests/__init__.py @@ -0,0 +1,4 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import common +from . import test_tier_validation diff --git a/base_tier_validation/tests/common.py b/base_tier_validation/tests/common.py new file mode 100644 index 0000000..b14a726 --- /dev/null +++ b/base_tier_validation/tests/common.py @@ -0,0 +1,13 @@ +# Copyright 2018 Eficent Business and IT Consulting Services S.L. +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + + +def setup_test_model(env, model_clses): + for model_cls in model_clses: + model_cls._build_model(env.registry, env.cr) + + env.registry.setup_models(env.cr) + env.registry.init_models( + env.cr, [model_cls._name for model_cls in model_clses], + dict(env.context, update_custom_fields=True) + ) diff --git a/base_tier_validation/tests/test_tier_validation.py b/base_tier_validation/tests/test_tier_validation.py new file mode 100644 index 0000000..a2d70fb --- /dev/null +++ b/base_tier_validation/tests/test_tier_validation.py @@ -0,0 +1,150 @@ +# Copyright 2018 Eficent Business and IT Consulting Services S.L. +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +from odoo.tests import common +from odoo.exceptions import ValidationError, UserError +from .common import setup_test_model +from .tier_validation_tester import TierValidationTester + + +@common.at_install(False) +@common.post_install(True) +class TierTierValidation(common.SavepointCase): + + @classmethod + def setUpClass(cls): + super(TierTierValidation, cls).setUpClass() + + setup_test_model(cls.env, [TierValidationTester]) + + cls.test_model = cls.env[TierValidationTester._name] + + cls.tester_model = cls.env['ir.model'].search([ + ('model', '=', 'tier.validation.tester')]) + + # Access record: + cls.env["ir.model.access"].create({ + 'name': "access.tester", + 'model_id': cls.tester_model.id, + 'perm_read': 1, + 'perm_write': 1, + 'perm_create': 1, + 'perm_unlink': 1, + }) + + # Create users: + group_ids = cls.env.ref('base.group_system').ids + cls.test_user_1 = cls.env['res.users'].create({ + 'name': 'John', + 'login': 'test1', + 'groups_id': [(6, 0, group_ids)], + }) + cls.test_user_2 = cls.env['res.users'].create({ + 'name': 'Mike', + 'login': 'test2', + }) + + # Create tier definition: + cls.tier_def_obj = cls.env['tier.definition'] + cls.tier_def_obj.create({ + 'model_id': cls.tester_model.id, + 'review_type': 'individual', + 'reviewer_id': cls.test_user_1.id, + 'python_code': 'rec.test_field > 1.0', + }) + + cls.test_record = cls.test_model.create({ + 'test_field': 2.5, + }) + + def test_01_auto_validation(self): + """When the user can validate all future reviews, it is not needed + to request a validation, the action can be done straight forward.""" + self.test_record.sudo(self.test_user_1.id).action_confirm() + self.assertEqual(self.test_record.state, 'confirmed') + + def test_02_no_auto_validation(self): + """User with no right to validate future reviews must request a + validation.""" + with self.assertRaises(ValidationError): + self.test_record.sudo(self.test_user_2.id).action_confirm() + + def test_03_request_validation_approved(self): + """User 2 request a validation and user 1 approves it.""" + self.assertFalse(self.test_record.review_ids) + reviews = self.test_record.sudo( + self.test_user_2.id).request_validation() + self.assertTrue(reviews) + record = self.test_record.sudo(self.test_user_1.id) + record.validate_tier() + self.assertTrue(record.validated) + + def test_04_request_validation_rejected(self): + """Request validation, rejection and reset.""" + self.assertFalse(self.test_record.review_ids) + reviews = self.test_record.sudo( + self.test_user_2.id).request_validation() + self.assertTrue(reviews) + record = self.test_record.sudo(self.test_user_1.id) + record.reject_tier() + self.assertTrue(record.review_ids) + self.assertTrue(record.rejected) + record.restart_validation() + self.assertFalse(record.review_ids) + + def test_05_under_validation(self): + """Write is forbidden in a record under validation.""" + self.assertFalse(self.test_record.review_ids) + reviews = self.test_record.sudo( + self.test_user_2.id).request_validation() + self.assertTrue(reviews) + record = self.test_record.sudo(self.test_user_1.id) + with self.assertRaises(ValidationError): + record.write({'test_field': 0.5}) + + def test_06_validation_process_open(self): + """Operation forbidden while a validation process is open.""" + self.assertFalse(self.test_record.review_ids) + reviews = self.test_record.sudo( + self.test_user_2.id).request_validation() + self.assertTrue(reviews) + record = self.test_record.sudo(self.test_user_1.id) + with self.assertRaises(ValidationError): + record.action_confirm() + + def test_07_search_reviewers(self): + """Test search methods.""" + reviews = self.test_record.sudo( + self.test_user_2.id).request_validation() + self.assertTrue(reviews) + record = self.test_record.sudo(self.test_user_1.id) + self.assertIn(self.test_user_1, record.reviewer_ids) + res = self.test_model.search( + [('reviewer_ids', 'in', self.test_user_1.id)]) + self.assertTrue(res) + + def test_08_search_validated(self): + """Test for the validated search method.""" + self.test_record.sudo(self.test_user_2.id).request_validation() + res = self.test_model.sudo(self.test_user_1.id).search( + [('validated', '=', False)]) + self.assertTrue(res) + + def test_09_wrong_tier_definition(self): + """Error should raise with incorrect python expresions on + tier definitions.""" + self.tier_def_obj.create({ + 'model_id': self.tester_model.id, + 'review_type': 'individual', + 'reviewer_id': self.test_user_1.id, + 'python_code': 'rec.not_existing_field > 1.0', + }) + with self.assertRaises(UserError): + self.test_record.sudo(self.test_user_1.id).action_confirm() + + def test_10_dummy_tier_definition(self): + """Test tier.definition methods.""" + res = self.tier_def_obj._get_tier_validation_model_names() + self.assertEqual(res, []) + res = self.tier_def_obj.onchange_model_id() + self.assertTrue(res) diff --git a/base_tier_validation/tests/tier_validation_tester.py b/base_tier_validation/tests/tier_validation_tester.py new file mode 100644 index 0000000..dc51caf --- /dev/null +++ b/base_tier_validation/tests/tier_validation_tester.py @@ -0,0 +1,21 @@ +# Copyright 2018 Eficent Business and IT Consulting Services S.L. +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class TierValidationTester(models.Model): + _name = 'tier.validation.tester' + _inherit = ['tier.validation'] + + state = fields.Selection( + selection=[('draft', 'Draft'), + ('confirmed', 'Confirmed'), + ('cancel', 'Cancel')], + default='draft', + ) + test_field = fields.Float() + + @api.multi + def action_confirm(self): + self.write({'state': 'confirmed'}) diff --git a/base_tier_validation/views/tier_definition_view.xml b/base_tier_validation/views/tier_definition_view.xml new file mode 100644 index 0000000..5aadc35 --- /dev/null +++ b/base_tier_validation/views/tier_definition_view.xml @@ -0,0 +1,61 @@ + + + + + + tier.definition.tree + tier.definition + + + + + + + + + + + + tier.definition.form + tier.definition + +
+ + + + + + + + + + + + + + + + +
+
+
+ + + Tier Definition + ir.actions.act_window + tier.definition + form + tree,form + + + + + +
diff --git a/base_tier_validation/views/tier_review_view.xml b/base_tier_validation/views/tier_review_view.xml new file mode 100644 index 0000000..df0509f --- /dev/null +++ b/base_tier_validation/views/tier_review_view.xml @@ -0,0 +1,22 @@ + + + + + + tier.review.tree + tier.review + + + + + + + + + + + +