From 3fd7b475742929fe98f687fb9aa071e061b8a3fd Mon Sep 17 00:00:00 2001 From: Kevin Khao Date: Wed, 4 Mar 2020 18:33:24 +0100 Subject: [PATCH 1/6] [ADD][12.0] base_user_role_profile: Add to 12.0 fixup! Logic and permissions fixes, new demo module, changes JS-side that reloads in a cleaner way on profile change fixup! removed unused imports, beautified JS fixup! Test coverage increase [FIX] Use write instead of assignment operator on create function: assignment on multiple records raises error fixup! Removed leftover copyright Apply suggestions from code review Co-Authored-By: David Beal --- base_user_role_profile/README.rst | 73 +++ base_user_role_profile/__init__.py | 1 + base_user_role_profile/__manifest__.py | 21 + base_user_role_profile/data/data.xml | 7 + base_user_role_profile/models/__init__.py | 4 + base_user_role_profile/models/ir_http.py | 26 ++ base_user_role_profile/models/profile.py | 19 + base_user_role_profile/models/role.py | 14 + base_user_role_profile/models/user.py | 81 ++++ base_user_role_profile/readme/CONFIGURE.rst | 2 + .../readme/CONTRIBUTORS.rst | 2 + base_user_role_profile/readme/DESCRIPTION.rst | 12 + base_user_role_profile/readme/USAGE.rst | 1 + .../security/ir.model.access.csv | 3 + .../static/description/icon.png | Bin 0 -> 18048 bytes .../static/description/index.html | 434 ++++++++++++++++++ .../static/src/js/switch_profile_menu.js | 79 ++++ .../static/src/xml/templates.xml | 13 + base_user_role_profile/tests/__init__.py | 1 + .../tests/test_user_role.py | 161 +++++++ base_user_role_profile/views/assets.xml | 9 + base_user_role_profile/views/profile.xml | 42 ++ base_user_role_profile/views/role.xml | 19 + base_user_role_profile/views/user.xml | 35 ++ 24 files changed, 1059 insertions(+) create mode 100644 base_user_role_profile/README.rst create mode 100644 base_user_role_profile/__init__.py create mode 100644 base_user_role_profile/__manifest__.py create mode 100644 base_user_role_profile/data/data.xml create mode 100644 base_user_role_profile/models/__init__.py create mode 100644 base_user_role_profile/models/ir_http.py create mode 100644 base_user_role_profile/models/profile.py create mode 100644 base_user_role_profile/models/role.py create mode 100644 base_user_role_profile/models/user.py create mode 100644 base_user_role_profile/readme/CONFIGURE.rst create mode 100644 base_user_role_profile/readme/CONTRIBUTORS.rst create mode 100644 base_user_role_profile/readme/DESCRIPTION.rst create mode 100644 base_user_role_profile/readme/USAGE.rst create mode 100644 base_user_role_profile/security/ir.model.access.csv create mode 100644 base_user_role_profile/static/description/icon.png create mode 100644 base_user_role_profile/static/description/index.html create mode 100644 base_user_role_profile/static/src/js/switch_profile_menu.js create mode 100644 base_user_role_profile/static/src/xml/templates.xml create mode 100644 base_user_role_profile/tests/__init__.py create mode 100644 base_user_role_profile/tests/test_user_role.py create mode 100644 base_user_role_profile/views/assets.xml create mode 100644 base_user_role_profile/views/profile.xml create mode 100644 base_user_role_profile/views/role.xml create mode 100644 base_user_role_profile/views/user.xml diff --git a/base_user_role_profile/README.rst b/base_user_role_profile/README.rst new file mode 100644 index 000000000..38c6a5965 --- /dev/null +++ b/base_user_role_profile/README.rst @@ -0,0 +1,73 @@ +============= +User profiles +============= + +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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--backend-lightgray.png?logo=github + :target: https://github.com/oca/server-backend/tree/12.0/base_user_role_profile + :alt: oca/server-backend + +|badge1| |badge2| |badge3| + +Extending the base_user_role module, this one adds the notion of profiles. Effectively profiles act as an additional filter to how the roles are used. Through the new widget, much in the same way that a user can switch companies when they are part of the multi company group, users have the possibility to change profiles when they are part of the multi profiles group. + +This allows users to switch their permission groups dynamically. This can be useful for example to: + - finer grain control on menu and model permissions (with record rules this becomes very flexible) + - break down complicated menus into simpler ones + - easily restrict users accidentally editing or creating records in O2M fields and in general misusing the interface, instead of excessively explaining things to them + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +Go to Configuration / Users / Profiles and create a profile. Go to Configuration / Users / Roles and define some role lines with profiles. + +Usage +===== + +Once you have set up at least one profile for a user, use the widget in the top bar to switch user profiles. Note that it is possible to use no profile; in this case, the user will only get the roles that always apply (i.e the ones with no profile_id). + +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 smashing it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Akretion + +Contributors +~~~~~~~~~~~~ + +* Kevin Khao +* Sébastien Beau + +Maintainers +~~~~~~~~~~~ + +This module is part of the `oca/server-backend `_ project on GitHub. + +You are welcome to contribute. diff --git a/base_user_role_profile/__init__.py b/base_user_role_profile/__init__.py new file mode 100644 index 000000000..0650744f6 --- /dev/null +++ b/base_user_role_profile/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/base_user_role_profile/__manifest__.py b/base_user_role_profile/__manifest__.py new file mode 100644 index 000000000..57da305fe --- /dev/null +++ b/base_user_role_profile/__manifest__.py @@ -0,0 +1,21 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "User profiles", + "version": "12.0.1.0.0", + "category": "Tools", + "author": "Akretion, Odoo Community Association (OCA)", + "license": "AGPL-3", + "website": "https://github.com/OCA/server-backend", + "depends": ["base_user_role", "web"], + "data": [ + "data/data.xml", + "security/ir.model.access.csv", + "views/user.xml", + "views/role.xml", + "views/profile.xml", + "views/assets.xml", + ], + "qweb": ["static/src/xml/templates.xml"], + "installable": True, +} diff --git a/base_user_role_profile/data/data.xml b/base_user_role_profile/data/data.xml new file mode 100644 index 000000000..42920ebbd --- /dev/null +++ b/base_user_role_profile/data/data.xml @@ -0,0 +1,7 @@ + + + + + No profile + + diff --git a/base_user_role_profile/models/__init__.py b/base_user_role_profile/models/__init__.py new file mode 100644 index 000000000..12328c7d2 --- /dev/null +++ b/base_user_role_profile/models/__init__.py @@ -0,0 +1,4 @@ +from . import profile +from . import user +from . import role +from . import ir_http diff --git a/base_user_role_profile/models/ir_http.py b/base_user_role_profile/models/ir_http.py new file mode 100644 index 000000000..db316b36c --- /dev/null +++ b/base_user_role_profile/models/ir_http.py @@ -0,0 +1,26 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from odoo import models +from odoo.http import request + + +class Http(models.AbstractModel): + _inherit = "ir.http" + + def session_info(self): # pragma: no cover + result = super().session_info() + user = request.env.user + allowed_profiles = [ + (profile.id, profile.name) for profile in user.profile_ids + ] + if len(allowed_profiles) > 1: + current_profile = (user.profile_id.id, user.profile_id.name) + result["user_profiles"] = { + "current_profile": current_profile, + "allowed_profiles": allowed_profiles, + } + else: + result["user_profiles"] = False + result["profile_id"] = ( + user.profile_id.id if request.session.uid else None + ) + return result diff --git a/base_user_role_profile/models/profile.py b/base_user_role_profile/models/profile.py new file mode 100644 index 000000000..d1f2818a3 --- /dev/null +++ b/base_user_role_profile/models/profile.py @@ -0,0 +1,19 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from odoo import fields, models + + +class ResUsersProfile(models.Model): + _name = "res.users.profile" + _description = "Role profile" + + name = fields.Char("Name") + user_ids = fields.Many2many( + "res.users", string="Allowed users", compute="_compute_user_ids" + ) + role_ids = fields.One2many("res.users.role", "profile_id", string="Roles") + + def _compute_user_ids(self): + for rec in self: + rec.user_ids = self.env["res.users"].search( + [("profile_ids", "in", rec.ids)] + ) diff --git a/base_user_role_profile/models/role.py b/base_user_role_profile/models/role.py new file mode 100644 index 000000000..558cb6e30 --- /dev/null +++ b/base_user_role_profile/models/role.py @@ -0,0 +1,14 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from odoo import fields, models + + +class ResUsersRole(models.Model): + _inherit = "res.users.role" + + profile_id = fields.Many2one("res.users.profile", "Profile",) + + +class ResUsersRoleLine(models.Model): + _inherit = "res.users.role.line" + + profile_id = fields.Many2one(related="role_id.profile_id") diff --git a/base_user_role_profile/models/user.py b/base_user_role_profile/models/user.py new file mode 100644 index 000000000..536ce52e5 --- /dev/null +++ b/base_user_role_profile/models/user.py @@ -0,0 +1,81 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from odoo import api, fields, models + + +class ResUsers(models.Model): + _inherit = "res.users" + + def _get_default_profile(self): + return self.env.ref("base_user_role_profile.default_profile") + + profile_id = fields.Many2one( + "res.users.profile", + "Current profile", + default=lambda self: self._get_default_profile, + ) + + profile_ids = fields.Many2many( + "res.users.profile", string="Currently allowed profiles", + ) + + def _get_action_root_menu(self): + # used JS-side. Reload the client; open the first available root menu + menu = self.env["ir.ui.menu"].search([("parent_id", "=", False)])[:1] + return { + "type": "ir.actions.client", + "tag": "reload", + "params": {"menu_id": menu.id}, + } + + def action_profile_change(self, vals): + self.write(vals) + return self._get_action_root_menu() + + @api.model + def create(self, vals): + new_record = super().create(vals) + if vals.get("company_id") or vals.get("role_line_ids"): + new_record.sudo()._compute_profile_ids() + return new_record + + def write(self, vals): + # inspired by base/models/res_users.py l. 491 + if self == self.env.user and vals.get("profile_id"): + self.sudo().write({"profile_id": vals["profile_id"]}) + del vals["profile_id"] + res = super().write(vals) + if ( + vals.get("company_id") + or vals.get("profile_id") + or vals.get("role_line_ids") + ): + self.sudo()._compute_profile_ids() + return res + + def _get_applicable_roles(self): + res = super()._get_applicable_roles() + res = res.filtered( + lambda r: not r.profile_id + or (r.profile_id.id == r.user_id.profile_id.id) + ) + return res + + def _update_profile_id(self): + default_profile = self.env.ref( + "base_user_role_profile.default_profile" + ) + if not self.profile_ids: + if self.profile_id != default_profile: + self.profile_id = default_profile + elif self.profile_id not in self.profile_ids: + self.write({"profile_id": self.profile_ids[0].id}) + + def _compute_profile_ids(self): + for rec in self: + role_lines = rec.role_line_ids + profiles = role_lines.filtered( + lambda r: r.company_id == rec.company_id + ).mapped("profile_id") + rec.profile_ids = profiles + # set defaults in case applicable profile changes + rec._update_profile_id() diff --git a/base_user_role_profile/readme/CONFIGURE.rst b/base_user_role_profile/readme/CONFIGURE.rst new file mode 100644 index 000000000..31f2dabee --- /dev/null +++ b/base_user_role_profile/readme/CONFIGURE.rst @@ -0,0 +1,2 @@ +Go to Configuration / Users / Profiles and create a profile. Go to Configuration / Users / Roles and define some role lines with profiles. +Be careful when defining role lines that company ids and profiles are correctly configured. diff --git a/base_user_role_profile/readme/CONTRIBUTORS.rst b/base_user_role_profile/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000..79f515ed4 --- /dev/null +++ b/base_user_role_profile/readme/CONTRIBUTORS.rst @@ -0,0 +1,2 @@ +* Kevin Khao +* Sébastien Beau diff --git a/base_user_role_profile/readme/DESCRIPTION.rst b/base_user_role_profile/readme/DESCRIPTION.rst new file mode 100644 index 000000000..3f2b4e36a --- /dev/null +++ b/base_user_role_profile/readme/DESCRIPTION.rst @@ -0,0 +1,12 @@ +Extending the base_user_role module, this one adds the notion of profiles. Effectively profiles act as an additional filter to how the roles are used. Through the new widget, much in the same way that a user can switch companies when they are part of the multi company group, users have the possibility to change profiles when they are part of the multi profiles group. + +This allows users to switch their permission groups dynamically. This can be useful for example to: + - finer grain control on menu and model permissions (with record rules this becomes very flexible) + - break down complicated menus into simpler ones + - easily restrict users accidentally editing or creating records in O2M fields and in general misusing the interface, instead of excessively explaining things to them + +When you define a role, you have the possibility to link it to a profile. Roles are applied to users in the following way: + - Apply user's roles without profiles in any case + - Apply user's roles that are linked to the currently selected profile + +Note that this module assumes a multicompany environment diff --git a/base_user_role_profile/readme/USAGE.rst b/base_user_role_profile/readme/USAGE.rst new file mode 100644 index 000000000..b76ca9eaa --- /dev/null +++ b/base_user_role_profile/readme/USAGE.rst @@ -0,0 +1 @@ +Once you have set up at least one profile for a user, use the widget in the top bar to switch user profiles. Note that it is possible to use no profile; in this case, the user will only get the roles that always apply (i.e the ones with no profile_id). diff --git a/base_user_role_profile/security/ir.model.access.csv b/base_user_role_profile/security/ir.model.access.csv new file mode 100644 index 000000000..acb21834c --- /dev/null +++ b/base_user_role_profile/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_res_users_profile,access_res_users_profile,model_res_users_profile,base.group_user,1,0,0,0 +access_res_users_role_line_users,access_res_users_role_line,model_res_users_role_line,base.group_user,1,0,0,0 diff --git a/base_user_role_profile/static/description/icon.png b/base_user_role_profile/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..4a8a6d74ee31c70efd2540873b14293f2f95acf9 GIT binary patch literal 18048 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 literal 0 HcmV?d00001 diff --git a/base_user_role_profile/static/description/index.html b/base_user_role_profile/static/description/index.html new file mode 100644 index 000000000..195494a06 --- /dev/null +++ b/base_user_role_profile/static/description/index.html @@ -0,0 +1,434 @@ + + + + + + +User profiles + + + +
+

