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 @@ + +
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'): + + + + % 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 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"})