Coverage for .tox / coverage / lib / python3.11 / site-packages / wuttaweb / views / users.py: 100%

184 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2025-12-28 15:23 -0600

1# -*- coding: utf-8; -*- 

2################################################################################ 

3# 

4# wuttaweb -- Web App for Wutta Framework 

5# Copyright © 2024-2025 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 users 

25""" 

26 

27from wuttjamaican.db.model import User 

28from wuttaweb.views import MasterView 

29from wuttaweb.forms import widgets 

30from wuttaweb.forms.schema import PersonRef, RoleRefs 

31 

32 

33class UserView(MasterView): # pylint: disable=abstract-method 

34 """ 

35 Master view for users. 

36 

37 Default route prefix is ``users``. 

38 

39 Notable URLs provided by this class: 

40 

41 * ``/users/`` 

42 * ``/users/new`` 

43 * ``/users/XXX`` 

44 * ``/users/XXX/edit`` 

45 * ``/users/XXX/delete`` 

46 """ 

47 

48 model_class = User 

49 

50 labels = { 

51 "api_tokens": "API Tokens", 

52 } 

53 

54 grid_columns = [ 

55 "username", 

56 "person", 

57 "active", 

58 ] 

59 

60 filter_defaults = { 

61 "username": {"active": True}, 

62 "active": {"active": True, "verb": "is_true"}, 

63 } 

64 sort_defaults = "username" 

65 

66 form_fields = [ 

67 "username", 

68 "person", 

69 "active", 

70 "prevent_edit", 

71 "roles", 

72 "api_tokens", 

73 ] 

74 

75 def get_query(self, session=None): # pylint: disable=empty-docstring 

76 """ """ 

77 query = super().get_query(session=session) 

78 

79 # nb. always join Person 

80 model = self.app.model 

81 query = query.outerjoin(model.Person) 

82 

83 return query 

84 

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

86 """ """ 

87 g = grid 

88 super().configure_grid(g) 

89 model = self.app.model 

90 

91 # never show these 

92 g.remove("person_uuid", "role_refs", "password") 

93 g.remove_filter("password") 

94 

95 # username 

96 g.set_link("username") 

97 

98 # person 

99 g.set_link("person") 

100 g.set_sorter("person", model.Person.full_name) 

101 g.set_filter("person", model.Person.full_name, label="Person Full Name") 

102 

103 def grid_row_class( # pylint: disable=empty-docstring,unused-argument 

104 self, user, data, i 

105 ): 

106 """ """ 

107 if not user.active: 

108 return "has-background-warning" 

109 return None 

110 

111 def is_editable(self, obj): # pylint: disable=empty-docstring 

112 """ """ 

113 user = obj 

114 

115 # only root can edit certain users 

116 if user.prevent_edit and not self.request.is_root: 

117 return False 

118 

119 return True 

120 

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

122 """ """ 

123 f = form 

124 super().configure_form(f) 

125 user = f.model_instance 

126 

127 # username 

128 f.set_validator("username", self.unique_username) 

129 

130 # person 

131 if self.creating or self.editing: 

132 f.fields.insert_after("person", "first_name") 

133 f.set_required("first_name", False) 

134 f.fields.insert_after("first_name", "last_name") 

135 f.set_required("last_name", False) 

136 f.remove("person") 

137 if self.editing: 

138 person = user.person 

139 if person: 

140 f.set_default("first_name", person.first_name) 

141 f.set_default("last_name", person.last_name) 

142 else: 

143 f.set_node("person", PersonRef(self.request)) 

144 

145 # password 

146 # nb. we must avoid 'password' as field name since 

147 # ColanderAlchemy wants to handle the raw/hashed value 

148 f.remove("password") 

149 # nb. no need for password field if readonly 

150 if self.creating or self.editing: 

151 # nb. use 'set_password' as field name 

152 f.append("set_password") 

153 f.set_required("set_password", False) 

154 f.set_widget("set_password", widgets.CheckedPasswordWidget()) 

155 

156 # roles 

157 f.append("roles") 

158 f.set_node("roles", RoleRefs(self.request)) 

159 if not self.creating: 

160 f.set_default("roles", [role.uuid.hex for role in user.roles]) 

161 

162 # api_tokens 

163 if self.viewing and self.has_perm("manage_api_tokens"): 

164 f.set_grid("api_tokens", self.make_api_tokens_grid(user)) 

165 else: 

166 f.remove("api_tokens") 

167 

168 def unique_username(self, node, value): # pylint: disable=empty-docstring 

169 """ """ 

170 model = self.app.model 

171 session = self.Session() 

172 

173 query = session.query(model.User).filter(model.User.username == value) 

174 

175 if self.editing: 

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

177 query = query.filter(model.User.uuid != uuid) 

178 

179 if query.count(): 

180 node.raise_invalid("Username must be unique") 

181 

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

183 """ """ 

184 auth = self.app.get_auth_handler() 

185 data = form.validated 

186 

187 # normal logic first 

188 user = super().objectify(form) 

189 

190 # maybe update person name 

191 if "first_name" in form or "last_name" in form: 

192 first_name = data.get("first_name") 

193 last_name = data.get("last_name") 

194 if self.creating and (first_name or last_name): 

195 user.person = auth.make_person( 

196 first_name=first_name, last_name=last_name 

197 ) 

198 elif self.editing: 

199 if first_name or last_name: 

200 if user.person: 

201 person = user.person 

202 if "first_name" in form: 

203 person.first_name = first_name 

204 if "last_name" in form: 

205 person.last_name = last_name 

206 person.full_name = self.app.make_full_name( 

207 person.first_name, person.last_name 

208 ) 

209 else: 

210 user.person = auth.make_person( 

211 first_name=first_name, last_name=last_name 

212 ) 

213 elif user.person: 

214 user.person = None 

215 

216 # maybe set user password 

217 if "set_password" in form and data.get("set_password"): 

218 auth.set_user_password(user, data["set_password"]) 

219 

220 # update roles for user 

221 # TODO 

222 # if self.has_perm('edit_roles'): 

223 self.update_roles(user, form) 

224 

225 return user 

226 

227 def update_roles(self, user, form): # pylint: disable=empty-docstring 

228 """ """ 

229 # TODO 

230 # if not self.has_perm('edit_roles'): 

231 # return 

232 data = form.validated 

233 if "roles" not in data: 

234 return 

235 

236 model = self.app.model 

237 session = self.Session() 

238 auth = self.app.get_auth_handler() 

239 

240 old_roles = {role.uuid for role in user.roles} 

241 new_roles = data["roles"] 

242 

243 admin = auth.get_role_administrator(session) 

244 ignored = { 

245 auth.get_role_authenticated(session).uuid, 

246 auth.get_role_anonymous(session).uuid, 

247 } 

248 

249 # add any new roles for the user, taking care to avoid certain 

250 # unwanted operations for built-in roles 

251 for uuid in new_roles: 

252 if uuid in ignored: 

253 continue 

254 if uuid in old_roles: 

255 continue 

256 if uuid == admin.uuid and not self.request.is_root: 

257 continue 

258 role = session.get(model.Role, uuid) 

259 user.roles.append(role) 

260 

261 # remove any roles which were *not* specified, taking care to 

262 # avoid certain unwanted operations for built-in roles 

263 for uuid in old_roles: 

264 if uuid in new_roles: 

265 continue 

266 if uuid == admin.uuid and not self.request.is_root: 

267 continue 

268 role = session.get(model.Role, uuid) 

269 user.roles.remove(role) 

270 

271 def make_api_tokens_grid(self, user): 

272 """ 

