diff --git a/web_easy_switch_operating_unit/README.rst b/web_easy_switch_operating_unit/README.rst new file mode 100644 index 0000000000..3a998eb265 --- /dev/null +++ b/web_easy_switch_operating_unit/README.rst @@ -0,0 +1,10 @@ +.. image:: https://img.shields.io/badge/license-AGPL--3-blue.png + :target: https://www.gnu.org/licenses/agpl + :alt: License: AGPL-3 + +========================================================== +Add menu to allow user to switch to another Operating Unit +========================================================== + +This module adds a new menu in the top bar to switch to another Operating Unit. + diff --git a/web_easy_switch_operating_unit/__init__.py b/web_easy_switch_operating_unit/__init__.py new file mode 100644 index 0000000000..0650744f6b --- /dev/null +++ b/web_easy_switch_operating_unit/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/web_easy_switch_operating_unit/__manifest__.py b/web_easy_switch_operating_unit/__manifest__.py new file mode 100644 index 0000000000..d9b7e8dea9 --- /dev/null +++ b/web_easy_switch_operating_unit/__manifest__.py @@ -0,0 +1,19 @@ +# Copyright (C) 2026 CIT-Services +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Easy Switch Operating Unit", + "version": "18.0.1.0.0", + "category": "web", + "author": "CIT-Services, Odoo Community Association (OCA)", + "license": "AGPL-3", + "website": "https://github.com/OCA/operating-unit", + "depends": ["web", "operating_unit"], + "data": ["views/res_users_views.xml"], + "assets": { + "web.assets_backend": [ + "web_easy_switch_operating_unit/static/src/js/*", + "web_easy_switch_operating_unit/static/src/xml/switch_operating_unit.xml", + ], + }, +} diff --git a/web_easy_switch_operating_unit/models/__init__.py b/web_easy_switch_operating_unit/models/__init__.py new file mode 100644 index 0000000000..9a5eb71871 --- /dev/null +++ b/web_easy_switch_operating_unit/models/__init__.py @@ -0,0 +1 @@ +from . import ir_http diff --git a/web_easy_switch_operating_unit/models/ir_http.py b/web_easy_switch_operating_unit/models/ir_http.py new file mode 100644 index 0000000000..b5300d5670 --- /dev/null +++ b/web_easy_switch_operating_unit/models/ir_http.py @@ -0,0 +1,30 @@ +# Copyright (C) 2026 CIT-Services +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import models +from odoo.http import request + + +class IrHttp(models.AbstractModel): + _inherit = "ir.http" + + def session_info(self): + info = super().session_info() + + if self.env.user.has_group("base.group_user"): + user = request.env.user + info.update( + { + "user_operating_units": { + "current_operating_unit": ( + user.default_operating_unit_id.id, + user.default_operating_unit_id.code, + ), + "allowed_operating_units": [ + (ou.id, ou.code) for ou in user.operating_unit_ids + ], + } + } + ) + + return info diff --git a/web_easy_switch_operating_unit/pyproject.toml b/web_easy_switch_operating_unit/pyproject.toml new file mode 100644 index 0000000000..4231d0cccb --- /dev/null +++ b/web_easy_switch_operating_unit/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/web_easy_switch_operating_unit/static/description/icon.png b/web_easy_switch_operating_unit/static/description/icon.png new file mode 100644 index 0000000000..ee270eb32c Binary files /dev/null and b/web_easy_switch_operating_unit/static/description/icon.png differ diff --git a/web_easy_switch_operating_unit/static/description/selection-off.png b/web_easy_switch_operating_unit/static/description/selection-off.png new file mode 100644 index 0000000000..c8e922cc52 Binary files /dev/null and b/web_easy_switch_operating_unit/static/description/selection-off.png differ diff --git a/web_easy_switch_operating_unit/static/description/selection-on.png b/web_easy_switch_operating_unit/static/description/selection-on.png new file mode 100644 index 0000000000..8faf7d4def Binary files /dev/null and b/web_easy_switch_operating_unit/static/description/selection-on.png differ diff --git a/web_easy_switch_operating_unit/static/src/js/switch_operating_unit.esm.js b/web_easy_switch_operating_unit/static/src/js/switch_operating_unit.esm.js new file mode 100644 index 0000000000..7c41cf8df5 --- /dev/null +++ b/web_easy_switch_operating_unit/static/src/js/switch_operating_unit.esm.js @@ -0,0 +1,56 @@ +/* eslint-disable jsdoc/check-tag-names */ +/** @odoo-module **/ +/* global window */ + +import {Component} from "@odoo/owl"; +import {registry} from "@web/core/registry"; +import {session} from "@web/session"; +import {useService} from "@web/core/utils/hooks"; +import {user} from "@web/core/user"; + +export class SwitchOperatingUnitMenu extends Component { + static template = "web_easy_switch_operating_unit.SwitchOperatingUnitMenu"; + static props = {}; + + setup() { + this.orm = useService("orm"); + if (!session.user_operating_units) { + this.user_operating_units = []; + this.allowed_operating_unit_ids = []; + this.current_operating_unit_id = false; + this.current_operating_unit_name = "No OU"; + return; + } + + this.user_operating_units = + session.user_operating_units.allowed_operating_units; + this.allowed_operating_unit_ids = this.user_operating_units.map((ou) => + parseInt(ou[0], 10) + ); + this.current_operating_unit_id = + session.user_operating_units.current_operating_unit[0]; + this.current_operating_unit_name = + session.user_operating_units.current_operating_unit[1]; + } + + async onSwitchOperatingUnitClick(operatingUnitId) { + await this.orm.write("res.users", [user.userId], { + default_operating_unit_id: operatingUnitId || false, + }); + window.location.reload(); + } +} + +const systrayItem = { + Component: SwitchOperatingUnitMenu, + isDisplayed() { + return ( + session.user_operating_units && + session.user_operating_units.allowed_operating_units.length > 0 + ); + }, +}; + +registry + .category("systray") + .add("SwitchOperatingUnitMenu", systrayItem, {sequence: 10}); diff --git a/web_easy_switch_operating_unit/static/src/xml/switch_operating_unit.xml b/web_easy_switch_operating_unit/static/src/xml/switch_operating_unit.xml new file mode 100644 index 0000000000..e3cb21f150 --- /dev/null +++ b/web_easy_switch_operating_unit/static/src/xml/switch_operating_unit.xml @@ -0,0 +1,71 @@ + + + + + + + + diff --git a/web_easy_switch_operating_unit/tests/__init__.py b/web_easy_switch_operating_unit/tests/__init__.py new file mode 100644 index 0000000000..e2983aa2a4 --- /dev/null +++ b/web_easy_switch_operating_unit/tests/__init__.py @@ -0,0 +1 @@ +from . import test_ir_http diff --git a/web_easy_switch_operating_unit/tests/test_ir_http.py b/web_easy_switch_operating_unit/tests/test_ir_http.py new file mode 100644 index 0000000000..ba1792989d --- /dev/null +++ b/web_easy_switch_operating_unit/tests/test_ir_http.py @@ -0,0 +1,94 @@ +# Copyright (C) 2026 CIT-Services +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from unittest.mock import MagicMock + +from odoo.tests import tagged +from odoo.tests.common import TransactionCase + + +@tagged("-at_install", "post_install") +class TestIrHttp(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.operating_unit_1 = cls.env["operating.unit"].create( + { + "name": "Operating Unit 1", + "code": "TEST_OU1", + "partner_id": cls.env.company.partner_id.id, + } + ) + cls.operating_unit_2 = cls.env["operating.unit"].create( + { + "name": "Operating Unit 2", + "code": "TEST_OU2", + "partner_id": cls.env.company.partner_id.id, + } + ) + + cls.user = cls.env["res.users"].create( + { + "name": "Test User", + "login": "test_ou_user", + "groups_id": [(6, 0, [cls.env.ref("base.group_user").id])], + "operating_unit_ids": [ + (6, 0, [cls.operating_unit_1.id, cls.operating_unit_2.id]) + ], + "default_operating_unit_id": cls.operating_unit_1.id, + } + ) + + cls.portal_user = cls.env["res.users"].create( + { + "name": "Portal User", + "login": "test_ou_portal", + "groups_id": [(6, 0, [cls.env.ref("base.group_portal").id])], + } + ) + + def test_session_info_internal_user(self): + """Test session_info for internal user includes operating unit data.""" + mock_request = MagicMock() + mock_request.env.user = self.user + mock_request.session.uid = self.user.id + mock_request.cookies = {} + + import odoo.http + + odoo.http._request_stack.push(mock_request) + try: + info = self.env["ir.http"].with_user(self.user).session_info() + self.assertIn("user_operating_units", info) + ou_data = info["user_operating_units"] + self.assertEqual( + ou_data["current_operating_unit"], + (self.operating_unit_1.id, self.operating_unit_1.code), + ) + + allowed_ous = ou_data["allowed_operating_units"] + self.assertEqual(len(allowed_ous), 2) + self.assertIn( + (self.operating_unit_1.id, self.operating_unit_1.code), allowed_ous + ) + self.assertIn( + (self.operating_unit_2.id, self.operating_unit_2.code), allowed_ous + ) + finally: + odoo.http._request_stack.pop() + + def test_session_info_portal_user(self): + """Test session_info for portal user does not include operating unit data.""" + mock_request = MagicMock() + mock_request.env.user = self.portal_user + mock_request.session.uid = self.portal_user.id + mock_request.cookies = {} + + import odoo.http + + odoo.http._request_stack.push(mock_request) + try: + info = self.env["ir.http"].with_user(self.portal_user).session_info() + self.assertNotIn("user_operating_units", info) + finally: + odoo.http._request_stack.pop() diff --git a/web_easy_switch_operating_unit/views/res_users_views.xml b/web_easy_switch_operating_unit/views/res_users_views.xml new file mode 100644 index 0000000000..0ade040863 --- /dev/null +++ b/web_easy_switch_operating_unit/views/res_users_views.xml @@ -0,0 +1,17 @@ + + + + res.users.preferences.operating.unit + res.users + + + + + + + +