diff --git a/base_tier_validation_delegation/README.rst b/base_tier_validation_delegation/README.rst new file mode 100644 index 0000000000..5972617e90 --- /dev/null +++ b/base_tier_validation_delegation/README.rst @@ -0,0 +1,100 @@ +=============================== +Base Tier Validation Delegation +=============================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:80e0eb6d81b34912b48ec36fcb243ad8154a470253cd6509da92e245dbea4f91 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/16.0/base_tier_validation_delegation + :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-16-0/server-ux-16-0-base_tier_validation_delegation + :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=16.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module provides the core functionality for users to delegate their tier validation tasks to another user when they are out of the office. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +To configure Tier Validations Delegation, you need to: + +1. Go to **Settings > Users & Companies > Users** and select their own user profile (or click their name in the top right corner and select **My Profile**). +2. Navigate to the **Delegation** tab. +3. Check the **On Holiday** box. +4. Optionally, set the **Holiday Start/End Dates**. If no dates are set, the delegation is considered active as long as the "On Holiday" box is checked. +5. Select a **Default Replacer**. This is the user who will receive all validation requests. + +The module includes two daily automated jobs: + +- Holiday Status Update: This job automatically checks the "On Holiday" box for any user whose Holiday Start Date is today. This activation triggers the same logic as a manual change, meaning all of their existing pending reviews are automatically reassigned to their replacer. It also unchecks the box for users whose Holiday End Date has passed. +- Delegation Reminder: This job sends a reminder notification to users 3 days before their scheduled holiday if they have not yet configured a replacer. + +Usage +===== + +To use this module, you need to: + +* Any new tier review assigned to the original user will be automatically assigned to the final user in the delegation chain. +* Any existing pending reviews will be reassigned when the "On Holiday" status is activated. +* A message is posted in the chatter of the related document to inform that the review has been delegated. + +Delegators can track the reviews they have delegated by going to **Settings > Tier Validations > My Delegated Reviews**. + +Administrators can manage all user delegations from **Settings > Users & Companies > Delegation Management**. + +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 +~~~~~~~ + +* 360 ERP + +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_delegation/__init__.py b/base_tier_validation_delegation/__init__.py new file mode 100644 index 0000000000..0650744f6b --- /dev/null +++ b/base_tier_validation_delegation/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/base_tier_validation_delegation/__manifest__.py b/base_tier_validation_delegation/__manifest__.py new file mode 100644 index 0000000000..a22ab593f3 --- /dev/null +++ b/base_tier_validation_delegation/__manifest__.py @@ -0,0 +1,20 @@ +# Copyright 2025 360ERP () +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl.html). + +{ + "name": "Base Tier Validation Delegation", + "summary": "Allows users to delegate tier validation tasks when out of office.", + "version": "16.0.1.0.0", + "development_status": "Beta", + "category": "Tools", + "website": "https://github.com/OCA/server-ux", + "author": "360 ERP, Odoo Community Association (OCA)", + "license": "AGPL-3", + "depends": ["base_tier_validation"], + "data": [ + "security/delegation_security.xml", + "data/cron.xml", + "views/res_users_views.xml", + "views/tier_review_views.xml", + ], +} diff --git a/base_tier_validation_delegation/data/cron.xml b/base_tier_validation_delegation/data/cron.xml new file mode 100644 index 0000000000..794eabe3cb --- /dev/null +++ b/base_tier_validation_delegation/data/cron.xml @@ -0,0 +1,38 @@ + + + + Delegation: Update Holiday Status + + code + model._cron_update_holiday_status() + + 1 + days + -1 + + Automatically activate or deactivate a user's 'On Holiday' status based on their configured start and end dates. + + + + Delegation: Send Holiday Reminder + + code + model._cron_send_delegation_reminder() + + 1 + days + -1 + + Notifies users 3 days before their scheduled holiday if they have not yet configured a replacer. + + diff --git a/base_tier_validation_delegation/models/__init__.py b/base_tier_validation_delegation/models/__init__.py new file mode 100644 index 0000000000..4463c67cff --- /dev/null +++ b/base_tier_validation_delegation/models/__init__.py @@ -0,0 +1,3 @@ +from . import res_users +from . import tier_review +from . import tier_validation diff --git a/base_tier_validation_delegation/models/res_users.py b/base_tier_validation_delegation/models/res_users.py new file mode 100644 index 0000000000..c123cdc732 --- /dev/null +++ b/base_tier_validation_delegation/models/res_users.py @@ -0,0 +1,174 @@ +# Copyright 2025 360ERP () +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl.html). + +import logging +from datetime import timedelta + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + +_logger = logging.getLogger(__name__) + + +class ResUsers(models.Model): + _inherit = "res.users" + + on_holiday = fields.Boolean( + help="Check this box if you are out of office and want to delegate your " + "validation tasks.", + ) + holiday_start_date = fields.Date() + holiday_end_date = fields.Date() + validation_replacer_id = fields.Many2one( + "res.users", + string="Default Replacer", + help="This user will receive your validation requests while you are on holiday.", + ) + + @api.constrains("on_holiday", "holiday_start_date", "holiday_end_date") + def _check_holiday_dates(self): + """Ensure end date is not before start date.""" + for user in self: + if ( + user.on_holiday + and user.holiday_start_date + and user.holiday_end_date + and user.holiday_start_date > user.holiday_end_date + ): + raise ValidationError( + _("Holiday End Date cannot be before the Start Date.") + ) + + @api.constrains("on_holiday", "validation_replacer_id") + def _check_validation_replacer(self): + """Ensures a user does not delegate to themselves or create a circular loop.""" + for user in self: + if not user.on_holiday or not user.validation_replacer_id: + continue + if user.validation_replacer_id == user: + raise ValidationError( + _("You cannot delegate validation tasks to yourself.") + ) + # Check for circular delegation (e.g., A->B->C->A) + next_replacer = user.validation_replacer_id + path = {user} + while next_replacer: + if next_replacer in path: + raise ValidationError( + _("You cannot create a circular delegation path.") + ) + path.add(next_replacer) + next_replacer = next_replacer.validation_replacer_id + + def _is_currently_on_holiday(self, today=None): + """ + Checks if a user is considered on holiday right now, respecting date ranges. + """ + self.ensure_one() + if not today: + today = fields.Date.context_today(self) + return ( + self.on_holiday + and self.validation_replacer_id + and (not self.holiday_start_date or self.holiday_start_date <= today) + and (not self.holiday_end_date or self.holiday_end_date >= today) + ) + + def _get_final_validation_replacer(self): + """ + Recursively finds the final active user in a delegation chain. + """ + self.ensure_one() + delegation_path = {self} + current_user = self + today = fields.Date.context_today(self) + + while current_user._is_currently_on_holiday(today=today): + next_user_candidate = current_user.validation_replacer_id + + if not next_user_candidate or not next_user_candidate.active: + _logger.debug( + "Delegation chain broken, falling back to '%s'.", current_user.login + ) + return current_user + + if next_user_candidate in delegation_path: + _logger.warning( + "Circular delegation detected, falling back to '%s'.", + current_user.login, + ) + return current_user + + delegation_path.add(next_user_candidate) + current_user = next_user_candidate + return current_user + + def write(self, vals): + """ + If a user's holiday status or replacer changes, find all their pending + reviews and trigger a re-computation of the reviewers. + """ + holiday_fields = [ + "on_holiday", + "holiday_start_date", + "holiday_end_date", + "validation_replacer_id", + ] + if not any(field in holiday_fields for field in vals): + return super().write(vals) + + users_to_recompute = self + + res = super().write(vals) + + if users_to_recompute: + self.env["tier.review"]._recompute_reviews_for_users(users_to_recompute) + return res + + @api.model + def _cron_update_holiday_status(self): + """ + A daily cron job to automatically activate or deactivate a user's + holiday status based on the configured start and end dates. + """ + _logger.info("CRON: Running automatic holiday status update.") + today = fields.Date.context_today(self) + users_to_activate = self.search( + [("on_holiday", "=", False), ("holiday_start_date", "=", today)] + ) + if users_to_activate: + users_to_activate.write({"on_holiday": True}) + users_to_deactivate = self.search( + [("on_holiday", "=", True), ("holiday_end_date", "<", today)] + ) + if users_to_deactivate: + users_to_deactivate.write({"on_holiday": False}) + _logger.info("CRON: Finished holiday status update.") + + @api.model + def _cron_send_delegation_reminder(self): + """ + Sends a reminder to users whose holiday is starting soon but have not + configured a replacer. + """ + _logger.info("CRON: Running delegation reminder check.") + reminder_date = fields.Date.context_today(self) + timedelta(days=3) + users_to_remind = self.search( + [ + ("holiday_start_date", "=", reminder_date), + ("on_holiday", "=", False), + ("validation_replacer_id", "=", False), + ] + ) + for user in users_to_remind: + user.partner_id.message_post( + body=_( + "Your holiday is scheduled to start on %s. Please remember to " + "configure a validation replacer in your preferences to avoid " + "blocking any documents." + ) + % user.holiday_start_date, + message_type="notification", + subtype_xmlid="mail.mt_comment", + ) + _logger.info("CRON: Finished delegation reminder check.") diff --git a/base_tier_validation_delegation/models/tier_review.py b/base_tier_validation_delegation/models/tier_review.py new file mode 100644 index 0000000000..b32bbe73ad --- /dev/null +++ b/base_tier_validation_delegation/models/tier_review.py @@ -0,0 +1,119 @@ +# Copyright 2025 360ERP () +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl.html). + +import logging + +from odoo import _, api, fields, models + +_logger = logging.getLogger(__name__) + + +class TierReview(models.Model): + _inherit = "tier.review" + + delegated_by_ids = fields.Many2many( + comodel_name="res.users", + relation="tier_review_delegated_by_rel", + column1="review_id", + column2="user_id", + string="Delegated By", + compute="_compute_reviewer_ids", + store=True, + help="Original users who delegated this review to the current reviewers.", + ) + + def _get_original_reviewers(self): + """ + Helper method to get the reviewers as defined on the tier definition, + bypassing any delegation logic from this module's override of `_get_reviewers`. + This is a safe copy of the logic from the base `base_tier_validation` module. + """ + self.ensure_one() + if self.definition_id.review_type == "individual": + return self.definition_id.reviewer_id + if self.definition_id.review_type == "group": + return self.definition_id.reviewer_group_id.users + if self.definition_id.review_type == "field": + resource = self.env[self.model].browse(self.res_id) + reviewer_field = getattr( + resource, self.definition_id.reviewer_field_id.name, False + ) + if reviewer_field and reviewer_field._name == "res.users": + return reviewer_field + return self.env["res.users"] + + @api.depends(lambda self: self._get_reviewer_fields()) + def _compute_reviewer_ids(self): + """ + Computes the final reviewers after applying delegation logic and also + populates `delegated_by_ids` with any users whose reviews were reassigned. + """ + old_reviewers_map = {rec.id: rec.reviewer_ids for rec in self} + res = super()._compute_reviewer_ids() + for rec in self: + original_reviewers = rec._get_original_reviewers() + final_reviewers = rec.reviewer_ids + # The difference between original and final reviewers are the delegators + delegators = original_reviewers - final_reviewers + rec.delegated_by_ids = delegators + + # Post chatter message on change + old_reviewers = old_reviewers_map.get(rec.id) + if old_reviewers is not None and old_reviewers != final_reviewers: + added = final_reviewers - old_reviewers + removed = old_reviewers - final_reviewers + if added and removed: + record = self.env[rec.model].browse(rec.res_id) + if record: + from_names = ", ".join(removed.mapped("name")) + to_names = ", ".join(added.mapped("name")) + body = _( + f"Review task delegated from {from_names}" + f" to {to_names}." + ) + record.message_post(body=body) + return res + + def _get_reviewers(self): + """ + Overrides the base method to apply delegation logic. It gets the + original reviewers and then substitutes anyone who is on holiday with + their designated replacer. + """ + original_reviewers = super()._get_reviewers() + final_reviewers = self.env["res.users"] + for user in original_reviewers: + final_replacer = user._get_final_validation_replacer() + if user != final_replacer: + _logger.debug( + "Review ID %s: User '%s' delegated to '%s'.", + self.id, + user.login, + final_replacer.login, + ) + final_reviewers |= final_replacer + # Return a unique set of reviewers + return final_reviewers + + @api.model + def _recompute_reviews_for_users(self, users): + """ + Finds all pending reviews assigned to a given set of users (or delegated + by them) and triggers a re-computation of their reviewers. + """ + if not users: + return + + # Find all pending reviews where any of the given users are either + # a current reviewer OR the original delegator. This ensures we find + # reviews even after they have been delegated. + domain = [ + ("status", "=", "pending"), + "|", + ("reviewer_ids", "in", users.ids), + ("delegated_by_ids", "in", users.ids), + ] + affected_reviews = self.search(domain) + + if affected_reviews: + affected_reviews._compute_reviewer_ids() diff --git a/base_tier_validation_delegation/models/tier_validation.py b/base_tier_validation_delegation/models/tier_validation.py new file mode 100644 index 0000000000..947f7e89a9 --- /dev/null +++ b/base_tier_validation_delegation/models/tier_validation.py @@ -0,0 +1,127 @@ +# Copyright 2025 360ERP () +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). + +import logging + +from odoo import models + +_logger = logging.getLogger(__name__) + + +class TierValidation(models.AbstractModel): + _inherit = "tier.validation" + + def _find_review_for_delegate(self): + """ + Finds a pending review where the current user is a delegate for one of + the original reviewers. + :return: A tuple of (tier.review, res.users) for the review and the + original delegator, or empty recordsets if not found. + """ + self.ensure_one() + user = self.env.user + for review in self.review_ids.filtered(lambda r: r.status == "pending"): + for original_reviewer in review._get_original_reviewers(): + if original_reviewer._get_final_validation_replacer() == user: + return review, original_reviewer + return self.env["tier.review"], self.env["res.users"] + + def _execute_as_delegate(self, action): + """ + Helper to perform an action (validate or reject) on behalf of a delegator. + It temporarily adds the current user to the reviewers list to pass + the base method's security checks. + :param action: string, either 'validate' or 'reject' + :return: A tuple containing (result_of_action, delegator_user, review) + """ + review, delegator = self._find_review_for_delegate() + if not review: + return None, self.env["res.users"], self.env["tier.review"] + + _logger.debug( + "DELEGATION [%s]: User '%s' is a delegate for review %s.", + action, + self.env.user.login, + review.id, + ) + # Temporarily add the delegate to pass base security checks + review.sudo().reviewer_ids = [(4, self.env.user.id)] + + # Add a context key to prevent re-entrant calls + ctx = self.with_context(in_delegation_flow=True).env.context + + if action == "validate": + res = super(TierValidation, self.with_context(**ctx))._validate_tier( + tiers=review + ) + else: + res = super(TierValidation, self.with_context(**ctx))._rejected_tier( + tiers=review + ) + + return res, delegator, review + + def _validate_tier(self, tiers=False): + """ + Allows a delegate user to validate a tier. + """ + self.ensure_one() + # If we are already in the delegation flow, do not re-run the logic. + if self.env.context.get("in_delegation_flow"): + return super()._validate_tier(tiers=tiers) + + # If user is a direct reviewer, use the standard method. + if self.review_ids.filtered( + lambda r: self.env.user in r.reviewer_ids and r.status == "pending" + ): + return super()._validate_tier(tiers=tiers) + + # If not a direct reviewer, check if they are a delegate. + res, _delegator, _review = self._execute_as_delegate("validate") + if res is not None: + return res + + # Fallback to the standard method (which will likely raise an error) + return super()._validate_tier(tiers=tiers) + + def _rejected_tier(self, tiers=False): + """ + Allows a delegate user to reject a tier. Advanced policies are + handled in the 'policy' module. + This method now returns context for inheriting modules. + :return: A tuple of (result, delegator, rejected_review) + """ + self.ensure_one() + # If we are already in the delegation flow, do not re-run the logic. + if self.env.context.get("in_delegation_flow"): + res = super()._rejected_tier(tiers=tiers) + return res, self.env["res.users"], self.env["tier.review"] + + user = self.env.user + delegator_to_notify = self.env["res.users"] + rejected_review = self.env["tier.review"] + res = None + + # If user is a direct reviewer, use the standard method. + if self.review_ids.filtered( + lambda r: user in r.reviewer_ids and r.status == "pending" + ): + res = super()._rejected_tier(tiers=tiers) + # Find the delegator if the current user is also a replacer for someone else + delegator_to_notify = self.review_ids.delegated_by_ids.filtered( + lambda u: u._get_final_validation_replacer() == user + ) + rejected_review = tiers or self.review_ids.filtered( + lambda r: r.status == "rejected" + ) + else: + # If not a direct reviewer, check if they are a delegate. + res, delegator_to_notify, rejected_review = self._execute_as_delegate( + "reject" + ) + if res is None: + # Fallback to the standard method if not a delegate + res = super()._rejected_tier(tiers=tiers) + + # Return all context for other modules to use + return res, delegator_to_notify, rejected_review diff --git a/base_tier_validation_delegation/readme/CONFIGURE.rst b/base_tier_validation_delegation/readme/CONFIGURE.rst new file mode 100644 index 0000000000..921364fd9d --- /dev/null +++ b/base_tier_validation_delegation/readme/CONFIGURE.rst @@ -0,0 +1,12 @@ +To configure Tier Validations Delegation, you need to: + +1. Go to **Settings > Users & Companies > Users** and select their own user profile (or click their name in the top right corner and select **My Profile**). +2. Navigate to the **Delegation** tab. +3. Check the **On Holiday** box. +4. Optionally, set the **Holiday Start/End Dates**. If no dates are set, the delegation is considered active as long as the "On Holiday" box is checked. +5. Select a **Default Replacer**. This is the user who will receive all validation requests. + +The module includes two daily automated jobs: + +- Holiday Status Update: This job automatically checks the "On Holiday" box for any user whose Holiday Start Date is today. This activation triggers the same logic as a manual change, meaning all of their existing pending reviews are automatically reassigned to their replacer. It also unchecks the box for users whose Holiday End Date has passed. +- Delegation Reminder: This job sends a reminder notification to users 3 days before their scheduled holiday if they have not yet configured a replacer. diff --git a/base_tier_validation_delegation/readme/DESCRIPTION.rst b/base_tier_validation_delegation/readme/DESCRIPTION.rst new file mode 100644 index 0000000000..a04a79dede --- /dev/null +++ b/base_tier_validation_delegation/readme/DESCRIPTION.rst @@ -0,0 +1 @@ +This module provides the core functionality for users to delegate their tier validation tasks to another user when they are out of the office. diff --git a/base_tier_validation_delegation/readme/USAGE.rst b/base_tier_validation_delegation/readme/USAGE.rst new file mode 100644 index 0000000000..253efa7234 --- /dev/null +++ b/base_tier_validation_delegation/readme/USAGE.rst @@ -0,0 +1,9 @@ +To use this module, you need to: + +* Any new tier review assigned to the original user will be automatically assigned to the final user in the delegation chain. +* Any existing pending reviews will be reassigned when the "On Holiday" status is activated. +* A message is posted in the chatter of the related document to inform that the review has been delegated. + +Delegators can track the reviews they have delegated by going to **Settings > Tier Validations > My Delegated Reviews**. + +Administrators can manage all user delegations from **Settings > Users & Companies > Delegation Management**. diff --git a/base_tier_validation_delegation/security/delegation_security.xml b/base_tier_validation_delegation/security/delegation_security.xml new file mode 100644 index 0000000000..e058dc5230 --- /dev/null +++ b/base_tier_validation_delegation/security/delegation_security.xml @@ -0,0 +1,11 @@ + + + + Delegation Administrator + + + The delegation administrator can manage the holiday/delegation settings for any user. + + diff --git a/base_tier_validation_delegation/static/description/index.html b/base_tier_validation_delegation/static/description/index.html new file mode 100644 index 0000000000..ef381cfcf0 --- /dev/null +++ b/base_tier_validation_delegation/static/description/index.html @@ -0,0 +1,445 @@ + + + + + +Base Tier Validation Delegation + + + +
+

