3
0
Fork 0

Compare commits

...

13 commits

Author SHA1 Message Date
Lance Edgar fab87d3303 bump: version 0.11.0 → 0.12.0 2024-08-22 14:51:08 -05:00
Lance Edgar a5c2931085 feat: add "copy link" button for sharing a grid view 2024-08-22 14:45:25 -05:00
Lance Edgar 1443f5253f feat: add initial support for proper grid filters
only "text contains" filter supported so far, more to come as needed
2024-08-22 14:35:49 -05:00
Lance Edgar 9751bf4c2e feat: add initial filtering logic to grid class
still missing the actual filters, subclass must provide those for now
2024-08-21 20:15:23 -05:00
Lance Edgar a042d511fb feat: add "searchable" column support for grids
frontend / basic only
2024-08-21 15:50:36 -05:00
Lance Edgar 770c4612d5 feat: improve page linkage between role/user/person
- show Users grid when viewing a Role
- add hyperlinks between things
2024-08-21 15:09:05 -05:00
Lance Edgar 9d261de45a feat: add basic autocomplete support, for Person
URL endpoint only for now, form widget to come later
2024-08-21 11:46:38 -05:00
Lance Edgar 4bf2bb42fb fix: cleanup templates for home, login pages
if context has 'image_url' then use that, otherwise configured logo
2024-08-21 00:36:02 -05:00
Lance Edgar a34b01a6c4 fix: cleanup logic for appinfo/configure
so tailbone can inherit this view and extend
2024-08-20 23:22:51 -05:00
Lance Edgar 1b4aaacc10 fix: expose settings for app node title, type 2024-08-20 22:15:11 -05:00
Lance Edgar d15ac46184 fix: show installed python packages on appinfo page 2024-08-20 21:26:38 -05:00
Lance Edgar 3665d69e0c fix: tweak login form to stop extending size of background card 2024-08-20 20:41:41 -05:00
Lance Edgar 2d9757f677 fix: add setting to auto-redirect anon users to login, from home page 2024-08-20 20:16:19 -05:00
33 changed files with 2832 additions and 437 deletions

View file

@ -5,6 +5,30 @@ 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.12.0 (2024-08-22)
### Feat
- add "copy link" button for sharing a grid view
- add initial support for proper grid filters
- add initial filtering logic to grid class
- add "searchable" column support for grids
- improve page linkage between role/user/person
- add basic autocomplete support, for Person
### Fix
- cleanup templates for home, login pages
- cleanup logic for appinfo/configure
- expose settings for app node title, type
- show installed python packages on appinfo page
- tweak login form to stop extending size of background card
- add setting to auto-redirect anon users to login, from home page
- add form padding, validators for /configure pages
- add padding around main form, via wrapper css
- show CRUD buttons in header only if relevant and user has access
- tweak style config for home link app title in main menu
## v0.11.0 (2024-08-20)
### Feat

View file

@ -6,7 +6,7 @@ build-backend = "hatchling.build"
[project]
name = "WuttaWeb"
version = "0.11.0"
version = "0.12.0"
description = "Web App for Wutta Framework"
readme = "README.md"
authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]
@ -41,7 +41,7 @@ dependencies = [
"pyramid_tm",
"waitress",
"WebHelpers2",
"WuttJamaican[db]>=0.12.0",
"WuttJamaican[db]>=0.12.1",
"zope.sqlalchemy>=1.5",
]

View file

@ -25,6 +25,7 @@ Base form classes
"""
import logging
from collections import OrderedDict
import colander
import deform
@ -311,6 +312,9 @@ class Form:
self.set_fields(fields or self.get_fields())
# nb. this tracks grid JSON data for inclusion in page template
self.grid_vue_data = OrderedDict()
def __contains__(self, name):
"""
Custom logic for the ``in`` operator, to allow easily checking
@ -750,6 +754,10 @@ class Form:
kwargs['appstruct'] = self.model_instance
form = deform.Form(schema, **kwargs)
# nb. must give a reference back to wutta form; this is
# for sake of field schema nodes and widgets, e.g. to
# access the main model instance
form.wutta_form = self
self.deform_form = form
return self.deform_form
@ -818,6 +826,17 @@ class Form:
output = render(template, context)
return HTML.literal(output)
def add_grid_vue_data(self, grid):
""" """
if not grid.key:
raise ValueError("grid must have a key!")
if grid.key in self.grid_vue_data:
log.warning("grid data with key '%s' already registered, "
"but will be replaced", grid.key)
self.grid_vue_data[grid.key] = grid.get_vue_data()
def render_vue_field(
self,
fieldname,

View file

@ -246,6 +246,9 @@ class ObjectRef(colander.SchemaType):
values.insert(0, self.empty_option)
kwargs['values'] = values
if 'url' not in kwargs:
kwargs['url'] = lambda person: self.request.route_url('people.view', uuid=person.uuid)
return widgets.ObjectRefWidget(self.request, **kwargs)
@ -321,6 +324,28 @@ class RoleRefs(WuttaSet):
return widgets.RoleRefsWidget(self.request, **kwargs)
class UserRefs(WuttaSet):
"""
Form schema type for the Role
:attr:`~wuttjamaican:wuttjamaican.db.model.auth.Role.users`
association proxy field.
This is a subclass of :class:`WuttaSet`. It uses a ``set`` of
:class:`~wuttjamaican:wuttjamaican.db.model.auth.User` ``uuid``
values for underlying data format.
"""
def widget_maker(self, **kwargs):
"""
Constructs a default widget for the field.
:returns: Instance of
:class:`~wuttaweb.forms.widgets.UserRefsWidget`.
"""
kwargs.setdefault('session', self.session)
return widgets.UserRefsWidget(self.request, **kwargs)
class Permissions(WuttaSet):
"""
Form schema type for the Role

View file

@ -44,6 +44,7 @@ from deform.widget import (Widget, TextInputWidget, TextAreaWidget,
from webhelpers2.html import HTML
from wuttaweb.db import Session
from wuttaweb.grids import Grid
class ObjectRefWidget(SelectWidget):
@ -83,9 +84,19 @@ class ObjectRefWidget(SelectWidget):
"""
readonly_template = 'readonly/objectref'
def __init__(self, request, *args, **kwargs):
def __init__(self, request, url=None, *args, **kwargs):
super().__init__(*args, **kwargs)
self.request = request
self.url = url
def get_template_values(self, field, cstruct, kw):
""" """
values = super().get_template_values(field, cstruct, kw)
if 'url' not in values and self.url and field.schema.model_instance:
values['url'] = self.url(field.schema.model_instance)
return values
class NotesWidget(TextAreaWidget):
@ -137,12 +148,17 @@ class RoleRefsWidget(WuttaCheckboxChoiceWidget):
"""
Widget for use with User
:attr:`~wuttjamaican:wuttjamaican.db.model.auth.User.roles` field.
This is the default widget for the
:class:`~wuttaweb.forms.schema.RoleRefs` type.
This is a subclass of :class:`WuttaCheckboxChoiceWidget`.
"""
readonly_template = 'readonly/rolerefs'
def serialize(self, field, cstruct, **kw):
""" """
model = self.app.model
# special logic when field is editable
readonly = kw.get('readonly', self.readonly)
if not readonly:
@ -159,10 +175,78 @@ class RoleRefsWidget(WuttaCheckboxChoiceWidget):
if val[0] != admin.uuid]
kw['values'] = values
else: # readonly
# roles
roles = []
if cstruct:
for uuid in cstruct:
role = self.session.query(model.Role).get(uuid)
if role:
roles.append(role)
kw['roles'] = roles
# url
url = lambda role: self.request.route_url('roles.view', uuid=role.uuid)
kw['url'] = url
# default logic from here
return super().serialize(field, cstruct, **kw)
class UserRefsWidget(WuttaCheckboxChoiceWidget):
"""
Widget for use with Role
:attr:`~wuttjamaican:wuttjamaican.db.model.auth.Role.users` field.
This is the default widget for the
:class:`~wuttaweb.forms.schema.UserRefs` type.
This is a subclass of :class:`WuttaCheckboxChoiceWidget`; however
it only supports readonly mode and does not use a template.
Rather, it generates and renders a
:class:`~wuttaweb.grids.base.Grid` showing the users list.
"""
def serialize(self, field, cstruct, **kw):
""" """
readonly = kw.get('readonly', self.readonly)
if not readonly:
raise NotImplementedError("edit not allowed for this widget")
model = self.app.model
columns = ['person', 'username', 'active']
# generate data set for users
users = []
if cstruct:
for uuid in cstruct:
user = self.session.query(model.User).get(uuid)
if user:
users.append(dict([(key, getattr(user, key))
for key in columns + ['uuid']]))
# grid
grid = Grid(self.request, key='roles.view.users',
columns=columns, data=users)
# view action
if self.request.has_perm('users.view'):
url = lambda user, i: self.request.route_url('users.view', uuid=user['uuid'])
grid.add_action('view', icon='eye', url=url)
grid.set_link('person')
grid.set_link('username')
# edit action
if self.request.has_perm('users.edit'):
url = lambda user, i: self.request.route_url('users.edit', uuid=user['uuid'])
grid.add_action('edit', url=url)
# render as simple <b-table>
# nb. must indicate we are a part of this form
form = getattr(field.parent, 'wutta_form', None)
return grid.render_table_element(form)
class PermissionsWidget(WuttaCheckboxChoiceWidget):
"""
Widget for use with Role

View file

@ -39,6 +39,7 @@ from webhelpers2.html import HTML
from wuttaweb.db import Session
from wuttaweb.util import FieldList, get_model_fields, make_json_safe
from wuttjamaican.util import UNSPECIFIED
log = logging.getLogger(__name__)
@ -282,6 +283,40 @@ class Grid:
Only relevant if :attr:`paginated` is true. If not specified,
constructor will assume ``1`` (first page).
.. attribute:: searchable_columns
Set of columns declared as searchable for the Vue component.
See also :meth:`set_searchable()` and :meth:`is_searchable()`.
.. attribute:: filterable
Boolean indicating whether the grid should show a "filters"
section where user can filter data in various ways. Default is
``False``.
.. attribute:: filters
Dict of :class:`~wuttaweb.grids.filters.GridFilter` instances
available for use with backend filtering.
Only relevant if :attr:`filterable` is true.
See also :meth:`set_filter()`.
.. attribute:: filter_defaults
Dict containing default state preferences for the filters.
See also :meth:`set_filter_defaults()`.
.. attribute:: joiners
Dict of "joiner" functions for use with backend filtering and
sorting.
See :meth:`set_joiner()` for more info.
"""
def __init__(
@ -306,6 +341,11 @@ class Grid:
pagesize_options=None,
pagesize=None,
page=1,
searchable_columns=None,
filterable=False,
filters=None,
filter_defaults=None,
joiners=None,
):
self.request = request
self.vue_tagname = vue_tagname
@ -316,6 +356,7 @@ class Grid:
self.renderers = renderers or {}
self.actions = actions or []
self.linked_columns = linked_columns or []
self.joiners = joiners or {}
self.config = self.request.wutta_config
self.app = self.config.get_app()
@ -344,6 +385,19 @@ class Grid:
self.pagesize = pagesize or self.get_pagesize()
self.page = page
# searching
self.searchable_columns = set(searchable_columns or [])
# filtering
self.filterable = filterable
if filters is not None:
self.filters = filters
elif self.filterable:
self.filters = self.make_backend_filters()
else:
self.filters = {}
self.set_filter_defaults(**(filter_defaults or {}))
def get_columns(self):
"""
Returns the official list of column names for the grid, or
@ -432,7 +486,7 @@ class Grid:
if key in self.columns:
self.columns.remove(key)
def set_label(self, key, label):
def set_label(self, key, label, column_only=False):
"""
Set/override the label for a column.
@ -440,11 +494,18 @@ class Grid:
:param label: New label for the column header.
:param column_only: Boolean indicating whether the label
should be applied *only* to the column header (if
``True``), vs. applying also to the filter (if ``False``).
See also :meth:`get_label()`. Label overrides are tracked via
:attr:`labels`.
"""
self.labels[key] = label
if not column_only and key in self.filters:
self.filters[key].label = label
def get_label(self, key):
"""
Returns the label text for a given column.
@ -543,6 +604,92 @@ class Grid:
return True
return False
def set_searchable(self, key, searchable=True):
"""
(Un)set the given column's searchable flag for the Vue
component.
See also :meth:`is_searchable()`. Flags are tracked via
:attr:`searchable_columns`.
"""
if searchable:
self.searchable_columns.add(key)
elif key in self.searchable_columns:
self.searchable_columns.remove(key)
def is_searchable(self, key):
"""
Check if the given column is marked as searchable for the Vue
component.
See also :meth:`set_searchable()`.
"""
return key in self.searchable_columns
def add_action(self, key, **kwargs):
"""
Convenience to add a new :class:`GridAction` instance to the
grid's :attr:`actions` list.
"""
self.actions.append(GridAction(self.request, key, **kwargs))
##############################
# joining methods
##############################
def set_joiner(self, key, joiner):
"""
Set/override the backend joiner for a column.
A "joiner" is sometimes needed when a column with "related but
not primary" data is involved in a sort or filter operation.
A sorter or filter may need to "join" other table(s) to get at
the appropriate data. But if a given column has both a sorter
and filter defined, and both are used at the same time, we
don't want the join to happen twice.
Hence we track joiners separately, also keyed by column name
(as are sorters and filters). When a column's sorter **and/or**
filter is needed, the joiner will be invoked.
:param key: Name of column.
:param joiner: A joiner callable, as described below.
A joiner callable must accept just one ``(data)`` arg and
return the "joined" data/query, for example::
model = app.model
grid = Grid(request, model_class=model.Person)
def join_external_profile_value(query):
return query.join(model.ExternalProfile)
def sort_external_profile(query, direction):
sortspec = getattr(model.ExternalProfile.description, direction)
return query.order_by(sortspec())
grid.set_joiner('external_profile', join_external_profile)
grid.set_sorter('external_profile', sort_external_profile)
See also :meth:`remove_joiner()`. Backend joiners are tracked
via :attr:`joiners`.
"""
self.joiners[key] = joiner
def remove_joiner(self, key):
"""
Remove the backend joiner for a column.
Note that this removes the joiner *function*, so there is no
way to apply joins for this column unless another joiner is
later defined for it.
See also :meth:`set_joiner()`.
"""
self.joiners.pop(key, None)
##############################
# sorting methods
##############################
@ -844,6 +991,147 @@ class Grid:
return key in self.sorters
return True
##############################
# filtering methods
##############################
def make_backend_filters(self, filters=None):
"""
Make backend filters for all columns in the grid.
This is called by the constructor, if :attr:`filterable` is
true.
For each column in the grid, this checks the provided
``filters`` and if the column is not yet in there, will call
:meth:`make_filter()` to add it.
.. note::
This only works if grid has a :attr:`model_class`. If not,
this method just returns the initial filters (or empty
dict).
:param filters: Optional dict of initial filters. Any
existing filters will be left intact, not replaced.
:returns: Final dict of all filters. Includes any from the
initial ``filters`` param as well as any which were
created.
"""
filters = filters or {}
if self.model_class:
for key in self.columns:
if key in filters:
continue
prop = getattr(self.model_class, key, None)
if (prop and hasattr(prop, 'property')
and isinstance(prop.property, orm.ColumnProperty)):
filters[prop.key] = self.make_filter(prop)
return filters
def make_filter(self, columninfo, **kwargs):
"""
Create and return a :class:`GridFilter` instance suitable for
use on the given column.
Code usually does not need to call this directly. See also
:meth:`set_filter()`, which calls this method automatically.
:param columninfo: Can be either a model property (see below),
or a column name.
:returns: A :class:`GridFilter` instance.
"""
model_property = None
if isinstance(columninfo, str):
key = columninfo
if self.model_class:
try:
mapper = sa.inspect(self.model_class)
except sa.exc.NoInspectionAvailable:
pass
else:
model_property = mapper.get_property(key)
if not model_property:
raise ValueError(f"cannot locate model property for key: {key}")
else:
model_property = columninfo
return GridFilter(self.request, model_property, **kwargs)
def set_filter(self, key, filterinfo=None, **kwargs):
"""
Set/override the backend filter for a column.
Only relevant if :attr:`filterable` is true.
:param key: Name of column.
:param filterinfo: Can be either a
:class:`~wuttweb.grids.filters.GridFilter` instance, or
else a model property (see below).
If ``filterinfo`` is a ``GridFilter`` instance, it will be
used as-is for the backend filter.
Otherwise :meth:`make_filter()` will be called to obtain the
backend filter. The ``filterinfo`` will be passed along to
that call; if it is empty then ``key`` will be used instead.
See also :meth:`remove_filter()`. Backend filters are tracked
via :attr:`filters`.
"""
filtr = None
if filterinfo and callable(filterinfo):
# filtr = filterinfo
raise NotImplementedError
else:
kwargs.setdefault('label', self.get_label(key))
filtr = self.make_filter(filterinfo or key, **kwargs)
self.filters[key] = filtr
def remove_filter(self, key):
"""
Remove the backend filter for a column.
This removes the filter *instance*, so there is no way to
filter by this column unless another filter is later defined
for it.
See also :meth:`set_filter()`.
"""
self.filters.pop(key, None)
def set_filter_defaults(self, **defaults):
"""
Set default state preferences for the grid filters.
These preferences will affect the initial grid display, until
user requests a different filtering method.
Each kwarg should be named by filter key, and the value should
be a dict of preferences for that filter. For instance::
grid.set_filter_defaults(name={'active': True,
'verb': 'contains',
'value': 'foo'},
value={'active': True})
Filter defaults are tracked via :attr:`filter_defaults`.
"""
filter_defaults = dict(getattr(self, 'filter_defaults', {}))
for key, values in defaults.items():
filtr = filter_defaults.setdefault(key, {})
filtr.update(values)
self.filter_defaults = filter_defaults
##############################
# paging methods
##############################
@ -934,6 +1222,15 @@ class Grid:
# initial default settings
settings = {}
if self.filterable:
for filtr in self.filters.values():
defaults = self.filter_defaults.get(filtr.key, {})
settings[f'filter.{filtr.key}.active'] = defaults.get('active',
filtr.default_active)
settings[f'filter.{filtr.key}.verb'] = defaults.get('verb',
filtr.default_verb)
settings[f'filter.{filtr.key}.value'] = defaults.get('value',
filtr.default_value)
if self.sortable:
if self.sort_defaults:
# nb. as of writing neither Buefy nor Oruga support a
@ -951,11 +1248,27 @@ class Grid:
# update settings dict based on what we find in the request
# and/or user session. always prioritize the former.
if self.request_has_settings('sort'):
# nb. do not read settings if user wants a reset
if self.request.GET.get('reset-view'):
# at this point we only have default settings, and we want
# to keep those *and* persist them for next time, below
pass
elif self.request_has_settings('filter'):
self.update_filter_settings(settings, src='request')
if self.request_has_settings('sort'):
self.update_sort_settings(settings, src='request')
else:
self.update_sort_settings(settings, src='session')
self.update_page_settings(settings)
elif self.request_has_settings('sort'):
self.update_filter_settings(settings, src='session')
self.update_sort_settings(settings, src='request')
self.update_page_settings(settings)
elif self.request_has_settings('page'):
self.update_filter_settings(settings, src='session')
self.update_sort_settings(settings, src='session')
self.update_page_settings(settings)
@ -964,6 +1277,7 @@ class Grid:
persist = False
# but still should load whatever is in user session
self.update_filter_settings(settings, src='session')
self.update_sort_settings(settings, src='session')
self.update_page_settings(settings)
@ -973,6 +1287,13 @@ class Grid:
# update ourself to reflect settings dict..
# filtering
if self.filterable:
for filtr in self.filters.values():
filtr.active = settings[f'filter.{filtr.key}.active']
filtr.verb = settings[f'filter.{filtr.key}.verb'] or filtr.default_verb
filtr.value = settings[f'filter.{filtr.key}.value']
# sorting
if self.sortable:
# nb. doing this for frontend sorting also
@ -997,11 +1318,18 @@ class Grid:
def request_has_settings(self, typ):
""" """
if typ == 'sort':
if typ == 'filter' and self.filterable:
for filtr in self.filters.values():
if filtr.key in self.request.GET:
return True
if 'filter' in self.request.GET: # user may be applying empty filters
return True
elif typ == 'sort' and self.sortable and self.sort_on_backend:
if 'sort1key' in self.request.GET:
return True
elif typ == 'page':
elif typ == 'page' and self.paginated and self.paginate_on_backend:
for key in ['pagesize', 'page']:
if key in self.request.GET:
return True
@ -1033,6 +1361,31 @@ class Grid:
# okay then, default it is
return default
def update_filter_settings(self, settings, src=None):
""" """
if not self.filterable:
return
for filtr in self.filters.values():
prefix = f'filter.{filtr.key}'
if src == 'request':
# consider filter active if query string contains a value for it
settings[f'{prefix}.active'] = filtr.key in self.request.GET
settings[f'{prefix}.verb'] = self.get_setting(
settings, f'{filtr.key}.verb', src='request', default='')
settings[f'{prefix}.value'] = self.get_setting(
settings, filtr.key, src='request', default='')
elif src == 'session':
settings[f'{prefix}.active'] = self.get_setting(
settings, f'{prefix}.active', src='session',
normalize=lambda v: str(v).lower() == 'true', default=False)
settings[f'{prefix}.verb'] = self.get_setting(
settings, f'{prefix}.verb', src='session', default='')
settings[f'{prefix}.value'] = self.get_setting(
settings, f'{prefix}.value', src='session', default='')
def update_sort_settings(self, settings, src=None):
""" """
if not (self.sortable and self.sort_on_backend):
@ -1099,8 +1452,18 @@ class Grid:
skey = f'grid.{self.key}.{key}'
self.request.session[skey] = value(key)
# filter settings
if self.filterable:
# always save all filters, with status
for filtr in self.filters.values():
persist(f'filter.{filtr.key}.active',
value=lambda k: 'true' if settings.get(k) else 'false')
persist(f'filter.{filtr.key}.verb')
persist(f'filter.{filtr.key}.value')
# sort settings
if self.sortable:
if self.sortable and self.sort_on_backend:
# first must clear all sort settings from dest. this is
# because number of sort settings will vary, so we delete
@ -1144,10 +1507,15 @@ class Grid:
See also these methods which may be called by this one:
* :meth:`filter_data()`
* :meth:`sort_data()`
* :meth:`paginate_data()`
"""
data = self.data or []
self.joined = set()
if self.filterable:
data = self.filter_data(data)
if self.sortable and self.sort_on_backend:
data = self.sort_data(data)
@ -1158,6 +1526,46 @@ class Grid:
return data
@property
def active_filters(self):
"""
Returns the list of currently active filters.
This inspects each :class:`GridFilter` in :attr:`filters` and
only returns the ones marked active.
"""
return [filtr for filtr in self.filters.values()
if filtr.active]
def filter_data(self, data, filters=None):
"""
Filter the given data and return the result. This is called
by :meth:`get_visible_data()`.
:param filters: Optional list of filters to use. If not
specified, the grid's :attr:`active_filters` are used.
"""
if filters is None:
filters = self.active_filters
if not filters:
return data
for filtr in filters:
key = filtr.key
if key in self.joiners and key not in self.joined:
data = self.joiners[key](data)
self.joined.add(key)
try:
data = filtr.apply_filter(data)
except VerbNotSupported as error:
log.warning("verb not supported for '%s' filter: %s", key, error.verb)
except:
log.exception("filtering data by '%s' failed!", key)
return data
def sort_data(self, data, sorters=None):
"""
Sort the given data and return the result. This is called by
@ -1188,6 +1596,11 @@ class Grid:
if not sortfunc:
return data
# join appropriate model if needed
if sortkey in self.joiners and sortkey not in self.joined:
data = self.joiners[sortkey](data)
self.joined.add(sortkey)
# invoke the sorter
data = sortfunc(data, sortdir)
@ -1229,6 +1642,58 @@ class Grid:
# rendering methods
##############################
def render_table_element(
self,
form=None,
template='/grids/table_element.mako',
**context):
"""
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 returns something like:
.. code-block:: html
<b-table :data="gridData['mykey']">
<!-- columns etc. -->
</b-table>
See :meth:`render_vue_template()` for a more complete variant.
Actual output will of course depend on grid attributes,
:attr:`key`, :attr:`columns` etc.
:param form: Reference to the
:class:`~wuttaweb.forms.base.Form` instance which
"contains" this grid. This is needed in order to ensure
the grid data is available to the form Vue component.
:param template: Path to Mako template which is used to render
the output.
.. note::
The above example shows ``gridData['mykey']`` as the Vue
data reference. This should "just work" if you provide the
correct ``form`` arg and the grid is contained directly by
that form's Vue component.
However, this may not account for all use cases. For now
we wait and see what comes up, but know the dust may not
yet be settled here.
"""
# nb. must register data for inclusion on page template
if form:
form.add_grid_vue_data(self)
# otherwise logic is the same, just different template
return self.render_vue_template(template=template, **context)
def render_vue_tag(self, **kwargs):
"""
Render the Vue component tag for the grid.
@ -1251,6 +1716,9 @@ class Grid:
"""
Render the Vue template block for the grid.
This is what you want for a "full-featured" grid which will
exist as its own unique Vue component on the frontend.
This returns something like:
.. code-block:: none
@ -1261,12 +1729,21 @@ class Grid:
</b-table>
</script>
<script>
WuttaGridData = {}
WuttaGrid = {
template: 'wutta-grid-template',
}
</script>
.. todo::
Why can't Sphinx render the above code block as 'html' ?
It acts like it can't handle a ``<script>`` tag at all?
See :meth:`render_table_element()` for a simpler variant.
Actual output will of course depend on grid attributes,
:attr:`vue_tagname` and :attr:`columns` etc.
@ -1313,8 +1790,13 @@ class Grid:
'field': 'foo',
'label': "Foo",
'sortable': True,
'searchable': False,
}
The full format is determined by Buefy; see the Column section
in its `Table docs
<https://buefy.org/documentation/table/#api-view>`_.
See also :meth:`get_vue_data()`.
"""
if not self.columns:
@ -1326,6 +1808,7 @@ class Grid:
'field': name,
'label': self.get_label(name),
'sortable': self.is_sortable(name),
'searchable': self.is_searchable(name),
})
return columns
@ -1352,6 +1835,26 @@ class Grid:
'order': sorter['dir']})
return sorters
def get_vue_filters(self):
"""
Returns a list of Vue-compatible filter definitions.
This returns the full set of :attr:`filters` but represents
each as a simple dict with the filter state.
"""
filters = []
for filtr in self.filters.values():
filters.append({
'key': filtr.key,
'active': filtr.active,
'visible': filtr.active,
'verbs': filtr.verbs,
'verb': filtr.verb,
'value': filtr.value,
'label': filtr.label,
})
return filters
def get_vue_data(self):
"""
Returns a list of Vue-compatible data records.
@ -1578,3 +2081,164 @@ class GridAction:
return self.url(obj, i)
return self.url
class GridFilter:
"""
Filter option for a grid. Represents both the "features" as well
as "state" for the filter.
:param request: Current :term:`request` object.
:param model_property: Property of a model class, representing the
column by which to filter. For instance,
``model.Person.full_name``.
:param \**kwargs: Any additional kwargs will be set as attributes
on the filter instance.
Filter instances have the following attributes:
.. attribute:: key
Unique key for the filter. This often corresponds to a "column
name" for the grid, but not always.
.. attribute:: label
Display label for the filter field.
.. attribute:: active
Boolean indicating whether the filter is currently active.
See also :attr:`verb` and :attr:`value`.
.. attribute:: verb
Verb for current filter, if :attr:`active` is true.
See also :attr:`value`.
.. attribute:: value
Value for current filter, if :attr:`active` is true.
See also :attr:`verb`.
.. attribute:: default_active
Boolean indicating whether the filter should be active by
default, i.e. when first displaying the grid.
See also :attr:`default_verb` and :attr:`default_value`.
.. attribute:: default_verb
Filter verb to use by default. This will be auto-selected when
the filter is first activated, or when first displaying the
grid if :attr:`default_active` is true.
See also :attr:`default_value`.
.. attribute:: default_value
Filter value to use by default. This will be auto-populated
when the filter is first activated, or when first displaying
the grid if :attr:`default_active` is true.
See also :attr:`default_verb`.
"""
def __init__(
self,
request,
model_property,
label=None,
default_active=False,
default_verb=None,
default_value=None,
**kwargs,
):
self.request = request
self.config = self.request.wutta_config
self.app = self.config.get_app()
self.model_property = model_property
self.key = self.model_property.key
self.label = label or self.app.make_title(self.key)
self.default_active = default_active
self.active = self.default_active
self.verbs = ['contains'] # TODO
self.default_verb = default_verb or self.verbs[0]
self.verb = self.default_verb
self.default_value = default_value
self.value = self.default_value
self.__dict__.update(kwargs)
def __repr__(self):
return ("GridFilter("
f"key='{self.key}', "
f"active={self.active}, "
f"verb='{self.verb}', "
f"value={repr(self.value)})")
def apply_filter(self, data, verb=None, value=UNSPECIFIED):
"""
Filter the given data set according to a verb/value pair.
If verb and/or value are not specified, will use :attr:`verb`
and/or :attr:`value` instead.
This method does not directly filter the data; rather it
delegates (based on ``verb``) to some other method. The
latter may choose *not* to filter the data, e.g. if ``value``
is empty, in which case this may return the original data set
unchanged.
:returns: The (possibly) filtered data set.
"""
if verb is None:
verb = self.verb
if not verb:
log.warn("missing verb for '%s' filter, will use default verb: %s",
self.key, self.default_verb)
verb = self.default_verb
if value is UNSPECIFIED:
value = self.value
func = getattr(self, f'filter_{verb}', None)
if not func:
raise VerbNotSupported(verb)
return func(data, value)
def filter_contains(self, query, value):
"""
Filter data with a full 'ILIKE' query.
"""
if value is None or value == '':
return query
criteria = []
for val in value.split():
val = val.replace('_', r'\_')
val = f'%{val}%'
criteria.append(self.model_property.ilike(val))
return query.filter(sa.and_(*criteria))
class VerbNotSupported(Exception):
""" """
def __init__(self, verb):
self.verb = verb
def __str__(self):
return f"unknown filter verb not supported: {self.verb}"