273 Make and return the grid for the API Tokens field. 

274 

275 This is only shown when current user has permission to manage 

276 API tokens for other users. 

277 

278 :rtype: :class:`~wuttaweb.grids.base.Grid` 

279 """ 

280 route_prefix = self.get_route_prefix() 

281 

282 grid = self.make_grid( 

283 key=f"{route_prefix}.view.api_tokens", 

284 data=[self.normalize_api_token(t) for t in user.api_tokens], 

285 columns=[ 

286 "description", 

287 "created", 

288 ], 

289 sortable=True, 

290 sort_on_backend=False, 

291 sort_defaults=[("created", "desc")], 

292 ) 

293 

294 if self.has_perm("manage_api_tokens"): 

295 

296 # create token 

297 button = self.make_button( 

298 "New", 

299 primary=True, 

300 icon_left="plus", 

301 **{"@click": "$emit('new-token')"}, 

302 ) 

303 grid.add_tool(button, key="create") 

304 

305 # delete token 

306 grid.add_action( 

307 "delete", 

308 url="#", 

309 icon="trash", 

310 link_class="has-text-danger", 

311 click_handler="$emit('delete-token', props.row)", 

312 ) 

313 

314 return grid 

315 

316 def normalize_api_token(self, token): # pylint: disable=empty-docstring 

317 """ """ 

318 return { 

319 "uuid": token.uuid.hex, 

320 "description": token.description, 

321 "created": self.app.render_datetime(token.created), 

322 } 

323 

324 def add_api_token(self): 

325 """ 

