Coverage for .tox / coverage / lib / python3.11 / site-packages / wuttaweb / views / roles.py: 100%
172 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-22 11:18 -0500
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-22 11:18 -0500
1# -*- coding: utf-8; -*-
2################################################################################
3#
4# wuttaweb -- Web App for Wutta Framework
5# Copyright © 2024-2026 Lance Edgar
6#
7# This file is part of Wutta Framework.
8#
9# Wutta Framework is free software: you can redistribute it and/or modify it
10# under the terms of the GNU General Public License as published by the Free
11# Software Foundation, either version 3 of the License, or (at your option) any
12# later version.
13#
14# Wutta Framework is distributed in the hope that it will be useful, but
15# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
16# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
17# more details.
18#
19# You should have received a copy of the GNU General Public License along with
20# Wutta Framework. If not, see <http://www.gnu.org/licenses/>.
21#
22################################################################################
23"""
24Views for roles
25"""
27from wuttjamaican.db.model import Role, Permission
28from wuttaweb.views import MasterView
29from wuttaweb.db import Session
30from wuttaweb.forms import widgets
31from wuttaweb.forms.schema import Permissions, RoleRef
32from wuttaweb.util import make_users_grid
35class RoleView(MasterView): # pylint: disable=abstract-method
36 """
37 Master view for roles.
39 Default route prefix is ``roles``.
41 Notable URLs provided by this class:
43 * ``/roles/``
44 * ``/roles/new``
45 * ``/roles/XXX``
46 * ``/roles/XXX/edit``
47 * ``/roles/XXX/delete``
48 """
50 model_class = Role
52 grid_columns = [
53 "name",
54 "notes",
55 ]
57 filter_defaults = {
58 "name": {"active": True},
59 }
60 sort_defaults = "name"
62 mergeable = True
63 merge_additive_fields = ["permission_count", "user_count"]
65 wutta_permissions = None
67 # TODO: master should handle this, possibly via configure_form()
68 def get_query(self, session=None): # pylint: disable=empty-docstring
69 """ """
70 model = self.app.model
71 query = super().get_query(session=session)
72 return query.order_by(model.Role.name)
74 def configure_grid(self, grid): # pylint: disable=empty-docstring
75 """ """
76 g = grid
77 super().configure_grid(g)
79 # name
80 g.set_link("name")
82 # notes
83 g.set_renderer("notes", self.grid_render_notes)
85 def is_editable(self, obj): # pylint: disable=empty-docstring
86 """ """
87 role = obj
88 session = self.app.get_session(role)
89 auth = self.app.get_auth_handler()
91 # only "root" can edit admin role
92 if role is auth.get_role_administrator(session):
93 return self.request.is_root
95 # other built-in roles require special perm
96 if role in (
97 auth.get_role_authenticated(session),
98 auth.get_role_anonymous(session),
99 ):
100 return self.has_perm("edit_builtin")
102 return True
104 def is_deletable(self, obj): # pylint: disable=empty-docstring
105 """ """
106 role = obj
107 session = self.app.get_session(role)
108 auth = self.app.get_auth_handler()
110 # prevent delete for built-in roles
111 if role is auth.get_role_authenticated(session):
112 return False
113 if role is auth.get_role_anonymous(session):
114 return False
115 if role is auth.get_role_administrator(session):
116 return False
118 return True
120 def configure_form(self, form): # pylint: disable=empty-docstring
121 """ """
122 f = form
123 super().configure_form(f)
124 role = f.model_instance
126 # never show these
127 f.remove("permission_refs", "user_refs")
129 # name
130 f.set_validator("name", self.unique_name)
132 # notes
133 f.set_widget("notes", widgets.NotesWidget())
135 # users
136 if not (self.creating or self.editing):
137 f.append("users")
138 f.set_grid("users", self.make_users_grid(role))
140 # permissions
141 f.append("permissions")
142 self.wutta_permissions = self.get_available_permissions()
143 f.set_node(
144 "permissions", Permissions(self.request, permissions=self.wutta_permissions)
145 )
146 if not self.creating:
147 f.set_default("permissions", list(role.permissions))
149 def make_users_grid(self, role):
150 """
151 Make and return the grid for the Users field.
153 This grid is shown for the Users field when viewing a Role.
155 :returns: Fully configured :class:`~wuttaweb.grids.base.Grid`
156 instance.
157 """
158 return make_users_grid(
159 self.request,
160 route_prefix=self.get_route_prefix(),
161 data=role.users,
162 columns=[
163 "username",
164 "person",
165 "active",
166 ],
167 )
169 def unique_name(self, node, value): # pylint: disable=empty-docstring
170 """ """
171 model = self.app.model
172 session = Session()
174 query = session.query(model.Role).filter(model.Role.name == value)
176 if self.editing:
177 uuid = self.request.matchdict["uuid"]
178 query = query.filter(model.Role.uuid != uuid)
180 if query.count():
181 node.raise_invalid("Name must be unique")
183 def get_available_permissions(self):
184 """
185 Returns all "available" permissions. This is used when
186 viewing or editing a role; the result is passed into the
187 :class:`~wuttaweb.forms.schema.Permissions` field schema.
189 The app itself must be made aware of each permission, in order
190 for them to found by this method. This is done via
191 :func:`~wuttaweb.auth.add_permission_group()` and
192 :func:`~wuttaweb.auth.add_permission()`.
194 When in "view" (readonly) mode, this method will return the
195 full set of known permissions.
197 However in "edit" mode, it will prune the set to remove any
198 permissions which the current user does not also have. The
199 idea here is to allow "many" users to manage roles, but ensure
200 they cannot "break out" of their own role by assigning extra
201 permissions to it.
203 The permissions returned will also be grouped, and each single
204 permission is also represented as a simple dict, e.g.::
206 {
207 'books': {
208 'key': 'books',
209 'label': "Books",
210 'perms': {
211 'books.list': {
212 'key': 'books.list',
213 'label': "Browse / search Books",
214 },
215 'books.view': {
216 'key': 'books.view',
217 'label': "View Book",
218 },
219 },
220 },
221 'widgets': {
222 'key': 'widgets',
223 'label': "Widgets",
224 'perms': {
225 'widgets.list': {
226 'key': 'widgets.list',
227 'label': "Browse / search Widgets",
228 },
229 'widgets.view': {
230 'key': 'widgets.view',
231 'label': "View Widget",
232 },
233 },
234 },
235 }
236 """
238 # get all known permissions from settings cache
239 permissions = self.request.registry.settings.get("wutta_permissions", {})
241 # when viewing, we allow all permissions to be exposed for all users
242 if self.viewing:
243 return permissions
245 # admin user gets to manage all permissions
246 if self.request.is_admin:
247 return permissions
249 # non-admin user can only see permissions they're granted
250 available = {}
251 for gkey, group in permissions.items():
252 for pkey, perm in group["perms"].items():
253 if self.request.has_perm(pkey):
254 if gkey not in available:
255 available[gkey] = {
256 "key": gkey,
257 "label": group["label"],
258 "perms": {},
259 }
260 available[gkey]["perms"][pkey] = perm
262 return available
264 def objectify(self, form): # pylint: disable=empty-docstring
265 """ """
266 # normal logic first
267 role = super().objectify(form)
269 # update permissions for role
270 self.update_permissions(role, form)
272 return role
274 def update_permissions(self, role, form): # pylint: disable=empty-docstring
275 """ """
276 if "permissions" not in form.validated:
277 return
279 auth = self.app.get_auth_handler()
280 available = self.wutta_permissions
281 permissions = form.validated["permissions"]
283 for group in available.values():
284 for pkey in group["perms"]:
285 if pkey in permissions:
286 auth.grant_permission(role, pkey)
287 else:
288 auth.revoke_permission(role, pkey)
290 def merge_get_data(self, obj): # pylint: disable=empty-docstring
291 """ """
292 data = super().merge_get_data(obj)
293 role = obj
295 data["permissions"] = role.permissions
296 data["permission_count"] = len(data["permissions"])
298 data["usernames"] = [user.username for user in role.users]
299 data["user_count"] = len(data["usernames"])
301 return data
303 def merge_get_final_data(
304 self, removing, keeping
305 ): # pylint: disable=empty-docstring
306 """ """
307 final = super().merge_get_final_data(removing, keeping)
309 permissions = set(removing["permissions"] + keeping["permissions"])
310 final["permission_count"] = len(permissions)
312 usernames = set(removing["usernames"] + keeping["usernames"])
313 final["user_count"] = len(usernames)
315 return final
317 def merge_why_not(self, removing, keeping):
318 """
319 This checks to ensure the "removing" role is not one of the
320 special built-in roles (Administrator, Authenticated,
321 Anonymous).
323 See also parent method:
324 :meth:`~wuttaweb.views.master.MasterView.merge_why_not()`
325 """
326 auth = self.app.get_auth_handler()
327 session = self.Session()
329 if removing is auth.get_role_administrator(session):
330 return "Cannot remove the Administrator role."
332 if removing is auth.get_role_anonymous(session):
333 return "Cannot remove the Anonymous role."
335 if removing is auth.get_role_authenticated(session):
336 return "Cannot remove the Authenticated role."
338 return None
340 def merge_execute(self, removing, keeping):
341 """
342 The logic to merge 2 roles is extended as follows:
344 Any users belonging to the "removing" role will be added to
345 the "keeping" role (if not already present).
347 Any permissions belonging to the "removing" role will be added
348 to the "keeping" role (if not already present).
350 See also parent method:
351 :meth:`~wuttaweb.views.master.MasterView.merge_execute()`
352 """
354 # transfer permissions
355 for perm in list(removing.permissions):
356 if perm not in keeping.permissions:
357 keeping.permissions.append(perm)
359 # transfer users
360 for user in list(removing.users):
361 if user not in keeping.users:
362 keeping.users.append(user)
364 # continue default merge
365 super().merge_execute(removing, keeping)
367 @classmethod
368 def defaults(cls, config): # pylint: disable=empty-docstring
369 """ """
370 cls._defaults(config)
371 cls._role_defaults(config)
373 @classmethod
374 def _role_defaults(cls, config):
375 permission_prefix = cls.get_permission_prefix()
376 model_title_plural = cls.get_model_title_plural()
378 # perm to edit built-in roles
379 config.add_wutta_permission(
380 permission_prefix,
381 f"{permission_prefix}.edit_builtin",
382 f"Edit the Built-in {model_title_plural}",
383 )
386class PermissionView(MasterView): # pylint: disable=abstract-method
387 """
388 Master view for permissions.
390 Default route prefix is ``permissions``.
392 Notable URLs provided by this class:
394 * ``/permissions/``
395 * ``/permissions/XXX``
396 * ``/permissions/XXX/delete``
397 """
399 model_class = Permission
400 creatable = False
401 editable = False
403 grid_columns = [
404 "role",
405 "permission",
406 ]
408 sort_defaults = "role"
410 form_fields = [
411 "role",
412 "permission",
413 ]
415 def get_query(self, **kwargs): # pylint: disable=empty-docstring,arguments-differ
416 """ """
417 query = super().get_query(**kwargs)
418 model = self.app.model
420 # always join on Role
421 query = query.join(model.Role)
423 return query
425 def configure_grid(self, grid): # pylint: disable=empty-docstring
426 """ """
427 g = grid
428 super().configure_grid(g)
429 model = self.app.model
431 # role
432 g.set_sorter("role", model.Role.name)
433 g.set_filter("role", model.Role.name, label="Role Name")
434 g.set_link("role")
436 # permission
437 g.set_link("permission")
439 def configure_form(self, form): # pylint: disable=empty-docstring
440 """ """
441 f = form
442 super().configure_form(f)
444 # role
445 f.set_node("role", RoleRef(self.request))
448def defaults(config, **kwargs): # pylint: disable=missing-function-docstring
449 base = globals()
451 RoleView = kwargs.get( # pylint: disable=invalid-name,redefined-outer-name
452 "RoleView", base["RoleView"]
453 )
454 RoleView.defaults(config)
456 PermissionView = kwargs.get( # pylint: disable=invalid-name,redefined-outer-name
457 "PermissionView", base["PermissionView"]
458 )
459 PermissionView.defaults(config)
462def includeme(config): # pylint: disable=missing-function-docstring
463 defaults(config)