Base Tier Validation Delegation

+ + +

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

+

This module provides the core functionality for users to delegate their tier validation tasks to another user when they are out of the office.

+

Table of contents

+ +
+

Configuration

+

To configure Tier Validations Delegation, you need to:

+
    +
  1. Go to Settings > Users & Companies > Users and select their own user profile (or click their name in the top right corner and select My Profile).
  2. +
  3. Navigate to the Delegation tab.
  4. +
  5. Check the On Holiday box.
  6. +
  7. Optionally, set the Holiday Start/End Dates. If no dates are set, the delegation is considered active as long as the “On Holiday” box is checked.
  8. +
  9. Select a Default Replacer. This is the user who will receive all validation requests.
  10. +
+

The module includes two daily automated jobs:

+
    +
  • Holiday Status Update: This job automatically checks the “On Holiday” box for any user whose Holiday Start Date is today. This activation triggers the same logic as a manual change, meaning all of their existing pending reviews are automatically reassigned to their replacer. It also unchecks the box for users whose Holiday End Date has passed.
  • +
  • Delegation Reminder: This job sends a reminder notification to users 3 days before their scheduled holiday if they have not yet configured a replacer.
  • +
+
+
+

Usage

+

To use this module, you need to:

+
    +
  • Any new tier review assigned to the original user will be automatically assigned to the final user in the delegation chain.
  • +
  • Any existing pending reviews will be reassigned when the “On Holiday” status is activated.
  • +
  • A message is posted in the chatter of the related document to inform that the review has been delegated.
  • +
