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 @@ - -
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'): - - - - % endif - - -<%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 name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - - 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"})