3
0
Fork 0

Compare commits

..

3 commits

8 changed files with 488 additions and 6 deletions

View file

@ -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

View file

@ -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",
]

View file

@ -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.

View file

@ -1,5 +1,22 @@
## -*- coding: utf-8; -*-
<${b}-table :data="gridContext['${grid.key}'].data">
<div>
% if grid.tools:
<div class="table-tools-wrapper">
% for html in grid.tools.values():
${html}
% endfor
</div>
% 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 @@
</template>
</${b}-table>
</div>

View file

@ -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'):
<b-modal :active.sync="newTokenShowDialog"
has-modal-card>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">
New API Token
</p>
</header>
<section class="modal-card-body">
<div v-if="!newTokenSaved">
<b-field label="Description"
:type="{'is-danger': !newTokenDescription}">
<b-input v-model.trim="newTokenDescription"
expanded
ref="newTokenDescription">
</b-input>
</b-field>
</div>
<div v-if="newTokenSaved">
<p class="block">
Your new API token is shown below.
</p>
<p class="block">
IMPORTANT:&nbsp; You must record this token elsewhere
for later reference.&nbsp; You will NOT be able to
recover the value if you lose it.
</p>
<b-field horizontal label="API Token">
{{ newTokenRaw }}
</b-field>
<b-field horizontal label="Description">
{{ newTokenDescription }}
</b-field>
</div>
</section>
<footer class="modal-card-foot">
<b-button @click="newTokenShowDialog = false">
{{ newTokenSaved ? "Close" : "Cancel" }}
</b-button>
<b-button v-if="!newTokenSaved"
type="is-primary"
icon-pack="fas"
icon-left="save"
@click="newTokenSave()"
:disabled="!newTokenDescription || newTokenSaving">
{{ newTokenSaving ? "Working, please wait..." : "Save" }}
</b-button>
</footer>
</div>
</b-modal>
% 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()}
<script>
% if master.has_perm('manage_api_tokens'):
ThisPageData.newTokenShowDialog = false
ThisPageData.newTokenDescription = null
ThisPageData.newTokenRaw = null
ThisPageData.newTokenSaved = false
ThisPageData.newTokenSaving = false
ThisPage.methods.newTokenInit = function() {
this.newTokenDescription = null
this.newTokenRaw = null
this.newTokenSaved = false
this.newTokenShowDialog = true
this.$nextTick(() => {
this.$refs.newTokenDescription.focus()
})
}
ThisPage.methods.newTokenSave = function() {
this.newTokenSaving = true
const url = '${master.get_action_url('add_api_token', instance)}'
const params = {
description: this.newTokenDescription,
}
this.wuttaPOST(url, params, response => {
this.newTokenSaving = false
this.newTokenRaw = response.data.token_string
${form.vue_component}Data.gridContext['users.view.api_tokens'].data.push(response.data)
this.newTokenSaved = true
}, response => {
this.newTokenSaving = false
})
}
ThisPage.methods.deleteTokenInit = function(token) {
if (!confirm("Really delete this API token?")) {
return
}
const url = '${master.get_action_url('delete_api_token', instance)}'
const params = {uuid: token.uuid}
this.wuttaPOST(url, params, response => {
const i = ${form.vue_component}Data.gridContext['users.view.api_tokens'].data.indexOf(token)
${form.vue_component}Data.gridContext['users.view.api_tokens'].data.splice(i, 1)
})
}
% endif
</script>
</%def>

View file

@ -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()

View file

@ -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

View file

@ -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"})