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
« 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"""
27from wuttjamaican.db.model import User
28from wuttaweb.views import MasterView
29from wuttaweb.forms import widgets
30from wuttaweb.forms.schema import PersonRef, RoleRefs
33class UserView(MasterView): # pylint: disable=abstract-method
34 """
35 Master view for users.
37 Default route prefix is ``users``.
39 Notable URLs provided by this class:
41 * ``/users/``
42 * ``/users/new``
43 * ``/users/XXX``
44 * ``/users/XXX/edit``
45 * ``/users/XXX/delete``
46 """
48 model_class = User
50 labels = {
51 "api_tokens": "API Tokens",
52 }
54 grid_columns = [
55 "username",
56 "person",
57 "active",
58 ]
60 filter_defaults = {
61 "username": {"active": True},
62 "active": {"active": True, "verb": "is_true"},
63 }
64 sort_defaults = "username"
66 form_fields = [
67 "username",
68 "person",
69 "active",
70 "prevent_edit",
71 "roles",
72 "api_tokens",
73 ]
75 def get_query(self, session=None): # pylint: disable=empty-docstring
76 """ """
77 query = super().get_query(session=session)
79 # nb. always join Person
80 model = self.app.model
81 query = query.outerjoin(model.Person)
83 return query
85 def configure_grid(self, grid): # pylint: disable=empty-docstring
86 """ """
87 g = grid
88 super().configure_grid(g)
89 model = self.app.model
91 # never show these
92 g.remove("person_uuid", "role_refs", "password")
93 g.remove_filter("password")
95 # username
96 g.set_link("username")
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")
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
111 def is_editable(self, obj): # pylint: disable=empty-docstring
112 """ """
113 user = obj
115 # only root can edit certain users
116 if user.prevent_edit and not self.request.is_root:
117 return False
119 return True
121 def configure_form(self, form): # pylint: disable=empty-docstring
122 """ """
123 f = form
124 super().configure_form(f)
125 user = f.model_instance
127 # username
128 f.set_validator("username", self.unique_username)
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))
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())
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])
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")
168 def unique_username(self, node, value): # pylint: disable=empty-docstring
169 """ """
170 model = self.app.model
171 session = self.Session()
173 query = session.query(model.User).filter(model.User.username == value)
175 if self.editing:
176 uuid = self.request.matchdict["uuid"]
177 query = query.filter(model.User.uuid != uuid)
179 if query.count():
180 node.raise_invalid("Username must be unique")
182 def objectify(self, form): # pylint: disable=empty-docstring
183 """ """
184 auth = self.app.get_auth_handler()
185 data = form.validated
187 # normal logic first
188 user = super().objectify(form)
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
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"])
220 # update roles for user
221 # TODO
222 # if self.has_perm('edit_roles'):
223 self.update_roles(user, form)
225 return user
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
236 model = self.app.model
237 session = self.Session()
238 auth = self.app.get_auth_handler()
240 old_roles = {role.uuid for role in user.roles}
241 new_roles = data["roles"]
243 admin = auth.get_role_administrator(session)
244 ignored = {
245 auth.get_role_authenticated(session).uuid,
246 auth.get_role_anonymous(session).uuid,
247 }
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)
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)
271 def make_api_tokens_grid(self, user):
272 """
273 Make and return the grid for the API Tokens field.
275 This is only shown when current user has permission to manage
276 API tokens for other users.
278 :rtype: :class:`~wuttaweb.grids.base.Grid`
279 """
280 route_prefix = self.get_route_prefix()
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 )
294 if self.has_perm("manage_api_tokens"):
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")
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 )
314 return grid
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 }
324 def add_api_token(self):
325 """
326 AJAX view for adding a new user API token.
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
337 token = auth.add_api_token(user, data["description"])
338 session.flush()
339 session.refresh(token)
341 result = self.normalize_api_token(token)
342 result["token_string"] = token.token_string
343 result["_action_url_delete"] = "#"
344 return result
346 def delete_api_token(self):
347 """
348 AJAX view for deleting a user API token.
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
360 token = session.get(model.UserAPIToken, data["uuid"])
361 if not token:
362 return {"error": "API token not found"}
364 if token.user is not user:
365 return {"error": "API token not found"}
367 auth.delete_api_token(token)
368 return {}
370 @classmethod
371 def defaults(cls, config): # pylint: disable=empty-docstring
372 """ """
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
379 cls._user_defaults(config)
380 cls._defaults(config)
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()
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 )
424def defaults(config, **kwargs): # pylint: disable=missing-function-docstring
425 base = globals()
427 UserView = kwargs.get( # pylint: disable=invalid-name,redefined-outer-name
428 "UserView", base["UserView"]
429 )
430 UserView.defaults(config)
433def includeme(config): # pylint: disable=missing-function-docstring
434 defaults(config)