View file

@ -6,11 +6,30 @@
<h3 class="block is-size-3">Basics</h3>
<div class="block" style="padding-left: 2rem; width: 50%;">
<b-field label="App Title">
<b-input name="${app.appname}.app_title"
v-model="simpleSettings['${app.appname}.app_title']"
@input="settingsNeedSaved = true">
</b-input>
<b-field grouped>
<b-field label="App Title">
<b-input name="${app.appname}.app_title"
v-model="simpleSettings['${app.appname}.app_title']"
@input="settingsNeedSaved = true">
</b-input>
</b-field>
<b-field label="Node Type">
## TODO: should be a dropdown, app handler defines choices
<b-input name="${app.appname}.node_type"
v-model="simpleSettings['${app.appname}.node_type']"
@input="settingsNeedSaved = true">
</b-input>
</b-field>
<b-field label="Node Title">
<b-input name="${app.appname}.node_title"
v-model="simpleSettings['${app.appname}.node_title']"
@input="settingsNeedSaved = true">
</b-input>
</b-field>
</b-field>
<b-field>
@ -24,6 +43,36 @@
</div>
<h3 class="block is-size-3">User/Auth</h3>
<div class="block" style="padding-left: 2rem; width: 50%;">
<div style="display: flex; align-items: center;">
<b-checkbox name="wuttaweb.home_redirect_to_login"
v-model="simpleSettings['wuttaweb.home_redirect_to_login']"
native-value="true"
@input="settingsNeedSaved = true">
Home Page auto-redirect to Login
</b-checkbox>
<${b}-tooltip position="${'right' if request.use_oruga else 'is-right'}">
<b-icon pack="fas" icon="info-circle" />
<template #content>
<p class="block">
If set, show the Login page instead of Home page for Anonymous users.
</p>
<p class="block has-text-weight-bold">
This only "enforces" Login for the Home page, not for
other pages. Anonymous users can see whatever the role
permissions authorize.
</p>
<p class="block">
If not set, Anonymous users will see the Home page without being redirected.
</p>
</template>
</${b}-tooltip>
</div>
</div>
<h3 class="block is-size-3">Web Libraries</h3>
<div class="block" style="padding-left: 2rem;">

View file

@ -16,6 +16,9 @@
<b-field horizontal label="App Title">
<span>${app.get_title()}</span>
</b-field>
<b-field horizontal label="Node Title">
<span>${app.get_node_title()}</span>
</b-field>
<b-field horizontal label="Production Mode">
<span>${config.production()}</span>
</b-field>
@ -46,12 +49,53 @@
</div>
</nav>
<${b}-collapse class="panel"
:open="false"
@open="openInstalledPackages">
<template #trigger="props">
<div class="panel-heading"
style="cursor: pointer;"
role="button">
## TODO: for some reason buefy will "reuse" the icon
## element in such a way that its display does not
## refresh. so to work around that, we use different
## structure for the two icons, so buefy is forced to
## re-draw
<b-icon v-if="props.open"
pack="fas"
icon="angle-down" />
<span v-if="!props.open">
<b-icon pack="fas"
icon="angle-right" />
</span>
<strong>Installed Packages</strong>
</div>
</template>
<div class="panel-block">
<div style="width: 100%;">
${grid.render_vue_tag(ref='packagesGrid')}
</div>
</div>
</${b}-collapse>
</%def>
<%def name="modify_vue_vars()">
${parent.modify_vue_vars()}
<script>
ThisPageData.configFiles = ${json.dumps([dict(path=p, priority=i) for i, p in enumerate(config.get_prioritized_files(), 1)])|n}
ThisPage.methods.openInstalledPackages = function() {
this.$refs.packagesGrid.fetchFirstData()
}
</script>
</%def>

View file

@ -4,13 +4,9 @@
<%def name="title()">Login</%def>
<%def name="render_this_page()">
${self.page_content()}
</%def>
<%def name="page_content()">
<div style="height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1rem;">
<div>${base_meta.full_logo()}</div>
<div>${base_meta.full_logo(image_url or None)}</div>
<div class="card">
<div class="card-content">
${form.render_vue_tag()}
@ -44,6 +40,3 @@
</script>
</%def>
${parent.body()}

View file