User profiles

+ + +

Beta License: AGPL-3 oca/server-backend

+

Extending the base_user_role module, this one adds the notion of profiles. Effectively profiles act as an additional filter to how the roles are used. Through the new widget, much in the same way that a user can switch companies when they are part of the multi company group, users have the possibility to change profiles when they are part of the multi profiles group.

+
+
This allows users to switch their permission groups dynamically. This can be useful for example to:
+
    +
  • finer grain control on menu and model permissions (with record rules this becomes very flexible)
  • +
  • break down complicated menus into simpler ones
  • +
  • easily restrict users accidentally editing or creating records in O2M fields and in general misusing the interface, instead of excessively explaining things to them
  • +
+
+
+

Table of contents

+ +
+

Configuration

+

Go to Configuration / Users / Profiles and create a profile. Go to Configuration / Users / Roles and define some role lines with profiles.

+
+
+

Usage

+

Once you have set up at least one profile for a user, use the widget in the top bar to switch user profiles. Note that it is possible to use no profile; in this case, the user will only get the roles that always apply (i.e the ones with no profile_id).

+
+
+

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 smashing it by providing a detailed and welcomed +feedback.

+

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

+
+
+

Credits

+
+

Authors

+
    +
  • Akretion
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

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

+

You are welcome to contribute.

