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

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""" 

26 

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 

33 

34 

35class RoleView(MasterView): # pylint: disable=abstract-method 

36 """ 

37 Master view for roles. 

38 

39 Default route prefix is ``roles``. 

40 

41 Notable URLs provided by this class: 

42 

43 * ``/roles/`` 

44 * ``/roles/new`` 

45 * ``/roles/XXX`` 

46 * ``/roles/XXX/edit`` 

47 * ``/roles/XXX/delete`` 

48 """ 

49 

50 model_class = Role 

51 

52 grid_columns = [ 

53 "name", 

54 "notes", 

55 ] 

56 

57 filter_defaults = { 

58 "name": {"active": True}, 

59 } 

60 sort_defaults = "name" 

61 

62 mergeable = True 

63 merge_additive_fields = ["permission_count", "user_count"] 

64 

65 wutta_permissions = None 

66 

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) 

73 

74 def configure_grid(self, grid): # pylint: disable=empty-docstring 

75 """ """ 

76 g = grid 

77 super().configure_grid(g) 

78 

79 # name 

80 g.set_link("name") 

81 

82 # notes 

83 g.set_renderer("notes", self.grid_render_notes) 

84 

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() 

90 

91 # only "root" can edit admin role 

92 if role is auth.get_role_administrator(session): 

93 return self.request.is_root 

94 

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") 

101 

102 return True 

103 

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() 

109 

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 

117 

118 return True 

119 

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 

127 

128 # never show these 

129 f.remove("permission_refs", "user_refs") 

130 

131 # name 

132 f.set_validator("name", self.unique_name) 

133 

134 # notes 

135 f.set_widget("notes", widgets.NotesWidget()) 

136 

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)) 

144 

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)) 

153 

154 def make_users_grid(self, role): 

155 """ 

156 Make and return the grid for the Users field. 

157 

158 This grid is shown for the Users field when viewing a Role. 

159 

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 ) 

173 

174 def unique_name(self, node, value): # pylint: disable=empty-docstring 

175 """ """ 

176 model = self.app.model 

177 session = Session() 

178 

179 query = session.query(model.Role).filter(model.Role.name == value) 

180 

181 if self.editing: 

182 uuid = self.request.matchdict["uuid"] 

183 query = query.filter(model.Role.uuid != uuid) 

184 

185 if query.count(): 

186 node.raise_invalid("Name must be unique") 

187 

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. 

