diff --git a/base_user_role_activity/README.rst b/base_user_role_activity/README.rst new file mode 100644 index 000000000..df849f90b --- /dev/null +++ b/base_user_role_activity/README.rst @@ -0,0 +1,108 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +===================== +User roles activities +===================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:b3aa00f609d45dcd82a69b2a3e13b327096ab1d25883b543e3e45538e854d40c + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/license-LGPL--3-blue.png + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fserver--backend-lightgray.png?logo=github + :target: https://github.com/OCA/server-backend/tree/19.0/base_user_role_activity + :alt: OCA/server-backend +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/server-backend-19-0/server-backend-19-0-base_user_role_activity + :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-backend&target_branch=19.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +System Parameter +================ + +The activity is created ``base_user_role_activity.reminder_days = 30`` +(days) before the end date by a scheduled action "User role expire +reminder" and automatically removed, when asignment end date on the user +role assignment is adapted. + +.. image:: https://raw.githubusercontent.com/OCA/server-backend/19.0/base_user_role_activity/static/description/role_assignment.drawio.png + :alt: role assignemnt + +Activity +======== + +When a user role is about to expire (default=30 days), an activity is +created for the manager of a user (employee). When the user has no +Manager, the activity is assigend to user. The activity is linked to the +users res.partner entity. + +.. image:: https://raw.githubusercontent.com/OCA/server-backend/19.0/base_user_role_activity/static/description/activity.drawio.png + :alt: activity + +**Table of contents** + +.. contents:: + :local: + +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 +------- + +* glueckkanja AG + +Contributors +------------ + +- Christopher Rogos (https://www.glueckkanja.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. + +.. |maintainer-CRogos| image:: https://github.com/CRogos.png?size=40px + :target: https://github.com/CRogos + :alt: CRogos + +Current `maintainer `__: + +|maintainer-CRogos| + +This module is part of the `OCA/server-backend `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/base_user_role_activity/__init__.py b/base_user_role_activity/__init__.py new file mode 100644 index 000000000..0650744f6 --- /dev/null +++ b/base_user_role_activity/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/base_user_role_activity/__manifest__.py b/base_user_role_activity/__manifest__.py new file mode 100644 index 000000000..6dbf6be0c --- /dev/null +++ b/base_user_role_activity/__manifest__.py @@ -0,0 +1,21 @@ +# Copyright 2025 gluekkanja AG +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + + +{ + "name": "User roles activities", + "summary": "Create activities to remind about user role expirations", + "version": "19.0.1.0.0", + "category": "Tools", + "author": "glueckkanja AG, Odoo Community Association (OCA)", + "license": "LGPL-3", + "maintainers": ["CRogos"], + "website": "https://github.com/OCA/server-backend", + "depends": ["base_user_role", "hr"], + "data": [ + "data/ir_cron.xml", + "data/mail_activity_type.xml", + "data/config_parameter.xml", + ], + "installable": True, +} diff --git a/base_user_role_activity/data/config_parameter.xml b/base_user_role_activity/data/config_parameter.xml new file mode 100644 index 000000000..39b723c70 --- /dev/null +++ b/base_user_role_activity/data/config_parameter.xml @@ -0,0 +1,11 @@ + + + + base_user_role_activity.reminder_days + 30 + + diff --git a/base_user_role_activity/data/ir_cron.xml b/base_user_role_activity/data/ir_cron.xml new file mode 100644 index 000000000..c3e200a97 --- /dev/null +++ b/base_user_role_activity/data/ir_cron.xml @@ -0,0 +1,12 @@ + + + + User role expire reminder + 1 + days + True + + code + model.cron_role_reminder() + + diff --git a/base_user_role_activity/data/mail_activity_type.xml b/base_user_role_activity/data/mail_activity_type.xml new file mode 100644 index 000000000..afbdcea87 --- /dev/null +++ b/base_user_role_activity/data/mail_activity_type.xml @@ -0,0 +1,16 @@ + + + + User Role Expires + fa-clock-o + res.partner + +
+ A user role assignment for %%(user)s is about to expire. +
- %%(roles)s +