@ -151,6 +151,33 @@
white-space: nowrap;
}
##############################
## grids
##############################
.wutta-filter {
display: flex;
gap: 0.5rem;
}
.wutta-filter .button.filter-toggle {
justify-content: left;
}
.wutta-filter .button.filter-toggle,
.wutta-filter .filter-verb {
min-width: 15rem;
}
.wutta-filter .filter-verb .select,
.wutta-filter .filter-verb .select select {
width: 100%;
}
##############################
## forms
##############################
.wutta-form-wrapper {
margin-left: 5rem;
margin-top: 2rem;
@ -501,7 +528,7 @@
label="Delete This" />
% endif
% elif master.editing:
% if instance_viewable and master.has_perm('view'):
% if master.has_perm('view'):
<wutta-button once
tag="a" href="${master.get_action_url('view', instance)}"
icon-left="eye"
@ -514,7 +541,7 @@
label="Delete This" />
% endif
% elif master.deleting:
% if instance_viewable and master.has_perm('view'):
% if master.has_perm('view'):
<wutta-button once
tag="a" href="${master.get_action_url('view', instance)}"
icon-left="eye"

View file

@ -1,6 +1,6 @@
## -*- coding: utf-8; -*-
<%def name="global_title()">${app.get_title()}</%def>
<%def name="global_title()">${app.get_node_title()}</%def>
<%def name="extra_styles()"></%def>
@ -12,8 +12,8 @@
${h.image(config.get('wuttaweb.header_logo_url', default=request.static_url('wuttaweb:static/img/favicon.ico')), "Header Logo", style="height: 49px;")}
</%def>
<%def name="full_logo()">
${h.image(config.get('wuttaweb.logo_url', default=request.static_url('wuttaweb:static/img/logo.png')), f"{app.get_title()} logo")}
<%def name="full_logo(image_url=None)">
${h.image(image_url or config.get('wuttaweb.logo_url', default=request.static_url('wuttaweb:static/img/logo.png')), f"{app.get_title()} logo")}
</%def>
<%def name="footer()">

View file

@ -3,6 +3,17 @@
<%def name="title()">Configure ${config_title}</%def>
<%def name="extra_styles()">
${parent.extra_styles()}
<style>
.wutta-form-wrapper {
width: 75%;
}
</style>
</%def>
<%def name="page_content()">
<br />
${self.buttons_content()}
@ -42,15 +53,14 @@
<b-button @click="purgeSettingsShowDialog = false">
Cancel
</b-button>
${h.form(request.current_route_url())}
${h.form(request.current_route_url(), **{'@submit': 'purgingSettings = true'})}
${h.csrf_token(request)}
${h.hidden('remove_settings', 'true')}
<b-button type="is-danger"
native-type="submit"
:disabled="purgingSettings"
icon-pack="fas"
icon-left="trash"
@click="purgingSettings = true">
icon-left="trash">
{{ purgingSettings ? "Working, please wait..." : "Remove All Settings for ${config_title}" }}
</b-button>
${h.end_form()}

View file

@ -1 +1,9 @@
<span>${str(field.schema.model_instance or '')}</span>
<tal:omit tal:define="url url|None;">
<a tal:condition="url"
href="${url}">
${str(field.schema.model_instance or '')}
</a>
<span tal:condition="not url">
${str(field.schema.model_instance or '')}
</span>
</tal:omit>

View file

@ -0,0 +1,7 @@
<ul class="list-group">
<tal:loop tal:repeat="role roles">
<li class="list-group-item">
<a href="${url(role)}">${role}</a>
</li>
</tal:loop>
</ul>

View file

@ -11,7 +11,12 @@
</section>
% if not form.readonly:
<div style="margin-top: 1.5rem; display: flex; gap: 0.5rem; justify-content: ${'end' if form.align_buttons_right else 'start'}; width: 100%; padding-left: 10rem;">
<br />
<div class="buttons"
% if form.align_buttons_right:
style="justify-content: right;"
% endif
>
% if form.show_button_cancel:
<wutta-button ${'once' if form.auto_disable_cancel else ''}
@ -63,6 +68,14 @@
% endif
% endif
% if form.grid_vue_data:
gridData: {
% for key, data in form.grid_vue_data.items():
'${key}': ${json.dumps(data)|n},
% endfor
},
% endif
}
</script>

View file

@ -0,0 +1,49 @@
## -*- coding: utf-8; -*-
<${b}-table :data="gridData['${grid.key}']">
% for column in grid.get_vue_columns():
<${b}-table-column field="${column['field']}"
label="${column['label']}"
v-slot="props"
:sortable="${json.dumps(column.get('sortable', False))|n}"
cell-class="c_${column['field']}">
% if grid.is_linked(column['field']):
<a :href="props.row._action_url_view"
v-html="props.row.${column['field']}" />
% else:
<span v-html="props.row.${column['field']}"></span>
% endif
</${b}-table-column>
% endfor
% if grid.actions:
<${b}-table-column field="actions"
label="Actions"
v-slot="props">
% for action in grid.actions:
<a v-if="props.row._action_url_${action.key}"
:href="props.row._action_url_${action.key}"
class="${action.link_class}">
${action.render_icon_and_label()}
</a>
&nbsp;
% endfor
</${b}-table-column>
% endif
<template #empty>
<section class="section">
<div class="content has-text-grey has-text-centered">
<p>
<b-icon
pack="fas"
icon="sad-tear"
size="is-large">
</b-icon>
</p>
<p>Nothing here.</p>
</div>
</section>
</template>
</${b}-table>

View file