193 

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()`. 

198 

199 When in "view" (readonly) mode, this method will return the 

200 full set of known permissions. 

201 

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. 

207 

208 The permissions returned will also be grouped, and each single 

209 permission is also represented as a simple dict, e.g.:: 

210 

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 """ 

242 

243 # get all known permissions from settings cache 

244 permissions = self.request.registry.settings.get("wutta_permissions", {}) 

245 

246 # when viewing, we allow all permissions to be exposed for all users 

247 if self.viewing: 

248 return permissions 

249 

250 # admin user gets to manage all permissions 

251 if self.request.is_admin: 

252 return permissions 

253 

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 

266 

267 return available 

268 

269 def objectify(self, form): # pylint: disable=empty-docstring 

270 """ """ 

271 # normal logic first 

272 role = super().objectify(form) 

273 

274 # update permissions for role 

275 self.update_permissions(role, form) 

276 

277 return role 

278 

279 def update_permissions(self, role, form): # pylint: disable=empty-docstring 

280 """ """ 

281 if "permissions" not in form.validated: 

282 return 

283 

284 auth = self.app.get_auth_handler() 

285 available = self.wutta_permissions 

286 permissions = form.validated["permissions"] 

287 

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) 

294 

295 def merge_get_data(self, obj): # pylint: disable=empty-docstring 

296 """ """ 

297 data = super().merge_get_data(obj) 

298 role = obj 

299 

300 data["permissions"] = role.permissions 

301 data["permission_count"] = len(data["permissions"]) 

302 

303 data["usernames"] = [user.username for user in role.users] 

304 data["user_count"] = len(data["usernames"]) 

305 

306 return data 

307 

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) 

313 

314 permissions = set(removing["permissions"] + keeping["permissions"]) 

315 final["permission_count"] = len(permissions) 

316 

317 usernames = set(removing["usernames"] + keeping["usernames"]) 

318 final["user_count"] = len(usernames) 

319 

320 return final 

321 

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). 

326 

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() 

332 

333 if removing is auth.get_role_administrator(session): 

334 return "Cannot remove the Administrator role." 

335 

336 if removing is auth.get_role_anyone(session): 

337 return "Cannot remove the Anyone role." 

338 

339 if removing is auth.get_role_authenticated(session): 

340 return "Cannot remove the Authenticated role." 

341 

342 return None 

343 

344 def merge_execute(self, removing, keeping): 

345 """ 

346 The logic to merge 2 roles is extended as follows: 

347 

348 Any users belonging to the "removing" role will be added to 

349 the "keeping" role (if not already present). 

350 

351 Any permissions belonging to the "removing" role will be added 

352 to the "keeping" role (if not already present). 

353 

354 See also parent method: 

355 :meth:`~wuttaweb.views.master.MasterView.merge_execute()` 

356 """ 

357 

358 # transfer permissions 

359 for perm in list(removing.permissions): 

360 if perm not in keeping.permissions: 

361 keeping.permissions.append(perm) 

362 

363 # transfer users 

364 for user in list(removing.users): 

365 if user not in keeping.users: 

366 keeping.users.append(user) 

367 

368 # continue default merge 

369 super().merge_execute(removing, keeping) 

370 

371 @classmethod 

372 def defaults(cls, config): # pylint: disable=empty-docstring 

373 """ """ 

374 cls._defaults(config) 

375 cls._role_defaults(config) 

376 

377 @classmethod 

378 def _role_defaults(cls, config): 

379 permission_prefix = cls.get_permission_prefix() 

380 model_title_plural = cls.get_model_title_plural() 

381 

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 ) 

388 

389 

390class PermissionView(MasterView): # pylint: disable=abstract-method 

391 """ 

392 Master view for permissions. 

393 

394 Default route prefix is ``permissions``. 

395 

396 Notable URLs provided by this class: 

397 

398 * ``/permissions/`` 

399 * ``/permissions/XXX`` 

400 * ``/permissions/XXX/delete`` 

401 """ 

402 

403 model_class = Permission 

404 creatable = False 

405 editable = False 

406 

407 grid_columns = [ 

408 "role", 

409 "permission", 

410 ] 

411 

412 sort_defaults = "role" 

413 

414 form_fields = [ 

415 "role", 

416 "permission", 

417 ] 

418 

419 def get_query(self, **kwargs): # pylint: disable=empty-docstring,arguments-differ 

420 """ """ 

421 query = super().get_query(**kwargs) 

422 model = self.app.model 

423 

424 # always join on Role 

425 query = query.join(model.Role) 

426 

427 return query 

428 

429 def configure_grid(self, grid): # pylint: disable=empty-docstring 

430 """ """ 

431 g = grid 

432 super().configure_grid(g) 

433 model = self.app.model 

434 

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") 

439 

440 # permission 

441 g.set_link("permission") 

442 

443 def configure_form(self, form): # pylint: disable=empty-docstring 

444 """ """ 

445 f = form 

446 super().configure_form(f) 

447 

448 # role 

449 f.set_node("role", RoleRef(self.request)) 

450 

451 

452def defaults(config, **kwargs): # pylint: disable=missing-function-docstring 

453 base = globals() 

454 

455 RoleView = kwargs.get( # pylint: disable=invalid-name,redefined-outer-name 

456 "RoleView", base["RoleView"] 

457 ) 

458 RoleView.defaults(config) 

459 

460 PermissionView = kwargs.get( # pylint: disable=invalid-name,redefined-outer-name 

461 "PermissionView", base["PermissionView"] 

462 ) 

463 PermissionView.defaults(config) 

464 

465 

466def includeme(config): # pylint: disable=missing-function-docstring 

467 defaults(config)