+

Delegators can track the reviews they have delegated by going to Settings > Tier Validations > My Delegated Reviews.

+

Administrators can manage all user delegations from Settings > Users & Companies > Delegation Management.

+
+
+

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

+
    +
  • 360 ERP
  • +
+
+
+

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_delegation/tests/__init__.py b/base_tier_validation_delegation/tests/__init__.py new file mode 100644 index 0000000000..409b1d3be2 --- /dev/null +++ b/base_tier_validation_delegation/tests/__init__.py @@ -0,0 +1 @@ +from . import test_delegation diff --git a/base_tier_validation_delegation/tests/test_delegation.py b/base_tier_validation_delegation/tests/test_delegation.py new file mode 100644 index 0000000000..02661e39df --- /dev/null +++ b/base_tier_validation_delegation/tests/test_delegation.py @@ -0,0 +1,430 @@ +# Copyright 2025 360ERP () +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl.html). + +from datetime import date, timedelta + +from odoo.exceptions import AccessError, ValidationError +from odoo.tests.common import tagged + +from odoo.addons.base_tier_validation.tests.common import CommonTierValidation + + +@tagged("post_install", "-at_install") +class TestTierValidationDelegation(CommonTierValidation): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.user_delegator = cls.test_user_1 + cls.user_replacer_b = cls.env["res.users"].create( + {"name": "User B (Replacer)", "login": "user_b", "email": "b@test.com"} + ) + cls.user_replacer_c = cls.env["res.users"].create( + {"name": "User C (Final)", "login": "user_c", "email": "c@test.com"} + ) + cls.admin_user = cls.env["res.users"].create( + {"name": "Delegation Admin", "login": "deleg_admin", "email": "da@test.com"} + ) + cls.delegation_admin_group = cls.env.ref( + "base_tier_validation_delegation.group_delegation_administrator" + ) + cls.admin_user.write({"groups_id": [(4, cls.delegation_admin_group.id)]}) + + cls.test_group = cls.env["res.groups"].create({"name": "Test Review Group"}) + cls.test_user_1.write({"groups_id": [(4, cls.test_group.id)]}) + cls.test_user_2.write({"groups_id": [(4, cls.test_group.id)]}) + + def tearDown(self): + super().tearDown() + users_to_reset = ( + self.user_delegator + | self.user_replacer_b + | self.user_replacer_c + | self.test_user_2 + ) + users_to_reset.write( + {"on_holiday": False, "validation_replacer_id": False, "active": True} + ) + + def _create_record_and_request_validation(self, test_field_value=2.5): + record = self.test_model.create({"test_field": test_field_value}) + record.with_user(self.test_user_2).request_validation() + reviews = self.env["tier.review"].search([("res_id", "=", record.id)]) + self.assertTrue(reviews, "HELPER: Failed to create any tier reviews.") + return record, reviews + + def test_01_new_validation_delegation(self): + """Test that a new validation is immediately delegated.""" + self.user_delegator.write( + {"on_holiday": True, "validation_replacer_id": self.user_replacer_b.id} + ) + _record, review = self._create_record_and_request_validation() + self.assertIn(self.user_replacer_b, review.reviewer_ids) + self.assertNotIn(self.user_delegator, review.reviewer_ids) + + def test_02_pending_validation_delegation(self): + """Test that a pending validation is re-assigned when a user goes on holiday.""" + _record, review = self._create_record_and_request_validation() + self.assertIn(self.user_delegator, review.reviewer_ids) + self.user_delegator.write( + {"on_holiday": True, "validation_replacer_id": self.user_replacer_b.id} + ) + self.assertIn(self.user_replacer_b, review.reviewer_ids) + self.assertNotIn(self.user_delegator, review.reviewer_ids) + + def test_03_delegation_with_date_range(self): + """Test that delegation only occurs within the specified date range.""" + today = date.today() + self.user_delegator.write( + { + "on_holiday": True, + "holiday_start_date": today + timedelta(days=5), + "validation_replacer_id": self.user_replacer_b.id, + } + ) + _record, review = self._create_record_and_request_validation() + self.assertIn( + self.user_delegator, + review.reviewer_ids, + "Review should not be delegated before the holiday start date.", + ) + self.user_delegator.holiday_start_date = today - timedelta(days=1) + review._compute_reviewer_ids() + self.assertIn( + self.user_replacer_b, + review.reviewer_ids, + "Review should be delegated once within the holiday period.", + ) + + def test_04_delegation_chain(self): + """Test that a review is delegated to the end of a chain (A->B->C).""" + self.user_delegator.write( + {"on_holiday": True, "validation_replacer_id": self.user_replacer_b.id} + ) + self.user_replacer_b.write( + {"on_holiday": True, "validation_replacer_id": self.user_replacer_c.id} + ) + _record, review = self._create_record_and_request_validation() + self.assertIn(self.user_replacer_c, review.reviewer_ids) + self.assertNotIn(self.user_delegator, review.reviewer_ids) + self.assertNotIn(self.user_replacer_b, review.reviewer_ids) + + def test_05_group_review_delegation(self): + """Test delegation for a review assigned to a group where one member is away.""" + group_tier_def = self.env["tier.definition"].create( + { + "model_id": self.tester_model.id, + "review_type": "group", + "reviewer_group_id": self.test_group.id, + } + ) + self.user_delegator.write( + {"on_holiday": True, "validation_replacer_id": self.user_replacer_c.id} + ) + _record, reviews = self._create_record_and_request_validation( + test_field_value=0.5 + ) + review = reviews.filtered(lambda r: r.definition_id == group_tier_def) + self.assertTrue(review) + self.assertIn(self.test_user_2, review.reviewer_ids) + self.assertIn(self.user_replacer_c, review.reviewer_ids) + self.assertNotIn(self.user_delegator, review.reviewer_ids) + + def test_06_user_returns_from_holiday_default(self): + """Test that pending reviews are reassigned back when a user returns.""" + self.user_delegator.write( + {"on_holiday": True, "validation_replacer_id": self.user_replacer_b.id} + ) + ( + _record_while_away, + review_while_away, + ) = self._create_record_and_request_validation() + self.assertIn(self.user_replacer_b, review_while_away.reviewer_ids) + self.user_delegator.write({"on_holiday": False}) + review_while_away.invalidate_recordset(["reviewer_ids"]) + self.assertIn( + self.user_delegator, + review_while_away.reviewer_ids, + "Pending review should be reassigned back to the original user.", + ) + self.assertNotIn( + self.user_replacer_b, + review_while_away.reviewer_ids, + "Replacer should be removed after the original user returns.", + ) + ( + _record_after_return, + review_after_return, + ) = self._create_record_and_request_validation() + self.assertIn(self.user_delegator, review_after_return.reviewer_ids) + + def test_07_no_replacer_configured(self): + """Test that if 'On Holiday' is checked with no replacer, delegation does not occur.""" + self.user_delegator.write({"on_holiday": True, "validation_replacer_id": False}) + _record, review = self._create_record_and_request_validation() + self.assertIn(self.user_delegator, review.reviewer_ids) + + def test_08_self_delegation_constraint(self): + """Test that a user cannot delegate to themselves.""" + with self.assertRaises( + ValidationError, msg="Should not be able to delegate to self." + ): + self.user_delegator.write( + { + "on_holiday": True, + "validation_replacer_id": self.user_delegator.id, + } + ) + + def test_10_visual_indicator_and_menu(self): + """Test that `delegated_by_ids` is set and the menu domain works.""" + self.user_delegator.write( + {"on_holiday": True, "validation_replacer_id": self.user_replacer_b.id} + ) + _record, review = self._create_record_and_request_validation() + self.assertEqual(review.delegated_by_ids, self.user_delegator) + delegated_reviews = ( + self.env["tier.review"] + .with_user(self.user_delegator) + .search([("delegated_by_ids", "in", [self.user_delegator.id])]) + ) + self.assertEqual(review, delegated_reviews) + + def test_11_admin_management(self): + """Test that an admin can edit others' settings, but a normal user cannot.""" + with self.assertRaises( + AccessError, + msg="Normal user should not be able to edit other users' delegation.", + ): + self.user_delegator.with_user(self.test_user_2).write({"on_holiday": True}) + self.user_delegator.with_user(self.user_delegator).write( + {"on_holiday": True, "validation_replacer_id": self.user_replacer_b.id} + ) + self.user_delegator.with_user(self.admin_user).write( + {"on_holiday": False, "validation_replacer_id": False} + ) + + def test_12_cron_job(self): + """Test the automatic activation/deactivation cron job.""" + today = date.today() + user_to_activate = self.user_replacer_b + user_to_deactivate = self.user_replacer_c + user_to_activate.write( + { + "on_holiday": False, + "holiday_start_date": today, + "validation_replacer_id": self.user_delegator.id, + } + ) + user_to_deactivate.write( + {"on_holiday": True, "holiday_end_date": today - timedelta(days=1)} + ) + self.env["res.users"]._cron_update_holiday_status() + self.assertTrue(user_to_activate.on_holiday) + self.assertFalse(user_to_deactivate.on_holiday) + + def test_14_circular_delegation_constraint(self): + """Test that a circular delegation (A->B->A) is prevented.""" + self.user_delegator.write( + {"on_holiday": True, "validation_replacer_id": self.user_replacer_b.id} + ) + with self.assertRaises( + ValidationError, msg="Should not be able to create a delegation loop." + ): + self.user_replacer_b.write( + { + "on_holiday": True, + "validation_replacer_id": self.user_delegator.id, + } + ) + + def test_15_delegation_to_archived_user(self): + """Test that delegation falls back to the original user if the replacer is archived.""" + self.user_replacer_b.action_archive() + self.assertFalse(self.user_replacer_b.active) + self.user_delegator.write( + {"on_holiday": True, "validation_replacer_id": self.user_replacer_b.id} + ) + _record, review = self._create_record_and_request_validation() + self.assertIn( + self.user_delegator, + review.reviewer_ids, + "Review should fall back to delegator if replacer is inactive.", + ) + self.assertNotIn(self.user_replacer_b, review.reviewer_ids) + + def test_16_multi_tier_delegation(self): + """Test that delegation works correctly in a multi-tier validation flow.""" + self.env["tier.definition"].create( + { + "model_id": self.tester_model.id, + "review_type": "individual", + "reviewer_id": self.user_replacer_c.id, + "sequence": 40, + } + ) + self.user_delegator.write( + {"on_holiday": True, "validation_replacer_id": self.user_replacer_b.id} + ) + record, reviews = self._create_record_and_request_validation() + + tier1_review = reviews.filtered( + lambda r: r.definition_id.reviewer_id == self.user_delegator + ) + tier2_review = reviews.filtered( + lambda r: r.definition_id.reviewer_id == self.user_replacer_c + ) + + self.assertIn(self.user_replacer_b, tier1_review.reviewer_ids) + + # Validate the first tier as the replacer + record.with_user(self.user_replacer_b).validate_tier() + + # Invalidate cache to ensure we read the latest status + tier1_review.invalidate_recordset(["status"]) + tier2_review.invalidate_recordset(["status"]) + + self.assertEqual(tier1_review.status, "approved", "Tier 1 should be approved.") + self.assertEqual( + tier2_review.status, "pending", "Tier 2 should now be pending." + ) + + def test_17_delegation_by_field_reviewer(self): + """ + Test Case for the Primary Fix. + + This test ensures that a pending review is correctly delegated when the + reviewer was assigned via a dynamic field (`review_type` = 'field'). + This was the main cause of the cron job issue. + """ + self.env.user.clear_caches() # Ensure fresh reads + + # 1. Setup: Create a tier definition that uses a field to find the reviewer. + reviewer_field = self.env["ir.model.fields"].search( + [ + ("model", "=", "tier.validation.tester"), + ("name", "=", "user_id"), + ], + limit=1, + ) + self.assertTrue(reviewer_field, "Setup failed: Could not find 'user_id' field.") + + field_tier_def = self.env["tier.definition"].create( + { + "model_id": self.tester_model.id, + "review_type": "field", + "reviewer_field_id": reviewer_field.id, + "name": "Field-Based Review", + } + ) + + # Create a record where the 'user_id' is our delegator + record = self.test_model.create( + {"test_field": 1.0, "user_id": self.user_delegator.id} + ) + record.with_user(self.test_user_2).request_validation() + review = self.env["tier.review"].search( + [ + ("res_id", "=", record.id), + ("definition_id", "=", field_tier_def.id), + ] + ) + + self.assertTrue(review, "Test setup failed: Review was not created.") + self.assertIn( + self.user_delegator, + review.reviewer_ids, + "Initial reviewer should be the user from the 'user_id' field.", + ) + + # 2. Action: The user goes on holiday. This triggers the fixed `write` + # method, which in turn calls the fixed `_recompute_reviews_for_users`. + self.user_delegator.write( + {"on_holiday": True, "validation_replacer_id": self.user_replacer_b.id} + ) + + # 3. Assert: The pending review should now be assigned to the replacer. + review.invalidate_recordset(["reviewer_ids"]) # Refresh the field from the DB + self.assertIn( + self.user_replacer_b, + review.reviewer_ids, + "Review should have been delegated to the replacer.", + ) + self.assertNotIn( + self.user_delegator, + review.reviewer_ids, + "Original reviewer should have been removed after delegation.", + ) + + def test_18_change_replacer_while_on_holiday(self): + """ + Test Case for the Secondary Fix. + + This test ensures that if a user is already on holiday and their + replacer is changed, their pending reviews are correctly moved from + the old replacer to the new one. + """ + # 1. Setup: User is on holiday, and a review is delegated to Replacer B. + self.user_delegator.write( + {"on_holiday": True, "validation_replacer_id": self.user_replacer_b.id} + ) + record, review = self._create_record_and_request_validation() + + self.assertIn( + self.user_replacer_b, + review.reviewer_ids, + "Initial delegation to Replacer B should have occurred.", + ) + self.assertNotIn(self.user_delegator, review.reviewer_ids) + + # 2. Action: While still on holiday, the user changes their replacer to C. + # This triggers the improved `write` method in res.users. + self.user_delegator.write({"validation_replacer_id": self.user_replacer_c.id}) + + # 3. Assert: The review should be moved from B to C. + review.invalidate_recordset(["reviewer_ids"]) + self.assertIn( + self.user_replacer_c, + review.reviewer_ids, + "Review should have been re-delegated to the new replacer (C).", + ) + self.assertNotIn( + self.user_replacer_b, + review.reviewer_ids, + "The old replacer (B) should no longer be a reviewer.", + ) + + def test_19_return_from_holiday_reassigns_pending(self): + """ + Test Case for returning from holiday. + + This test verifies the new behavior introduced by the fix: when a user + returns from holiday (`on_holiday`=False), their pending reviews that + were delegated are now reassigned back to them. + """ + # 1. Setup: User is on holiday, and a review is delegated. + self.user_delegator.write( + {"on_holiday": True, "validation_replacer_id": self.user_replacer_b.id} + ) + _record, review = self._create_record_and_request_validation() + + self.assertIn( + self.user_replacer_b, + review.reviewer_ids, + "Review should be with the replacer while user is on holiday.", + ) + + # 2. Action: The user returns from holiday. + self.user_delegator.write({"on_holiday": False}) + + # 3. Assert: The pending review is reassigned back to the original user. + review.invalidate_recordset(["reviewer_ids"]) + self.assertIn( + self.user_delegator, + review.reviewer_ids, + "Pending review should be reassigned back to the original user upon their return.", + ) + self.assertNotIn( + self.user_replacer_b, + review.reviewer_ids, + "Replacer should be removed from the review once the original user returns.", + ) diff --git a/base_tier_validation_delegation/views/res_users_views.xml b/base_tier_validation_delegation/views/res_users_views.xml new file mode 100644 index 0000000000..c9e5831bc3 --- /dev/null +++ b/base_tier_validation_delegation/views/res_users_views.xml @@ -0,0 +1,113 @@ + + + + res.users + + + + + + + + + + + + + + + + res.users.form.simple.modif.delegation + res.users + + + + + + + + + + + + + + + + res.users + + + + + + + + + res.users.tree.delegation + res.users + + + + + + + + + + + Delegation Management + res.users + tree,form + + {'search_default_delegation_active': 1} + + + diff --git a/base_tier_validation_delegation/views/tier_review_views.xml b/base_tier_validation_delegation/views/tier_review_views.xml new file mode 100644 index 0000000000..9b93df5ef7 --- /dev/null +++ b/base_tier_validation_delegation/views/tier_review_views.xml @@ -0,0 +1,73 @@ + + + + + tier.review + + + + + + + + + + tier.review.form + tier.review + +
+ + + + + + + + + + + + + + + + + + +
+
+
+ + + My Delegated Reviews + tier.review + tree,form + [('delegated_by_ids', 'in', [uid])] + {'search_default_pending': 1} + +

+ No reviews have been delegated by you. +

+

+ This screen shows the reviews that you have delegated to other users. +

+
+
+ + +
diff --git a/setup/base_tier_validation_delegation/odoo/addons/base_tier_validation_delegation b/setup/base_tier_validation_delegation/odoo/addons/base_tier_validation_delegation new file mode 120000 index 0000000000..16c73e3e98 --- /dev/null +++ b/setup/base_tier_validation_delegation/odoo/addons/base_tier_validation_delegation @@ -0,0 +1 @@ +../../../../base_tier_validation_delegation \ No newline at end of file diff --git a/setup/base_tier_validation_delegation/setup.py b/setup/base_tier_validation_delegation/setup.py new file mode 100644 index 0000000000..28c57bb640 --- /dev/null +++ b/setup/base_tier_validation_delegation/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)