Compare commits
3 commits
e3c0a8d99e
...
8a09fb1a3c
Author | SHA1 | Date | |
---|---|---|---|
8a09fb1a3c | |||
72565cc49c | |||
fcfa47af4a |
8 changed files with 488 additions and 6 deletions
18
CHANGELOG.md
18
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/)
|
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).
|
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)
|
## v0.22.0 (2025-06-29)
|
||||||
|
|
||||||
### Feat
|
### Feat
|
||||||
|
|
|
@ -6,7 +6,7 @@ build-backend = "hatchling.build"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "WuttaWeb"
|
name = "WuttaWeb"
|
||||||
version = "0.22.0"
|
version = "0.23.0"
|
||||||
description = "Web App for Wutta Framework"
|
description = "Web App for Wutta Framework"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
authors = [{name = "Lance Edgar", email = "lance@wuttaproject.org"}]
|
authors = [{name = "Lance Edgar", email = "lance@wuttaproject.org"}]
|
||||||
|
@ -44,7 +44,7 @@ dependencies = [
|
||||||
"pyramid_tm",
|
"pyramid_tm",
|
||||||
"waitress",
|
"waitress",
|
||||||
"WebHelpers2",
|
"WebHelpers2",
|
||||||
"WuttJamaican[db]>=0.20.6",
|
"WuttJamaican[db]>=0.22.0",
|
||||||
"zope.sqlalchemy>=1.5",
|
"zope.sqlalchemy>=1.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
@ -2041,9 +2041,9 @@ class Grid:
|
||||||
"""
|
"""
|
||||||
Render a simple Vue table element for the grid.
|
Render a simple Vue table element for the grid.
|
||||||
|
|
||||||
This is what you want for a "simple" grid which does require a
|
This is what you want for a "simple" grid which does not
|
||||||
unique Vue component, but can instead use the standard table
|
require a unique Vue component, but can instead use the
|
||||||
component.
|
standard table component.
|
||||||
|
|
||||||
This returns something like:
|
This returns something like:
|
||||||
|
|
||||||
|
@ -2227,6 +2227,35 @@ class Grid:
|
||||||
'order': sorter['dir']})
|
'order': sorter['dir']})
|
||||||
return sorters
|
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):
|
def get_vue_filters(self):
|
||||||
"""
|
"""
|
||||||
Returns a list of Vue-compatible filter definitions.
|
Returns a list of Vue-compatible filter definitions.
|
||||||
|
|
|
@ -1,5 +1,22 @@
|
||||||
## -*- coding: utf-8; -*-
|
## -*- 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():
|
% for column in grid.get_vue_columns():
|
||||||
% if not column['hidden']:
|
% if not column['hidden']:
|
||||||
|
@ -52,3 +69,5 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
</${b}-table>
|
</${b}-table>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
126
src/wuttaweb/templates/users/view.mako
Normal file
126
src/wuttaweb/templates/users/view.mako
Normal 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: You must record this token elsewhere
|
||||||
|
for later reference. 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>
|
|
@ -48,6 +48,10 @@ class UserView(MasterView):
|
||||||
"""
|
"""
|
||||||
model_class = User
|
model_class = User
|
||||||
|
|
||||||
|
labels = {
|
||||||
|
'api_tokens': "API Tokens",
|
||||||
|
}
|
||||||
|
|
||||||
grid_columns = [
|
grid_columns = [
|
||||||
'username',
|
'username',
|
||||||
'person',
|
'person',
|
||||||
|
@ -66,6 +70,7 @@ class UserView(MasterView):
|
||||||
'active',
|
'active',
|
||||||
'prevent_edit',
|
'prevent_edit',
|
||||||
'roles',
|
'roles',
|
||||||
|
'api_tokens',
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_query(self, session=None):
|
def get_query(self, session=None):
|
||||||
|
@ -151,6 +156,12 @@ class UserView(MasterView):
|
||||||
if not self.creating:
|
if not self.creating:
|
||||||
f.set_default('roles', [role.uuid.hex for role in user.roles])
|
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):
|
def unique_username(self, node, value):
|
||||||
""" """
|
""" """
|
||||||
model = self.app.model
|
model = self.app.model
|
||||||
|
@ -251,6 +262,93 @@ class UserView(MasterView):
|
||||||
role = session.get(model.Role, uuid)
|
role = session.get(model.Role, uuid)
|
||||||
user.roles.remove(role)
|
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
|
@classmethod
|
||||||
def defaults(cls, config):
|
def defaults(cls, config):
|
||||||
""" """
|
""" """
|
||||||
|
@ -260,8 +358,38 @@ class UserView(MasterView):
|
||||||
app = wutta_config.get_app()
|
app = wutta_config.get_app()
|
||||||
cls.model_class = app.model.User
|
cls.model_class = app.model.User
|
||||||
|
|
||||||
|
cls._user_defaults(config)
|
||||||
cls._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):
|
def defaults(config, **kwargs):
|
||||||
base = globals()
|
base = globals()
|
||||||
|
|
|
@ -1610,6 +1610,50 @@ class TestGrid(WebTestCase):
|
||||||
sorters = grid.get_vue_active_sorters()
|
sorters = grid.get_vue_active_sorters()
|
||||||
self.assertEqual(sorters, [{'field': 'name', 'order': 'asc'}])
|
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):
|
def test_get_vue_filters(self):
|
||||||
model = self.app.model
|
model = self.app.model
|
||||||
|
|
||||||
|
|
|
@ -6,6 +6,7 @@ from sqlalchemy import orm
|
||||||
|
|
||||||
import colander
|
import colander
|
||||||
|
|
||||||
|
from wuttaweb.grids import Grid
|
||||||
from wuttaweb.views import users as mod
|
from wuttaweb.views import users as mod
|
||||||
from wuttaweb.testing import WebTestCase
|
from wuttaweb.testing import WebTestCase
|
||||||
|
|
||||||
|
@ -122,6 +123,18 @@ class TestUserView(WebTestCase):
|
||||||
view.configure_form(form)
|
view.configure_form(form)
|
||||||
self.assertNotIn('password', 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):
|
def test_unique_username(self):
|
||||||
model = self.app.model
|
model = self.app.model
|
||||||
view = self.make_view()
|
view = self.make_view()
|
||||||
|
@ -317,3 +330,108 @@ class TestUserView(WebTestCase):
|
||||||
user = view.objectify(form)
|
user = view.objectify(form)
|
||||||
self.assertIs(user, barney)
|
self.assertIs(user, barney)
|
||||||
self.assertEqual(len(user.roles), 2)
|
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"})
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue