diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6ceb1bd..89a7fc2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,24 @@ All notable changes to wuttaweb will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
+## v0.23.0 (2025-08-09)
+
+### Feat
+
+- add tools to manage user API tokens
+
+### Fix
+
+- add default sorter, tools for basic table-element grid
+- add custom password+confirmation widget for Vue3 + Oruga
+- fix butterfly wrapper for b-notification component
+- add butterfly wrapper for b-timepicker component
+- style tweaks for butterfly/oruga; mostly expand fields
+- fix b-datepicker component wrapper per oruga 0.9.0
+- fix b-button component wrapper per oruga 0.9.0
+- update butterfly component for b-autocomplete, per oruga 0.11.4
+- update default versions for Vue3 + Oruga + FontAwesome
+
## v0.22.0 (2025-06-29)
### Feat
diff --git a/pyproject.toml b/pyproject.toml
index 7ca867e..ef67862 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
[project]
name = "WuttaWeb"
-version = "0.22.0"
+version = "0.23.0"
description = "Web App for Wutta Framework"
readme = "README.md"
authors = [{name = "Lance Edgar", email = "lance@wuttaproject.org"}]
@@ -44,7 +44,7 @@ dependencies = [
"pyramid_tm",
"waitress",
"WebHelpers2",
- "WuttJamaican[db]>=0.20.6",
+ "WuttJamaican[db]>=0.22.0",
"zope.sqlalchemy>=1.5",
]
diff --git a/src/wuttaweb/grids/base.py b/src/wuttaweb/grids/base.py
index fcfff93..6bf7274 100644
--- a/src/wuttaweb/grids/base.py
+++ b/src/wuttaweb/grids/base.py
@@ -2041,9 +2041,9 @@ class Grid:
"""
Render a simple Vue table element for the grid.
- This is what you want for a "simple" grid which does require a
- unique Vue component, but can instead use the standard table
- component.
+ This is what you want for a "simple" grid which does not
+ require a unique Vue component, but can instead use the
+ standard table component.
This returns something like:
@@ -2227,6 +2227,35 @@ class Grid:
'order': sorter['dir']})
return sorters
+ def get_vue_first_sorter(self):
+ """
+ Returns the first active sorter, if applicable.
+
+ This method is used to declare the initial sort for a simple
+ table component, i.e. for use with the ``table-element.mako``
+ template. It generally is assumed that frontend sorting is in
+ use, as opposed to backend sorting, although it should work
+ for either scenario.
+
+ This checks :attr:`active_sorters` and if set, will use the
+ first sorter from that. Note that ``active_sorters`` will
+ *not* be set unless :meth:`load_settings()` has been called.
+
+ Otherwise this will use the first sorter from
+ :attr:`sort_defaults` which is defined in constructor.
+
+ :returns: The first sorter in format ``[sortkey, sortdir]``,
+ or ``None``.
+ """
+ if hasattr(self, 'active_sorters'):
+ if self.active_sorters:
+ sorter = self.active_sorters[0]
+ return [sorter['key'], sorter['dir']]
+
+ elif self.sort_defaults:
+ sorter = self.sort_defaults[0]
+ return [sorter.sortkey, sorter.sortdir]
+
def get_vue_filters(self):
"""
Returns a list of Vue-compatible filter definitions.
diff --git a/src/wuttaweb/templates/grids/table_element.mako b/src/wuttaweb/templates/grids/table_element.mako
index 3c40338..c964089 100644
--- a/src/wuttaweb/templates/grids/table_element.mako
+++ b/src/wuttaweb/templates/grids/table_element.mako
@@ -1,5 +1,22 @@
## -*- coding: utf-8; -*-
-<${b}-table :data="gridContext['${grid.key}'].data">
+
+
+ % if grid.tools:
+
+ % for html in grid.tools.values():
+ ${html}
+ % endfor
+
+ % endif
+
+<${b}-table :data="gridContext['${grid.key}'].data"
+
+ ## sorting
+ % if grid.sortable:
+ :default-sort="${grid.get_vue_first_sorter() or 'null'}"
+ % endif
+
+ icon-pack="fas">
% for column in grid.get_vue_columns():
% if not column['hidden']:
@@ -52,3 +69,5 @@
${b}-table>
+
+
diff --git a/src/wuttaweb/templates/users/view.mako b/src/wuttaweb/templates/users/view.mako
new file mode 100644
index 0000000..bc5681f
--- /dev/null
+++ b/src/wuttaweb/templates/users/view.mako
@@ -0,0 +1,126 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/master/view.mako" />
+
+<%def name="page_content()">
+ ${parent.page_content()}
+
+ % if master.has_perm('manage_api_tokens'):
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Your new API token is shown below.
+
+
+ IMPORTANT: You must record this token elsewhere
+ for later reference. You will NOT be able to
+ recover the value if you lose it.
+
+
+ {{ newTokenRaw }}
+
+
+ {{ newTokenDescription }}
+
+
+
+
+
+
+
+ % endif
+%def>
+
+<%def name="render_form_tag()">
+ % if master.has_perm('manage_api_tokens'):
+ ${form.render_vue_tag(**{'@new-token': 'newTokenInit', '@delete-token': 'deleteTokenInit'})}
+ % else:
+ ${form.render_vue_tag()}
+ % endif
+%def>
+
+<%def name="modify_vue_vars()">
+ ${parent.modify_vue_vars()}
+
+%def>
diff --git a/src/wuttaweb/views/users.py b/src/wuttaweb/views/users.py
index 760152d..aa5841e 100644
--- a/src/wuttaweb/views/users.py
+++ b/src/wuttaweb/views/users.py
@@ -48,6 +48,10 @@ class UserView(MasterView):
"""
model_class = User
+ labels = {
+ 'api_tokens': "API Tokens",
+ }
+
grid_columns = [
'username',
'person',
@@ -66,6 +70,7 @@ class UserView(MasterView):
'active',
'prevent_edit',
'roles',
+ 'api_tokens',
]
def get_query(self, session=None):
@@ -151,6 +156,12 @@ class UserView(MasterView):
if not self.creating:
f.set_default('roles', [role.uuid.hex for role in user.roles])
+ # api_tokens
+ if self.viewing and self.has_perm('manage_api_tokens'):
+ f.set_grid('api_tokens', self.make_api_tokens_grid(user))
+ else:
+ f.remove('api_tokens')
+
def unique_username(self, node, value):
""" """
model = self.app.model
@@ -251,6 +262,93 @@ class UserView(MasterView):
role = session.get(model.Role, uuid)
user.roles.remove(role)
+ def make_api_tokens_grid(self, user):
+ """
+ Make and return the grid for the API Tokens field.
+
+ This is only shown when current user has permission to manage
+ API tokens for other users.
+
+ :rtype: :class:`~wuttaweb.grids.base.Grid`
+ """
+ model = self.app.model
+ route_prefix = self.get_route_prefix()
+
+ grid = self.make_grid(key=f'{route_prefix}.view.api_tokens',
+ data=[self.normalize_api_token(t) for t in user.api_tokens],
+ columns=[
+ 'description',
+ 'created',
+ ],
+ sortable=True,
+ sort_on_backend=False,
+ sort_defaults=[('created', 'desc')])
+
+ if self.has_perm('manage_api_tokens'):
+
+ # create token
+ button = self.make_button("New", primary=True, icon_left='plus', **{'@click': "$emit('new-token')"})
+ grid.add_tool(button, key='create')
+
+ # delete token
+ grid.add_action('delete', url='#', icon='trash', link_class='has-text-danger', click_handler="$emit('delete-token', props.row)")
+
+ return grid
+
+ def normalize_api_token(self, token):
+ """ """
+ return {
+ 'uuid': token.uuid.hex,
+ 'description': token.description,
+ 'created': self.app.render_datetime(token.created),
+ }
+
+ def add_api_token(self):
+ """
+ AJAX view for adding a new user API token.
+
+ This calls
+ :meth:`wuttjamaican:wuttjamaican.auth.AuthHandler.add_api_token()`
+ for the creation logic.
+ """
+ session = self.Session()
+ auth = self.app.get_auth_handler()
+ user = self.get_instance()
+ data = self.request.json_body
+
+ token = auth.add_api_token(user, data['description'])
+ session.flush()
+ session.refresh(token)
+
+ result = self.normalize_api_token(token)
+ result['token_string'] = token.token_string
+ result['_action_url_delete'] = '#'
+ return result
+
+ def delete_api_token(self):
+ """
+ AJAX view for deleting a user API token.
+
+ This calls
+ :meth:`wuttjamaican:wuttjamaican.auth.AuthHandler.delete_api_token()`
+ for the deletion logic.
+ """
+ model = self.app.model
+ session = self.Session()
+ auth = self.app.get_auth_handler()
+ user = self.get_instance()
+ data = self.request.json_body
+
+ token = session.get(model.UserAPIToken, data['uuid'])
+ if not token:
+ return {'error': "API token not found"}
+
+ if token.user is not user:
+ return {'error': "API token not found"}
+
+ auth.delete_api_token(token)
+ return {}
+
@classmethod
def defaults(cls, config):
""" """
@@ -260,8 +358,38 @@ class UserView(MasterView):
app = wutta_config.get_app()
cls.model_class = app.model.User
+ cls._user_defaults(config)
cls._defaults(config)
+ @classmethod
+ def _user_defaults(cls, config):
+ """
+ Provide extra default configuration for the User master view.
+ """
+ route_prefix = cls.get_route_prefix()
+ permission_prefix = cls.get_permission_prefix()
+ instance_url_prefix = cls.get_instance_url_prefix()
+ model_title = cls.get_model_title()
+
+ # manage API tokens
+ config.add_wutta_permission(permission_prefix,
+ f'{permission_prefix}.manage_api_tokens',
+ f"Manage API tokens for any {model_title}")
+ config.add_route(f'{route_prefix}.add_api_token',
+ f'{instance_url_prefix}/add-api-token',
+ request_method='POST')
+ config.add_view(cls, attr='add_api_token',
+ route_name=f'{route_prefix}.add_api_token',
+ permission=f'{permission_prefix}.manage_api_tokens',
+ renderer='json')
+ config.add_route(f'{route_prefix}.delete_api_token',
+ f'{instance_url_prefix}/delete-api-token',
+ request_method='POST')
+ config.add_view(cls, attr='delete_api_token',
+ route_name=f'{route_prefix}.delete_api_token',
+ permission=f'{permission_prefix}.manage_api_tokens',
+ renderer='json')
+
def defaults(config, **kwargs):
base = globals()
diff --git a/tests/grids/test_base.py b/tests/grids/test_base.py
index 167ba8c..2e336d0 100644
--- a/tests/grids/test_base.py
+++ b/tests/grids/test_base.py
@@ -1610,6 +1610,50 @@ class TestGrid(WebTestCase):
sorters = grid.get_vue_active_sorters()
self.assertEqual(sorters, [{'field': 'name', 'order': 'asc'}])
+ def test_get_vue_first_sorter(self):
+
+ # empty by default
+ grid = self.make_grid(key='foo', sortable=True)
+ sorter = grid.get_vue_first_sorter()
+ self.assertIsNone(sorter)
+
+ # will use first element from sort_defaults when applicable...
+
+ # basic
+ grid = self.make_grid(key='foo', sortable=True, sort_defaults='name')
+ sorter = grid.get_vue_first_sorter()
+ self.assertEqual(sorter, ['name', 'asc'])
+
+ # descending
+ grid = self.make_grid(key='foo', sortable=True, sort_defaults=('name', 'desc'))
+ sorter = grid.get_vue_first_sorter()
+ self.assertEqual(sorter, ['name', 'desc'])
+
+ # multiple
+ grid = self.make_grid(key='foo', sortable=True, sort_defaults=[('key', 'asc'), ('name', 'asc')])
+ sorter = grid.get_vue_first_sorter()
+ self.assertEqual(sorter, ['key', 'asc'])
+
+ # will use first element from active_sorters when applicable...
+
+ # basic
+ grid = self.make_grid(key='foo', sortable=True)
+ grid.active_sorters = [{'key': 'name', 'dir': 'asc'}]
+ sorter = grid.get_vue_first_sorter()
+ self.assertEqual(sorter, ['name', 'asc'])
+
+ # descending
+ grid = self.make_grid(key='foo', sortable=True)
+ grid.active_sorters = [{'key': 'name', 'dir': 'desc'}]
+ sorter = grid.get_vue_first_sorter()
+ self.assertEqual(sorter, ['name', 'desc'])
+
+ # multiple
+ grid = self.make_grid(key='foo', sortable=True)
+ grid.active_sorters = [{'key': 'key', 'dir': 'asc'}, {'key': 'name', 'dir': 'asc'}]
+ sorter = grid.get_vue_first_sorter()
+ self.assertEqual(sorter, ['key', 'asc'])
+
def test_get_vue_filters(self):
model = self.app.model
diff --git a/tests/views/test_users.py b/tests/views/test_users.py
index 96f4404..dbd369e 100644
--- a/tests/views/test_users.py
+++ b/tests/views/test_users.py
@@ -6,6 +6,7 @@ from sqlalchemy import orm
import colander
+from wuttaweb.grids import Grid
from wuttaweb.views import users as mod
from wuttaweb.testing import WebTestCase
@@ -122,6 +123,18 @@ class TestUserView(WebTestCase):
view.configure_form(form)
self.assertNotIn('password', form)
+ # api tokens grid shown only if current user has perm
+ with patch.object(view, 'viewing', new=True):
+ form = view.make_form(model_instance=barney)
+ self.assertIn('api_tokens', form)
+ view.configure_form(form)
+ self.assertNotIn('api_tokens', form)
+ with patch.object(self.request, 'is_root', new=True):
+ form = view.make_form(model_instance=barney)
+ self.assertIn('api_tokens', form)
+ view.configure_form(form)
+ self.assertIn('api_tokens', form)
+
def test_unique_username(self):
model = self.app.model
view = self.make_view()
@@ -317,3 +330,108 @@ class TestUserView(WebTestCase):
user = view.objectify(form)
self.assertIs(user, barney)
self.assertEqual(len(user.roles), 2)
+
+ def test_normalize_api_token(self):
+ model = self.app.model
+ auth = self.app.get_auth_handler()
+ view = self.make_view()
+
+ user = model.User(username='foo')
+ self.session.add(user)
+ token = auth.add_api_token(user, 'test token')
+ self.session.commit()
+
+ normal = view.normalize_api_token(token)
+ self.assertIn('uuid', normal)
+ self.assertEqual(normal['uuid'], token.uuid.hex)
+ self.assertIn('description', normal)
+ self.assertEqual(normal['description'], 'test token')
+ self.assertIn('created', normal)
+
+ def test_make_api_tokens_grid(self):
+ model = self.app.model
+ auth = self.app.get_auth_handler()
+ view = self.make_view()
+
+ user = model.User(username='foo')
+ self.session.add(user)
+ token1 = auth.add_api_token(user, 'test1')
+ token2 = auth.add_api_token(user, 'test2')
+ self.session.commit()
+
+ # grid should have 2 records but no tools/actions
+ grid = view.make_api_tokens_grid(user)
+ self.assertIsInstance(grid, Grid)
+ self.assertEqual(len(grid.data), 2)
+ self.assertEqual(len(grid.tools), 0)
+ self.assertEqual(len(grid.actions), 0)
+
+ # create + delete allowed
+ with patch.object(self.request, 'is_root', new=True):
+ grid = view.make_api_tokens_grid(user)
+ self.assertEqual(len(grid.tools), 1)
+ self.assertIn('create', grid.tools)
+ self.assertEqual(len(grid.actions), 1)
+ self.assertEqual(grid.actions[0].key, 'delete')
+
+ def test_add_api_token(self):
+ model = self.app.model
+ view = self.make_view()
+
+ user = model.User(username='foo')
+ self.session.add(user)
+ self.session.commit()
+ self.session.refresh(user)
+ self.assertEqual(len(user.api_tokens), 0)
+
+ with patch.object(view, 'Session', return_value=self.session):
+ with patch.object(self.request, 'matchdict', new={'uuid': user.uuid}):
+ with patch.object(self.request, 'json_body', create=True,
+ new={'description': 'testing'}):
+ result = view.add_api_token()
+ self.assertEqual(len(user.api_tokens), 1)
+ token = user.api_tokens[0]
+ self.assertEqual(result['token_string'], token.token_string)
+ self.assertEqual(result['description'], 'testing')
+
+ def test_delete_api_token(self):
+ model = self.app.model
+ auth = self.app.get_auth_handler()
+ view = self.make_view()
+
+ user = model.User(username='foo')
+ self.session.add(user)
+ token1 = auth.add_api_token(user, 'test1')
+ token2 = auth.add_api_token(user, 'test2')
+ self.session.commit()
+ self.session.refresh(user)
+ self.assertEqual(len(user.api_tokens), 2)
+
+ with patch.object(view, 'Session', return_value=self.session):
+ with patch.object(self.request, 'matchdict', new={'uuid': user.uuid}):
+
+ # normal behavior
+ with patch.object(self.request, 'json_body', create=True,
+ new={'uuid': token1.uuid.hex}):
+ result = view.delete_api_token()
+ self.assertEqual(result, {})
+ self.session.refresh(user)
+ self.assertEqual(len(user.api_tokens), 1)
+ token = user.api_tokens[0]
+ self.assertIs(token, token2)
+
+ # token for wrong user
+ user2 = model.User(username='bar')
+ self.session.add(user2)
+ token3 = auth.add_api_token(user2, 'test3')
+ self.session.commit()
+ with patch.object(self.request, 'json_body', create=True,
+ new={'uuid': token3.uuid.hex}):
+ result = view.delete_api_token()
+ self.assertEqual(result, {'error': "API token not found"})
+
+ # token not found
+ with patch.object(self.request, 'json_body', create=True,
+ new={'uuid': self.app.make_true_uuid().hex}):
+ result = view.delete_api_token()
+ self.assertEqual(result, {'error': "API token not found"})