+ Please review and take the necessary action. +
+
+
+
diff --git a/base_user_role_activity/models/__init__.py b/base_user_role_activity/models/__init__.py new file mode 100644 index 000000000..bb28a6fdd --- /dev/null +++ b/base_user_role_activity/models/__init__.py @@ -0,0 +1,2 @@ +from . import role_line +from . import user diff --git a/base_user_role_activity/models/role_line.py b/base_user_role_activity/models/role_line.py new file mode 100644 index 000000000..3d07332d0 --- /dev/null +++ b/base_user_role_activity/models/role_line.py @@ -0,0 +1,54 @@ +import datetime +import logging + +from odoo import api, fields, models + +_logger = logging.getLogger(__name__) + + +class ResUsersRoleLine(models.Model): + _inherit = "res.users.role.line" + + def write(self, vals): + res = super().write(vals) + + if "date_to" in vals: + self.mapped("user_id").activity_update_role_reminder() + return + + return res + + def unlink(self): + users = self.mapped("user_id") + res = super().unlink() + users.activity_update_role_reminder() + return res + + @api.model + def cron_role_reminder(self): + _logger.info("Trigger role expiration reminders") + users = self.search(self._get_reminder_days_domain()).mapped("user_id") + users = users.filtered( + lambda user: not user.partner_id.activity_search( + ["base_user_role_activity.mail_activity_role_expire"] + ) + ) + users.activity_update_role_reminder() + + @api.model + def _get_reminder_days_domain(self): + reminder_days = int( + self.env["ir.config_parameter"] + .sudo() + .get_param("base_user_role_activity.reminder_days", 30) + ) + today = fields.Date.today() + domain = [ + ("date_to", ">=", today), + ("date_to", "<=", today + datetime.timedelta(days=reminder_days)), + "|", + ("date_from", "=", False), + ("date_from", "<", fields.Date.today()), # ignore short term roles + ] + + return domain diff --git a/base_user_role_activity/models/user.py b/base_user_role_activity/models/user.py new file mode 100644 index 000000000..8e14f791a --- /dev/null +++ b/base_user_role_activity/models/user.py @@ -0,0 +1,74 @@ +from markupsafe import Markup + +from odoo import models + + +class ResUsers(models.Model): + _inherit = "res.users" + + def activity_update_role_reminder(self): + activity_type_xmlid = "base_user_role_activity.mail_activity_role_expire" + for user in self: + expiring_lines = user.role_line_ids.filtered_domain( + self.env["res.users.role.line"]._get_reminder_days_domain() + ) + existing_active_activities = user.partner_id.activity_search( + [activity_type_xmlid] + ) + if not expiring_lines: + if existing_active_activities: + # cleanup outdated activities + existing_active_activities.unlink() + continue + + min_deadline = min(expiring_lines.mapped("date_to")) + if existing_active_activities: + existing_active_activity = existing_active_activities[0] + if existing_active_activity.date_deadline == min_deadline: + continue # No update needed + # something changed, remove existing activity and recreate + existing_active_activities.unlink() + + existing_activities = user.partner_id.with_context( + active_test=False + ).activity_search( + [activity_type_xmlid], + additional_domain=[("date_deadline", ">=", min_deadline)], + ) + if existing_activities: + continue # An activity with the correct deadline already processed + + activity_type_id = self.env["ir.model.data"]._xmlid_to_res_id( + activity_type_xmlid, raise_if_not_found=False + ) + activity_type = self.env["mail.activity.type"].browse(activity_type_id) + + # receive manager independet of selected company + employees = ( + self.env["hr.employee"] + .sudo() + .search([("user_id", "=", user.id), ("parent_id", "!=", False)]) + ) + manager = employees.filtered( + lambda e: e.company_id == self.env.company + ).parent_id + if not manager and employees: + manager = employees[0].parent_id + + user.partner_id.activity_schedule( + activity_type_id=activity_type_id, + date_deadline=min(expiring_lines.mapped("date_to")), + note=activity_type.default_note + % { + "user": user.self._get_html_link(), + "roles": Markup( + "
-".join( + [ + f"{line.role_id.name} ({line.date_to})" + for line in expiring_lines + ] + ) + ), + }, + user_id=manager.user_id.id or user.id, + ) diff --git a/base_user_role_activity/pyproject.toml b/base_user_role_activity/pyproject.toml new file mode 100644 index 000000000..4231d0ccc --- /dev/null +++ b/base_user_role_activity/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/base_user_role_activity/readme/CONTRIBUTORS.md b/base_user_role_activity/readme/CONTRIBUTORS.md new file mode 100644 index 000000000..0b26e7993 --- /dev/null +++ b/base_user_role_activity/readme/CONTRIBUTORS.md @@ -0,0 +1,3 @@ +- Christopher Rogos \<\> + () + diff --git a/base_user_role_activity/readme/DESCRIPTION.md b/base_user_role_activity/readme/DESCRIPTION.md new file mode 100644 index 000000000..a5b13c489 --- /dev/null +++ b/base_user_role_activity/readme/DESCRIPTION.md @@ -0,0 +1,14 @@ +# System Parameter + + +The activity is created `base_user_role_activity.reminder_days = 30` (days) before the end date by a scheduled action "User role expire reminder" and automatically removed, when asignment end date on the user role assignment is adapted. + +![role assignemnt](../static/description/role_assignment.drawio.png) + + +# Activity +When a user role is about to expire (default=30 days), an activity is created for the manager of a user (employee). When the user has no Manager, the activity is assigend to user. The activity is linked to the users res.partner entity. + +![activity](../static/description/activity.drawio.png) + + diff --git a/base_user_role_activity/static/description/activity.drawio.png b/base_user_role_activity/static/description/activity.drawio.png new file mode 100644 index 000000000..841f739e9 Binary files /dev/null and b/base_user_role_activity/static/description/activity.drawio.png differ diff --git a/base_user_role_activity/static/description/icon.png b/base_user_role_activity/static/description/icon.png new file mode 100644 index 000000000..3a0328b51 Binary files /dev/null and b/base_user_role_activity/static/description/icon.png differ diff --git a/base_user_role_activity/static/description/index.html b/base_user_role_activity/static/description/index.html new file mode 100644 index 000000000..236a5121e --- /dev/null +++ b/base_user_role_activity/static/description/index.html @@ -0,0 +1,435 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

User roles activities

+ +

Beta License: LGPL-3 OCA/server-backend Translate me on Weblate Try me on Runboat

+
+

System Parameter

+

The activity is created base_user_role_activity.reminder_days = 30 +(days) before the end date by a scheduled action “User role expire +reminder” and automatically removed, when asignment end date on the user +role assignment is adapted.

+role assignemnt +
+
+

Activity

+

When a user role is about to expire (default=30 days), an activity is +created for the manager of a user (employee). When the user has no +Manager, the activity is assigend to user. The activity is linked to the +users res.partner entity.

+activity +

Table of contents

+
+
+

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

+
    +
  • glueckkanja AG
  • +
+
+
+

Contributors

+ +
+
+

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.

+

Current maintainer:

+

CRogos

+

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

+

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

+
+
+
+
+ + diff --git a/base_user_role_activity/static/description/role_assignment.drawio.png b/base_user_role_activity/static/description/role_assignment.drawio.png new file mode 100644 index 000000000..5e4f96d0f Binary files /dev/null and b/base_user_role_activity/static/description/role_assignment.drawio.png differ diff --git a/base_user_role_activity/tests/__init__.py b/base_user_role_activity/tests/__init__.py new file mode 100644 index 000000000..a2883b193 --- /dev/null +++ b/base_user_role_activity/tests/__init__.py @@ -0,0 +1 @@ +from . import test_user_role_activity diff --git a/base_user_role_activity/tests/test_user_role_activity.py b/base_user_role_activity/tests/test_user_role_activity.py new file mode 100644 index 000000000..c7dbf1cb3 --- /dev/null +++ b/base_user_role_activity/tests/test_user_role_activity.py @@ -0,0 +1,295 @@ +# Test for activity_update_role_reminder +from freezegun import freeze_time + +from odoo import fields +from odoo.tests.common import TransactionCase, tagged + +ACTIVITY_XMLID = "base_user_role_activity.mail_activity_role_expire" + + +@tagged("post_install", "-at_install") +class TestActivityUpdateRoleReminder(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + # Multi-company dataset for manager selection tests + cls.company_a = cls.env["res.company"].create({"name": "Company A"}) + cls.company_b = cls.env["res.company"].create({"name": "Company B"}) + + cls.manager_user_a = cls.env["res.users"].create( + { + "name": "Manager A", + "login": "manager_a_role_activity", + "company_id": cls.company_a.id, + "company_ids": [fields.Command.set([cls.company_a.id])], + } + ) + cls.manager_user_b = cls.env["res.users"].create( + { + "name": "Manager B", + "login": "manager_b_role_activity", + "company_id": cls.company_b.id, + "company_ids": [fields.Command.set([cls.company_b.id])], + } + ) + + cls.user = cls.env["res.users"].create( + { + "name": "Employee User", + "login": "employee_user_role_activity", + "company_id": cls.company_a.id, + "company_ids": [ + fields.Command.set([cls.company_a.id, cls.company_b.id]) + ], + } + ) + cls.partner = cls.user.partner_id + + cls.manager_employee_a = cls.env["hr.employee"].create( + { + "name": "Manager Employee A", + "user_id": cls.manager_user_a.id, + "company_id": cls.company_a.id, + } + ) + cls.manager_employee_b = cls.env["hr.employee"].create( + { + "name": "Manager Employee B", + "user_id": cls.manager_user_b.id, + "company_id": cls.company_b.id, + } + ) + + cls.employee_in_a = cls.env["hr.employee"].create( + { + "name": "Employee in A", + "user_id": cls.user.id, + "company_id": cls.company_a.id, + "parent_id": cls.manager_employee_a.id, + } + ) + cls.employee_in_b = cls.env["hr.employee"].create( + { + "name": "Employee in B", + "user_id": cls.user.id, + "company_id": cls.company_b.id, + "parent_id": cls.manager_employee_b.id, + } + ) + + cls.role = cls.env["res.users.role"].create( + { + "name": "Test Role", + } + ) + cls.role_2 = cls.env["res.users.role"].create( + { + "name": "Test Role 2", + } + ) + cls.role_line_1 = cls.env["res.users.role.line"].create( + { + "user_id": cls.user.id, + "role_id": cls.role.id, + "date_from": fields.Date.from_string("2024-01-01"), + "date_to": fields.Date.from_string("2025-02-01"), + } + ) + cls.role_line_2 = cls.env["res.users.role.line"].create( + { + "user_id": cls.user.id, + "role_id": cls.role_2.id, + "date_from": fields.Date.from_string("2024-01-12"), + "date_to": fields.Date.from_string("2025-01-12"), + } + ) + cls.activity_type = cls.env.ref(ACTIVITY_XMLID) + + @freeze_time("2025-01-01") + def test_create_activity_when_expiring_lines(self): + """Should create activity when expiring lines exist and no activity exists.""" + self.user.activity_update_role_reminder() + activities = self.partner.activity_search([ACTIVITY_XMLID]) + self.assertTrue(activities) + + @freeze_time("2025-01-01") + def test_cleanup_activity_when_no_expiring_lines(self): + """Should remove activity when no expiring lines exist.""" + self.user.activity_update_role_reminder() # Create activity + self.role_line_1.unlink() + self.role_line_2.unlink() + activities = self.partner.activity_search([ACTIVITY_XMLID]) + self.assertFalse(activities) + + @freeze_time("2025-01-01") + def test_no_update_needed_if_deadline_unchanged(self): + """Should not update activity if deadline is unchanged.""" + self.user.activity_update_role_reminder() # Create activity + activities_before = self.partner.activity_search([ACTIVITY_XMLID]) + self.user.activity_update_role_reminder() # Should not update + activities_after = self.partner.activity_search([ACTIVITY_XMLID]) + self.assertEqual(activities_before.ids, activities_after.ids) + + @freeze_time("2025-01-01") + def test_update_activity_if_deadline_changed(self): + """Should update activity if deadline changes.""" + self.user.activity_update_role_reminder() # Create activity + self.role_line_1.write({"date_to": fields.Date.from_string("2025-01-11")}) + activities = self.partner.activity_search([ACTIVITY_XMLID]) + self.assertTrue(activities) + self.assertEqual( + activities[0].date_deadline, fields.Date.from_string("2025-01-11") + ) + + @freeze_time("2025-01-01") + def test_no_duplicate_activity_for_same_deadline(self): + """Should not create duplicate activities for same deadline.""" + self.user.activity_update_role_reminder() # Create activity + self.user.activity_update_role_reminder() # Should not create duplicate + activities = self.partner.activity_search([ACTIVITY_XMLID]) + self.assertEqual(len(activities), 1) + + @freeze_time("2025-01-01") + def test_no_duplicate_activity_when_done(self): + """Should not create duplicate activities for same deadline.""" + self.user.activity_update_role_reminder() # Create activity + activities = self.partner.activity_search([ACTIVITY_XMLID]) + self.assertEqual(len(activities), 1) + activities.action_done() + self.user.activity_update_role_reminder() # Should not create duplicate + activities = self.partner.activity_search([ACTIVITY_XMLID]) + self.assertFalse(activities) + + @freeze_time("2024-12-09") + def test_no_activity_for_before_reminder(self): + """Should not create duplicate activities for same deadline.""" + self.user.activity_update_role_reminder() # Create activity + activities = self.partner.activity_search([ACTIVITY_XMLID]) + self.assertFalse(activities) + + @freeze_time("2025-01-01") + def test_activity_for_no_date_from(self): + """Should not create duplicate activities for same deadline.""" + + user_2 = self.env["res.users"].create( + { + "name": "Test User 2", + "login": "testuser2", + } + ) + self.env["res.users.role.line"].create( + { + "user_id": user_2.id, + "role_id": self.role.id, + "date_to": fields.Date.from_string("2025-01-10"), + } + ) + user_2.activity_update_role_reminder() # Create activity + activities = user_2.partner_id.activity_search([ACTIVITY_XMLID]) + self.assertEqual(len(activities), 1) + + def _get_activities(self): + return self.user.partner_id.activity_search([ACTIVITY_XMLID]) + + def _cleanup_activities(self): + self._get_activities().unlink() + + # ------------------------------------------------------------------ + # tests + # ------------------------------------------------------------------ + + @freeze_time("2025-01-15") + def test_manager_from_current_company_is_assigned(self): + """Activity user_id should be Manager A when env.company is Company A.""" + self._cleanup_activities() + env_a = self.env( + context=dict(self.env.context, allowed_company_ids=[self.company_a.id]) + ) + env_a["res.users"].browse(self.user.id).activity_update_role_reminder() + + activities = self._get_activities() + self.assertTrue(activities, "An activity should have been created") + self.assertEqual( + activities[0].user_id, + self.manager_user_a, + "Activity should be assigned to Manager A (current company)", + ) + + @freeze_time("2025-01-15") + def test_fallback_to_other_company_manager(self): + """When env.company has no matching employee, fall back to first found.""" + self._cleanup_activities() + # Use a third company that user has no employee record in + company_c = self.env["res.company"].create({"name": "Company C (no employee)"}) + env_c = self.env( + context=dict(self.env.context, allowed_company_ids=[company_c.id]) + ) + env_c["res.users"].browse(self.user.id).activity_update_role_reminder() + + activities = self._get_activities() + self.assertTrue(activities, "An activity should have been created") + # Fallback: first employee found is in Company A (earlier creation order) + self.assertIn( + activities[0].user_id, + self.manager_user_a | self.manager_user_b, + "Activity should fall back to one of the existing managers", + ) + + @freeze_time("2025-01-15") + def test_preferred_company_b_manager_when_env_is_company_b(self): + """Activity user_id should be Manager B when env.company is Company B.""" + self._cleanup_activities() + env_b = self.env( + context=dict(self.env.context, allowed_company_ids=[self.company_b.id]) + ) + env_b["res.users"].browse(self.user.id).activity_update_role_reminder() + + activities = self._get_activities() + self.assertTrue(activities, "An activity should have been created") + self.assertEqual( + activities[0].user_id, + self.manager_user_b, + "Activity should be assigned to Manager B (current company)", + ) + + @freeze_time("2025-01-15") + def test_no_manager_falls_back_to_user(self): + # When user has no employee records with a parent, activity is self-assigned. + self._cleanup_activities() + # Create a user with no hr.employee record + standalone_user = self.env["res.users"].create( + { + "name": "Standalone User", + "login": "standalone_role_activity", + "company_id": self.company_a.id, + "company_ids": [fields.Command.set([self.company_a.id])], + } + ) + role_line = self.env["res.users.role.line"].create( + { + "user_id": standalone_user.id, + "role_id": self.role.id, + "date_from": fields.Date.from_string("2024-01-01"), + "date_to": fields.Date.from_string("2025-02-01"), + } + ) + try: + standalone_user.activity_update_role_reminder() + activities = standalone_user.partner_id.activity_search([ACTIVITY_XMLID]) + self.assertTrue(activities, "An activity should have been created") + self.assertEqual( + activities[0].user_id, + standalone_user, + "Activity should be self-assigned when no manager exists", + ) + finally: + role_line.unlink() + standalone_user.partner_id.activity_search([ACTIVITY_XMLID]).unlink() + + @freeze_time("2025-01-01") + def test_cron_role_reminder_creates_activity(self): + """Cron path uses .search() instead of filtered_domain and creates reminder.""" + self._cleanup_activities() + self.env["res.users.role.line"].cron_role_reminder() + activities = self.partner.activity_search([ACTIVITY_XMLID]) + self.assertTrue(activities)