Coverage for .tox/coverage/lib/python3.13/site-packages/wuttaweb/views/roles.py: 100%
175 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-24 16:36 -0500
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-24 16:36 -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_anyone(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_anyone(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 auth = self.app.get_auth_handler()
125 session = self.Session()
126 role = f.model_instance
128 # never show these
129 f.remove("permission_refs", "user_refs")
131 # name
132 f.set_validator("name", self.unique_name)
134 # notes
135 f.set_widget("notes", widgets.NotesWidget())
137 # users
138 if not (self.creating or self.editing):
139 if role is not auth.get_role_anyone(
140 session
141 ) and role is not auth.get_role_authenticated(session):
142 f.append("users")
143 f.set_grid("users", self.make_users_grid(role))
145 # permissions
146 f.append("permissions")
147 self.wutta_permissions = self.get_available_permissions()
148 f.set_node(
149 "permissions", Permissions(self.request, permissions=self.wutta_permissions)
150 )
151 if not self.creating:
152 f.set_default("permissions", list(role.permissions))
154 def make_users_grid(self, role):
155 """
156 Make and return the grid for the Users field.
158 This grid is shown for the Users field when viewing a Role.
160 :returns: Fully configured :class:`~wuttaweb.grids.base.Grid`
161 instance.
162 """
163 return make_users_grid(
164 self.request,
165 route_prefix=self.get_route_prefix(),
166 data=role.users,
167 columns=[
168 "username",
169 "person",
170 "active",
171 ],
172 )
174 def unique_name(self, node, value): # pylint: disable=empty-docstring
175 """ """
176 model = self.app.model
177 session = Session()
179 query = session.query(model.Role).filter(model.Role.name == value)
181 if self.editing:
182 uuid = self.request.matchdict["uuid"]
183 query = query.filter(model.Role.uuid != uuid)
185 if query.count():
186 node.raise_invalid("Name must be unique")
188 def get_available_permissions(self):
189 """
190 Returns all "available" permissions. This is used when
191 viewing or editing a role; the result is passed into the
192 :class:`~wuttaweb.forms.schema.Permissions` field schema.
194 The app itself must be made aware of each permission, in order
195 for them to found by this method. This is done via
196 :func:`~wuttaweb.auth.add_permission_group()` and
197 :func:`~wuttaweb.auth.add_permission()`.
199 When in "view" (readonly) mode, this method will return the
200 full set of known permissions.
202 However in "edit" mode, it will prune the set to remove any
203 permissions which the current user does not also have. The
204 idea here is to allow "many" users to manage roles, but ensure
205 they cannot "break out" of their own role by assigning extra
206 permissions to it.
208 The permissions returned will also be grouped, and each single
209 permission is also represented as a simple dict, e.g.::
211 {
212 'books': {
213 'key': 'books',
214 'label': "Books",
215 'perms': {
216 'books.list': {
217 'key': 'books.list',
218 'label': "Browse / search Books",
219 },
220 'books.view': {
221 'key': 'books.view',
222 'label': "View Book",
223 },
224 },
225 },
226 'widgets': {
227 'key': 'widgets',
228 'label': "Widgets",
229 'perms': {
230 'widgets.list': {
231 'key': 'widgets.list',
232 'label': "Browse / search Widgets",
233 },
234 'widgets.view': {
235 'key': 'widgets.view',
236 'label': "View Widget",
237 },
238 },
239 },
240 }
241 """
243 # get all known permissions from settings cache
244 permissions = self.request.registry.settings.get("wutta_permissions", {})
246 # when viewing, we allow all permissions to be exposed for all users
247 if self.viewing:
248 return permissions
250 # admin user gets to manage all permissions
251 if self.request.is_admin:
252 return permissions
254 # non-admin user can only see permissions they're granted
255 available = {}
256 for gkey, group in permissions.items():
257 for pkey, perm in group["perms"].items():
258 if self.request.has_perm(pkey):
259 if gkey not in available:
260 available[gkey] = {
261 "key": gkey,
262 "label": group["label"],
263 "perms": {},
264 }
265 available[gkey]["perms"][pkey] = perm
267 return available
269 def objectify(self, form): # pylint: disable=empty-docstring
270 """ """
271 # normal logic first
272 role = super().objectify(form)
274 # update permissions for role
275 self.update_permissions(role, form)
277 return role
279 def update_permissions(self, role, form): # pylint: disable=empty-docstring
280 """ """
281 if "permissions" not in form.validated:
282 return
284 auth = self.app.get_auth_handler()
285 available = self.wutta_permissions
286 permissions = form.validated["permissions"]
288 for group in available.values():
289 for pkey in group["perms"]:
290 if pkey in permissions:
291 auth.grant_permission(role, pkey)
292 else:
293 auth.revoke_permission(role, pkey)
295 def merge_get_data(self, obj): # pylint: disable=empty-docstring
296 """ """
297 data = super().merge_get_data(obj)
298 role = obj
300 data["permissions"] = role.permissions
301 data["permission_count"] = len(data["permissions"])
303 data["usernames"] = [user.username for user in role.users]
304 data["user_count"] = len(data["usernames"])
306 return data
308 def merge_get_final_data(
309 self, removing, keeping
310 ): # pylint: disable=empty-docstring
311 """ """
312 final = super().merge_get_final_data(removing, keeping)
314 permissions = set(removing["permissions"] + keeping["permissions"])
315 final["permission_count"] = len(permissions)
317 usernames = set(removing["usernames"] + keeping["usernames"])
318 final["user_count"] = len(usernames)
320 return final
322 def merge_why_not(self, removing, keeping):
323 """
324 This checks to ensure the "removing" role is not one of the
325 special built-in roles (Administrator, Authenticated, Anyone).
327 See also parent method:
328 :meth:`~wuttaweb.views.master.MasterView.merge_why_not()`
329 """
330 auth = self.app.get_auth_handler()
331 session = self.Session()
333 if removing is auth.get_role_administrator(session):
334 return "Cannot remove the Administrator role."
336 if removing is auth.get_role_anyone(session):
337 return "Cannot remove the Anyone role."
339 if removing is auth.get_role_authenticated(session):
340 return "Cannot remove the Authenticated role."
342 return None
344 def merge_execute(self, removing, keeping):
345 """
346 The logic to merge 2 roles is extended as follows:
348 Any users belonging to the "removing" role will be added to
349 the "keeping" role (if not already present).
351 Any permissions belonging to the "removing" role will be added
352 to the "keeping" role (if not already present).
354 See also parent method:
355 :meth:`~wuttaweb.views.master.MasterView.merge_execute()`
356 """
358 # transfer permissions
359 for perm in list(removing.permissions):
360 if perm not in keeping.permissions:
361 keeping.permissions.append(perm)
363 # transfer users
364 for user in list(removing.users):
365 if user not in keeping.users:
366 keeping.users.append(user)
368 # continue default merge
369 super().merge_execute(removing, keeping)
371 @classmethod
372 def defaults(cls, config): # pylint: disable=empty-docstring
373 """ """
374 cls._defaults(config)
375 cls._role_defaults(config)
377 @classmethod
378 def _role_defaults(cls, config):
379 permission_prefix = cls.get_permission_prefix()
380 model_title_plural = cls.get_model_title_plural()
382 # perm to edit built-in roles
383 config.add_wutta_permission(
384 permission_prefix,
385 f"{permission_prefix}.edit_builtin",
386 f"Edit the Built-in {model_title_plural}",
387 )
390class PermissionView(MasterView): # pylint: disable=abstract-method
391 """
392 Master view for permissions.
394 Default route prefix is ``permissions``.
396 Notable URLs provided by this class:
398 * ``/permissions/``
399 * ``/permissions/XXX``
400 * ``/permissions/XXX/delete``
401 """
403 model_class = Permission
404 creatable = False
405 editable = False
407 grid_columns = [
408 "role",
409 "permission",
410 ]
412 sort_defaults = "role"
414 form_fields = [
415 "role",
416 "permission",
417 ]
419 def get_query(self, **kwargs): # pylint: disable=empty-docstring,arguments-differ
420 """ """
421 query = super().get_query(**kwargs)
422 model = self.app.model
424 # always join on Role
425 query = query.join(model.Role)
427 return query
429 def configure_grid(self, grid): # pylint: disable=empty-docstring
430 """ """
431 g = grid
432 super().configure_grid(g)
433 model = self.app.model
435 # role
436 g.set_sorter("role", model.Role.name)
437 g.set_filter("role", model.Role.name, label="Role Name")
438 g.set_link("role")
440 # permission
441 g.set_link("permission")
443 def configure_form(self, form): # pylint: disable=empty-docstring
444 """ """
445 f = form
446 super().configure_form(f)
448 # role
449 f.set_node("role", RoleRef(self.request))
452def defaults(config, **kwargs): # pylint: disable=missing-function-docstring
453 base = globals()
455 RoleView = kwargs.get( # pylint: disable=invalid-name,redefined-outer-name
456 "RoleView", base["RoleView"]
457 )
458 RoleView.defaults(config)
460 PermissionView = kwargs.get( # pylint: disable=invalid-name,redefined-outer-name
461 "PermissionView", base["PermissionView"]
462 )
463 PermissionView.defaults(config)
466def includeme(config): # pylint: disable=missing-function-docstring
467 defaults(config)