@ -1,129 +1,228 @@
## -*- coding: utf-8; -*-
<script type="text/x-template" id="${grid.vue_tagname}-template">
<${b}-table :data="data"
:loading="loading"
narrowed
hoverable
icon-pack="fas"
<div>
<div style="display: flex; justify-content: space-between; margin-bottom: 0.5em;">
## sorting
% if grid.sortable:
## nb. buefy/oruga only support *one* default sorter
:default-sort="sorters.length ? [sorters[0].field, sorters[0].order] : null"
% if grid.sort_on_backend:
backend-sorting
@sort="onSort"
% endif
% if grid.sort_multiple:
% if grid.sort_on_backend:
## TODO: there is a bug (?) which prevents the arrow
## from displaying for simple default single-column sort,
## when multi-column sort is allowed for the table. for
## now we work around that by waiting until mount to
## enable the multi-column support. see also
## https://github.com/buefy/buefy/issues/2584
:sort-multiple="allowMultiSort"
:sort-multiple-data="sortingPriority"
@sorting-priority-removed="sortingPriorityRemoved"
% else:
sort-multiple
% endif
## nb. user must ctrl-click column header for multi-sort
sort-multiple-key="ctrlKey"
% endif
% endif
% if grid.filterable:
<form action="${request.path_url}" method="GET"
@submit.prevent="applyFilters()">
## paging
% if grid.paginated:
paginated
pagination-size="${'small' if request.use_oruga else 'is-small'}"
:per-page="perPage"
:current-page="currentPage"
@page-change="onPageChange"
% if grid.paginate_on_backend:
backend-pagination
:total="pagerStats.item_count"
% endif
% endif
>
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
<wutta-filter v-for="filtr in filters"
:key="filtr.key"
:filter="filtr"
:is-small="smallFilters"
ref="gridFilters" />
% for column in grid.get_vue_columns():
<${b}-table-column field="${column['field']}"
label="${column['label']}"
v-slot="props"
:sortable="${json.dumps(column.get('sortable', False))|n}"
cell-class="c_${column['field']}">
% if grid.is_linked(column['field']):
<a :href="props.row._action_url_view"
v-html="props.row.${column['field']}" />
% else:
<span v-html="props.row.${column['field']}"></span>
% endif
</${b}-table-column>
% endfor
<div class="buttons">
% if grid.actions:
<${b}-table-column field="actions"
label="Actions"
v-slot="props">
% for action in grid.actions:
<a v-if="props.row._action_url_${action.key}"
:href="props.row._action_url_${action.key}"
class="${action.link_class}">
${action.render_icon_and_label()}
</a>
&nbsp;
% endfor
</${b}-table-column>
% endif
<b-button @click="copyDirectLink()"
title="Copy grid link to clipboard"
:is-small="smallFilters">
<b-icon pack="fas" icon="share-alt" />
</b-button>
<template #empty>
<section class="section">
<div class="content has-text-grey has-text-centered">
<p>
<b-icon
pack="fas"
icon="sad-tear"
size="is-large">
</b-icon>
</p>
<p>Nothing here.</p>
</div>
</section>
</template>
<b-button type="is-primary"
native-type="submit"
icon-pack="fas"
icon-left="filter"
:size="smallFilters ? 'is-small' : null">
Apply Filters
</b-button>
<b-button v-if="!addFilterShow"
@click="addFilterInit()"
icon-pack="fas"
icon-left="plus"
:size="smallFilters ? 'is-small' : null">
Add Filter
</b-button>
<b-autocomplete v-if="addFilterShow"
ref="addFilterAutocomplete"
:data="addFilterChoices"
v-model="addFilterTerm"
placeholder="Add Filter"
field="key"
:custom-formatter="formatAddFilterItem"
open-on-focus
keep-first
clearable
clear-on-select
@select="addFilterSelect"
icon-pack="fas"
:size="smallFilters ? 'is-small' : null" />
<b-button @click="resetView()"
icon-pack="fas"
icon-left="undo"
:size="smallFilters ? 'is-small' : null">
Reset View
</b-button>
<b-button v-show="activeFilters"
@click="clearFilters()"
icon-pack="fas"
icon-left="trash"
:size="smallFilters ? 'is-small' : null">
No Filters
</b-button>
## TODO: this semi-works but is not persisted for user
## <b-button v-if="!smallFilters"
## @click="smallFilters = true"
## icon-pack="fas"
## icon-left="compress"
## title="toggle filter size" />
##
## <span v-if="smallFilters">
## <b-button @click="smallFilters = false"
## icon-pack="fas"
## icon-left="expand"
## size="is-small"
## title="toggle filter size" />
## </span>
</div>
% if grid.paginated:
<template #footer>
<div style="display: flex; justify-content: space-between;">
<div></div>
<div v-if="pagerStats.first_item"
style="display: flex; gap: 0.5rem; align-items: center;">
<span>
showing
{{ renderNumber(pagerStats.first_item) }}
- {{ renderNumber(pagerStats.last_item) }}
of {{ renderNumber(pagerStats.item_count) }} results;
</span>
<b-select v-model="perPage"
% if grid.paginate_on_backend:
@input="onPageSizeChange"
% endif
size="is-small">
<option v-for="size in pageSizeOptions"
:value="size">
{{ size }}
</option>
</b-select>
<span>
per page
</span>
</div>
</div>
</template>
</form>
% endif
</${b}-table>
</div>
<${b}-table :data="data"
:loading="loading"
narrowed
hoverable
icon-pack="fas"
## sorting
% if grid.sortable:
## nb. buefy/oruga only support *one* default sorter
:default-sort="sorters.length ? [sorters[0].field, sorters[0].order] : null"
% if grid.sort_on_backend:
backend-sorting
@sort="onSort"
% endif
% if grid.sort_multiple:
% if grid.sort_on_backend:
## TODO: there is a bug (?) which prevents the arrow
## from displaying for simple default single-column sort,
## when multi-column sort is allowed for the table. for
## now we work around that by waiting until mount to
## enable the multi-column support. see also
## https://github.com/buefy/buefy/issues/2584
:sort-multiple="allowMultiSort"
:sort-multiple-data="sortingPriority"
@sorting-priority-removed="sortingPriorityRemoved"
% else:
sort-multiple
% endif
## nb. user must ctrl-click column header for multi-sort
sort-multiple-key="ctrlKey"
% endif
% endif
## paging
% if grid.paginated:
paginated
pagination-size="${'small' if request.use_oruga else 'is-small'}"
:per-page="perPage"
:current-page="currentPage"
@page-change="onPageChange"
% if grid.paginate_on_backend:
backend-pagination
:total="pagerStats.item_count"
% endif
% endif
>
% for column in grid.get_vue_columns():
<${b}-table-column field="${column['field']}"
label="${column['label']}"
v-slot="props"
:sortable="${json.dumps(column.get('sortable', False))|n}"
:searchable="${json.dumps(column.get('searchable', False))|n}"
cell-class="c_${column['field']}">
% if grid.is_linked(column['field']):
<a :href="props.row._action_url_view"
v-html="props.row.${column['field']}" />
% else:
<span v-html="props.row.${column['field']}"></span>
% endif
</${b}-table-column>
% endfor
% if grid.actions:
<${b}-table-column field="actions"
label="Actions"
v-slot="props">
% for action in grid.actions:
<a v-if="props.row._action_url_${action.key}"
:href="props.row._action_url_${action.key}"
class="${action.link_class}">
${action.render_icon_and_label()}
</a>
&nbsp;
% endfor
</${b}-table-column>
% endif
<template #empty>
<section class="section">
<div class="content has-text-grey has-text-centered">
<p>
<b-icon
pack="fas"
icon="sad-tear"
size="is-large">
</b-icon>
</p>
<p>Nothing here.</p>
</div>
</section>
</template>
% if grid.paginated:
<template #footer>
<div style="display: flex; justify-content: space-between;">
<div></div>
<div v-if="pagerStats.first_item"
style="display: flex; gap: 0.5rem; align-items: center;">
<span>
showing
{{ renderNumber(pagerStats.first_item) }}
- {{ renderNumber(pagerStats.last_item) }}
of {{ renderNumber(pagerStats.item_count) }} results;
</span>
<b-select v-model="perPage"
% if grid.paginate_on_backend:
@input="onPageSizeChange"
% endif
size="is-small">
<option v-for="size in pageSizeOptions"
:value="size">
{{ size }}
</option>
</b-select>
<span>
per page
</span>
</div>
</div>
</template>
% endif
</${b}-table>
## dummy input field needed for sharing links on *insecure* sites
% if getattr(request, 'scheme', None) == 'http':
<b-input v-model="shareLink" ref="shareLink" v-show="shareLink"></b-input>
% endif
</div>
</script>
<script>
@ -134,6 +233,22 @@
data: ${grid.vue_component}CurrentData,
loading: false,
## nb. this tracks whether grid.fetchFirstData() happened
fetchedFirstData: false,
## dummy input value needed for sharing links on *insecure* sites
% if getattr(request, 'scheme', None) == 'http':
shareLink: null,
% endif
## filtering
% if grid.filterable:
filters: ${json.dumps(grid.get_vue_filters())|n},
addFilterShow: false,
addFilterTerm: '',
smallFilters: false,
% endif
## sorting
% if grid.sortable:
sorters: ${json.dumps(grid.get_vue_active_sorters())|n},
@ -171,6 +286,52 @@
template: '#${grid.vue_tagname}-template',
computed: {
directLink() {
const params = new URLSearchParams(this.getAllParams())
return `${request.path_url}?${'$'}{params}`
},
% if grid.filterable:
addFilterChoices() {
// parse list of search terms
const terms = []
for (let term of this.addFilterTerm.toLowerCase().split(' ')) {
term = term.trim()
if (term) {
terms.push(term)
}
}
// show all if no search terms
if (!terms.length) {
return this.filters
}
// only show filters matching all search terms
return this.filters.filter(option => {
let label = option.label.toLowerCase()
for (let term of terms) {
if (label.indexOf(term) < 0) {
return false
}
}
return true
})
},
activeFilters() {
for (let filtr of this.filters) {
if (filtr.active) {
return true
}
}
return false
},
% endif
% if not grid.paginate_on_backend:
pagerStats() {
@ -208,12 +369,50 @@
methods: {
copyDirectLink() {
if (navigator.clipboard) {
// this is the way forward, but requires HTTPS
navigator.clipboard.writeText(this.directLink)
} else {
// use deprecated 'copy' command, but this just
// tells the browser to copy currently-selected
// text..which means we first must "add" some text
// to screen, and auto-select that, before copying
// to clipboard
this.shareLink = this.directLink
this.$nextTick(() => {
let input = this.$refs.shareLink.$el.firstChild
input.select()
document.execCommand('copy')
// re-hide the dummy input
this.shareLink = null
})
}
this.$buefy.toast.open({
message: "Link was copied to clipboard",
type: 'is-info',
duration: 2000, // 2 seconds
})
},
renderNumber(value) {
if (value != undefined) {
return value.toLocaleString('en')
}
},
getAllParams() {
return {
...this.getBasicParams(),
% if grid.filterable:
...this.getFilterParams(),
% endif
}
},
getBasicParams() {
const params = {
% if grid.paginated and grid.paginate_on_backend:
@ -230,9 +429,23 @@
return params
},
async fetchData() {
## nb. this is meant to call for a grid which is hidden at
## first, when it is first being shown to the user. and if
## it was initialized with empty data set.
async fetchFirstData() {
if (this.fetchedFirstData) {
return
}
await this.fetchData()
this.fetchedFirstData = true
},
let params = new URLSearchParams(this.getBasicParams())
async fetchData(params) {
if (params === undefined || params === null) {
params = this.getBasicParams()
}
params = new URLSearchParams(params)
if (!params.has('partial')) {
params.append('partial', true)
}
@ -266,6 +479,129 @@
})
},
resetView() {
this.loading = true
// use current url proper, plus reset param
let url = '?reset-view=true'
// add current hash, to preserve that in redirect
if (location.hash) {
url += '&hash=' + location.hash.slice(1)
}
location.href = url
},
% if grid.filterable:
formatAddFilterItem(filtr) {
return filtr.label || filtr.key
},
addFilterInit() {
this.addFilterShow = true
this.$nextTick(() => {
const input = this.$refs.addFilterAutocomplete.$el.querySelector('input')
input.addEventListener('keydown', this.addFilterKeydown)
this.$refs.addFilterAutocomplete.focus()
})
},
addFilterKeydown(event) {
// ESC will clear searchbox
if (event.which == 27) {
this.addFilterHide()
}
},
addFilterHide() {
const input = this.$refs.addFilterAutocomplete.$el.querySelector('input')
input.removeEventListener('keydown', this.addFilterKeydown)
this.addFilterTerm = ''
this.addFilterShow = false
},
addFilterSelect(filtr) {
this.addFilter(filtr.key)
this.addFilterHide()
},
findFilter(key) {
for (let filtr of this.filters) {
if (filtr.key == key) {
return filtr
}
}
},
findFilterComponent(key) {
for (let filtr of this.$refs.gridFilters) {
if (filtr.filter.key == key) {
return filtr
}
}
},
addFilter(key) {
// show the filter
let filtr = this.findFilter(key)
filtr.active = true
filtr.visible = true
// focus the filter
filtr = this.findFilterComponent(key)
this.$nextTick(() => {
filtr.focusValue()
})
},
clearFilters() {
// explicitly deactivate all filters
for (let filter of this.filters) {
filter.active = false
}
// then just "apply" as normal
this.applyFilters()
},
applyFilters(params) {
if (params === undefined) {
params = this.getFilterParams()
}
// hide inactive filters
for (let filter of this.filters) {
if (!filter.active) {
filter.visible = false
}
}
// fetch new data
params.filter = true
this.fetchData(params)
},
getFilterParams() {
const params = {}
for (let filter of this.filters) {
if (filter.active) {
params[filter.key] = filter.value
params[filter.key+'.verb'] = filter.verb
}
}
if (Object.keys(params).length) {
params.filter = 'true'
}
return params
},
% endif
% if grid.sortable and grid.sort_on_backend:
onSort(field, order, event) {

View file

@ -4,16 +4,9 @@
<%def name="title()">Home</%def>
<%def name="render_this_page()">
${self.page_content()}
</%def>
<%def name="page_content()">
<div style="height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1rem;">
<div>${base_meta.full_logo()}</div>
<div>${base_meta.full_logo(image_url or None)}</div>
<h1 class="is-size-1">Welcome to ${app.get_title()}</h1>
</div>
</%def>
${parent.body()}

View file

@ -1,6 +1,8 @@
<%def name="make_wutta_components()">
${self.make_wutta_button_component()}
${self.make_wutta_filter_component()}
${self.make_wutta_filter_value_component()}
</%def>
<%def name="make_wutta_button_component()">
@ -69,3 +71,97 @@
Vue.component('wutta-button', WuttaButton)
</script>
</%def>
<%def name="make_wutta_filter_component()">
<script type="text/x-template" id="wutta-filter-template">
<div v-show="filter.visible"
class="wutta-filter">
<b-button @click="filter.active = !filter.active"
class="filter-toggle"
icon-pack="fas"
:icon-left="filter.active ? 'check' : null"
:size="isSmall ? 'is-small' : null">
{{ filter.label }}
</b-button>
<div v-show="filter.active"
style="display: flex; gap: 0.5rem;">
<b-select v-model="filter.verb"
class="filter-verb"
:size="isSmall ? 'is-small' : null">
<option v-for="verb in filter.verbs"
:key="verb"
:value="verb">
{{ verb }}
</option>
</b-select>
<wutta-filter-value v-model="filter.value"
ref="filterValue"
:is-small="isSmall" />
</div>
</div>
</script>
<script>
const WuttaFilter = {
template: '#wutta-filter-template',
props: {
filter: Object,
isSmall: Boolean,
},
methods: {
focusValue: function() {
this.$refs.filterValue.focus()
}
}
}
Vue.component('wutta-filter', WuttaFilter)
</script>
</%def>
<%def name="make_wutta_filter_value_component()">
<script type="text/x-template" id="wutta-filter-value-template">
<div class="wutta-filter-value">
<b-input v-model="inputValue"
ref="valueInput"
@input="val => $emit('input', val)"
:size="isSmall ? 'is-small' : null" />
</div>
</script>
<script>
const WuttaFilterValue = {
template: '#wutta-filter-value-template',
props: {
value: String,
isSmall: Boolean,
},
data() {
return {
inputValue: this.value,
}
},
methods: {
focus: function() {
this.$refs.valueInput.focus()
}
},
}
Vue.component('wutta-filter-value', WuttaFilterValue)
</script>
</%def>

View file

@ -53,6 +53,11 @@ class CommonView(View):
if not user:
return self.redirect(self.request.route_url('setup'))
# maybe auto-redirect anons to login
if not self.request.user:
if self.config.get_bool('wuttaweb.home_redirect_to_login'):
return self.redirect(self.request.route_url('login'))
return {
'index_title': self.app.get_title(),
}

View file

@ -181,6 +181,23 @@ class MasterView(View):
This is optional; see also :meth:`get_grid_columns()`.
.. attribute:: filterable
Boolean indicating whether the grid for the :meth:`index()`
view should allow filtering of data. Default is ``True``.
This is used by :meth:`make_model_grid()` to set the grid's
:attr:`~wuttaweb.grids.base.Grid.filterable` flag.
.. attribute:: filter_defaults
Optional dict of default filter state.
This is used by :meth:`make_model_grid()` to set the grid's
:attr:`~wuttaweb.grids.base.Grid.filter_defaults`.
Only relevant if :attr:`filterable` is true.
.. attribute:: sortable
Boolean indicating whether the grid for the :meth:`index()`
@ -263,6 +280,12 @@ class MasterView(View):
This is optional; see also :meth:`get_form_fields()`.
.. attribute:: has_autocomplete
Boolean indicating whether the view model supports
"autocomplete" - i.e. it should have an :meth:`autocomplete()`
view. Default is ``False``.
.. attribute:: configurable
Boolean indicating whether the master view supports
@ -277,6 +300,8 @@ class MasterView(View):
# features
listable = True
has_grid = True
filterable = True
filter_defaults = None
sortable = True
sort_on_backend = True
sort_defaults = None
@ -286,6 +311,7 @@ class MasterView(View):
viewable = True
editable = True
deletable = True
has_autocomplete = False
configurable = False
# current action
@ -330,13 +356,26 @@ class MasterView(View):
if self.has_grid:
grid = self.make_model_grid()
# so-called 'partial' requests get just data, no html
# handle "full" vs. "partial" differently
if self.request.GET.get('partial'):
# so-called 'partial' requests get just data, no html
context = {'data': grid.get_vue_data()}
if grid.paginated and grid.paginate_on_backend:
context['pager_stats'] = grid.get_vue_pager_stats()
return self.json_response(context)
else: # full, not partial
# nb. when user asks to reset view, it is via the query
# string. if so we then redirect to discard that.
if self.request.GET.get('reset-view'):
# nb. we want to preserve url hash if applicable
kw = {'_query': None,
'_anchor': self.request.GET.get('hash')}
return self.redirect(self.request.current_route_url(**kw))
context['grid'] = grid
return self.render_to_response('index', context)
@ -573,6 +612,84 @@ class MasterView(View):
session = self.app.get_session(obj)
session.delete(obj)
##############################
# autocomplete methods
##############################
def autocomplete(self):
"""
View which accepts a single ``term`` param, and returns a JSON
list of autocomplete results to match.
By default, this view is included only if
:attr:`has_autocomplete` is true. It usually maps to a URL
like ``/widgets/autocomplete``.
Subclass generally does not need to override this method, but
rather should override the others which this calls:
* :meth:`autocomplete_data()`
* :meth:`autocomplete_normalize()`
"""
term = self.request.GET.get('term', '')
if not term:
return []
data = self.autocomplete_data(term)
if not data:
return []
max_results = 100 # TODO
results = []
for obj in data[:max_results]:
normal = self.autocomplete_normalize(obj)
if normal:
results.append(normal)
return results
def autocomplete_data(self, term):
"""
Should return the data/query for the "matching" model records,
based on autocomplete search term. This is called by
:meth:`autocomplete()`.
Subclass must override this; default logic returns no data.
:param term: String search term as-is from user, e.g. "foo bar".
:returns: List of data records, or SQLAlchemy query.
"""
def autocomplete_normalize(self, obj):
"""
Should return a "normalized" version of the given model
record, suitable for autocomplete JSON results. This is
called by :meth:`autocomplete()`.
Subclass may need to override this; default logic is
simplistic but will work for basic models. It returns the
"autocomplete results" dict for the object::
{
'value': obj.uuid,
'label': str(obj),
}
The 2 keys shown are required; any other keys will be ignored
by the view logic but may be useful on the frontend widget.
:param obj: Model record/instance.
:returns: Dict of "autocomplete results" format, as shown
above.
"""
return {
'value': obj.uuid,
'label': str(obj),
}
##############################
# configure methods
##############################
@ -1123,6 +1240,8 @@ class MasterView(View):
kwargs['actions'] = actions
kwargs.setdefault('filterable', self.filterable)
kwargs.setdefault('filter_defaults', self.filter_defaults)
kwargs.setdefault('sortable', self.sortable)
kwargs.setdefault('sort_multiple', not self.request.use_oruga)
kwargs.setdefault('sort_on_backend', self.sort_on_backend)
@ -1208,8 +1327,10 @@ class MasterView(View):
self.set_labels(grid)
for key in self.get_model_key():
grid.set_link(key)
# TODO: i thought this was a good idea but if so it
# needs a try/catch in case of no model class
# for key in self.get_model_key():
# grid.set_link(key)
def grid_render_notes(self, record, key, value, maxlen=100):
"""
@ -1886,6 +2007,15 @@ class MasterView(View):
f'{permission_prefix}.delete',
f"Delete {model_title}")
# autocomplete
if cls.has_autocomplete:
config.add_route(f'{route_prefix}.autocomplete',
f'{url_prefix}/autocomplete')
config.add_view(cls, attr='autocomplete',
route_name=f'{route_prefix}.autocomplete',
renderer='json',
permission=f'{route_prefix}.list')
# configure
if cls.configurable:
config.add_route(f'{route_prefix}.configure',

View file

@ -24,8 +24,11 @@
Views for people
"""
import sqlalchemy as sa
from wuttjamaican.db.model import Person
from wuttaweb.views import MasterView
from wuttaweb.forms.schema import UserRefs
class PersonView(MasterView):
@ -46,6 +49,7 @@ class PersonView(MasterView):
model_title_plural = "People"
route_prefix = 'people'
sort_defaults = 'full_name'
has_autocomplete = True
grid_columns = [
'full_name',
@ -54,6 +58,10 @@ class PersonView(MasterView):
'last_name',
]
filter_defaults = {
'full_name': {'active': True},
}
def configure_grid(self, g):
""" """
super().configure_grid(g)
@ -67,23 +75,36 @@ class PersonView(MasterView):
# last_name
g.set_link('last_name')
# TODO: master should handle this?
def configure_form(self, f):
""" """
super().configure_form(f)
person = f.model_instance
# first_name
# TODO: master should handle these? (nullable column)
f.set_required('first_name', False)
# middle_name
f.set_required('middle_name', False)
# last_name
f.set_required('last_name', False)
# users
if 'users' in f:
f.fields.remove('users')
# nb. colanderalchemy wants to do some magic for the true
# 'users' relationship, so we use a different field name
f.remove('users')
if not (self.creating or self.editing):
f.append('_users')
f.set_readonly('_users')
f.set_node('_users', UserRefs(self.request))
f.set_default('_users', [u.uuid for u in person.users])
def autocomplete_query(self, term):
""" """
model = self.app.model
session = self.Session()
query = session.query(model.Person)
criteria = [model.Person.full_name.ilike(f'%{word}%')
for word in term.split()]
query = query.filter(sa.and_(*criteria))\
.order_by(model.Person.full_name)
return query
def view_profile(self, session=None):
""" """

View file

@ -28,7 +28,7 @@ from wuttjamaican.db.model import Role
from wuttaweb.views import MasterView
from wuttaweb.db import Session
from wuttaweb.forms import widgets
from wuttaweb.forms.schema import Permissions
from wuttaweb.forms.schema import UserRefs, Permissions
class RoleView(MasterView):
@ -52,6 +52,10 @@ class RoleView(MasterView):
'notes',
]
filter_defaults = {
'name': {'active': True},
}
# TODO: master should handle this, possibly via configure_form()
def get_query(self, session=None):
""" """
@ -115,6 +119,13 @@ class RoleView(MasterView):
# notes
f.set_widget('notes', widgets.NotesWidget())
# users
if not (self.creating or self.editing):
f.append('users')
f.set_readonly('users')
f.set_node('users', UserRefs(self.request))
f.set_default('users', [u.uuid for u in role.users])
# permissions
f.append('permissions')
self.wutta_permissions = self.get_available_permissions()

View file

@ -24,6 +24,10 @@
Views for app settings
"""
import json
import os
import sys
import subprocess
from collections import OrderedDict
from wuttjamaican.db.model import Setting
@ -47,58 +51,59 @@ class AppInfoView(MasterView):
model_name = 'AppInfo'
model_title_plural = "App Info"
route_prefix = 'appinfo'
has_grid = False
sort_on_backend = False
sort_defaults = 'name'
paginated = False
creatable = False
viewable = False
editable = False
deletable = False
configurable = True
def configure_get_simple_settings(self):
""" """
return [
grid_columns = [
'name',
'version',
'editable_project_location',
]
# basics
{'name': f'{self.app.appname}.app_title'},
{'name': f'{self.app.appname}.production',
'type': bool},
# TODO: for tailbone backward compat with get_liburl() etc.
weblib_config_prefix = None
# web libs
{'name': 'wuttaweb.libver.vue'},
{'name': 'wuttaweb.liburl.vue'},
{'name': 'wuttaweb.libver.vue_resource'},
{'name': 'wuttaweb.liburl.vue_resource'},
{'name': 'wuttaweb.libver.buefy'},
{'name': 'wuttaweb.liburl.buefy'},
{'name': 'wuttaweb.libver.buefy.css'},
{'name': 'wuttaweb.liburl.buefy.css'},
{'name': 'wuttaweb.libver.fontawesome'},
{'name': 'wuttaweb.liburl.fontawesome'},
{'name': 'wuttaweb.libver.bb_vue'},
{'name': 'wuttaweb.liburl.bb_vue'},
{'name': 'wuttaweb.libver.bb_oruga'},
{'name': 'wuttaweb.liburl.bb_oruga'},
{'name': 'wuttaweb.libver.bb_oruga_bulma'},
{'name': 'wuttaweb.liburl.bb_oruga_bulma'},
{'name': 'wuttaweb.libver.bb_oruga_bulma_css'},
{'name': 'wuttaweb.liburl.bb_oruga_bulma_css'},
{'name': 'wuttaweb.libver.bb_fontawesome_svg_core'},
{'name': 'wuttaweb.liburl.bb_fontawesome_svg_core'},
{'name': 'wuttaweb.libver.bb_free_solid_svg_icons'},
{'name': 'wuttaweb.liburl.bb_free_solid_svg_icons'},
{'name': 'wuttaweb.libver.bb_vue_fontawesome'},
{'name': 'wuttaweb.liburl.bb_vue_fontawesome'},
]
def configure_get_context(self, **kwargs):
def get_grid_data(self, columns=None, session=None):
""" """
# normal context
context = super().configure_get_context(**kwargs)
# nb. init with empty data, only load it upon user request
if not self.request.GET.get('partial'):
return []
# we will add `weblibs` to context, based on config values
weblibs = OrderedDict([
# TODO: pretty sure this is not cross-platform. probably some
# sort of pip methods belong on the app handler? or it should
# have a pip handler for all that?
pip = os.path.join(sys.prefix, 'bin', 'pip')
output = subprocess.check_output([pip, 'list', '--format=json'], text=True)
data = json.loads(output.strip())
# must avoid null values for sort to work right
for pkg in data:
pkg.setdefault('editable_project_location', '')
return data
def configure_grid(self, g):
""" """
super().configure_grid(g)
g.sort_multiple = False
# name
g.set_searchable('name')
# editable_project_location
g.set_searchable('editable_project_location')
def get_weblibs(self):
""" """
return OrderedDict([
('vue', "(Vue2) Vue"),
('vue_resource', "(Vue2) vue-resource"),
('buefy', "(Vue2) Buefy"),
@ -113,6 +118,48 @@ class AppInfoView(MasterView):
('bb_vue_fontawesome', "(Vue3) @fortawesome/vue-fontawesome"),
])
def configure_get_simple_settings(self):
""" """
simple_settings = [
# basics
{'name': f'{self.app.appname}.app_title'},
{'name': f'{self.app.appname}.node_type'},
{'name': f'{self.app.appname}.node_title'},
{'name': f'{self.app.appname}.production',
'type': bool},
# user/auth
{'name': 'wuttaweb.home_redirect_to_login',
'type': bool, 'default': False},
]
def getval(key):
return self.config.get(f'wuttaweb.{key}')
weblibs = self.get_weblibs()
for key, title in weblibs.items():
simple_settings.append({
'name': f'wuttaweb.libver.{key}',
'default': getval(f'libver.{key}'),
})
simple_settings.append({
'name': f'wuttaweb.liburl.{key}',
'default': getval(f'liburl.{key}'),
})
return simple_settings
def configure_get_context(self, **kwargs):
""" """
# normal context
context = super().configure_get_context(**kwargs)
# we will add `weblibs` to context, based on config values
weblibs = self.get_weblibs()
for key in weblibs:
title = weblibs[key]
weblibs[key] = {
@ -122,13 +169,18 @@ class AppInfoView(MasterView):
# nb. these values are exactly as configured, and are
# used for editing the settings
'configured_version': get_libver(self.request, key,
prefix=self.weblib_config_prefix,
configured_only=True),
'configured_url': get_liburl(self.request, key,
prefix=self.weblib_config_prefix,
configured_only=True),
# nb. these are for display only
'default_version': get_libver(self.request, key, default_only=True),
'live_url': get_liburl(self.request, key),
'default_version': get_libver(self.request, key,
prefix=self.weblib_config_prefix,
default_only=True),
'live_url': get_liburl(self.request, key,
prefix=self.weblib_config_prefix),
}
context['weblibs'] = list(weblibs.values())
@ -149,8 +201,19 @@ class SettingView(MasterView):
"""
model_class = Setting
model_title = "Raw Setting"
filter_defaults = {
'name': {'active': True},
}
sort_defaults = 'name'
# TODO: master should handle this (per model key)
def configure_grid(self, g):
""" """
super().configure_grid(g)
# name
g.set_link('name')
def configure_form(self, f):
""" """
super().configure_form(f)

View file

@ -55,6 +55,10 @@ class UserView(MasterView):
'active',
]
filter_defaults = {
'username': {'active': True},
}
# TODO: master should handle this, possibly via configure_form()
def get_query(self, session=None):
""" """

View file

@ -10,6 +10,7 @@ from pyramid import testing
from wuttjamaican.conf import WuttaConfig
from wuttaweb.forms import base, widgets
from wuttaweb import helpers
from wuttaweb.grids import Grid
class TestForm(TestCase):
@ -405,6 +406,29 @@ class TestForm(TestCase):
self.assertIn('<script type="text/x-template" id="wutta-form-template">', html)
self.assertNotIn('@submit', html)
def test_add_grid_vue_data(self):
form = self.make_form()
# grid must have key
grid = Grid(self.request)
self.assertRaises(ValueError, form.add_grid_vue_data, grid)
# otherwise it works
grid = Grid(self.request, key='foo')
self.assertEqual(len(form.grid_vue_data), 0)
form.add_grid_vue_data(grid)
self.assertEqual(len(form.grid_vue_data), 1)
self.assertIn('foo', form.grid_vue_data)
self.assertEqual(form.grid_vue_data['foo'], [])
# calling again with same key will replace data
records = [{'foo': 1}, {'foo': 2}]
grid = Grid(self.request, key='foo', columns=['foo'], data=records)
form.add_grid_vue_data(grid)
self.assertEqual(len(form.grid_vue_data), 1)
self.assertIn('foo', form.grid_vue_data)
self.assertEqual(form.grid_vue_data['foo'], records)
def test_render_vue_finalize(self):
form = self.make_form()
html = form.render_vue_finalize()

View file

@ -9,6 +9,7 @@ from sqlalchemy import orm
from wuttjamaican.conf import WuttaConfig
from wuttaweb.forms import schema as mod
from wuttaweb.forms import widgets
from tests.util import DataTestCase
@ -200,6 +201,19 @@ class TestPersonRef(DataTestCase):
self.assertIsNot(sorted_query, query)
class TestUserRefs(DataTestCase):
def setUp(self):
self.setup_db()
self.request = testing.DummyRequest(wutta_config=self.config)
def test_widget_maker(self):
model = self.app.model
typ = mod.UserRefs(self.request, session=self.session)
widget = typ.widget_maker()
self.assertIsInstance(widget, widgets.UserRefsWidget)
class TestRoleRefs(DataTestCase):
def setUp(self):

View file

@ -1,11 +1,13 @@
# -*- coding: utf-8; -*-
from unittest.mock import patch
import colander
import deform
from pyramid import testing
from wuttaweb.forms import widgets as mod
from wuttaweb.forms.schema import PersonRef, RoleRefs, Permissions
from wuttaweb.forms.schema import PersonRef, RoleRefs, UserRefs, Permissions
from tests.util import WebTestCase
@ -36,7 +38,18 @@ class TestObjectRefWidget(WebTestCase):
widget = mod.ObjectRefWidget(self.request)
field = self.make_field(node)
html = widget.serialize(field, person.uuid, readonly=True)
self.assertEqual(html.strip(), '<span>Betty Boop</span>')
self.assertIn('Betty Boop', html)
self.assertNotIn('<a', html)
# with hyperlink
node = colander.SchemaNode(PersonRef(self.request, session=self.session))
node.model_instance = person
widget = mod.ObjectRefWidget(self.request, url=lambda p: '/foo')
field = self.make_field(node)
html = widget.serialize(field, person.uuid, readonly=True)
self.assertIn('Betty Boop', html)
self.assertIn('<a', html)
self.assertIn('href="/foo"', html)
class TestRoleRefsWidget(WebTestCase):
@ -48,6 +61,7 @@ class TestRoleRefsWidget(WebTestCase):
return deform.Field(node, **kwargs)
def test_serialize(self):
self.pyramid_config.add_route('roles.view', '/roles/{uuid}')
model = self.app.model
auth = self.app.get_auth_handler()
admin = auth.get_role_administrator(self.session)
@ -77,6 +91,49 @@ class TestRoleRefsWidget(WebTestCase):
self.assertIn(blokes.uuid, html)
class TestUserRefsWidget(WebTestCase):
def make_field(self, node, **kwargs):
# TODO: not sure why default renderer is in use even though
# pyramid_deform was included in setup? but this works..
kwargs.setdefault('renderer', deform.Form.default_renderer)
return deform.Field(node, **kwargs)
def test_serialize(self):
model = self.app.model
# nb. we let the field construct the widget via our type
node = colander.SchemaNode(UserRefs(self.request, session=self.session))
field = self.make_field(node)
widget = field.widget
# readonly is required
self.assertRaises(NotImplementedError, widget.serialize, field, set())
self.assertRaises(NotImplementedError, widget.serialize, field, set(), readonly=False)
# empty
html = widget.serialize(field, set(), readonly=True)
self.assertIn('<b-table ', html)
# with data, no actions
user = model.User(username='barney')
self.session.add(user)
self.session.commit()
html = widget.serialize(field, {user.uuid}, readonly=True)
self.assertIn('<b-table ', html)
self.assertNotIn('Actions', html)
self.assertNotIn('View', html)
self.assertNotIn('Edit', html)
# with view/edit actions
with patch.object(self.request, 'is_root', new=True):
html = widget.serialize(field, {user.uuid}, readonly=True)
self.assertIn('<b-table ', html)
self.assertIn('Actions', html)
self.assertIn('View', html)
self.assertIn('Edit', html)
class TestPermissionsWidget(WebTestCase):
def make_field(self, node, **kwargs):

View file

@ -1,7 +1,7 @@
# -*- coding: utf-8; -*-
from unittest import TestCase
from unittest.mock import patch
from unittest.mock import patch, MagicMock
from sqlalchemy import orm
from paginate import Page
@ -10,7 +10,8 @@ from pyramid import testing
from wuttjamaican.conf import WuttaConfig
from wuttaweb.grids import base as mod
from wuttaweb.forms import FieldList
from wuttaweb.util import FieldList
from wuttaweb.forms import Form
from tests.util import WebTestCase
@ -85,6 +86,32 @@ class TestGrid(WebTestCase):
sort_multiple=True)
self.assertFalse(grid.sort_multiple)
def test_constructor_filtering(self):
model = self.app.model
# defaults, not filterable
grid = self.make_grid()
self.assertFalse(grid.filterable)
self.assertEqual(grid.filters, {})
# defaults, filterable
grid = self.make_grid(filterable=True)
self.assertTrue(grid.filterable)
self.assertEqual(grid.filters, {})
# filters may be pre-populated
with patch.object(mod.Grid, 'make_filter', return_value=42):
grid = self.make_grid(model_class=model.Setting, filterable=True)
self.assertEqual(len(grid.filters), 2)
self.assertIn('name', grid.filters)
self.assertIn('value', grid.filters)
# can specify filters
grid = self.make_grid(model_class=model.Setting, filterable=True,
filters={'name': 42})
self.assertTrue(grid.filterable)
self.assertEqual(grid.filters, {'name': 42})
def test_vue_tagname(self):
grid = self.make_grid()
self.assertEqual(grid.vue_tagname, 'wutta-grid')
@ -124,17 +151,28 @@ class TestGrid(WebTestCase):
self.assertEqual(grid.columns, ['one', 'four'])
def test_set_label(self):
grid = self.make_grid(columns=['foo', 'bar'])
model = self.app.model
with patch.object(mod.Grid, 'make_filter'):
# nb. filters are MagicMock instances
grid = self.make_grid(model_class=model.Setting, filterable=True)
self.assertEqual(grid.labels, {})
# basic
grid.set_label('foo', "Foo Fighters")
self.assertEqual(grid.labels['foo'], "Foo Fighters")
grid.set_label('name', "NAME COL")
self.assertEqual(grid.labels['name'], "NAME COL")
# can replace label
grid.set_label('foo', "Different")
self.assertEqual(grid.labels['foo'], "Different")
self.assertEqual(grid.get_label('foo'), "Different")
grid.set_label('name', "Different")
self.assertEqual(grid.labels['name'], "Different")
self.assertEqual(grid.get_label('name'), "Different")
# can update only column, not filter
self.assertEqual(grid.labels, {'name': "Different"})
self.assertIn('name', grid.filters)
self.assertEqual(grid.filters['name'].label, "Different")
grid.set_label('name', "COLUMN ONLY", column_only=True)
self.assertEqual(grid.get_label('name'), "COLUMN ONLY")
self.assertEqual(grid.filters['name'].label, "Different")
def test_get_label(self):
grid = self.make_grid(columns=['foo', 'bar'])
@ -186,6 +224,34 @@ class TestGrid(WebTestCase):
self.assertFalse(grid.is_linked('foo'))
self.assertTrue(grid.is_linked('bar'))
def test_searchable_columns(self):
grid = self.make_grid(columns=['foo', 'bar'])
self.assertEqual(grid.searchable_columns, set())
self.assertFalse(grid.is_searchable('foo'))
grid.set_searchable('foo')
self.assertEqual(grid.searchable_columns, {'foo'})
self.assertTrue(grid.is_searchable('foo'))
self.assertFalse(grid.is_searchable('bar'))
grid.set_searchable('bar')
self.assertEqual(grid.searchable_columns, {'foo', 'bar'})
self.assertTrue(grid.is_searchable('foo'))
self.assertTrue(grid.is_searchable('bar'))
grid.set_searchable('foo', False)
self.assertEqual(grid.searchable_columns, {'bar'})
self.assertFalse(grid.is_searchable('foo'))
self.assertTrue(grid.is_searchable('bar'))
def test_add_action(self):
grid = self.make_grid()
self.assertEqual(len(grid.actions), 0)
grid.add_action('view')
self.assertEqual(len(grid.actions), 1)
self.assertIsInstance(grid.actions[0], mod.GridAction)
def test_get_pagesize_options(self):
grid = self.make_grid()
@ -313,20 +379,76 @@ class TestGrid(WebTestCase):
grid.load_settings()
self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'desc'}])
# filter settings are loaded, applied, saved
grid = self.make_grid(key='settings', model_class=model.Setting,
filterable=True)
self.assertEqual(len(grid.filters), 2)
self.assertEqual(len(grid.active_filters), 0)
self.assertNotIn('grid.settings.filter.name.active', self.request.session)
self.assertNotIn('grid.settings.filter.value.active', self.request.session)
self.request.GET = {'name': 'john', 'name.verb': 'contains'}
grid.load_settings()
self.assertTrue(grid.filters['name'].active)
self.assertEqual(grid.filters['name'].verb, 'contains')
self.assertEqual(grid.filters['name'].value, 'john')
self.assertTrue(self.request.session['grid.settings.filter.name.active'])
self.assertEqual(self.request.session['grid.settings.filter.name.verb'], 'contains')
self.assertEqual(self.request.session['grid.settings.filter.name.value'], 'john')
# filter + sort settings are loaded, applied, saved
self.request.session.invalidate()
grid = self.make_grid(key='settings', model_class=model.Setting,
sortable=True, filterable=True)
self.assertEqual(len(grid.filters), 2)
self.assertEqual(len(grid.active_filters), 0)
self.assertNotIn('grid.settings.filter.name.active', self.request.session)
self.assertNotIn('grid.settings.filter.value.active', self.request.session)
self.assertNotIn('grid.settings.sorters.length', self.request.session)
self.request.GET = {'name': 'john', 'name.verb': 'contains',
'sort1key': 'name', 'sort1dir': 'asc'}
grid.load_settings()
self.assertTrue(grid.filters['name'].active)
self.assertEqual(grid.filters['name'].verb, 'contains')
self.assertEqual(grid.filters['name'].value, 'john')
self.assertTrue(self.request.session['grid.settings.filter.name.active'])
self.assertEqual(self.request.session['grid.settings.filter.name.verb'], 'contains')
self.assertEqual(self.request.session['grid.settings.filter.name.value'], 'john')
self.assertEqual(self.request.session['grid.settings.sorters.length'], 1)
self.assertEqual(self.request.session['grid.settings.sorters.1.key'], 'name')
self.assertEqual(self.request.session['grid.settings.sorters.1.dir'], 'asc')
# can reset view to defaults
self.request.GET = {'reset-view': 'true'}
grid.load_settings()
self.assertEqual(grid.active_filters, [])
self.assertIsNone(grid.filters['name'].value)
def test_request_has_settings(self):
grid = self.make_grid(key='foo')
model = self.app.model
grid = self.make_grid(key='settings', model_class=model.Setting)
# paging
self.assertFalse(grid.request_has_settings('page'))
with patch.object(self.request, 'GET', new={'pagesize': '20'}):
self.assertTrue(grid.request_has_settings('page'))
with patch.object(self.request, 'GET', new={'page': '1'}):
self.assertTrue(grid.request_has_settings('page'))
with patch.object(grid, 'paginated', new=True):
with patch.object(self.request, 'GET', new={'pagesize': '20'}):
self.assertTrue(grid.request_has_settings('page'))
with patch.object(self.request, 'GET', new={'page': '1'}):
self.assertTrue(grid.request_has_settings('page'))
# sorting
self.assertFalse(grid.request_has_settings('sort'))
with patch.object(self.request, 'GET', new={'sort1key': 'name'}):
self.assertTrue(grid.request_has_settings('sort'))
with patch.object(grid, 'sortable', new=True):
with patch.object(self.request, 'GET', new={'sort1key': 'name'}):
self.assertTrue(grid.request_has_settings('sort'))
# filtering
grid = self.make_grid(key='settings', model_class=model.Setting, filterable=True)
self.assertFalse(grid.request_has_settings('filter'))
with patch.object(grid, 'filterable', new=True):
with patch.object(self.request, 'GET', new={'name': 'john', 'name.verb': 'contains'}):
self.assertTrue(grid.request_has_settings('filter'))
with patch.object(self.request, 'GET', new={'filter': '1'}):
self.assertTrue(grid.request_has_settings('filter'))
def test_get_setting(self):
grid = self.make_grid(key='foo')
@ -371,6 +493,40 @@ class TestGrid(WebTestCase):
value = grid.get_setting(settings, 'pagesize', src='session', normalize=int)
self.assertEqual(value, 35)
def test_update_filter_settings(self):
model = self.app.model
# nothing happens if not filterable
grid = self.make_grid(key='settings', model_class=model.Setting)
settings = {}
self.request.session['grid.settings.filter.name.active'] = True
self.request.session['grid.settings.filter.name.verb'] = 'contains'
self.request.session['grid.settings.filter.name.value'] = 'john'
grid.update_filter_settings(settings, src='session')
self.assertEqual(settings, {})
# nb. now use a filterable grid
grid = self.make_grid(key='settings', model_class=model.Setting,
filterable=True)
# settings are updated from session
settings = {}
self.request.session['grid.settings.filter.name.active'] = True
self.request.session['grid.settings.filter.name.verb'] = 'contains'
self.request.session['grid.settings.filter.name.value'] = 'john'
grid.update_filter_settings(settings, src='session')
self.assertTrue(settings['filter.name.active'])
self.assertEqual(settings['filter.name.verb'], 'contains')
self.assertEqual(settings['filter.name.value'], 'john')
# settings are updated from request
self.request.GET = {'value': 'sally', 'value.verb': 'contains'}
grid.update_filter_settings(settings, src='request')
self.assertFalse(settings['filter.name.active'])
self.assertTrue(settings['filter.value.active'])
self.assertEqual(settings['filter.value.verb'], 'contains')
self.assertEqual(settings['filter.value.value'], 'sally')
def test_update_sort_settings(self):
model = self.app.model
@ -481,6 +637,25 @@ class TestGrid(WebTestCase):
self.assertNotIn('grid.settings.sorters.2.key', self.request.session)
self.assertNotIn('grid.settings.sorters.2.dir', self.request.session)
# nb. now switch to filterable-only grid
grid = self.make_grid(key='settings', model_class=model.Setting,
filterable=True)
self.assertIn('name', grid.filters)
self.assertEqual(grid.filters['name'].key, 'name')
# no error if empty settings; does not save values
grid.persist_settings({}, dest='session')
self.assertNotIn('grid.settings.filters.name', self.request.session)
# provided values are saved
grid.persist_settings({'filter.name.active': True,
'filter.name.verb': 'contains',
'filter.name.value': 'john'},
dest='session')
self.assertTrue(self.request.session['grid.settings.filter.name.active'])
self.assertEqual(self.request.session['grid.settings.filter.name.verb'], 'contains')
self.assertEqual(self.request.session['grid.settings.filter.name.value'], 'john')
##############################
# sorting methods
##############################
@ -600,6 +775,23 @@ class TestGrid(WebTestCase):
sorted_data = sorter(sample_data, 'asc')
self.assertEqual(dict(sorted_data[0]), {'name': 'foo1', 'value': 'ONE'})
def test_set_joiner(self):
# basic
grid = self.make_grid(columns=['foo', 'bar'], sortable=True, sort_on_backend=True)
self.assertEqual(grid.joiners, {})
grid.set_joiner('foo', 42)
self.assertEqual(grid.joiners, {'foo': 42})
def test_remove_joiner(self):
# basic
grid = self.make_grid(columns=['foo', 'bar'], sortable=True, sort_on_backend=True,
joiners={'foo': 42})
self.assertEqual(grid.joiners, {'foo': 42})
grid.remove_joiner('foo')
self.assertEqual(grid.joiners, {})
def test_set_sorter(self):
model = self.app.model
@ -697,6 +889,109 @@ class TestGrid(WebTestCase):
grid.sortable = False
self.assertFalse(grid.is_sortable('name'))
def test_make_backend_filters(self):
model = self.app.model
# default is empty
grid = self.make_grid()
filters = grid.make_backend_filters()
self.assertEqual(filters, {})
# makes filters if model class
with patch.object(mod.Grid, 'make_filter'):
# nb. filters are MagicMock instances
grid = self.make_grid(model_class=model.Setting)
filters = grid.make_backend_filters()
self.assertEqual(len(filters), 2)
self.assertIn('name', filters)
self.assertIn('value', filters)
# does not replace supplied filters
myfilters = {'value': 42}
with patch.object(mod.Grid, 'make_filter'):
# nb. filters are MagicMock instances
grid = self.make_grid(model_class=model.Setting)
filters = grid.make_backend_filters(myfilters)
self.assertEqual(len(filters), 2)
self.assertIn('name', filters)
self.assertIn('value', filters)
self.assertEqual(filters['value'], 42)
self.assertEqual(myfilters['value'], 42)
def test_make_filter(self):
model = self.app.model
# basic
grid = self.make_grid(model_class=model.Setting)
filtr = grid.make_filter('name')
self.assertIsInstance(filtr, mod.GridFilter)
# property
grid = self.make_grid(model_class=model.Setting)
filtr = grid.make_filter(model.Setting.name)
self.assertIsInstance(filtr, mod.GridFilter)
# invalid model class
grid = self.make_grid(model_class=42)
self.assertRaises(ValueError, grid.make_filter, 'name')
def test_set_filter(self):
model = self.app.model
with patch.object(mod.Grid, 'make_filter', return_value=42):
# auto from model property
grid = self.make_grid(model_class=model.Setting)
self.assertEqual(grid.filters, {})
grid.set_filter('name', model.Setting.name)
self.assertIn('name', grid.filters)
# auto from column name
grid = self.make_grid(model_class=model.Setting)
self.assertEqual(grid.filters, {})
grid.set_filter('name', 'name')
self.assertIn('name', grid.filters)
# auto from key
grid = self.make_grid(model_class=model.Setting)
self.assertEqual(grid.filters, {})
grid.set_filter('name')
self.assertIn('name', grid.filters)
# explicit is not yet implemented
grid = self.make_grid(model_class=model.Setting)
self.assertEqual(grid.filters, {})
self.assertRaises(NotImplementedError, grid.set_filter, 'name', lambda q: q)
def test_remove_filter(self):
model = self.app.model
# basics
with patch.object(mod.Grid, 'make_filter'):
# nb. filters are MagicMock instances
grid = self.make_grid(model_class=model.Setting, filterable=True)
self.assertEqual(len(grid.filters), 2)
self.assertIn('name', grid.filters)
self.assertIn('value', grid.filters)
grid.remove_filter('value')
self.assertNotIn('value', grid.filters)
def test_set_filter_defaults(self):
model = self.app.model
# empty by default
grid = self.make_grid(model_class=model.Setting, filterable=True)
self.assertEqual(grid.filter_defaults, {})
# can specify via method call
grid.set_filter_defaults(name={'active': True})
self.assertEqual(grid.filter_defaults, {'name': {'active': True}})
# can specify via constructor
grid = self.make_grid(model_class=model.Setting, filterable=True,
filter_defaults={'name': {'active': True}})
self.assertEqual(grid.filter_defaults, {'name': {'active': True}})
##############################
# data methods
##############################
@ -722,14 +1017,98 @@ class TestGrid(WebTestCase):
# data is sorted and paginated
grid = self.make_grid(model_class=model.Setting,
data=sample_query,
filterable=True,
sortable=True, sort_on_backend=True,
sort_defaults=('name', 'desc'),
paginated=True, paginate_on_backend=True,
pagesize=4, page=2)
grid.load_settings()
visible = grid.get_visible_data()
# nb. for now the filtering is mocked
with patch.object(grid, 'filter_data') as filter_data:
filter_data.side_effect = lambda q: q
visible = grid.get_visible_data()
filter_data.assert_called_once_with(sample_query)
self.assertEqual([s.name for s in visible], ['foo5', 'foo4', 'foo3', 'foo2'])
def test_filter_data(self):
model = self.app.model
sample_data = [
{'name': 'foo1', 'value': 'ONE'},
{'name': 'foo2', 'value': 'two'},
{'name': 'foo3', 'value': 'ggg'},
{'name': 'foo4', 'value': 'ggg'},
{'name': 'foo5', 'value': 'ggg'},
{'name': 'foo6', 'value': 'six'},
{'name': 'foo7', 'value': 'seven'},
{'name': 'foo8', 'value': 'eight'},
{'name': 'foo9', 'value': 'nine'},
]
for setting in sample_data:
self.app.save_setting(self.session, setting['name'], setting['value'])
self.session.commit()
sample_query = self.session.query(model.Setting)
grid = self.make_grid(key='settings', model_class=model.Setting, filterable=True)
# not filtered by default
grid.load_settings()
self.assertEqual(grid.active_filters, [])
filtered_query = grid.filter_data(sample_query)
self.assertIs(filtered_query, sample_query)
# can be filtered per session settings
self.request.session['grid.settings.filter.value.active'] = True
self.request.session['grid.settings.filter.value.verb'] = 'contains'
self.request.session['grid.settings.filter.value.value'] = 'ggg'
grid.load_settings()
self.assertEqual(len(grid.active_filters), 1)
self.assertEqual(grid.active_filters[0].key, 'value')
filtered_query = grid.filter_data(sample_query)
self.assertIsInstance(filtered_query, orm.Query)
self.assertIsNot(filtered_query, sample_query)
self.assertEqual(filtered_query.count(), 3)
# can be filtered per request settings
self.request.GET = {'value': 's', 'value.verb': 'contains'}
grid.load_settings()
self.assertEqual(len(grid.active_filters), 1)
self.assertEqual(grid.active_filters[0].key, 'value')
filtered_query = grid.filter_data(sample_query)
self.assertIsInstance(filtered_query, orm.Query)
self.assertEqual(filtered_query.count(), 2)
# not filtered if verb is invalid
self.request.GET = {'value': 'ggg', 'value.verb': 'doesnotexist'}
grid.load_settings()
self.assertEqual(len(grid.active_filters), 1)
self.assertEqual(grid.active_filters[0].verb, 'doesnotexist')
filtered_query = grid.filter_data(sample_query)
self.assertIs(filtered_query, sample_query)
self.assertEqual(filtered_query.count(), 9)
# not filtered if error
self.request.GET = {'value': 'ggg', 'value.verb': 'contains'}
grid.load_settings()
self.assertEqual(len(grid.active_filters), 1)
self.assertEqual(grid.active_filters[0].verb, 'contains')
filtered_query = grid.filter_data(sample_query)
self.assertIsNot(filtered_query, sample_query)
self.assertEqual(filtered_query.count(), 3)
with patch.object(grid.active_filters[0], 'filter_contains', side_effect=RuntimeError):
filtered_query = grid.filter_data(sample_query)
self.assertIs(filtered_query, sample_query)
self.assertEqual(filtered_query.count(), 9)
# joiner is invoked
self.assertEqual(len(grid.active_filters), 1)
self.assertEqual(grid.active_filters[0].key, 'value')
joiner = MagicMock(side_effect=lambda q: q)
grid.joiners = {'value': joiner}
grid.joined = set()
filtered_query = grid.filter_data(sample_query)
joiner.assert_called_once_with(sample_query)
self.assertEqual(filtered_query.count(), 3)
def test_sort_data(self):
model = self.app.model
sample_data = [
@ -798,6 +1177,21 @@ class TestGrid(WebTestCase):
self.assertEqual(sorted_data[0]['name'], 'foo1')
self.assertEqual(sorted_data[-1]['name'], 'foo9')
# now try with a joiner
query = self.session.query(model.User)
grid = self.make_grid(model_class=model.User,
data=query,
columns=['username', 'full_name'],
sortable=True, sort_on_backend=True,
sort_defaults='full_name',
joiners={
'full_name': lambda q: q.join(model.Person),
})
grid.set_sorter('full_name', model.Person.full_name)
grid.load_settings()
data = grid.get_visible_data()
self.assertIsInstance(data, orm.Query)
def test_paginate_data(self):
model = self.app.model
sample_data = [
@ -855,6 +1249,25 @@ class TestGrid(WebTestCase):
html = grid.render_vue_template()
self.assertIn('<script type="text/x-template" id="wutta-grid-template">', html)
def test_render_table_element(self):
self.pyramid_config.include('pyramid_mako')
self.pyramid_config.add_subscriber('wuttaweb.subscribers.before_render',
'pyramid.events.BeforeRender')
grid = self.make_grid(key='foobar', columns=['foo', 'bar'])
# form not required
html = grid.render_table_element()
self.assertNotIn('<script ', html)
self.assertIn('<b-table ', html)
# form will register grid data
form = Form(self.request)
self.assertEqual(len(form.grid_vue_data), 0)
html = grid.render_table_element(form)
self.assertEqual(len(form.grid_vue_data), 1)
self.assertIn('foobar', form.grid_vue_data)
def test_render_vue_finalize(self):
grid = self.make_grid()
html = grid.render_vue_finalize()
@ -892,6 +1305,15 @@ class TestGrid(WebTestCase):
sorters = grid.get_vue_active_sorters()
self.assertEqual(sorters, [{'field': 'name', 'order': 'asc'}])
def test_get_vue_filters(self):
model = self.app.model
# basic
grid = self.make_grid(key='settings', model_class=model.Setting, filterable=True)
grid.load_settings()
filters = grid.get_vue_filters()
self.assertEqual(len(filters), 2)
def test_get_vue_data(self):
# empty if no columns defined
@ -999,3 +1421,86 @@ class TestGridAction(TestCase):
action = self.make_action('blarg', url=lambda o, i: '/yeehaw')
url = action.get_url(obj)
self.assertEqual(url, '/yeehaw')
class TestGridFilter(WebTestCase):
def setUp(self):
self.setup_web()
model = self.app.model
self.sample_data = [
{'name': 'foo1', 'value': 'ONE'},
{'name': 'foo2', 'value': 'two'},
{'name': 'foo3', 'value': 'ggg'},
{'name': 'foo4', 'value': 'ggg'},
{'name': 'foo5', 'value': 'ggg'},
{'name': 'foo6', 'value': 'six'},
{'name': 'foo7', 'value': 'seven'},
{'name': 'foo8', 'value': 'eight'},
{'name': 'foo9', 'value': 'nine'},
]
for setting in self.sample_data:
self.app.save_setting(self.session, setting['name'], setting['value'])
self.session.commit()
self.sample_query = self.session.query(model.Setting)
def make_filter(self, model_property, **kwargs):
return mod.GridFilter(self.request, model_property, **kwargs)
def test_repr(self):
model = self.app.model
filtr = self.make_filter(model.Setting.name)
self.assertEqual(repr(filtr), "GridFilter(key='name', active=False, verb='contains', value=None)")
def test_apply_filter(self):
model = self.app.model
filtr = self.make_filter(model.Setting.value)
# default verb used as fallback
self.assertEqual(filtr.default_verb, 'contains')
filtr.verb = None
with patch.object(filtr, 'filter_contains', side_effect=lambda q, v: q) as filter_contains:
filtered_query = filtr.apply_filter(self.sample_query, value='foo')
filter_contains.assert_called_once_with(self.sample_query, 'foo')
self.assertIsNone(filtr.verb)
# filter verb used as fallback
filtr.verb = 'equal'
with patch.object(filtr, 'filter_equal', create=True, side_effect=lambda q, v: q) as filter_equal:
filtered_query = filtr.apply_filter(self.sample_query, value='foo')
filter_equal.assert_called_once_with(self.sample_query, 'foo')
# filter value used as fallback
filtr.verb = 'contains'
filtr.value = 'blarg'
with patch.object(filtr, 'filter_contains', side_effect=lambda q, v: q) as filter_contains:
filtered_query = filtr.apply_filter(self.sample_query)
filter_contains.assert_called_once_with(self.sample_query, 'blarg')
# error if invalid verb
self.assertRaises(mod.VerbNotSupported, filtr.apply_filter,
self.sample_query, verb='doesnotexist')
def test_filter_contains(self):
model = self.app.model
filtr = self.make_filter(model.Setting.value)
self.assertEqual(self.sample_query.count(), 9)
# not filtered for empty value
filtered_query = filtr.filter_contains(self.sample_query, None)
self.assertIs(filtered_query, self.sample_query)
filtered_query = filtr.filter_contains(self.sample_query, '')
self.assertIs(filtered_query, self.sample_query)
# filtered by value
filtered_query = filtr.filter_contains(self.sample_query, 'ggg')
self.assertIsNot(filtered_query, self.sample_query)
self.assertEqual(filtered_query.count(), 3)
class TestVerbNotSupported(TestCase):
def test_basic(self):
error = mod.VerbNotSupported('equal')
self.assertEqual(str(error), "unknown filter verb not supported: equal")

View file

@ -24,6 +24,7 @@ class TestCommonView(WebTestCase):
def test_home(self):
self.pyramid_config.add_route('setup', '/setup')
self.pyramid_config.add_route('login', '/login')
model = self.app.model
view = self.make_view()
@ -40,6 +41,16 @@ class TestCommonView(WebTestCase):
context = view.home(session=self.session)
self.assertEqual(context['index_title'], self.app.get_title())
# but if configured, anons will be redirected to login
self.config.setdefault('wuttaweb.home_redirect_to_login', 'true')
response = view.home(session=self.session)
self.assertEqual(response.status_code, 302)
# now only an auth'ed user can see home page
self.request.user = user
context = view.home(session=self.session)
self.assertEqual(context['index_title'], self.app.get_title())
def test_setup(self):
self.pyramid_config.add_route('home', '/')
self.pyramid_config.add_route('login', '/login')

View file

@ -10,7 +10,7 @@ from pyramid.response import Response
from pyramid.httpexceptions import HTTPNotFound
from wuttjamaican.conf import WuttaConfig
from wuttaweb.views import master
from wuttaweb.views import master as mod
from wuttaweb.views import View
from wuttaweb.subscribers import new_request_set_user
from tests.util import WebTestCase
@ -19,14 +19,15 @@ from tests.util import WebTestCase
class TestMasterView(WebTestCase):
def make_view(self):
return master.MasterView(self.request)
return mod.MasterView(self.request)
def test_defaults(self):
with patch.multiple(master.MasterView, create=True,
with patch.multiple(mod.MasterView, create=True,
model_name='Widget',
model_key='uuid',
has_autocomplete=True,
configurable=True):
master.MasterView.defaults(self.pyramid_config)
mod.MasterView.defaults(self.pyramid_config)
##############################
# class methods
@ -35,317 +36,315 @@ class TestMasterView(WebTestCase):
def test_get_model_class(self):
# no model class by default
self.assertIsNone(master.MasterView.get_model_class())
self.assertIsNone(mod.MasterView.get_model_class())
# subclass may specify
MyModel = MagicMock()
with patch.multiple(master.MasterView, create=True,
with patch.multiple(mod.MasterView, create=True,
model_class=MyModel):
self.assertIs(master.MasterView.get_model_class(), MyModel)
self.assertIs(mod.MasterView.get_model_class(), MyModel)
def test_get_model_name(self):
# error by default (since no model class)
self.assertRaises(AttributeError, master.MasterView.get_model_name)
self.assertRaises(AttributeError, mod.MasterView.get_model_name)
# subclass may specify model name
master.MasterView.model_name = 'Widget'
self.assertEqual(master.MasterView.get_model_name(), 'Widget')
del master.MasterView.model_name
with patch.multiple(mod.MasterView, create=True,
model_name='Widget'):
self.assertEqual(mod.MasterView.get_model_name(), 'Widget')
# or it may specify model class
MyModel = MagicMock(__name__='Blaster')
with patch.multiple(master.MasterView, create=True,
with patch.multiple(mod.MasterView, create=True,
model_class=MyModel):
self.assertEqual(master.MasterView.get_model_name(), 'Blaster')
self.assertEqual(mod.MasterView.get_model_name(), 'Blaster')
def test_get_model_name_normalized(self):
# error by default (since no model class)
self.assertRaises(AttributeError, master.MasterView.get_model_name_normalized)
self.assertRaises(AttributeError, mod.MasterView.get_model_name_normalized)
# subclass may specify *normalized* model name
master.MasterView.model_name_normalized = 'widget'
self.assertEqual(master.MasterView.get_model_name_normalized(), 'widget')
del master.MasterView.model_name_normalized
with patch.multiple(mod.MasterView, create=True,
model_name_normalized='widget'):
self.assertEqual(mod.MasterView.get_model_name_normalized(), 'widget')
# or it may specify *standard* model name
master.MasterView.model_name = 'Blaster'
self.assertEqual(master.MasterView.get_model_name_normalized(), 'blaster')
del master.MasterView.model_name
with patch.multiple(mod.MasterView, create=True,
model_name='Blaster'):
self.assertEqual(mod.MasterView.get_model_name_normalized(), 'blaster')
# or it may specify model class
MyModel = MagicMock(__name__='Dinosaur')
with patch.multiple(master.MasterView, create=True,
with patch.multiple(mod.MasterView, create=True,
model_class=MyModel):
self.assertEqual(master.MasterView.get_model_name_normalized(), 'dinosaur')
self.assertEqual(mod.MasterView.get_model_name_normalized(), 'dinosaur')
def test_get_model_title(self):
# error by default (since no model class)
self.assertRaises(AttributeError, master.MasterView.get_model_title)
self.assertRaises(AttributeError, mod.MasterView.get_model_title)
# subclass may specify model title
master.MasterView.model_title = 'Wutta Widget'
self.assertEqual(master.MasterView.get_model_title(), "Wutta Widget")
del master.MasterView.model_title
with patch.multiple(mod.MasterView, create=True,
model_title='Wutta Widget'):
self.assertEqual(mod.MasterView.get_model_title(), "Wutta Widget")
# or it may specify model name
master.MasterView.model_name = 'Blaster'
self.assertEqual(master.MasterView.get_model_title(), "Blaster")
del master.MasterView.model_name
with patch.multiple(mod.MasterView, create=True,
model_name='Blaster'):
self.assertEqual(mod.MasterView.get_model_title(), "Blaster")
# or it may specify model class
MyModel = MagicMock(__name__='Dinosaur')
with patch.multiple(master.MasterView, create=True,
with patch.multiple(mod.MasterView, create=True,
model_class=MyModel):
self.assertEqual(master.MasterView.get_model_title(), "Dinosaur")
self.assertEqual(mod.MasterView.get_model_title(), "Dinosaur")
def test_get_model_title_plural(self):
# error by default (since no model class)
self.assertRaises(AttributeError, master.MasterView.get_model_title_plural)
self.assertRaises(AttributeError, mod.MasterView.get_model_title_plural)
# subclass may specify *plural* model title
master.MasterView.model_title_plural = 'People'
self.assertEqual(master.MasterView.get_model_title_plural(), "People")
del master.MasterView.model_title_plural
with patch.multiple(mod.MasterView, create=True,
model_title_plural='People'):
self.assertEqual(mod.MasterView.get_model_title_plural(), "People")
# or it may specify *singular* model title
master.MasterView.model_title = 'Wutta Widget'
self.assertEqual(master.MasterView.get_model_title_plural(), "Wutta Widgets")
del master.MasterView.model_title
with patch.multiple(mod.MasterView, create=True,
model_title='Wutta Widget'):
self.assertEqual(mod.MasterView.get_model_title_plural(), "Wutta Widgets")
# or it may specify model name
master.MasterView.model_name = 'Blaster'
self.assertEqual(master.MasterView.get_model_title_plural(), "Blasters")
del master.MasterView.model_name
with patch.multiple(mod.MasterView, create=True,
model_name='Blaster'):
self.assertEqual(mod.MasterView.get_model_title_plural(), "Blasters")
# or it may specify model class
MyModel = MagicMock(__name__='Dinosaur')
with patch.multiple(master.MasterView, create=True,
with patch.multiple(mod.MasterView, create=True,
model_class=MyModel):
self.assertEqual(master.MasterView.get_model_title_plural(), "Dinosaurs")
self.assertEqual(mod.MasterView.get_model_title_plural(), "Dinosaurs")
def test_get_model_key(self):
# error by default (since no model class)
self.assertRaises(AttributeError, master.MasterView.get_model_key)
self.assertRaises(AttributeError, mod.MasterView.get_model_key)
# subclass may specify model key
master.MasterView.model_key = 'uuid'
self.assertEqual(master.MasterView.get_model_key(), ('uuid',))
del master.MasterView.model_key
with patch.multiple(mod.MasterView, create=True,
model_key='uuid'):
self.assertEqual(mod.MasterView.get_model_key(), ('uuid',))
def test_get_route_prefix(self):
# error by default (since no model class)
self.assertRaises(AttributeError, master.MasterView.get_route_prefix)
self.assertRaises(AttributeError, mod.MasterView.get_route_prefix)
# subclass may specify route prefix
master.MasterView.route_prefix = 'widgets'
self.assertEqual(master.MasterView.get_route_prefix(), 'widgets')
del master.MasterView.route_prefix
with patch.multiple(mod.MasterView, create=True,
route_prefix='widgets'):
self.assertEqual(mod.MasterView.get_route_prefix(), 'widgets')
# subclass may specify *normalized* model name
master.MasterView.model_name_normalized = 'blaster'
self.assertEqual(master.MasterView.get_route_prefix(), 'blasters')
del master.MasterView.model_name_normalized
with patch.multiple(mod.MasterView, create=True,
model_name_normalized='blaster'):
self.assertEqual(mod.MasterView.get_route_prefix(), 'blasters')
# or it may specify *standard* model name
master.MasterView.model_name = 'Dinosaur'
self.assertEqual(master.MasterView.get_route_prefix(), 'dinosaurs')
del master.MasterView.model_name
with patch.multiple(mod.MasterView, create=True,
model_name = 'Dinosaur'):
self.assertEqual(mod.MasterView.get_route_prefix(), 'dinosaurs')
# or it may specify model class
MyModel = MagicMock(__name__='Truck')
with patch.multiple(master.MasterView, create=True,
with patch.multiple(mod.MasterView, create=True,
model_class=MyModel):
self.assertEqual(master.MasterView.get_route_prefix(), 'trucks')
self.assertEqual(mod.MasterView.get_route_prefix(), 'trucks')
def test_get_permission_prefix(self):
# error by default (since no model class)
self.assertRaises(AttributeError, master.MasterView.get_permission_prefix)
self.assertRaises(AttributeError, mod.MasterView.get_permission_prefix)
# subclass may specify permission prefix
with patch.object(master.MasterView, 'permission_prefix', new='widgets', create=True):
self.assertEqual(master.MasterView.get_permission_prefix(), 'widgets')
with patch.object(mod.MasterView, 'permission_prefix', new='widgets', create=True):
self.assertEqual(mod.MasterView.get_permission_prefix(), 'widgets')
# subclass may specify route prefix
with patch.object(master.MasterView, 'route_prefix', new='widgets', create=True):
self.assertEqual(master.MasterView.get_permission_prefix(), 'widgets')
with patch.object(mod.MasterView, 'route_prefix', new='widgets', create=True):
self.assertEqual(mod.MasterView.get_permission_prefix(), 'widgets')
# or it may specify model class
Truck = MagicMock(__name__='Truck')
with patch.object(master.MasterView, 'model_class', new=Truck, create=True):
self.assertEqual(master.MasterView.get_permission_prefix(), 'trucks')
with patch.object(mod.MasterView, 'model_class', new=Truck, create=True):
self.assertEqual(mod.MasterView.get_permission_prefix(), 'trucks')
def test_get_url_prefix(self):
# error by default (since no model class)
self.assertRaises(AttributeError, master.MasterView.get_url_prefix)
self.assertRaises(AttributeError, mod.MasterView.get_url_prefix)
# subclass may specify url prefix
master.MasterView.url_prefix = '/widgets'
self.assertEqual(master.MasterView.get_url_prefix(), '/widgets')
del master.MasterView.url_prefix
with patch.multiple(mod.MasterView, create=True,
url_prefix='/widgets'):
self.assertEqual(mod.MasterView.get_url_prefix(), '/widgets')
# or it may specify route prefix
master.MasterView.route_prefix = 'trucks'
self.assertEqual(master.MasterView.get_url_prefix(), '/trucks')
del master.MasterView.route_prefix
with patch.multiple(mod.MasterView, create=True,
route_prefix='trucks'):
self.assertEqual(mod.MasterView.get_url_prefix(), '/trucks')
# or it may specify *normalized* model name
master.MasterView.model_name_normalized = 'blaster'
self.assertEqual(master.MasterView.get_url_prefix(), '/blasters')
del master.MasterView.model_name_normalized
with patch.multiple(mod.MasterView, create=True,
model_name_normalized='blaster'):
self.assertEqual(mod.MasterView.get_url_prefix(), '/blasters')
# or it may specify *standard* model name
master.MasterView.model_name = 'Dinosaur'
self.assertEqual(master.MasterView.get_url_prefix(), '/dinosaurs')
del master.MasterView.model_name
with patch.multiple(mod.MasterView, create=True,
model_name='Dinosaur'):
self.assertEqual(mod.MasterView.get_url_prefix(), '/dinosaurs')
# or it may specify model class
MyModel = MagicMock(__name__='Machine')
with patch.multiple(master.MasterView, create=True,
with patch.multiple(mod.MasterView, create=True,
model_class=MyModel):
self.assertEqual(master.MasterView.get_url_prefix(), '/machines')
self.assertEqual(mod.MasterView.get_url_prefix(), '/machines')
def test_get_instance_url_prefix(self):
# error by default (since no model class)
self.assertRaises(AttributeError, master.MasterView.get_instance_url_prefix)
self.assertRaises(AttributeError, mod.MasterView.get_instance_url_prefix)
# typical example with url_prefix and simple key
master.MasterView.url_prefix = '/widgets'
master.MasterView.model_key = 'uuid'
self.assertEqual(master.MasterView.get_instance_url_prefix(), '/widgets/{uuid}')
del master.MasterView.url_prefix
del master.MasterView.model_key
with patch.multiple(mod.MasterView, create=True,
url_prefix='/widgets',
model_key='uuid'):
self.assertEqual(mod.MasterView.get_instance_url_prefix(), '/widgets/{uuid}')
# typical example with composite key
master.MasterView.url_prefix = '/widgets'
master.MasterView.model_key = ('foo', 'bar')
self.assertEqual(master.MasterView.get_instance_url_prefix(), '/widgets/{foo}|{bar}')
del master.MasterView.url_prefix
del master.MasterView.model_key
with patch.multiple(mod.MasterView, create=True,
url_prefix='/widgets',
model_key=('foo', 'bar')):
self.assertEqual(mod.MasterView.get_instance_url_prefix(), '/widgets/{foo}|{bar}')
def test_get_template_prefix(self):
# error by default (since no model class)
self.assertRaises(AttributeError, master.MasterView.get_template_prefix)
self.assertRaises(AttributeError, mod.MasterView.get_template_prefix)
# subclass may specify template prefix
master.MasterView.template_prefix = '/widgets'
self.assertEqual(master.MasterView.get_template_prefix(), '/widgets')
del master.MasterView.template_prefix
with patch.multiple(mod.MasterView, create=True,
template_prefix='/widgets'):
self.assertEqual(mod.MasterView.get_template_prefix(), '/widgets')
# or it may specify url prefix
master.MasterView.url_prefix = '/trees'
self.assertEqual(master.MasterView.get_template_prefix(), '/trees')
del master.MasterView.url_prefix
with patch.multiple(mod.MasterView, create=True,
url_prefix='/trees'):
self.assertEqual(mod.MasterView.get_template_prefix(), '/trees')
# or it may specify route prefix
master.MasterView.route_prefix = 'trucks'
self.assertEqual(master.MasterView.get_template_prefix(), '/trucks')
del master.MasterView.route_prefix
with patch.multiple(mod.MasterView, create=True,
route_prefix='trucks'):
self.assertEqual(mod.MasterView.get_template_prefix(), '/trucks')
# or it may specify *normalized* model name
master.MasterView.model_name_normalized = 'blaster'
self.assertEqual(master.MasterView.get_template_prefix(), '/blasters')
del master.MasterView.model_name_normalized
with patch.multiple(mod.MasterView, create=True,
model_name_normalized='blaster'):
self.assertEqual(mod.MasterView.get_template_prefix(), '/blasters')
# or it may specify *standard* model name
master.MasterView.model_name = 'Dinosaur'
self.assertEqual(master.MasterView.get_template_prefix(), '/dinosaurs')
del master.MasterView.model_name
with patch.multiple(mod.MasterView, create=True,
model_name='Dinosaur'):
self.assertEqual(mod.MasterView.get_template_prefix(), '/dinosaurs')
# or it may specify model class
MyModel = MagicMock(__name__='Machine')
with patch.multiple(master.MasterView, create=True,
with patch.multiple(mod.MasterView, create=True,
model_class=MyModel):
self.assertEqual(master.MasterView.get_template_prefix(), '/machines')
self.assertEqual(mod.MasterView.get_template_prefix(), '/machines')
def test_get_grid_key(self):
# error by default (since no model class)
self.assertRaises(AttributeError, master.MasterView.get_grid_key)
self.assertRaises(AttributeError, mod.MasterView.get_grid_key)
# subclass may specify grid key
master.MasterView.grid_key = 'widgets'
self.assertEqual(master.MasterView.get_grid_key(), 'widgets')
del master.MasterView.grid_key
with patch.multiple(mod.MasterView, create=True,
grid_key='widgets'):
self.assertEqual(mod.MasterView.get_grid_key(), 'widgets')
# or it may specify route prefix
master.MasterView.route_prefix = 'trucks'
self.assertEqual(master.MasterView.get_grid_key(), 'trucks')
del master.MasterView.route_prefix
with patch.multiple(mod.MasterView, create=True,
route_prefix='trucks'):
self.assertEqual(mod.MasterView.get_grid_key(), 'trucks')
# or it may specify *normalized* model name
master.MasterView.model_name_normalized = 'blaster'
self.assertEqual(master.MasterView.get_grid_key(), 'blasters')
del master.MasterView.model_name_normalized
with patch.multiple(mod.MasterView, create=True,
model_name_normalized='blaster'):
self.assertEqual(mod.MasterView.get_grid_key(), 'blasters')
# or it may specify *standard* model name
master.MasterView.model_name = 'Dinosaur'
self.assertEqual(master.MasterView.get_grid_key(), 'dinosaurs')
del master.MasterView.model_name
with patch.multiple(mod.MasterView, create=True,
model_name='Dinosaur'):
self.assertEqual(mod.MasterView.get_grid_key(), 'dinosaurs')
# or it may specify model class
MyModel = MagicMock(__name__='Machine')
with patch.multiple(master.MasterView, create=True,
with patch.multiple(mod.MasterView, create=True,
model_class=MyModel):
self.assertEqual(master.MasterView.get_grid_key(), 'machines')
self.assertEqual(mod.MasterView.get_grid_key(), 'machines')
def test_get_config_title(self):
# error by default (since no model class)
self.assertRaises(AttributeError, master.MasterView.get_config_title)
self.assertRaises(AttributeError, mod.MasterView.get_config_title)
# subclass may specify config title
master.MasterView.config_title = 'Widgets'
self.assertEqual(master.MasterView.get_config_title(), "Widgets")
del master.MasterView.config_title
with patch.multiple(mod.MasterView, create=True,
config_title='Widgets'):
self.assertEqual(mod.MasterView.get_config_title(), "Widgets")
# subclass may specify *plural* model title
master.MasterView.model_title_plural = 'People'
self.assertEqual(master.MasterView.get_config_title(), "People")
del master.MasterView.model_title_plural
with patch.multiple(mod.MasterView, create=True,
model_title_plural='People'):
self.assertEqual(mod.MasterView.get_config_title(), "People")
# or it may specify *singular* model title
master.MasterView.model_title = 'Wutta Widget'
self.assertEqual(master.MasterView.get_config_title(), "Wutta Widgets")
del master.MasterView.model_title
with patch.multiple(mod.MasterView, create=True,
model_title='Wutta Widget'):
self.assertEqual(mod.MasterView.get_config_title(), "Wutta Widgets")
# or it may specify model name
master.MasterView.model_name = 'Blaster'
self.assertEqual(master.MasterView.get_config_title(), "Blasters")
del master.MasterView.model_name
with patch.multiple(mod.MasterView, create=True,
model_name='Blaster'):
self.assertEqual(mod.MasterView.get_config_title(), "Blasters")
# or it may specify model class
MyModel = MagicMock(__name__='Dinosaur')
with patch.multiple(master.MasterView, create=True,
with patch.multiple(mod.MasterView, create=True,
model_class=MyModel):
self.assertEqual(master.MasterView.get_config_title(), "Dinosaurs")
self.assertEqual(mod.MasterView.get_config_title(), "Dinosaurs")
##############################
# support methods
##############################
def test_get_class_hierarchy(self):
class MyView(master.MasterView):
class MyView(mod.MasterView):
pass
view = MyView(self.request)
classes = view.get_class_hierarchy()
self.assertEqual(classes, [View, master.MasterView, MyView])
self.assertEqual(classes, [View, mod.MasterView, MyView])
def test_has_perm(self):
model = self.app.model
auth = self.app.get_auth_handler()
with patch.multiple(master.MasterView, create=True,
with patch.multiple(mod.MasterView, create=True,
model_name='Setting'):
view = self.make_view()
@ -374,7 +373,7 @@ class TestMasterView(WebTestCase):
model = self.app.model
auth = self.app.get_auth_handler()
with patch.multiple(master.MasterView, create=True,
with patch.multiple(mod.MasterView, create=True,
model_name='Setting'):
view = self.make_view()
@ -410,33 +409,36 @@ class TestMasterView(WebTestCase):
# basic sanity check using /master/index.mako
# (nb. it skips /widgets/index.mako since that doesn't exist)
with patch.multiple(master.MasterView, create=True,
with patch.multiple(mod.MasterView, create=True,
model_name='Widget',
creatable=False):
view = master.MasterView(self.request)
view = mod.MasterView(self.request)
response = view.render_to_response('index', {})
self.assertIsInstance(response, Response)
# basic sanity check using /appinfo/index.mako
with patch.multiple(master.MasterView, create=True,
with patch.multiple(mod.MasterView, create=True,
model_name='AppInfo',
route_prefix='appinfo',
url_prefix='/appinfo',
creatable=False):
view = master.MasterView(self.request)
response = view.render_to_response('index', {})
view = mod.MasterView(self.request)
response = view.render_to_response('index', {
# nb. grid is required for this template
'grid': MagicMock(),
})
self.assertIsInstance(response, Response)
# bad template name causes error
master.MasterView.model_name = 'Widget'
self.assertRaises(IOError, view.render_to_response, 'nonexistent', {})
del master.MasterView.model_name
with patch.multiple(mod.MasterView, create=True,
model_name='Widget'):
self.assertRaises(IOError, view.render_to_response, 'nonexistent', {})
def test_get_index_title(self):
master.MasterView.model_title_plural = "Wutta Widgets"
view = master.MasterView(self.request)
self.assertEqual(view.get_index_title(), "Wutta Widgets")
del master.MasterView.model_title_plural
with patch.multiple(mod.MasterView, create=True,
model_title_plural = "Wutta Widgets"):
view = mod.MasterView(self.request)
self.assertEqual(view.get_index_title(), "Wutta Widgets")
def test_collect_labels(self):
@ -447,14 +449,14 @@ class TestMasterView(WebTestCase):
# labels come from all classes; subclass wins
with patch.object(View, 'labels', new={'foo': "Foo", 'bar': "Bar"}, create=True):
with patch.object(master.MasterView, 'labels', new={'foo': "FOO FIGHTERS"}, create=True):
with patch.object(mod.MasterView, 'labels', new={'foo': "FOO FIGHTERS"}, create=True):
view = self.make_view()
labels = view.collect_labels()
self.assertEqual(labels, {'foo': "FOO FIGHTERS", 'bar': "Bar"})
def test_set_labels(self):
model = self.app.model
with patch.object(master.MasterView, 'model_class', new=model.Setting, create=True):
with patch.object(mod.MasterView, 'model_class', new=model.Setting, create=True):
# no labels by default
view = self.make_view()
@ -463,7 +465,7 @@ class TestMasterView(WebTestCase):
self.assertEqual(grid.labels, {})
# labels come from all classes; subclass wins
with patch.object(master.MasterView, 'labels', new={'name': "SETTING NAME"}, create=True):
with patch.object(mod.MasterView, 'labels', new={'name': "SETTING NAME"}, create=True):
view = self.make_view()
view.set_labels(grid)
self.assertEqual(grid.labels, {'name': "SETTING NAME"})
@ -472,27 +474,27 @@ class TestMasterView(WebTestCase):
model = self.app.model
# no model class
with patch.multiple(master.MasterView, create=True,
with patch.multiple(mod.MasterView, create=True,
model_name='Widget',
model_key='uuid'):
view = master.MasterView(self.request)
view = mod.MasterView(self.request)
grid = view.make_model_grid()
self.assertIsNone(grid.model_class)
# explicit model class
with patch.multiple(master.MasterView, create=True,
with patch.multiple(mod.MasterView, create=True,
model_class=model.Setting):
grid = view.make_model_grid(session=self.session)
self.assertIs(grid.model_class, model.Setting)
# no actions by default
with patch.multiple(master.MasterView, create=True,
with patch.multiple(mod.MasterView, create=True,
model_class=model.Setting):
grid = view.make_model_grid(session=self.session)
self.assertEqual(grid.actions, [])
# now let's test some more actions logic
with patch.multiple(master.MasterView, create=True,
with patch.multiple(mod.MasterView, create=True,
model_class=model.Setting,
viewable=True,
editable=True,
@ -515,14 +517,14 @@ class TestMasterView(WebTestCase):
view = self.make_view()
# empty by default
self.assertFalse(hasattr(master.MasterView, 'model_class'))
self.assertFalse(hasattr(mod.MasterView, 'model_class'))
data = view.get_grid_data(session=self.session)
self.assertEqual(data, [])
# grid with model class will produce data query
with patch.multiple(master.MasterView, create=True,
with patch.multiple(mod.MasterView, create=True,
model_class=model.Setting):
view = master.MasterView(self.request)
view = mod.MasterView(self.request)
query = view.get_grid_data(session=self.session)
self.assertIsInstance(query, orm.Query)
data = query.all()
@ -533,9 +535,9 @@ class TestMasterView(WebTestCase):
model = self.app.model
# uuid field is pruned
with patch.multiple(master.MasterView, create=True,
with patch.multiple(mod.MasterView, create=True,
model_class=model.Setting):
view = master.MasterView(self.request)
view = mod.MasterView(self.request)
grid = view.make_grid(model_class=model.Setting,
columns=['uuid', 'name', 'value'])
self.assertIn('uuid', grid.columns)
@ -571,13 +573,13 @@ class TestMasterView(WebTestCase):
self.assertEqual(self.session.query(model.Setting).count(), 1)
# default not implemented
view = master.MasterView(self.request)
view = mod.MasterView(self.request)
self.assertRaises(NotImplementedError, view.get_instance)
# fetch from DB if model class is known
with patch.multiple(master.MasterView, create=True,
with patch.multiple(mod.MasterView, create=True,
model_class=model.Setting):
view = master.MasterView(self.request)
view = mod.MasterView(self.request)
# existing setting is returned
self.request.matchdict = {'name': 'foo'}
@ -596,9 +598,9 @@ class TestMasterView(WebTestCase):
self.session.add(setting)
self.session.commit()
with patch.multiple(master.MasterView, create=True,
with patch.multiple(mod.MasterView, create=True,
model_class=model.Setting):
master.MasterView.defaults(self.pyramid_config)
mod.MasterView.defaults(self.pyramid_config)
view = self.make_view()
url = view.get_action_url_view(setting, 0)
self.assertEqual(url, self.request.route_url('settings.view', name='foo'))
@ -608,9 +610,9 @@ class TestMasterView(WebTestCase):
setting = model.Setting(name='foo', value='bar')
self.session.add(setting)
self.session.commit()
with patch.multiple(master.MasterView, create=True,
with patch.multiple(mod.MasterView, create=True,
model_class=model.Setting):
master.MasterView.defaults(self.pyramid_config)
mod.MasterView.defaults(self.pyramid_config)
view = self.make_view()
# typical
@ -627,9 +629,9 @@ class TestMasterView(WebTestCase):
setting = model.Setting(name='foo', value='bar')
self.session.add(setting)
self.session.commit()
with patch.multiple(master.MasterView, create=True,
with patch.multiple(mod.MasterView, create=True,
model_class=model.Setting):
master.MasterView.defaults(self.pyramid_config)
mod.MasterView.defaults(self.pyramid_config)
view = self.make_view()
# typical
@ -645,15 +647,15 @@ class TestMasterView(WebTestCase):
model = self.app.model
# no model class
with patch.multiple(master.MasterView, create=True,
with patch.multiple(mod.MasterView, create=True,
model_name='Widget',
model_key='uuid'):
view = master.MasterView(self.request)
view = mod.MasterView(self.request)
form = view.make_model_form()
self.assertIsNone(form.model_class)
# explicit model class
with patch.multiple(master.MasterView, create=True,
with patch.multiple(mod.MasterView, create=True,
model_class=model.Setting):
form = view.make_model_form()
self.assertIs(form.model_class, model.Setting)
@ -662,9 +664,9 @@ class TestMasterView(WebTestCase):
model = self.app.model
# uuid field is pruned
with patch.multiple(master.MasterView, create=True,
with patch.multiple(mod.MasterView, create=True,
model_class=model.Setting):
view = master.MasterView(self.request)
view = mod.MasterView(self.request)
form = view.make_form(model_class=model.Setting,
fields=['uuid', 'name', 'value'])
self.assertIn('uuid', form.fields)
@ -678,17 +680,17 @@ class TestMasterView(WebTestCase):
self.assertEqual(self.session.query(model.Setting).count(), 1)
# no model class
with patch.multiple(master.MasterView, create=True,
with patch.multiple(mod.MasterView, create=True,
model_name='Widget',
model_key='uuid'):
view = master.MasterView(self.request)
view = mod.MasterView(self.request)
form = view.make_model_form(fields=['name', 'description'])
form.validated = {'name': 'first'}
obj = view.objectify(form)
self.assertIs(obj, form.validated)
# explicit model class (editing)
with patch.multiple(master.MasterView, create=True,
with patch.multiple(mod.MasterView, create=True,
model_class=model.Setting,
editing=True):
form = view.make_model_form()
@ -700,7 +702,7 @@ class TestMasterView(WebTestCase):
self.assertEqual(obj.value, 'blarg')
# explicit model class (creating)
with patch.multiple(master.MasterView, create=True,
with patch.multiple(mod.MasterView, create=True,
model_class=model.Setting,
creating=True):
form = view.make_model_form()
@ -712,9 +714,9 @@ class TestMasterView(WebTestCase):
def test_persist(self):
model = self.app.model
with patch.multiple(master.MasterView, create=True,
with patch.multiple(mod.MasterView, create=True,
model_class=model.Setting):
view = master.MasterView(self.request)
view = mod.MasterView(self.request)
# new instance is persisted
setting = model.Setting(name='foo', value='bar')
@ -738,12 +740,12 @@ class TestMasterView(WebTestCase):
self.pyramid_config.add_route('settings.delete', '/settings/{name}/delete')
# sanity/coverage check using /settings/
with patch.multiple(master.MasterView, create=True,
with patch.multiple(mod.MasterView, create=True,
model_name='Setting',
model_key='name',
get_index_url=MagicMock(return_value='/settings/'),
grid_columns=['name', 'value']):
view = master.MasterView(self.request)
view = mod.MasterView(self.request)
response = view.index()
# then again with data, to include view action url
@ -759,6 +761,12 @@ class TestMasterView(WebTestCase):
self.assertEqual(response.status_code, 200)
self.assertEqual(response.content_type, 'application/json')
# redirects when view is reset
self.request.GET = {'reset-view': '1', 'hash': 'foo'}
with patch.object(self.request, 'current_route_url'):
response = view.index()
self.assertEqual(response.status_code, 302)
def test_create(self):
self.pyramid_config.include('wuttaweb.views.common')
self.pyramid_config.include('wuttaweb.views.auth')
@ -766,12 +774,12 @@ class TestMasterView(WebTestCase):
model = self.app.model
# sanity/coverage check using /settings/new
with patch.multiple(master.MasterView, create=True,
with patch.multiple(mod.MasterView, create=True,
model_name='Setting',
model_key='name',
get_index_url=MagicMock(return_value='/settings/'),
form_fields=['name', 'value']):
view = master.MasterView(self.request)
view = mod.MasterView(self.request)
# no setting yet
self.assertIsNone(self.app.get_setting(self.session, 'foo.bar'))
@ -822,13 +830,13 @@ class TestMasterView(WebTestCase):
# sanity/coverage check using /settings/XXX
setting = {'name': 'foo.bar', 'value': 'baz'}
self.request.matchdict = {'name': 'foo.bar'}
with patch.multiple(master.MasterView, create=True,
with patch.multiple(mod.MasterView, create=True,
model_name='Setting',
model_key='name',
get_index_url=MagicMock(return_value='/settings/'),
grid_columns=['name', 'value'],
form_fields=['name', 'value']):
view = master.MasterView(self.request)
view = mod.MasterView(self.request)
with patch.object(view, 'get_instance', return_value=setting):
response = view.view()
@ -851,12 +859,12 @@ class TestMasterView(WebTestCase):
# sanity/coverage check using /settings/XXX/edit
self.request.matchdict = {'name': 'foo.bar'}
with patch.multiple(master.MasterView, create=True,
with patch.multiple(mod.MasterView, create=True,
model_name='Setting',
model_key='name',
get_index_url=MagicMock(return_value='/settings/'),
form_fields=['name', 'value']):
view = master.MasterView(self.request)
view = mod.MasterView(self.request)
with patch.object(view, 'get_instance', new=get_instance):
# get the form page
@ -915,12 +923,12 @@ class TestMasterView(WebTestCase):
# sanity/coverage check using /settings/XXX/delete
self.request.matchdict = {'name': 'foo.bar'}
with patch.multiple(master.MasterView, create=True,
with patch.multiple(mod.MasterView, create=True,
model_name='Setting',
model_key='name',
get_index_url=MagicMock(return_value='/settings/'),
form_fields=['name', 'value']):
view = master.MasterView(self.request)
view = mod.MasterView(self.request)
with patch.object(view, 'get_instance', new=get_instance):
# get the form page
@ -957,14 +965,55 @@ class TestMasterView(WebTestCase):
self.session.commit()
setting = self.session.query(model.Setting).one()
with patch.multiple(master.MasterView, create=True,
with patch.multiple(mod.MasterView, create=True,
model_class=model.Setting,
form_fields=['name', 'value']):
view = master.MasterView(self.request)
view = mod.MasterView(self.request)
view.delete_instance(setting)
self.session.commit()
self.assertEqual(self.session.query(model.Setting).count(), 0)
def test_autocomplete(self):
model = self.app.model
person1 = model.Person(full_name="George Jones")
self.session.add(person1)
person2 = model.Person(full_name="George Strait")
self.session.add(person2)
self.session.commit()
# no results for empty term
self.request.GET = {}
view = self.make_view()
results = view.autocomplete()
self.assertEqual(len(results), 0)
# search yields no results
self.request.GET = {'term': 'sally'}
view = self.make_view()
with patch.object(view, 'autocomplete_data', return_value=[]):
view = self.make_view()
results = view.autocomplete()
self.assertEqual(len(results), 0)
# search yields 2 results
self.request.GET = {'term': 'george'}
view = self.make_view()
with patch.object(view, 'autocomplete_data', return_value=[person1, person2]):
results = view.autocomplete()
self.assertEqual(len(results), 2)
self.assertEqual([res['value'] for res in results],
[p.uuid for p in [person1, person2]])
def test_autocomplete_normalize(self):
model = self.app.model
view = self.make_view()
person = model.Person(full_name="Betty Boop", uuid='bogus')
normal = view.autocomplete_normalize(person)
self.assertEqual(normal, {'value': 'bogus',
'label': "Betty Boop"})
def test_configure(self):
self.pyramid_config.include('wuttaweb.views.common')
self.pyramid_config.include('wuttaweb.views.auth')
@ -980,10 +1029,10 @@ class TestMasterView(WebTestCase):
{'name': 'wutta.value2', 'save_if_empty': False},
]
view = master.MasterView(self.request)
view = mod.MasterView(self.request)
with patch.object(self.request, 'current_route_url', return_value='/appinfo/configure'):
with patch.object(master, 'Session', return_value=self.session):
with patch.multiple(master.MasterView, create=True,
with patch.object(mod, 'Session', return_value=self.session):
with patch.multiple(mod.MasterView, create=True,
model_name='AppInfo',
route_prefix='appinfo',
template_prefix='/appinfo',

View file

@ -35,11 +35,50 @@ class TestPersonView(WebTestCase):
model = self.app.model
view = self.make_view()
form = view.make_form(model_class=model.Person)
form.set_fields(form.get_model_fields())
self.assertEqual(form.required_fields, {})
view.configure_form(form)
self.assertTrue(form.required_fields)
self.assertFalse(form.required_fields['middle_name'])
# required fields
with patch.object(view, 'creating', new=True):
form.set_fields(form.get_model_fields())
self.assertEqual(form.required_fields, {})
view.configure_form(form)
self.assertTrue(form.required_fields)
self.assertFalse(form.required_fields['middle_name'])
person = model.Person(full_name="Barney Rubble")
user = model.User(username='barney', person=person)
self.session.add(user)
self.session.commit()
# users field
with patch.object(view, 'viewing', new=True):
form = view.make_form(model_instance=person)
self.assertEqual(form.defaults, {})
view.configure_form(form)
self.assertIn('_users', form.defaults)
def test_autocomplete_query(self):
model = self.app.model
person1 = model.Person(full_name="George Jones")
self.session.add(person1)
person2 = model.Person(full_name="George Strait")
self.session.add(person2)
self.session.commit()
view = self.make_view()
with patch.object(view, 'Session', return_value=self.session):
# both people match
query = view.autocomplete_query('george')
self.assertEqual(query.count(), 2)
# just 1 match
query = view.autocomplete_query('jones')
self.assertEqual(query.count(), 1)
# no matches
query = view.autocomplete_query('sally')
self.assertEqual(query.count(), 0)
def test_view_profile(self):
self.pyramid_config.include('wuttaweb.views.common')

View file

@ -18,6 +18,19 @@ class TestAppInfoView(WebTestCase):
def make_view(self):
return mod.AppInfoView(self.request)
def test_get_grid_data(self):
view = self.make_view()
# empty data by default
data = view.get_grid_data()
self.assertEqual(data, [])
# 'partial' request returns data
self.request.GET = {'partial': '1'}
data = view.get_grid_data()
self.assertIsInstance(data, list)
self.assertTrue(data)
def test_index(self):
# sanity/coverage check
view = self.make_view()
@ -54,6 +67,14 @@ class TestSettingView(WebTestCase):
data = query.all()
self.assertEqual(len(data), 1)
def test_configure_grid(self):
model = self.app.model
view = self.make_view()
grid = view.make_grid(model_class=model.Setting)
self.assertFalse(grid.is_linked('name'))
view.configure_grid(grid)
self.assertTrue(grid.is_linked('name'))
def test_configure_form(self):
view = self.make_view()
form = view.make_form(fields=view.get_form_fields())