326 AJAX view for adding a new user API token. 

327 

328 This calls 

329 :meth:`wuttjamaican:wuttjamaican.auth.AuthHandler.add_api_token()` 

330 for the creation logic. 

331 """ 

332 session = self.Session() 

333 auth = self.app.get_auth_handler() 

334 user = self.get_instance() 

335 data = self.request.json_body 

336 

337 token = auth.add_api_token(user, data["description"]) 

338 session.flush() 

339 session.refresh(token) 

340 

341 result = self.normalize_api_token(token) 

342 result["token_string"] = token.token_string 

343 result["_action_url_delete"] = "#" 

344 return result 

345 

346 def delete_api_token(self): 

347 """ 

348 AJAX view for deleting a user API token. 

349 

350 This calls 

351 :meth:`wuttjamaican:wuttjamaican.auth.AuthHandler.delete_api_token()` 

352 for the deletion logic. 

353 """ 

354 model = self.app.model 

355 session = self.Session() 

356 auth = self.app.get_auth_handler() 

357 user = self.get_instance() 

358 data = self.request.json_body 

359 

360 token = session.get(model.UserAPIToken, data["uuid"]) 

361 if not token: 

362 return {"error": "API token not found"} 

363 

364 if token.user is not user: 

365 return {"error": "API token not found"} 

366 

367 auth.delete_api_token(token) 

368 return {} 

369 

370 @classmethod 

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

372 """ """ 

373 

374 # nb. User may come from custom model 

375 wutta_config = config.registry.settings["wutta_config"] 

376 app = wutta_config.get_app() 

377 cls.model_class = app.model.User 

378 

379 cls._user_defaults(config) 

380 cls._defaults(config) 

381 

382 @classmethod 

383 def _user_defaults(cls, config): 

384 """ 

385 Provide extra default configuration for the User master view. 

386 """ 

387 route_prefix = cls.get_route_prefix() 

388 permission_prefix = cls.get_permission_prefix() 

389 instance_url_prefix = cls.get_instance_url_prefix() 

390 model_title = cls.get_model_title() 

391 

392 # manage API tokens 

393 config.add_wutta_permission( 

394 permission_prefix, 

395 f"{permission_prefix}.manage_api_tokens", 

396 f"Manage API tokens for any {model_title}", 

397 ) 

398 config.add_route( 

399 f"{route_prefix}.add_api_token", 

400 f"{instance_url_prefix}/add-api-token", 

401 request_method="POST", 

402 ) 

403 config.add_view( 

404 cls, 

405 attr="add_api_token", 

406 route_name=f"{route_prefix}.add_api_token", 

407 permission=f"{permission_prefix}.manage_api_tokens", 

408 renderer="json", 

409 ) 

410 config.add_route( 

411 f"{route_prefix}.delete_api_token", 

412 f"{instance_url_prefix}/delete-api-token", 

413 request_method="POST", 

414 ) 

415 config.add_view( 

416 cls, 

417 attr="delete_api_token", 

418 route_name=f"{route_prefix}.delete_api_token", 

419 permission=f"{permission_prefix}.manage_api_tokens", 

420 renderer="json", 

421 ) 

422 

423 

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

425 base = globals() 

426 

427 UserView = kwargs.get( # pylint: disable=invalid-name,redefined-outer-name 

428 "UserView", base["UserView"] 

429 ) 

430 UserView.defaults(config) 

431 

432 

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

434 defaults(config)