+
+
+
+ + diff --git a/base_user_role_profile/static/src/js/switch_profile_menu.js b/base_user_role_profile/static/src/js/switch_profile_menu.js new file mode 100644 index 000000000..e6fe6a8ac --- /dev/null +++ b/base_user_role_profile/static/src/js/switch_profile_menu.js @@ -0,0 +1,79 @@ +odoo.define('web.SwitchProfileMenu', function(require) { + "use strict"; + + var config = require('web.config'); + var core = require('web.core'); + var session = require('web.session'); + var SystrayMenu = require('web.SystrayMenu'); + var Widget = require('web.Widget') + var _t = core._t; + + var SwitchProfileMenu = Widget.extend({ + template: 'SwitchProfileMenu', + events: { + 'click .dropdown-item[data-menu]': '_onClick', + }, + + init: function() { + this._super.apply(this, arguments); + this.isMobile = config.device.isMobile; + this._onClick = _.debounce(this._onClick, 1500, true); + }, + + willStart: function() { + return session.user_profiles ? this._super() : $.Deferred().reject(); + }, + + start: function() { + var profilesList = ''; + if (this.isMobile) { + profilesList = '
  • ' + + _t('Tap on the list to change profile') + '
  • '; + } else { + this.$('.oe_topbar_name').text(session.user_profiles.current_profile[1]); + } + _.each(session.user_profiles.allowed_profiles, function(profile) { + var a = ''; + if (profile[0] == session.user_profiles.current_profile[0]) { + a = ''; + } else { + a = ''; + } + profilesList += '' + a + profile[1] + ''; + }); + this.$('.dropdown-menu').html(profilesList); + return this._super(); + }, + + _onClick: function(ev) { + var self = this; + ev.preventDefault(); + var profileID = $(ev.currentTarget).data('profile-id'); + // We use this instead of the location.reload() because permissions change + // and we might land on a menu that we don't have permissions for. Thus it + // is cleaner to reload any root menu + this._rpc({ + model: 'res.users', + method: 'action_profile_change', + args: [ + [session.uid], { + 'profile_id': profileID + } + ], + }) + .done( + function(result) { + self.trigger_up('do_action', { + action: result, + }) + } + ) + }, + }); + + SystrayMenu.Items.push(SwitchProfileMenu); + + return SwitchProfileMenu; + +}); diff --git a/base_user_role_profile/static/src/xml/templates.xml b/base_user_role_profile/static/src/xml/templates.xml new file mode 100644 index 000000000..2cff8d529 --- /dev/null +++ b/base_user_role_profile/static/src/xml/templates.xml @@ -0,0 +1,13 @@ + + + + +
  • + +
  • +
    + +
    diff --git a/base_user_role_profile/tests/__init__.py b/base_user_role_profile/tests/__init__.py new file mode 100644 index 000000000..f8c808c78 --- /dev/null +++ b/base_user_role_profile/tests/__init__.py @@ -0,0 +1 @@ +from . import test_user_role diff --git a/base_user_role_profile/tests/test_user_role.py b/base_user_role_profile/tests/test_user_role.py new file mode 100644 index 000000000..8d989956b --- /dev/null +++ b/base_user_role_profile/tests/test_user_role.py @@ -0,0 +1,161 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from odoo.tests.common import TransactionCase + + +class TestUserProfile(TransactionCase): + def _helper_unpack_groups_role(self, role): + role_group_ids = role.trans_implied_ids.ids + role_group_ids.append(role.group_id.id) + return sorted(set(role_group_ids)) + + def _helper_unpack_groups_group(self, group): + group_ids = group.trans_implied_ids.ids + group_ids.append(group.id) + return sorted(set(group_ids)) + + def setUp(self): + super().setUp() + self.user_model = self.env["res.users"] + self.role_model = self.env["res.users.role"] + + self.company1 = self.env.ref("base.main_company") + self.company2 = self.env["res.company"].create({"name": "company2"}) + + self.default_user = self.env.ref("base.default_user") + user_vals = { + "name": "USER TEST (ROLES)", + "login": "user_test_roles", + "company_ids": [(6, 0, [self.company1.id, self.company2.id])], + "company_id": self.company1.id, + } + self.user_id = self.user_model.create(user_vals) + + self.profile1_id = self.env["res.users.profile"].create( + {"name": "profile1"} + ) + self.profile2_id = self.env["res.users.profile"].create( + {"name": "profile2"} + ) + + # role 1 + self.group_user_id = self.env.ref("base.group_user") + self.group_no_one_id = self.env.ref("base.group_no_one") + + # role 2 + self.group_system_id = self.env.ref("base.group_system") + self.group_multi_company_id = self.env.ref("base.group_multi_company") + + # role 3 + self.group_erp_manager_id = self.env.ref("base.group_erp_manager") + self.group_partner_manager_id = self.env.ref( + "base.group_partner_manager" + ) + + # roles 1 and 2 have a profile, role 3 no profile + vals = { + "name": "ROLE_1", + "implied_ids": [ + (6, 0, [self.group_user_id.id, self.group_no_one_id.id]) + ], + "profile_id": self.profile1_id.id, + } + self.role1_id = self.role_model.create(vals) + self.role1_group_ids = self._helper_unpack_groups_role(self.role1_id) + + vals = { + "name": "ROLE_2", + "implied_ids": [ + ( + 6, + 0, + [self.group_system_id.id, self.group_multi_company_id.id], + ) + ], + "profile_id": self.profile2_id.id, + } + self.role2_id = self.role_model.create(vals) + self.role2_group_ids = self._helper_unpack_groups_role(self.role2_id) + + vals = { + "name": "ROLE_3", + "implied_ids": [ + ( + 6, + 0, + [ + self.group_erp_manager_id.id, + self.group_partner_manager_id.id, + ], + ) + ], + } + self.role3_id = self.role_model.create(vals) + self.role3_group_ids = self._helper_unpack_groups_role(self.role3_id) + + def test_filter_by_profile(self): + line1_vals = {"role_id": self.role1_id.id, "user_id": self.user_id.id} + self.user_id.write({"role_line_ids": [(0, 0, line1_vals)]}) + line2_vals = {"role_id": self.role2_id.id, "user_id": self.user_id.id} + self.user_id.write({"role_line_ids": [(0, 0, line2_vals)]}) + self.assertEqual( + self.user_id.profile_ids, self.profile1_id + self.profile2_id + ) + self.assertEqual(self.user_id.profile_id, self.profile1_id) + self.user_id.action_profile_change({"profile_id": self.profile1_id.id}) + + user_group_ids = sorted( + set([group.id for group in self.user_id.groups_id]) + ) + expected_group_ids = sorted(set(self.role1_group_ids)) + self.assertEqual(user_group_ids, expected_group_ids) + + self.user_id.action_profile_change({"profile_id": self.profile2_id.id}) + + user_group_ids = sorted( + set([group.id for group in self.user_id.groups_id]) + ) + expected_group_ids = sorted(set(self.role2_group_ids)) + self.assertEqual(user_group_ids, expected_group_ids) + + def test_allow_by_noprofile(self): + line1_vals = {"role_id": self.role1_id.id, "user_id": self.user_id.id} + self.user_id.write({"role_line_ids": [(0, 0, line1_vals)]}) + line2_vals = {"role_id": self.role3_id.id, "user_id": self.user_id.id} + self.user_id.write({"role_line_ids": [(0, 0, line2_vals)]}) + self.assertEqual(self.user_id.profile_ids, self.profile1_id) + user_group_ids = [] + for group in self.user_id.groups_id: + user_group_ids += self._helper_unpack_groups_group(group) + user_group_ids = set(user_group_ids) + expected_groups = set(self.role1_group_ids + self.role3_group_ids) + self.assertEqual(user_group_ids, expected_groups) + + def test_sync_profile_change_company(self): + line1_vals = { + "role_id": self.role1_id.id, + "user_id": self.user_id.id, + "company_id": self.company1.id, + } + self.user_id.write({"role_line_ids": [(0, 0, line1_vals)]}) + line2_vals = { + "role_id": self.role2_id.id, + "user_id": self.user_id.id, + "company_id": self.company2.id, + } + + self.user_id.write({"role_line_ids": [(0, 0, line2_vals)]}) + self.assertEqual(self.user_id.profile_ids, self.profile1_id) + + user_group_ids = sorted( + set([group.id for group in self.user_id.groups_id]) + ) + expected_group_ids = sorted(set(self.role1_group_ids)) + self.assertEqual(user_group_ids, expected_group_ids) + + self.user_id.company_id = self.company2 + self.assertEqual(self.user_id.profile_ids, self.profile2_id) + user_group_ids = sorted( + set([group.id for group in self.user_id.groups_id]) + ) + expected_group_ids = sorted(set(self.role2_group_ids)) + self.assertEqual(user_group_ids, expected_group_ids) diff --git a/base_user_role_profile/views/assets.xml b/base_user_role_profile/views/assets.xml new file mode 100644 index 000000000..8368b9568 --- /dev/null +++ b/base_user_role_profile/views/assets.xml @@ -0,0 +1,9 @@ + + + +