diff --git a/CHANGELOG.md b/CHANGELOG.md
index 89a7fc2..6ceb1bd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,24 +5,6 @@ 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 ef67862..7ca867e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
[project]
name = "WuttaWeb"
-version = "0.23.0"
+version = "0.22.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.22.0",
+ "WuttJamaican[db]>=0.20.6",
"zope.sqlalchemy>=1.5",
]
diff --git a/src/wuttaweb/grids/base.py b/src/wuttaweb/grids/base.py
index 6bf7274..fcfff93 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 not
- require a unique Vue component, but can instead use the
- standard table component.
+ 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 returns something like:
@@ -2227,35 +2227,6 @@ 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 c964089..3c40338 100644
--- a/src/wuttaweb/templates/grids/table_element.mako
+++ b/src/wuttaweb/templates/grids/table_element.mako
@@ -1,22 +1,5 @@
## -*- coding: utf-8; -*-
-
-
- % 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">
+<${b}-table :data="gridContext['${grid.key}'].data">
% for column in grid.get_vue_columns():
% if not column['hidden']:
@@ -69,5 +52,3 @@
${b}-table>
-
-
diff --git a/src/wuttaweb/templates/users/view.mako b/src/wuttaweb/templates/users/view.mako
deleted file mode 100644
index bc5681f..0000000
--- a/src/wuttaweb/templates/users/view.mako
+++ /dev/null
@@ -1,126 +0,0 @@
-## -*- 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 aa5841e..760152d 100644
--- a/src/wuttaweb/views/users.py
+++ b/src/wuttaweb/views/users.py
@@ -48,10 +48,6 @@ class UserView(MasterView):
"""
model_class = User
- labels = {
- 'api_tokens': "API Tokens",
- }
-
grid_columns = [
'username',
'person',
@@ -70,7 +66,6 @@ class UserView(MasterView):
'active',
'prevent_edit',
'roles',
- 'api_tokens',
]
def get_query(self, session=None):
@@ -156,12 +151,6 @@ 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
@@ -262,93 +251,6 @@ 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):
""" """
@@ -358,38 +260,8 @@ 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 2e336d0..167ba8c 100644
--- a/tests/grids/test_base.py
+++ b/tests/grids/test_base.py
@@ -1610,50 +1610,6 @@ 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 dbd369e..96f4404 100644
--- a/tests/views/test_users.py
+++ b/tests/views/test_users.py
@@ -6,7 +6,6 @@ from sqlalchemy import orm
import colander
-from wuttaweb.grids import Grid
from wuttaweb.views import users as mod
from wuttaweb.testing import WebTestCase
@@ -123,18 +122,6 @@ 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()
@@ -330,108 +317,3 @@ 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"})