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

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_anonymous(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_anonymous(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 role = f.model_instance 

125 

126 # never show these 

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

128 

129 # name 

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

131 

132 # notes 

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

134 

135 # users 

136 if not (self.creating or self.editing): 

137 f.append("users") 

138 f.set_grid("users", self.make_users_grid(role)) 

139 

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

148 

149 def make_users_grid(self, role): 

150 """ 

151 Make and return the grid for the Users field. 

152 

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

154 

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 ) 

168 

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

170 """ """ 

171 model = self.app.model 

172 session = Session() 

173 

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

175 

176 if self.editing: 

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

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

179 

180 if query.count(): 

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

182 

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. 

188 

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

193 

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

195 full set of known permissions. 

196 

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. 

202 

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

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

205 

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

237 

238 # get all known permissions from settings cache 

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

240 

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

242 if self.viewing: 

243 return permissions 

244 

245 # admin user gets to manage all permissions 

246 if self.request.is_admin: 

247 return permissions 

248 

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 

261 

262 return available 

263 

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

265 """ """ 

266 # normal logic first 

267 role = super().objectify(form) 

268 

269 # update permissions for role 

270 self.update_permissions(role, form) 

271 

272 return role 

273 

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

275 """ """ 

276 if "permissions" not in form.validated: 

277 return 

278 

279 auth = self.app.get_auth_handler() 

280 available = self.wutta_permissions 

281 permissions = form.validated["permissions"] 

282 

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) 

289 

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

291 """ """ 

292 data = super().merge_get_data(obj) 

293 role = obj 

294 

295 data["permissions"] = role.permissions 

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

297 

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

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

300 

301 return data 

302 

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) 

308 

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

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

311 

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

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

314 

315 return final 

316 

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

322 

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

328 

329 if removing is auth.get_role_administrator(session): 

330 return "Cannot remove the Administrator role." 

331 

332 if removing is auth.get_role_anonymous(session): 

333 return "Cannot remove the Anonymous role." 

334 

335 if removing is auth.get_role_authenticated(session): 

336 return "Cannot remove the Authenticated role." 

337 

338 return None 

339 

340 def merge_execute(self, removing, keeping): 

341 """ 

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

343 

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

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

346 

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

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

349 

350 See also parent method: 

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

352 """ 

353 

354 # transfer permissions 

355 for perm in list(removing.permissions): 

356 if perm not in keeping.permissions: 

357 keeping.permissions.append(perm) 

358 

359 # transfer users 

360 for user in list(removing.users): 

361 if user not in keeping.users: 

362 keeping.users.append(user) 

363 

364 # continue default merge 

365 super().merge_execute(removing, keeping) 

366 

367 @classmethod 

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

369 """ """ 

370 cls._defaults(config) 

371 cls._role_defaults(config) 

372 

373 @classmethod 

374 def _role_defaults(cls, config): 

375 permission_prefix = cls.get_permission_prefix() 

376 model_title_plural = cls.get_model_title_plural() 

377 

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 ) 

384 

385 

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

387 """ 

388 Master view for permissions. 

389 

390 Default route prefix is ``permissions``. 

391 

392 Notable URLs provided by this class: 

393 

394 * ``/permissions/`` 

395 * ``/permissions/XXX`` 

396 * ``/permissions/XXX/delete`` 

397 """ 

398 

399 model_class = Permission 

400 creatable = False 

401 editable = False 

402 

403 grid_columns = [ 

404 "role", 

405 "permission", 

406 ] 

407 

408 sort_defaults = "role" 

409 

410 form_fields = [ 

411 "role", 

412 "permission", 

413 ] 

414 

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

416 """ """ 

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

418 model = self.app.model 

419 

420 # always join on Role 

421 query = query.join(model.Role) 

422 

423 return query 

424 

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

426 """ """ 

427 g = grid 

428 super().configure_grid(g) 

429 model = self.app.model 

430 

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

435 

436 # permission 

437 g.set_link("permission") 

438 

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

440 """ """ 

441 f = form 

442 super().configure_form(f) 

443 

444 # role 

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

446 

447 

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

449 base = globals() 

450 

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

452 "RoleView", base["RoleView"] 

453 ) 

454 RoleView.defaults(config) 

455 

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

457 "PermissionView", base["PermissionView"] 

458 ) 

459 PermissionView.defaults(config) 

460 

461 

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

463 defaults(config)