From 328d91407146561abffc8b05e875b4546ab47e11 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 22 Mar 2026 11:10:01 -0500 Subject: [PATCH] fix: add support for merging 2 roles --- src/wuttaweb/views/master.py | 1 + src/wuttaweb/views/roles.py | 82 ++++++++++++++++++- tests/views/test_roles.py | 147 +++++++++++++++++++++++++++++++++++ 3 files changed, 229 insertions(+), 1 deletion(-) diff --git a/src/wuttaweb/views/master.py b/src/wuttaweb/views/master.py index 170afb6..b4cc829 100644 --- a/src/wuttaweb/views/master.py +++ b/src/wuttaweb/views/master.py @@ -3300,6 +3300,7 @@ class MasterView(View): # pylint: disable=too-many-public-methods removing_data, keeping_data, self.merge_get_final_data(removing_data, keeping_data), + fields=self.merge_get_all_fields(), ) context = {"removing": removing, "keeping": keeping, "diff": diff} diff --git a/src/wuttaweb/views/roles.py b/src/wuttaweb/views/roles.py index f1f9b1e..9d63920 100644 --- a/src/wuttaweb/views/roles.py +++ b/src/wuttaweb/views/roles.py @@ -2,7 +2,7 @@ ################################################################################ # # wuttaweb -- Web App for Wutta Framework -# Copyright © 2024-2025 Lance Edgar +# Copyright © 2024-2026 Lance Edgar # # This file is part of Wutta Framework. # @@ -59,6 +59,9 @@ class RoleView(MasterView): # pylint: disable=abstract-method } sort_defaults = "name" + mergeable = True + merge_additive_fields = ["permission_count", "user_count"] + wutta_permissions = None # TODO: master should handle this, possibly via configure_form() @@ -284,6 +287,83 @@ class RoleView(MasterView): # pylint: disable=abstract-method else: auth.revoke_permission(role, pkey) + def merge_get_data(self, obj): # pylint: disable=empty-docstring + """ """ + data = super().merge_get_data(obj) + role = obj + + data["permissions"] = role.permissions + data["permission_count"] = len(data["permissions"]) + + data["usernames"] = [user.username for user in role.users] + data["user_count"] = len(data["usernames"]) + + return data + + def merge_get_final_data( + self, removing, keeping + ): # pylint: disable=empty-docstring + """ """ + final = super().merge_get_final_data(removing, keeping) + + permissions = set(removing["permissions"] + keeping["permissions"]) + final["permission_count"] = len(permissions) + + usernames = set(removing["usernames"] + keeping["usernames"]) + final["user_count"] = len(usernames) + + return final + + def merge_why_not(self, removing, keeping): + """ + This checks to ensure the "removing" role is not one of the + special built-in roles (Administrator, Authenticated, + Anonymous). + + See also parent method: + :meth:`~wuttaweb.views.master.MasterView.merge_why_not()` + """ + auth = self.app.get_auth_handler() + session = self.Session() + + if removing is auth.get_role_administrator(session): + return "Cannot remove the Administrator role." + + if removing is auth.get_role_anonymous(session): + return "Cannot remove the Anonymous role." + + if removing is auth.get_role_authenticated(session): + return "Cannot remove the Authenticated role." + + return None + + def merge_execute(self, removing, keeping): + """ + The logic to merge 2 roles is extended as follows: + + Any users belonging to the "removing" role will be added to + the "keeping" role (if not already present). + + Any permissions belonging to the "removing" role will be added + to the "keeping" role (if not already present). + + See also parent method: + :meth:`~wuttaweb.views.master.MasterView.merge_execute()` + """ + + # transfer permissions + for perm in list(removing.permissions): + if perm not in keeping.permissions: + keeping.permissions.append(perm) + + # transfer users + for user in list(removing.users): + if user not in keeping.users: + keeping.users.append(user) + + # continue default merge + super().merge_execute(removing, keeping) + @classmethod def defaults(cls, config): # pylint: disable=empty-docstring """ """ diff --git a/tests/views/test_roles.py b/tests/views/test_roles.py index 0eef4d2..eed5cfd 100644 --- a/tests/views/test_roles.py +++ b/tests/views/test_roles.py @@ -284,6 +284,153 @@ class TestRoleView(WebTestCase): self.assertIs(role, blokes) self.assertEqual(blokes.permissions, ["widgets.polish", "widgets.view"]) + def test_merge_get_data(self): + model = self.app.model + auth = self.app.get_auth_handler() + + role = model.Role(name="whatever") + auth.grant_permission(role, "people.list") + auth.grant_permission(role, "people.view") + auth.grant_permission(role, "people.edit") + self.session.add(role) + + user1 = model.User(username="user1") + user1.roles.append(role) + self.session.add(user1) + + user2 = model.User(username="user2") + user2.roles.append(role) + self.session.add(user2) + + self.session.commit() + self.assertEqual(len(role.permissions), 3) + self.assertEqual(len(role.users), 2) + + view = self.make_view() + with patch.object(view, "Session", return_value=self.session): + data = view.merge_get_data(role) + self.assertEqual( + sorted(data["permissions"]), + ["people.edit", "people.list", "people.view"], + ) + self.assertEqual(data["permission_count"], 3) + self.assertEqual(data["usernames"], ["user1", "user2"]) + self.assertEqual(data["user_count"], 2) + + def test_merge_get_final_data(self): + model = self.app.model + auth = self.app.get_auth_handler() + + role1 = model.Role(name="whatever1") + auth.grant_permission(role1, "people.list") + auth.grant_permission(role1, "people.view") + auth.grant_permission(role1, "people.edit") + self.session.add(role1) + + role2 = model.Role(name="whatever2") + auth.grant_permission(role2, "people.list") + auth.grant_permission(role2, "people.view") + self.session.add(role2) + + user1 = model.User(username="user1") + user1.roles.append(role1) + self.session.add(user1) + + user2 = model.User(username="user2") + user2.roles.append(role1) + user2.roles.append(role2) + self.session.add(user2) + + self.session.commit() + self.assertEqual(len(role1.permissions), 3) + self.assertEqual(len(role1.users), 2) + self.assertEqual(len(role2.permissions), 2) + self.assertEqual(len(role2.users), 1) + + view = self.make_view() + with patch.object(view, "Session", return_value=self.session): + removing = view.merge_get_data(role1) + keeping = view.merge_get_data(role2) + final = view.merge_get_final_data(removing, keeping) + self.assertEqual(final["permission_count"], 3) + self.assertEqual(final["user_count"], 2) + + def test_merge_why_not(self): + model = self.app.model + auth = self.app.get_auth_handler() + + role1 = model.Role(name="whatever1") + self.session.add(role1) + role2 = model.Role(name="whatever2") + self.session.add(role2) + self.session.commit() + + view = self.make_view() + with patch.object(view, "Session", return_value=self.session): + + # normal merge is allowed + self.assertIsNone(view.merge_why_not(role1, role2)) + + # special roles can be part of a merge if they are being "kept" + # but not if being "removed" + + admin = auth.get_role_administrator(self.session) + self.assertIsNone(view.merge_why_not(role1, admin)) + reason = view.merge_why_not(admin, role1) + self.assertEqual(reason, "Cannot remove the Administrator role.") + + authed = auth.get_role_authenticated(self.session) + self.assertIsNone(view.merge_why_not(role1, authed)) + reason = view.merge_why_not(authed, role1) + self.assertEqual(reason, "Cannot remove the Authenticated role.") + + anon = auth.get_role_anonymous(self.session) + self.assertIsNone(view.merge_why_not(role1, anon)) + reason = view.merge_why_not(anon, role1) + self.assertEqual(reason, "Cannot remove the Anonymous role.") + + def test_merge_execute(self): + model = self.app.model + auth = self.app.get_auth_handler() + + role1 = model.Role(name="whatever1") + auth.grant_permission(role1, "people.list") + auth.grant_permission(role1, "people.view") + auth.grant_permission(role1, "people.edit") + self.session.add(role1) + + role2 = model.Role(name="whatever2") + auth.grant_permission(role2, "people.list") + auth.grant_permission(role2, "people.view") + self.session.add(role2) + + user1 = model.User(username="user1") + user1.roles.append(role1) + self.session.add(user1) + + user2 = model.User(username="user2") + user2.roles.append(role1) + user2.roles.append(role2) + self.session.add(user2) + + self.session.commit() + self.assertEqual(self.session.query(model.Role).count(), 2) + self.assertEqual(len(role1.permissions), 3) + self.assertEqual(len(role1.users), 2) + self.assertEqual(len(role2.permissions), 2) + self.assertEqual(len(role2.users), 1) + + view = self.make_view() + with patch.object(view, "Session", return_value=self.session): + view.merge_execute(role1, role2) + self.session.commit() + self.assertEqual(self.session.query(model.Role).count(), 1) + self.assertNotIn(role1, self.session) + self.assertIn(role2, self.session) + self.assertIs(role2, self.session.query(model.Role).one()) + self.assertEqual(len(role2.permissions), 3) + self.assertEqual(len(role2.users), 2) + class TestPermissionView(WebTestCase):