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/)
 | 
			
		||||
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
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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",
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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.
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										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
 | 
			
		||||
 | 
			
		||||
    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()
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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"})
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue