Compare commits
13 commits
e9d59062ca
...
fab87d3303
Author | SHA1 | Date | |
---|---|---|---|
![]() |
fab87d3303 | ||
![]() |
a5c2931085 | ||
![]() |
1443f5253f | ||
![]() |
9751bf4c2e | ||
![]() |
a042d511fb | ||
![]() |
770c4612d5 | ||
![]() |
9d261de45a | ||
![]() |
4bf2bb42fb | ||
![]() |
a34b01a6c4 | ||
![]() |
1b4aaacc10 | ||
![]() |
d15ac46184 | ||
![]() |
3665d69e0c | ||
![]() |
2d9757f677 |
24
CHANGELOG.md
24
CHANGELOG.md
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
]
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}"
|
||||
|
|
|
@ -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;">
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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()}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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()">
|
||||
|
|
|
@ -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()}
|
||||
|
|
|
@ -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>
|
||||
|
|
7
src/wuttaweb/templates/deform/readonly/rolerefs.pt
Normal file
7
src/wuttaweb/templates/deform/readonly/rolerefs.pt
Normal 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>
|
|
@ -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>
|
||||
|
|
49
src/wuttaweb/templates/grids/table_element.mako
Normal file
49
src/wuttaweb/templates/grids/table_element.mako
Normal 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>
|
||||
|
||||
% 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>
|
|
@ -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>
|
||||
|
||||
% 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>
|
||||
|
||||
% 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) {
|
||||
|
|
|
@ -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()}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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(),
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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):
|
||||
""" """
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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):
|
||||
""" """
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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())
|
||||
|
|
Loading…
Reference in a new issue