Compare commits
2 commits
2ad1ae9c49
...
4c467f5267
Author | SHA1 | Date | |
---|---|---|---|
![]() |
4c467f5267 | ||
![]() |
754e0989e4 |
6
docs/api/wuttaweb/grids.base.rst
Normal file
6
docs/api/wuttaweb/grids.base.rst
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
|
||||||
|
``wuttaweb.grids.base``
|
||||||
|
=======================
|
||||||
|
|
||||||
|
.. automodule:: wuttaweb.grids.base
|
||||||
|
:members:
|
6
docs/api/wuttaweb/grids.rst
Normal file
6
docs/api/wuttaweb/grids.rst
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
|
||||||
|
``wuttaweb.grids``
|
||||||
|
==================
|
||||||
|
|
||||||
|
.. automodule:: wuttaweb.grids
|
||||||
|
:members:
|
|
@ -12,6 +12,8 @@
|
||||||
db
|
db
|
||||||
forms
|
forms
|
||||||
forms.base
|
forms.base
|
||||||
|
grids
|
||||||
|
grids.base
|
||||||
handler
|
handler
|
||||||
helpers
|
helpers
|
||||||
menus
|
menus
|
||||||
|
|
|
@ -26,6 +26,7 @@ Forms Library
|
||||||
The ``wuttaweb.forms`` namespace contains the following:
|
The ``wuttaweb.forms`` namespace contains the following:
|
||||||
|
|
||||||
* :class:`~wuttaweb.forms.base.Form`
|
* :class:`~wuttaweb.forms.base.Form`
|
||||||
|
* :class:`~wuttaweb.forms.base.FieldList`
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from .base import Form
|
from .base import Form, FieldList
|
||||||
|
|
|
@ -44,7 +44,8 @@ class FieldList(list):
|
||||||
of :class:`python:list`.
|
of :class:`python:list`.
|
||||||
|
|
||||||
You normally would not need to instantiate this yourself, but it
|
You normally would not need to instantiate this yourself, but it
|
||||||
is used under the hood for e.g. :attr:`Form.fields`.
|
is used under the hood for :attr:`Form.fields` as well as
|
||||||
|
:attr:`~wuttaweb.grids.base.Grid.columns`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def insert_before(self, field, newfield):
|
def insert_before(self, field, newfield):
|
||||||
|
@ -105,15 +106,48 @@ class Form:
|
||||||
|
|
||||||
Form instances contain the following attributes:
|
Form instances contain the following attributes:
|
||||||
|
|
||||||
|
.. attribute:: request
|
||||||
|
|
||||||
|
Reference to current :term:`request` object.
|
||||||
|
|
||||||
.. attribute:: fields
|
.. attribute:: fields
|
||||||
|
|
||||||
:class:`FieldList` instance containing string field names for
|
:class:`FieldList` instance containing string field names for
|
||||||
the form. By default, fields will appear in the same order as
|
the form. By default, fields will appear in the same order as
|
||||||
they are in this list.
|
they are in this list.
|
||||||
|
|
||||||
.. attribute:: request
|
.. attribute:: schema
|
||||||
|
|
||||||
Reference to current :term:`request` object.
|
Colander-based schema object for the form. This is optional;
|
||||||
|
if not specified an attempt will be made to construct one
|
||||||
|
automatically.
|
||||||
|
|
||||||
|
See also :meth:`get_schema()`.
|
||||||
|
|
||||||
|
.. attribute:: model_class
|
||||||
|
|
||||||
|
Optional "class" for the model. If set, this usually would be
|
||||||
|
a SQLAlchemy mapped class. This may be used instead of
|
||||||
|
specifying the :attr:`schema`.
|
||||||
|
|
||||||
|
.. attribute:: model_instance
|
||||||
|
|
||||||
|
Optional instance from which initial form data should be
|
||||||
|
obtained. In simple cases this might be a dict, or maybe an
|
||||||
|
instance of :attr:`model_class`.
|
||||||
|
|
||||||
|
Note that this also may be used instead of specifying the
|
||||||
|
:attr:`schema`, if the instance belongs to a class which is
|
||||||
|
SQLAlchemy-mapped. (In that case :attr:`model_class` can be
|
||||||
|
determined automatically.)
|
||||||
|
|
||||||
|
.. attribute:: readonly
|
||||||
|
|
||||||
|
Boolean indicating the form does not allow submit. In practice
|
||||||
|
this means there will not even be a ``<form>`` tag involved.
|
||||||
|
|
||||||
|
Default for this is ``False`` in which case the ``<form>`` tag
|
||||||
|
will exist and submit is allowed.
|
||||||
|
|
||||||
.. attribute:: action_url
|
.. attribute:: action_url
|
||||||
|
|
||||||
|
@ -160,6 +194,9 @@ class Form:
|
||||||
request,
|
request,
|
||||||
fields=None,
|
fields=None,
|
||||||
schema=None,
|
schema=None,
|
||||||
|
model_class=None,
|
||||||
|
model_instance=None,
|
||||||
|
readonly=False,
|
||||||
labels={},
|
labels={},
|
||||||
action_url=None,
|
action_url=None,
|
||||||
vue_tagname='wutta-form',
|
vue_tagname='wutta-form',
|
||||||
|
@ -171,6 +208,7 @@ class Form:
|
||||||
):
|
):
|
||||||
self.request = request
|
self.request = request
|
||||||
self.schema = schema
|
self.schema = schema
|
||||||
|
self.readonly = readonly
|
||||||
self.labels = labels or {}
|
self.labels = labels or {}
|
||||||
self.action_url = action_url
|
self.action_url = action_url
|
||||||
self.vue_tagname = vue_tagname
|
self.vue_tagname = vue_tagname
|
||||||
|
@ -183,6 +221,9 @@ class Form:
|
||||||
self.config = self.request.wutta_config
|
self.config = self.request.wutta_config
|
||||||
self.app = self.config.get_app()
|
self.app = self.config.get_app()
|
||||||
|
|
||||||
|
self.model_class = model_class
|
||||||
|
self.model_instance = model_instance
|
||||||
|
|
||||||
if fields is not None:
|
if fields is not None:
|
||||||
self.set_fields(fields)
|
self.set_fields(fields)
|
||||||
elif self.schema:
|
elif self.schema:
|
||||||
|
@ -259,9 +300,22 @@ class Form:
|
||||||
"""
|
"""
|
||||||
Return the :class:`colander:colander.Schema` object for the
|
Return the :class:`colander:colander.Schema` object for the
|
||||||
form, generating it automatically if necessary.
|
form, generating it automatically if necessary.
|
||||||
|
|
||||||
|
Note that if :attr:`schema` is already set, that will be
|
||||||
|
returned as-is.
|
||||||
"""
|
"""
|
||||||
if not self.schema:
|
if not self.schema:
|
||||||
raise NotImplementedError
|
|
||||||
|
if self.fields:
|
||||||
|
schema = colander.Schema()
|
||||||
|
for name in self.fields:
|
||||||
|
schema.add(colander.SchemaNode(
|
||||||
|
colander.String(),
|
||||||
|
name=name))
|
||||||
|
self.schema = schema
|
||||||
|
|
||||||
|
else: # no fields
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
return self.schema
|
return self.schema
|
||||||
|
|
||||||
|
@ -272,7 +326,12 @@ class Form:
|
||||||
"""
|
"""
|
||||||
if not hasattr(self, 'deform_form'):
|
if not hasattr(self, 'deform_form'):
|
||||||
schema = self.get_schema()
|
schema = self.get_schema()
|
||||||
form = deform.Form(schema)
|
kwargs = {}
|
||||||
|
|
||||||
|
if self.model_instance:
|
||||||
|
kwargs['appstruct'] = self.model_instance
|
||||||
|
|
||||||
|
form = deform.Form(schema, **kwargs)
|
||||||
self.deform_form = form
|
self.deform_form = form
|
||||||
|
|
||||||
return self.deform_form
|
return self.deform_form
|
||||||
|
@ -332,7 +391,7 @@ class Form:
|
||||||
output = render(template, context)
|
output = render(template, context)
|
||||||
return HTML.literal(output)
|
return HTML.literal(output)
|
||||||
|
|
||||||
def render_vue_field(self, fieldname):
|
def render_vue_field(self, fieldname, readonly=None):
|
||||||
"""
|
"""
|
||||||
Render the given field completely, i.e. ``<b-field>`` wrapper
|
Render the given field completely, i.e. ``<b-field>`` wrapper
|
||||||
with label and containing a widget.
|
with label and containing a widget.
|
||||||
|
@ -349,11 +408,19 @@ class Form:
|
||||||
<!-- widget element(s) -->
|
<!-- widget element(s) -->
|
||||||
</b-field>
|
</b-field>
|
||||||
"""
|
"""
|
||||||
dform = self.get_deform()
|
|
||||||
field = dform[fieldname]
|
if readonly is None:
|
||||||
|
readonly = self.readonly
|
||||||
|
|
||||||
# render the field widget or whatever
|
# render the field widget or whatever
|
||||||
html = field.serialize()
|
dform = self.get_deform()
|
||||||
|
field = dform[fieldname]
|
||||||
|
kw = {}
|
||||||
|
if readonly:
|
||||||
|
kw['readonly'] = True
|
||||||
|
html = field.serialize(**kw)
|
||||||
|
|
||||||
|
# mark all that as safe
|
||||||
html = HTML.literal(html)
|
html = HTML.literal(html)
|
||||||
|
|
||||||
# render field label
|
# render field label
|
||||||
|
|
31
src/wuttaweb/grids/__init__.py
Normal file
31
src/wuttaweb/grids/__init__.py
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
# -*- coding: utf-8; -*-
|
||||||
|
################################################################################
|
||||||
|
#
|
||||||
|
# wuttaweb -- Web App for Wutta Framework
|
||||||
|
# Copyright © 2024 Lance Edgar
|
||||||
|
#
|
||||||
|
# This file is part of Wutta Framework.
|
||||||
|
#
|
||||||
|
# Wutta Framework is free software: you can redistribute it and/or modify it
|
||||||
|
# under the terms of the GNU General Public License as published by the Free
|
||||||
|
# Software Foundation, either version 3 of the License, or (at your option) any
|
||||||
|
# later version.
|
||||||
|
#
|
||||||
|
# Wutta Framework is distributed in the hope that it will be useful, but
|
||||||
|
# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
||||||
|
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
||||||
|
# more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License along with
|
||||||
|
# Wutta Framework. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
################################################################################
|
||||||
|
"""
|
||||||
|
Grids Library
|
||||||
|
|
||||||
|
The ``wuttaweb.grids`` namespace contains the following:
|
||||||
|
|
||||||
|
* :class:`~wuttaweb.grids.base.Grid`
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .base import Grid, GridAction
|
344
src/wuttaweb/grids/base.py
Normal file
344
src/wuttaweb/grids/base.py
Normal file
|
@ -0,0 +1,344 @@
|
||||||
|
# -*- coding: utf-8; -*-
|
||||||
|
################################################################################
|
||||||
|
#
|
||||||
|
# wuttaweb -- Web App for Wutta Framework
|
||||||
|
# Copyright © 2024 Lance Edgar
|
||||||
|
#
|
||||||
|
# This file is part of Wutta Framework.
|
||||||
|
#
|
||||||
|
# Wutta Framework is free software: you can redistribute it and/or modify it
|
||||||
|
# under the terms of the GNU General Public License as published by the Free
|
||||||
|
# Software Foundation, either version 3 of the License, or (at your option) any
|
||||||
|
# later version.
|
||||||
|
#
|
||||||
|
# Wutta Framework is distributed in the hope that it will be useful, but
|
||||||
|
# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
||||||
|
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
||||||
|
# more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License along with
|
||||||
|
# Wutta Framework. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
################################################################################
|
||||||
|
"""
|
||||||
|
Base grid classes
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pyramid.renderers import render
|
||||||
|
from webhelpers2.html import HTML
|
||||||
|
|
||||||
|
from wuttaweb.forms import FieldList
|
||||||
|
|
||||||
|
|
||||||
|
class Grid:
|
||||||
|
"""
|
||||||
|
Base class for all grids.
|
||||||
|
|
||||||
|
:param request: Reference to current :term:`request` object.
|
||||||
|
|
||||||
|
:param columns: List of column names for the grid. This is
|
||||||
|
optional; if not specified an attempt will be made to deduce
|
||||||
|
the list automatically. See also :attr:`columns`.
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
|
||||||
|
Some parameters are not explicitly described above. However
|
||||||
|
their corresponding attributes are described below.
|
||||||
|
|
||||||
|
Grid instances contain the following attributes:
|
||||||
|
|
||||||
|
.. attribute:: key
|
||||||
|
|
||||||
|
Presumably unique key for the grid; used to track per-grid
|
||||||
|
sort/filter settings etc.
|
||||||
|
|
||||||
|
.. attribute:: columns
|
||||||
|
|
||||||
|
:class:`~wuttaweb.forms.base.FieldList` instance containing
|
||||||
|
string column names for the grid. Columns will appear in the
|
||||||
|
same order as they are in this list.
|
||||||
|
|
||||||
|
See also :meth:`set_columns()`.
|
||||||
|
|
||||||
|
.. attribute:: data
|
||||||
|
|
||||||
|
Data set for the grid. This should either be a list of dicts
|
||||||
|
(or objects with dict-like access to fields, corresponding to
|
||||||
|
model records) or else an object capable of producing such a
|
||||||
|
list, e.g. SQLAlchemy query.
|
||||||
|
|
||||||
|
.. attribute:: actions
|
||||||
|
|
||||||
|
List of :class:`GridAction` instances represenging action links
|
||||||
|
to be shown for each record in the grid.
|
||||||
|
|
||||||
|
.. attribute:: vue_tagname
|
||||||
|
|
||||||
|
String name for Vue component tag. By default this is
|
||||||
|
``'wutta-grid'``. See also :meth:`render_vue_tag()`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
request,
|
||||||
|
key=None,
|
||||||
|
columns=None,
|
||||||
|
data=None,
|
||||||
|
actions=[],
|
||||||
|
vue_tagname='wutta-grid',
|
||||||
|
):
|
||||||
|
self.request = request
|
||||||
|
self.key = key
|
||||||
|
self.data = data
|
||||||
|
self.actions = actions or []
|
||||||
|
self.vue_tagname = vue_tagname
|
||||||
|
|
||||||
|
self.config = self.request.wutta_config
|
||||||
|
self.app = self.config.get_app()
|
||||||
|
|
||||||
|
if columns is not None:
|
||||||
|
self.set_columns(columns)
|
||||||
|
else:
|
||||||
|
self.columns = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def vue_component(self):
|
||||||
|
"""
|
||||||
|
String name for the Vue component, e.g. ``'WuttaGrid'``.
|
||||||
|
|
||||||
|
This is a generated value based on :attr:`vue_tagname`.
|
||||||
|
"""
|
||||||
|
words = self.vue_tagname.split('-')
|
||||||
|
return ''.join([word.capitalize() for word in words])
|
||||||
|
|
||||||
|
def set_columns(self, columns):
|
||||||
|
"""
|
||||||
|
Explicitly set the list of grid columns.
|
||||||
|
|
||||||
|
This will overwrite :attr:`columns` with a new
|
||||||
|
:class:`~wuttaweb.forms.base.FieldList` instance.
|
||||||
|
|
||||||
|
:param columns: List of string column names.
|
||||||
|
"""
|
||||||
|
self.columns = FieldList(columns)
|
||||||
|
|
||||||
|
def render_vue_tag(self, **kwargs):
|
||||||
|
"""
|
||||||
|
Render the Vue component tag for the grid.
|
||||||
|
|
||||||
|
By default this simply returns:
|
||||||
|
|
||||||
|
.. code-block:: html
|
||||||
|
|
||||||
|
<wutta-grid></wutta-grid>
|
||||||
|
|
||||||
|
The actual output will depend on various grid attributes, in
|
||||||
|
particular :attr:`vue_tagname`.
|
||||||
|
"""
|
||||||
|
return HTML.tag(self.vue_tagname, **kwargs)
|
||||||
|
|
||||||
|
def render_vue_template(
|
||||||
|
self,
|
||||||
|
template='/grids/vue_template.mako',
|
||||||
|
**context):
|
||||||
|
"""
|
||||||
|
Render the Vue template block for the grid.
|
||||||
|
|
||||||
|
This returns something like:
|
||||||
|
|
||||||
|
.. code-block:: none
|
||||||
|
|
||||||
|
<script type="text/x-template" id="wutta-grid-template">
|
||||||
|
<b-table>
|
||||||
|
<!-- columns etc. -->
|
||||||
|
</b-table>
|
||||||
|
</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?
|
||||||
|
|
||||||
|
Actual output will of course depend on grid attributes,
|
||||||
|
:attr:`vue_tagname` and :attr:`columns` etc.
|
||||||
|
|
||||||
|
:param template: Path to Mako template which is used to render
|
||||||
|
the output.
|
||||||
|
"""
|
||||||
|
context['grid'] = self
|
||||||
|
context.setdefault('request', self.request)
|
||||||
|
output = render(template, context)
|
||||||
|
return HTML.literal(output)
|
||||||
|
|
||||||
|
def get_vue_columns(self):
|
||||||
|
"""
|
||||||
|
Returns a list of Vue-compatible column definitions.
|
||||||
|
|
||||||
|
This uses :attr:`columns` as the basis; each definition
|
||||||
|
returned will be a dict in this format::
|
||||||
|
|
||||||
|
{
|
||||||
|
'field': 'foo',
|
||||||
|
'label': "Foo",
|
||||||
|
}
|
||||||
|
|
||||||
|
See also :meth:`get_vue_data()`.
|
||||||
|
"""
|
||||||
|
if not self.columns:
|
||||||
|
raise ValueError(f"you must define columns for the grid! key = {self.key}")
|
||||||
|
|
||||||
|
columns = []
|
||||||
|
for name in self.columns:
|
||||||
|
columns.append({
|
||||||
|
'field': name,
|
||||||
|
'label': self.app.make_title(name),
|
||||||
|
})
|
||||||
|
return columns
|
||||||
|
|
||||||
|
def get_vue_data(self):
|
||||||
|
"""
|
||||||
|
Returns a list of Vue-compatible data records.
|
||||||
|
|
||||||
|
This uses :attr:`data` as the basis, but may add some extra
|
||||||
|
values to each record for sake of action URLs etc.
|
||||||
|
|
||||||
|
See also :meth:`get_vue_columns()`.
|
||||||
|
"""
|
||||||
|
# use data as-is unless we have actions
|
||||||
|
if not self.actions:
|
||||||
|
return self.data
|
||||||
|
|
||||||
|
# we have action(s), so add URL(s) for each record in data
|
||||||
|
data = []
|
||||||
|
for i, record in enumerate(self.data):
|
||||||
|
record = dict(record)
|
||||||
|
for action in self.actions:
|
||||||
|
url = action.get_url(record, i)
|
||||||
|
key = f'_action_url_{action.key}'
|
||||||
|
record[key] = url
|
||||||
|
data.append(record)
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class GridAction:
|
||||||
|
"""
|
||||||
|
Represents a "row action" hyperlink within a grid context.
|
||||||
|
|
||||||
|
All such actions are displayed as a group, in a dedicated
|
||||||
|
**Actions** column in the grid. So each row in the grid has its
|
||||||
|
own set of action links.
|
||||||
|
|
||||||
|
A :class:`Grid` can have one (or zero) or more of these in its
|
||||||
|
:attr:`~Grid.actions` list. You can call
|
||||||
|
:meth:`~wuttaweb.views.base.View.make_grid_action()` to add custom
|
||||||
|
actions from within a view.
|
||||||
|
|
||||||
|
:param request: Current :term:`request` object.
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
|
||||||
|
Some parameters are not explicitly described above. However
|
||||||
|
their corresponding attributes are described below.
|
||||||
|
|
||||||
|
.. attribute:: key
|
||||||
|
|
||||||
|
String key for the action (e.g. ``'edit'``), unique within the
|
||||||
|
grid.
|
||||||
|
|
||||||
|
.. attribute:: label
|
||||||
|
|
||||||
|
Label to be displayed for the action link. If not set, will be
|
||||||
|
generated from :attr:`key` by calling
|
||||||
|
:meth:`~wuttjamaican:wuttjamaican.app.AppHandler.make_title()`.
|
||||||
|
|
||||||
|
See also :meth:`render_label()`.
|
||||||
|
|
||||||
|
.. attribute:: url
|
||||||
|
|
||||||
|
URL for the action link, if applicable. This *can* be a simple
|
||||||
|
string, however that will cause every row in the grid to have
|
||||||
|
the same URL for this action.
|
||||||
|
|
||||||
|
A better way is to specify a callable which can return a unique
|
||||||
|
URL for each record. The callable should expect ``(obj, i)``
|
||||||
|
args, for instance::
|
||||||
|
|
||||||
|
def myurl(obj, i):
|
||||||
|
return request.route_url('widgets.view', uuid=obj.uuid)
|
||||||
|
|
||||||
|
action = GridAction(request, 'view', url=myurl)
|
||||||
|
|
||||||
|
See also :meth:`get_url()`.
|
||||||
|
|
||||||
|
.. attribute:: icon
|
||||||
|
|
||||||
|
Name of icon to be shown for the action link.
|
||||||
|
|
||||||
|
See also :meth:`render_icon()`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
request,
|
||||||
|
key,
|
||||||
|
label=None,
|
||||||
|
url=None,
|
||||||
|
icon=None,
|
||||||
|
):
|
||||||
|
self.request = request
|
||||||
|
self.config = self.request.wutta_config
|
||||||
|
self.app = self.config.get_app()
|
||||||
|
self.key = key
|
||||||
|
self.url = url
|
||||||
|
self.label = label or self.app.make_title(key)
|
||||||
|
self.icon = icon or key
|
||||||
|
|
||||||
|
def render_icon(self):
|
||||||
|
"""
|
||||||
|
Render the HTML snippet for the action link icon.
|
||||||
|
|
||||||
|
This uses :attr:`icon` to identify the named icon to be shown.
|
||||||
|
Output is something like (here ``'trash'`` is the icon name):
|
||||||
|
|
||||||
|
.. code-block:: html
|
||||||
|
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
"""
|
||||||
|
if self.request.use_oruga:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
return HTML.tag('i', class_=f'fas fa-{self.icon}')
|
||||||
|
|
||||||
|
def render_label(self):
|
||||||
|
"""
|
||||||
|
Render the label text for the action link.
|
||||||
|
|
||||||
|
Default behavior is to return :attr:`label` as-is.
|
||||||
|
"""
|
||||||
|
return self.label
|
||||||
|
|
||||||
|
def get_url(self, obj, i=None):
|
||||||
|
"""
|
||||||
|
Returns the action link URL for the given object (model
|
||||||
|
instance).
|
||||||
|
|
||||||
|
If :attr:`url` is a simple string, it is returned as-is.
|
||||||
|
|
||||||
|
But if :attr:`url` is a callable (which is typically the most
|
||||||
|
useful), that will be called with the same ``(obj, i)`` args
|
||||||
|
passed along.
|
||||||
|
|
||||||
|
:param obj: Model instance of whatever type the parent grid is
|
||||||
|
setup to use.
|
||||||
|
|
||||||
|
:param i: Zero-based sequence for the object, within the
|
||||||
|
parent grid.
|
||||||
|
|
||||||
|
See also :attr:`url`.
|
||||||
|
"""
|
||||||
|
if callable(self.url):
|
||||||
|
return self.url(obj, i)
|
||||||
|
|
||||||
|
return self.url
|
|
@ -122,6 +122,11 @@ class MenuHandler(GenericHandler):
|
||||||
'route': 'appinfo',
|
'route': 'appinfo',
|
||||||
'perm': 'appinfo.list',
|
'perm': 'appinfo.list',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
'title': "Raw Settings",
|
||||||
|
'route': 'settings',
|
||||||
|
'perm': 'settings.list',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -10,29 +10,31 @@
|
||||||
% endfor
|
% endfor
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<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;">
|
% 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;">
|
||||||
|
|
||||||
% if form.show_button_reset:
|
% if form.show_button_reset:
|
||||||
<b-button native-type="reset">
|
<b-button native-type="reset">
|
||||||
Reset
|
Reset
|
||||||
|
</b-button>
|
||||||
|
% endif
|
||||||
|
|
||||||
|
<b-button type="is-primary"
|
||||||
|
native-type="submit"
|
||||||
|
% if form.auto_disable_submit:
|
||||||
|
:disabled="formSubmitting"
|
||||||
|
% endif
|
||||||
|
icon-pack="fas"
|
||||||
|
icon-left="${form.button_icon_submit}">
|
||||||
|
% if form.auto_disable_submit:
|
||||||
|
{{ formSubmitting ? "Working, please wait..." : "${form.button_label_submit}" }}
|
||||||
|
% else:
|
||||||
|
${form.button_label_submit}
|
||||||
|
% endif
|
||||||
</b-button>
|
</b-button>
|
||||||
% endif
|
|
||||||
|
|
||||||
<b-button type="is-primary"
|
</div>
|
||||||
native-type="submit"
|
% endif
|
||||||
% if form.auto_disable_submit:
|
|
||||||
:disabled="formSubmitting"
|
|
||||||
% endif
|
|
||||||
icon-pack="fas"
|
|
||||||
icon-left="${form.button_icon_submit}">
|
|
||||||
% if form.auto_disable_submit:
|
|
||||||
{{ formSubmitting ? "Working, please wait..." : "${form.button_label_submit}" }}
|
|
||||||
% else:
|
|
||||||
${form.button_label_submit}
|
|
||||||
% endif
|
|
||||||
</b-button>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
${h.end_form()}
|
${h.end_form()}
|
||||||
</script>
|
</script>
|
||||||
|
|
48
src/wuttaweb/templates/grids/vue_template.mako
Normal file
48
src/wuttaweb/templates/grids/vue_template.mako
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
## -*- coding: utf-8; -*-
|
||||||
|
|
||||||
|
<script type="text/x-template" id="${grid.vue_tagname}-template">
|
||||||
|
<${b}-table :data="data"
|
||||||
|
hoverable
|
||||||
|
:loading="loading">
|
||||||
|
|
||||||
|
% for column in grid.get_vue_columns():
|
||||||
|
<${b}-table-column field="${column['field']}"
|
||||||
|
label="${column['label']}"
|
||||||
|
v-slot="props"
|
||||||
|
cell-class="c_${column['field']}">
|
||||||
|
<span v-html="props.row.${column['field']}"></span>
|
||||||
|
</${b}-table-column>
|
||||||
|
% endfor
|
||||||
|
|
||||||
|
% if grid.actions:
|
||||||
|
<${b}-table-column field="actions"
|
||||||
|
label="Actions"
|
||||||
|
v-slot="props">
|
||||||
|
% for action in grid.actions:
|
||||||
|
<a :href="props.row._action_url_${action.key}">
|
||||||
|
${action.render_icon()}
|
||||||
|
${action.render_label()}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
% endfor
|
||||||
|
</${b}-table-column>
|
||||||
|
% endif
|
||||||
|
|
||||||
|
</${b}-table>
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
let ${grid.vue_component} = {
|
||||||
|
template: '#${grid.vue_tagname}-template',
|
||||||
|
methods: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
let ${grid.vue_component}CurrentData = ${json.dumps(grid.get_vue_data())|n}
|
||||||
|
|
||||||
|
let ${grid.vue_component}Data = {
|
||||||
|
data: ${grid.vue_component}CurrentData,
|
||||||
|
loading: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
5
src/wuttaweb/templates/master/form.mako
Normal file
5
src/wuttaweb/templates/master/form.mako
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
## -*- coding: utf-8; -*-
|
||||||
|
<%inherit file="/form.mako" />
|
||||||
|
|
||||||
|
|
||||||
|
${parent.body()}
|
|
@ -3,11 +3,30 @@
|
||||||
|
|
||||||
<%def name="title()">${index_title}</%def>
|
<%def name="title()">${index_title}</%def>
|
||||||
|
|
||||||
|
## nb. avoid hero bar for index page
|
||||||
<%def name="content_title()"></%def>
|
<%def name="content_title()"></%def>
|
||||||
|
|
||||||
<%def name="page_content()">
|
<%def name="page_content()">
|
||||||
<p>TODO: index page content</p>
|
% if grid is not Undefined:
|
||||||
|
${grid.render_vue_tag()}
|
||||||
|
% endif
|
||||||
</%def>
|
</%def>
|
||||||
|
|
||||||
|
<%def name="render_this_page_template()">
|
||||||
|
${parent.render_this_page_template()}
|
||||||
|
% if grid is not Undefined:
|
||||||
|
${grid.render_vue_template()}
|
||||||
|
% endif
|
||||||
|
</%def>
|
||||||
|
|
||||||
|
<%def name="finalize_this_page_vars()">
|
||||||
|
${parent.finalize_this_page_vars()}
|
||||||
|
% if grid is not Undefined:
|
||||||
|
<script>
|
||||||
|
${grid.vue_component}.data = function() { return ${grid.vue_component}Data }
|
||||||
|
Vue.component('${grid.vue_tagname}', ${grid.vue_component})
|
||||||
|
</script>
|
||||||
|
% endif
|
||||||
|
</%def>
|
||||||
|
|
||||||
${parent.body()}
|
${parent.body()}
|
||||||
|
|
9
src/wuttaweb/templates/master/view.mako
Normal file
9
src/wuttaweb/templates/master/view.mako
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
## -*- coding: utf-8; -*-
|
||||||
|
<%inherit file="/master/form.mako" />
|
||||||
|
|
||||||
|
<%def name="title()">${index_title} » ${instance_title}</%def>
|
||||||
|
|
||||||
|
<%def name="content_title()">${instance_title}</%def>
|
||||||
|
|
||||||
|
|
||||||
|
${parent.body()}
|
|
@ -26,7 +26,7 @@ Base Logic for Views
|
||||||
|
|
||||||
from pyramid import httpexceptions
|
from pyramid import httpexceptions
|
||||||
|
|
||||||
from wuttaweb import forms
|
from wuttaweb import forms, grids
|
||||||
|
|
||||||
|
|
||||||
class View:
|
class View:
|
||||||
|
@ -68,11 +68,31 @@ class View:
|
||||||
Make and return a new :class:`~wuttaweb.forms.base.Form`
|
Make and return a new :class:`~wuttaweb.forms.base.Form`
|
||||||
instance, per the given ``kwargs``.
|
instance, per the given ``kwargs``.
|
||||||
|
|
||||||
This is the "default" form factory which merely invokes
|
This is the "base" factory which merely invokes the
|
||||||
the constructor.
|
constructor.
|
||||||
"""
|
"""
|
||||||
return forms.Form(self.request, **kwargs)
|
return forms.Form(self.request, **kwargs)
|
||||||
|
|
||||||
|
def make_grid(self, **kwargs):
|
||||||
|
"""
|
||||||
|
Make and return a new :class:`~wuttaweb.grids.base.Grid`
|
||||||
|
instance, per the given ``kwargs``.
|
||||||
|
|
||||||
|
This is the "base" factory which merely invokes the
|
||||||
|
constructor.
|
||||||
|
"""
|
||||||
|
return grids.Grid(self.request, **kwargs)
|
||||||
|
|
||||||
|
def make_grid_action(self, key, **kwargs):
|
||||||
|
"""
|
||||||
|
Make and return a new :class:`~wuttaweb.grids.base.GridAction`
|
||||||
|
instance, per the given ``key`` and ``kwargs``.
|
||||||
|
|
||||||
|
This is the "base" factory which merely invokes the
|
||||||
|
constructor.
|
||||||
|
"""
|
||||||
|
return grids.GridAction(self.request, key, **kwargs)
|
||||||
|
|
||||||
def notfound(self):
|
def notfound(self):
|
||||||
"""
|
"""
|
||||||
Convenience method, to raise a HTTP 404 Not Found exception::
|
Convenience method, to raise a HTTP 404 Not Found exception::
|
||||||
|
|
|
@ -100,6 +100,25 @@ class MasterView(View):
|
||||||
Code should not access this directly but instead call
|
Code should not access this directly but instead call
|
||||||
:meth:`get_model_title_plural()`.
|
:meth:`get_model_title_plural()`.
|
||||||
|
|
||||||
|
.. attribute:: model_key
|
||||||
|
|
||||||
|
Optional override for the view's "model key" - e.g. ``'id'``
|
||||||
|
(string for simple case) or composite key such as
|
||||||
|
``('id_field', 'name_field')``.
|
||||||
|
|
||||||
|
If :attr:`model_class` is set to a SQLAlchemy mapped class, the
|
||||||
|
model key can be determined automatically.
|
||||||
|
|
||||||
|
Code should not access this directly but instead call
|
||||||
|
:meth:`get_model_key()`.
|
||||||
|
|
||||||
|
.. attribute:: grid_key
|
||||||
|
|
||||||
|
Optional override for the view's grid key, e.g. ``'widgets'``.
|
||||||
|
|
||||||
|
Code should not access this directly but instead call
|
||||||
|
:meth:`get_grid_key()`.
|
||||||
|
|
||||||
.. attribute:: config_title
|
.. attribute:: config_title
|
||||||
|
|
||||||
Optional override for the view's "config" title, e.g. ``"Wutta
|
Optional override for the view's "config" title, e.g. ``"Wutta
|
||||||
|
@ -138,6 +157,29 @@ class MasterView(View):
|
||||||
i.e. it should have an :meth:`index()` view. Default value is
|
i.e. it should have an :meth:`index()` view. Default value is
|
||||||
``True``.
|
``True``.
|
||||||
|
|
||||||
|
.. attribute:: has_grid
|
||||||
|
|
||||||
|
Boolean indicating whether the :meth:`index()` view should
|
||||||
|
include a grid. Default value is ``True``.
|
||||||
|
|
||||||
|
.. attribute:: grid_columns
|
||||||
|
|
||||||
|
List of columns for the :meth:`index()` view grid.
|
||||||
|
|
||||||
|
This is optional; see also :meth:`index_get_grid_columns()`.
|
||||||
|
|
||||||
|
.. attribute:: viewable
|
||||||
|
|
||||||
|
Boolean indicating whether the view model supports "viewing" -
|
||||||
|
i.e. it should have a :meth:`view()` view. Default value is
|
||||||
|
``True``.
|
||||||
|
|
||||||
|
.. attribute:: form_fields
|
||||||
|
|
||||||
|
List of columns for the model form.
|
||||||
|
|
||||||
|
This is optional; see also :meth:`get_form_fields()`.
|
||||||
|
|
||||||
.. attribute:: configurable
|
.. attribute:: configurable
|
||||||
|
|
||||||
Boolean indicating whether the master view supports
|
Boolean indicating whether the master view supports
|
||||||
|
@ -151,9 +193,13 @@ class MasterView(View):
|
||||||
|
|
||||||
# features
|
# features
|
||||||
listable = True
|
listable = True
|
||||||
|
has_grid = True
|
||||||
|
viewable = True
|
||||||
configurable = False
|
configurable = False
|
||||||
|
|
||||||
# current action
|
# current action
|
||||||
|
listing = False
|
||||||
|
viewing = False
|
||||||
configuring = False
|
configuring = False
|
||||||
|
|
||||||
##############################
|
##############################
|
||||||
|
@ -170,12 +216,158 @@ class MasterView(View):
|
||||||
|
|
||||||
By default, this view is included only if :attr:`listable` is
|
By default, this view is included only if :attr:`listable` is
|
||||||
true.
|
true.
|
||||||
|
|
||||||
|
The default view logic will show a "grid" (table) with the
|
||||||
|
model data (unless :attr:`has_grid` is false).
|
||||||
|
|
||||||
|
See also related methods, which are called by this one:
|
||||||
|
|
||||||
|
* :meth:`index_make_grid()`
|
||||||
"""
|
"""
|
||||||
|
self.listing = True
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
'index_url': None, # avoid title link since this *is* the index
|
'index_url': None, # nb. avoid title link since this *is* the index
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if self.has_grid:
|
||||||
|
context['grid'] = self.index_make_grid()
|
||||||
|
|
||||||
return self.render_to_response('index', context)
|
return self.render_to_response('index', context)
|
||||||
|
|
||||||
|
def index_make_grid(self, **kwargs):
|
||||||
|
"""
|
||||||
|
Create and return a :class:`~wuttaweb.grids.base.Grid`
|
||||||
|
instance for use with the :meth:`index()` view.
|
||||||
|
|
||||||
|
See also related methods, which are called by this one:
|
||||||
|
|
||||||
|
* :meth:`get_grid_key()`
|
||||||
|
* :meth:`index_get_grid_columns()`
|
||||||
|
* :meth:`index_get_grid_data()`
|
||||||
|
* :meth:`index_configure_grid()`
|
||||||
|
"""
|
||||||
|
if 'key' not in kwargs:
|
||||||
|
kwargs['key'] = self.get_grid_key()
|
||||||
|
|
||||||
|
if 'columns' not in kwargs:
|
||||||
|
kwargs['columns'] = self.index_get_grid_columns()
|
||||||
|
|
||||||
|
if 'data' not in kwargs:
|
||||||
|
kwargs['data'] = self.index_get_grid_data()
|
||||||
|
|
||||||
|
if 'actions' not in kwargs:
|
||||||
|
actions = []
|
||||||
|
|
||||||
|
# TODO: should split this off into index_get_grid_actions() ?
|
||||||
|
if self.viewable:
|
||||||
|
actions.append(self.make_grid_action('view', icon='eye',
|
||||||
|
url=self.get_action_url_view))
|
||||||
|
|
||||||
|
kwargs['actions'] = actions
|
||||||
|
|
||||||
|
grid = self.make_grid(**kwargs)
|
||||||
|
self.index_configure_grid(grid)
|
||||||
|
return grid
|
||||||
|
|
||||||
|
def index_get_grid_columns(self):
|
||||||
|
"""
|
||||||
|
Returns the default list of grid column names, for the
|
||||||
|
:meth:`index()` view.
|
||||||
|
|
||||||
|
This is called by :meth:`index_make_grid()`; in the resulting
|
||||||
|
:class:`~wuttaweb.grids.base.Grid` instance, this becomes
|
||||||
|
:attr:`~wuttaweb.grids.base.Grid.columns`.
|
||||||
|
|
||||||
|
This method may return ``None``, in which case the grid may
|
||||||
|
(try to) generate its own default list.
|
||||||
|
|
||||||
|
Subclass may define :attr:`grid_columns` for simple cases, or
|
||||||
|
can override this method if needed.
|
||||||
|
|
||||||
|
Also note that :meth:`index_configure_grid()` may be used to
|
||||||
|
further modify the final column set, regardless of what this
|
||||||
|
method returns. So a common pattern is to declare all
|
||||||
|
"supported" columns by setting :attr:`grid_columns` but then
|
||||||
|
optionally remove or replace some of those within
|
||||||
|
:meth:`index_configure_grid()`.
|
||||||
|
"""
|
||||||
|
if hasattr(self, 'grid_columns'):
|
||||||
|
return self.grid_columns
|
||||||
|
|
||||||
|
def index_get_grid_data(self):
|
||||||
|
"""
|
||||||
|
Returns the grid data for the :meth:`index()` view.
|
||||||
|
|
||||||
|
This is called by :meth:`index_make_grid()`; in the resulting
|
||||||
|
:class:`~wuttaweb.grids.base.Grid` instance, this becomes
|
||||||
|
:attr:`~wuttaweb.grids.base.Grid.data`.
|
||||||
|
|
||||||
|
As of now there is not yet a "sane" default for this method;
|
||||||
|
it simply returns an empty list. Subclass should override as
|
||||||
|
needed.
|
||||||
|
"""
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_action_url_view(self, obj, i):
|
||||||
|
"""
|
||||||
|
Returns the "view" grid action URL for the given object.
|
||||||
|
|
||||||
|
Most typically this is like ``/widgets/XXX`` where ``XXX``
|
||||||
|
represents the object's key/ID.
|
||||||
|
"""
|
||||||
|
route_prefix = self.get_route_prefix()
|
||||||
|
|
||||||
|
kw = {}
|
||||||
|
for key in self.get_model_key():
|
||||||
|
kw[key] = obj[key]
|
||||||
|
|
||||||
|
return self.request.route_url(f'{route_prefix}.view', **kw)
|
||||||
|
|
||||||
|
def index_configure_grid(self, grid):
|
||||||
|
"""
|
||||||
|
Configure the grid for the :meth:`index()` view.
|
||||||
|
|
||||||
|
This is called by :meth:`index_make_grid()`.
|
||||||
|
|
||||||
|
There is no default logic here; subclass should override as
|
||||||
|
needed. The ``grid`` param will already be "complete" and
|
||||||
|
ready to use as-is, but this method can further modify it
|
||||||
|
based on request details etc.
|
||||||
|
"""
|
||||||
|
|
||||||
|
##############################
|
||||||
|
# view methods
|
||||||
|
##############################
|
||||||
|
|
||||||
|
def view(self):
|
||||||
|
"""
|
||||||
|
View to "view" details of an existing model record.
|
||||||
|
|
||||||
|
This usually corresponds to a URL like ``/widgets/XXX``
|
||||||
|
where ``XXX`` represents the key/ID for the record.
|
||||||
|
|
||||||
|
By default, this view is included only if :attr:`viewable` is
|
||||||
|
true.
|
||||||
|
|
||||||
|
The default view logic will show a read-only form with field
|
||||||
|
values displayed.
|
||||||
|
|
||||||
|
See also related methods, which are called by this one:
|
||||||
|
|
||||||
|
* :meth:`make_model_form()`
|
||||||
|
"""
|
||||||
|
self.viewing = True
|
||||||
|
instance = self.get_instance()
|
||||||
|
form = self.make_model_form(instance, readonly=True)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'instance': instance,
|
||||||
|
'instance_title': self.get_instance_title(instance),
|
||||||
|
'form': form,
|
||||||
|
}
|
||||||
|
return self.render_to_response('view', context)
|
||||||
|
|
||||||
##############################
|
##############################
|
||||||
# configure methods
|
# configure methods
|
||||||
##############################
|
##############################
|
||||||
|
@ -451,14 +643,12 @@ class MasterView(View):
|
||||||
Save the given settings to the DB; this is called by
|
Save the given settings to the DB; this is called by
|
||||||
:meth:`configure()`.
|
:meth:`configure()`.
|
||||||
|
|
||||||
This method expected a list of name/value dicts and will
|
This method expects a list of name/value dicts and will simply
|
||||||
simply save each to the DB, with no "conversion" logic.
|
save each to the DB, with no "conversion" logic.
|
||||||
|
|
||||||
:param settings: List of normalized setting definitions, as
|
:param settings: List of normalized setting definitions, as
|
||||||
returned by :meth:`configure_gather_settings()`.
|
returned by :meth:`configure_gather_settings()`.
|
||||||
"""
|
"""
|
||||||
# app = self.get_rattail_app()
|
|
||||||
|
|
||||||
# nb. must avoid self.Session here in case that does not point
|
# nb. must avoid self.Session here in case that does not point
|
||||||
# to our primary app DB
|
# to our primary app DB
|
||||||
session = Session()
|
session = Session()
|
||||||
|
@ -470,26 +660,6 @@ class MasterView(View):
|
||||||
# support methods
|
# support methods
|
||||||
##############################
|
##############################
|
||||||
|
|
||||||
def get_index_title(self):
|
|
||||||
"""
|
|
||||||
Returns the main index title for the master view.
|
|
||||||
|
|
||||||
By default this returns the value from
|
|
||||||
:meth:`get_model_title_plural()`. Subclass may override as
|
|
||||||
needed.
|
|
||||||
"""
|
|
||||||
return self.get_model_title_plural()
|
|
||||||
|
|
||||||
def get_index_url(self, **kwargs):
|
|
||||||
"""
|
|
||||||
Returns the URL for master's :meth:`index()` view.
|
|
||||||
|
|
||||||
NB. this returns ``None`` if :attr:`listable` is false.
|
|
||||||
"""
|
|
||||||
if self.listable:
|
|
||||||
route_prefix = self.get_route_prefix()
|
|
||||||
return self.request.route_url(route_prefix, **kwargs)
|
|
||||||
|
|
||||||
def render_to_response(self, template, context):
|
def render_to_response(self, template, context):
|
||||||
"""
|
"""
|
||||||
Locate and render an appropriate template, with the given
|
Locate and render an appropriate template, with the given
|
||||||
|
@ -568,6 +738,110 @@ class MasterView(View):
|
||||||
"""
|
"""
|
||||||
return [f'/master/{template}.mako']
|
return [f'/master/{template}.mako']
|
||||||
|
|
||||||
|
def get_index_title(self):
|
||||||
|
"""
|
||||||
|
Returns the main index title for the master view.
|
||||||
|
|
||||||
|
By default this returns the value from
|
||||||
|
:meth:`get_model_title_plural()`. Subclass may override as
|
||||||
|
needed.
|
||||||
|
"""
|
||||||
|
return self.get_model_title_plural()
|
||||||
|
|
||||||
|
def get_index_url(self, **kwargs):
|
||||||
|
"""
|
||||||
|
Returns the URL for master's :meth:`index()` view.
|
||||||
|
|
||||||
|
NB. this returns ``None`` if :attr:`listable` is false.
|
||||||
|
"""
|
||||||
|
if self.listable:
|
||||||
|
route_prefix = self.get_route_prefix()
|
||||||
|
return self.request.route_url(route_prefix, **kwargs)
|
||||||
|
|
||||||
|
def get_instance(self):
|
||||||
|
"""
|
||||||
|
This should return the "current" model instance based on the
|
||||||
|
request details (e.g. route kwargs).
|
||||||
|
|
||||||
|
If the instance cannot be found, this should raise a HTTP 404
|
||||||
|
exception, i.e. :meth:`~wuttaweb.views.base.View.notfound()`.
|
||||||
|
|
||||||
|
There is no "sane" default logic here; subclass *must*
|
||||||
|
override or else a ``NotImplementedError`` is raised.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError("you must define get_instance() method "
|
||||||
|
f" for view class: {self.__class__}")
|
||||||
|
|
||||||
|
def get_instance_title(self, instance):
|
||||||
|
"""
|
||||||
|
Return the human-friendly "title" for the instance, to be used
|
||||||
|
in the page title when viewing etc.
|
||||||
|
|
||||||
|
Default logic returns the value from ``str(instance)``;
|
||||||
|
subclass may override if needed.
|
||||||
|
"""
|
||||||
|
return str(instance)
|
||||||
|
|
||||||
|
def make_model_form(self, model_instance=None, **kwargs):
|
||||||
|
"""
|
||||||
|
Create and return a :class:`~wuttaweb.forms.base.Form`
|
||||||
|
for the view model.
|
||||||
|
|
||||||
|
Note that this method is called for multiple "CRUD" views,
|
||||||
|
e.g.:
|
||||||
|
|
||||||
|
* :meth:`view()`
|
||||||
|
|
||||||
|
See also related methods, which are called by this one:
|
||||||
|
|
||||||
|
* :meth:`get_form_fields()`
|
||||||
|
* :meth:`configure_form()`
|
||||||
|
"""
|
||||||
|
kwargs['model_instance'] = model_instance
|
||||||
|
|
||||||
|
if 'fields' not in kwargs:
|
||||||
|
kwargs['fields'] = self.get_form_fields()
|
||||||
|
|
||||||
|
form = self.make_form(**kwargs)
|
||||||
|
self.configure_form(form)
|
||||||
|
return form
|
||||||
|
|
||||||
|
def get_form_fields(self):
|
||||||
|
"""
|
||||||
|
Returns the initial list of field names for the model form.
|
||||||
|
|
||||||
|
This is called by :meth:`make_model_form()`; in the resulting
|
||||||
|
:class:`~wuttaweb.forms.base.Form` instance, this becomes
|
||||||
|
:attr:`~wuttaweb.forms.base.Form.fields`.
|
||||||
|
|
||||||
|
This method may return ``None``, in which case the form may
|
||||||
|
(try to) generate its own default list.
|
||||||
|
|
||||||
|
Subclass may define :attr:`form_fields` for simple cases, or
|
||||||
|
can override this method if needed.
|
||||||
|
|
||||||
|
Note that :meth:`configure_form()` may be used to further
|
||||||
|
modify the final field list, regardless of what this method
|
||||||
|
returns. So a common pattern is to declare all "supported"
|
||||||
|
fields by setting :attr:`form_fields` but then optionally
|
||||||
|
remove or replace some in :meth:`configure_form()`.
|
||||||
|
"""
|
||||||
|
if hasattr(self, 'form_fields'):
|
||||||
|
return self.form_fields
|
||||||
|
|
||||||
|
def configure_form(self, form):
|
||||||
|
"""
|
||||||
|
Configure the given model form, as needed.
|
||||||
|
|
||||||
|
This is called by :meth:`make_model_form()` - for multiple
|
||||||
|
CRUD views.
|
||||||
|
|
||||||
|
There is no default logic here; subclass should override if
|
||||||
|
needed. The ``form`` param will already be "complete" and
|
||||||
|
ready to use as-is, but this method can further modify it
|
||||||
|
based on request details etc.
|
||||||
|
"""
|
||||||
|
|
||||||
##############################
|
##############################
|
||||||
# class methods
|
# class methods
|
||||||
##############################
|
##############################
|
||||||
|
@ -663,6 +937,32 @@ class MasterView(View):
|
||||||
model_title = cls.get_model_title()
|
model_title = cls.get_model_title()
|
||||||
return f"{model_title}s"
|
return f"{model_title}s"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_model_key(cls):
|
||||||
|
"""
|
||||||
|
Returns the "model key" for the master view.
|
||||||
|
|
||||||
|
This should return a tuple containing one or more "field
|
||||||
|
names" corresponding to the primary key for data records.
|
||||||
|
|
||||||
|
In the most simple/common scenario, where the master view
|
||||||
|
represents a Wutta-based SQLAlchemy model, the return value
|
||||||
|
for this method is: ``('uuid',)``
|
||||||
|
|
||||||
|
But there is no "sane" default for other scenarios, in which
|
||||||
|
case subclass should define :attr:`model_key`. If the model
|
||||||
|
key cannot be determined, raises ``AttributeError``.
|
||||||
|
|
||||||
|
:returns: Tuple of field names comprising the model key.
|
||||||
|
"""
|
||||||
|
if hasattr(cls, 'model_key'):
|
||||||
|
keys = cls.model_key
|
||||||
|
if isinstance(keys, str):
|
||||||
|
keys = [keys]
|
||||||
|
return tuple(keys)
|
||||||
|
|
||||||
|
raise AttributeError(f"you must define model_key for view class: {cls}")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_route_prefix(cls):
|
def get_route_prefix(cls):
|
||||||
"""
|
"""
|
||||||
|
@ -713,6 +1013,27 @@ class MasterView(View):
|
||||||
route_prefix = cls.get_route_prefix()
|
route_prefix = cls.get_route_prefix()
|
||||||
return f'/{route_prefix}'
|
return f'/{route_prefix}'
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_instance_url_prefix(cls):
|
||||||
|
"""
|
||||||
|
Generate the URL prefix specific to an instance for this model
|
||||||
|
view. This will include model key param placeholders; it
|
||||||
|
winds up looking like:
|
||||||
|
|
||||||
|
* ``/widgets/{uuid}``
|
||||||
|
* ``/resources/{foo}|{bar}|{baz}``
|
||||||
|
|
||||||
|
The former being the most simple/common, and the latter
|
||||||
|
showing what a "composite" model key looks like, with pipe
|
||||||
|
symbols separating the key parts.
|
||||||
|
"""
|
||||||
|
prefix = cls.get_url_prefix() + '/'
|
||||||
|
for i, key in enumerate(cls.get_model_key()):
|
||||||
|
if i:
|
||||||
|
prefix += '|'
|
||||||
|
prefix += f'{{{key}}}'
|
||||||
|
return prefix
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_template_prefix(cls):
|
def get_template_prefix(cls):
|
||||||
"""
|
"""
|
||||||
|
@ -738,6 +1059,26 @@ class MasterView(View):
|
||||||
|
|
||||||
return cls.get_url_prefix()
|
return cls.get_url_prefix()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_grid_key(cls):
|
||||||
|
"""
|
||||||
|
Returns the (presumably) unique key to be used for the primary
|
||||||
|
grid in the :meth:`index()` view. This key may also be used
|
||||||
|
as the basis (key prefix) for secondary grids.
|
||||||
|
|
||||||
|
This is called from :meth:`index_make_grid()`; in the
|
||||||
|
resulting :class:`~wuttaweb.grids.base.Grid` instance, this
|
||||||
|
becomes :attr:`~wuttaweb.grids.base.Grid.key`.
|
||||||
|
|
||||||
|
The default logic for this method will call
|
||||||
|
:meth:`get_route_prefix()` and return that value as-is. A
|
||||||
|
subclass may override by assigning :attr:`grid_key`.
|
||||||
|
"""
|
||||||
|
if hasattr(cls, 'grid_key'):
|
||||||
|
return cls.grid_key
|
||||||
|
|
||||||
|
return cls.get_route_prefix()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_config_title(cls):
|
def get_config_title(cls):
|
||||||
"""
|
"""
|
||||||
|
@ -794,6 +1135,13 @@ class MasterView(View):
|
||||||
config.add_view(cls, attr='index',
|
config.add_view(cls, attr='index',
|
||||||
route_name=route_prefix)
|
route_name=route_prefix)
|
||||||
|
|
||||||
|
# view
|
||||||
|
if cls.viewable:
|
||||||
|
instance_url_prefix = cls.get_instance_url_prefix()
|
||||||
|
config.add_route(f'{route_prefix}.view', instance_url_prefix)
|
||||||
|
config.add_view(cls, attr='view',
|
||||||
|
route_name=f'{route_prefix}.view')
|
||||||
|
|
||||||
# configure
|
# configure
|
||||||
if cls.configurable:
|
if cls.configurable:
|
||||||
config.add_route(f'{route_prefix}.configure',
|
config.add_route(f'{route_prefix}.configure',
|
||||||
|
|
|
@ -26,8 +26,11 @@ Views for app settings
|
||||||
|
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
|
|
||||||
|
from wuttjamaican.db.model import Setting
|
||||||
|
|
||||||
from wuttaweb.views import MasterView
|
from wuttaweb.views import MasterView
|
||||||
from wuttaweb.util import get_libver, get_liburl
|
from wuttaweb.util import get_libver, get_liburl
|
||||||
|
from wuttaweb.db import Session
|
||||||
|
|
||||||
|
|
||||||
class AppInfoView(MasterView):
|
class AppInfoView(MasterView):
|
||||||
|
@ -38,10 +41,16 @@ class AppInfoView(MasterView):
|
||||||
|
|
||||||
* ``/appinfo/``
|
* ``/appinfo/``
|
||||||
* ``/appinfo/configure``
|
* ``/appinfo/configure``
|
||||||
|
|
||||||
|
See also :class:`SettingView`.
|
||||||
"""
|
"""
|
||||||
model_name = 'AppInfo'
|
model_name = 'AppInfo'
|
||||||
model_title_plural = "App Info"
|
model_title_plural = "App Info"
|
||||||
route_prefix = 'appinfo'
|
route_prefix = 'appinfo'
|
||||||
|
has_grid = False
|
||||||
|
viewable = False
|
||||||
|
editable = False
|
||||||
|
deletable = False
|
||||||
configurable = True
|
configurable = True
|
||||||
|
|
||||||
def configure_get_simple_settings(self):
|
def configure_get_simple_settings(self):
|
||||||
|
@ -103,8 +112,6 @@ class AppInfoView(MasterView):
|
||||||
('bb_vue_fontawesome', "(BB) @fortawesome/vue-fontawesome"),
|
('bb_vue_fontawesome', "(BB) @fortawesome/vue-fontawesome"),
|
||||||
])
|
])
|
||||||
|
|
||||||
# import ipdb; ipdb.set_trace()
|
|
||||||
|
|
||||||
for key in weblibs:
|
for key in weblibs:
|
||||||
title = weblibs[key]
|
title = weblibs[key]
|
||||||
weblibs[key] = {
|
weblibs[key] = {
|
||||||
|
@ -127,12 +134,79 @@ class AppInfoView(MasterView):
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
class SettingView(MasterView):
|
||||||
|
"""
|
||||||
|
Master view for the "raw" settings table.
|
||||||
|
|
||||||
|
Notable URLs provided by this class:
|
||||||
|
|
||||||
|
* ``/settings/``
|
||||||
|
|
||||||
|
See also :class:`AppInfoView`.
|
||||||
|
"""
|
||||||
|
model_class = Setting
|
||||||
|
model_title = "Raw Setting"
|
||||||
|
|
||||||
|
# TODO: this should be deduced by master
|
||||||
|
model_key = 'name'
|
||||||
|
|
||||||
|
# TODO: try removing these
|
||||||
|
grid_columns = [
|
||||||
|
'name',
|
||||||
|
'value',
|
||||||
|
]
|
||||||
|
form_fields = list(grid_columns)
|
||||||
|
|
||||||
|
# TODO: should define query, let master handle the rest
|
||||||
|
def index_get_grid_data(self, session=None):
|
||||||
|
""" """
|
||||||
|
model = self.app.model
|
||||||
|
|
||||||
|
session = session or Session()
|
||||||
|
query = session.query(model.Setting)\
|
||||||
|
.order_by(model.Setting.name)
|
||||||
|
|
||||||
|
settings = []
|
||||||
|
for setting in query:
|
||||||
|
settings.append(self.normalize_setting(setting))
|
||||||
|
|
||||||
|
return settings
|
||||||
|
|
||||||
|
# TODO: master should handle this (but not as dict)
|
||||||
|
def normalize_setting(self, setting):
|
||||||
|
""" """
|
||||||
|
return {
|
||||||
|
'name': setting.name,
|
||||||
|
'value': setting.value,
|
||||||
|
}
|
||||||
|
|
||||||
|
# TODO: master should handle this
|
||||||
|
def get_instance(self, session=None):
|
||||||
|
""" """
|
||||||
|
model = self.app.model
|
||||||
|
session = session or Session()
|
||||||
|
name = self.request.matchdict['name']
|
||||||
|
setting = session.query(model.Setting).get(name)
|
||||||
|
if setting:
|
||||||
|
return self.normalize_setting(setting)
|
||||||
|
|
||||||
|
return self.notfound()
|
||||||
|
|
||||||
|
# TODO: master should handle this
|
||||||
|
def get_instance_title(self, setting):
|
||||||
|
""" """
|
||||||
|
return setting['name']
|
||||||
|
|
||||||
|
|
||||||
def defaults(config, **kwargs):
|
def defaults(config, **kwargs):
|
||||||
base = globals()
|
base = globals()
|
||||||
|
|
||||||
AppInfoView = kwargs.get('AppInfoView', base['AppInfoView'])
|
AppInfoView = kwargs.get('AppInfoView', base['AppInfoView'])
|
||||||
AppInfoView.defaults(config)
|
AppInfoView.defaults(config)
|
||||||
|
|
||||||
|
SettingView = kwargs.get('SettingView', base['SettingView'])
|
||||||
|
SettingView.defaults(config)
|
||||||
|
|
||||||
|
|
||||||
def includeme(config):
|
def includeme(config):
|
||||||
defaults(config)
|
defaults(config)
|
||||||
|
|
|
@ -59,8 +59,8 @@ class TestForm(TestCase):
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
testing.tearDown()
|
testing.tearDown()
|
||||||
|
|
||||||
def make_form(self, request=None, **kwargs):
|
def make_form(self, **kwargs):
|
||||||
return base.Form(request or self.request, **kwargs)
|
return base.Form(self.request, **kwargs)
|
||||||
|
|
||||||
def make_schema(self):
|
def make_schema(self):
|
||||||
schema = colander.Schema(children=[
|
schema = colander.Schema(children=[
|
||||||
|
@ -124,19 +124,33 @@ class TestForm(TestCase):
|
||||||
self.assertIs(form.schema, schema)
|
self.assertIs(form.schema, schema)
|
||||||
self.assertIs(form.get_schema(), schema)
|
self.assertIs(form.get_schema(), schema)
|
||||||
|
|
||||||
# auto-generating schema not yet supported
|
# schema is auto-generated if fields provided
|
||||||
form = self.make_form(fields=['foo', 'bar'])
|
form = self.make_form(fields=['foo', 'bar'])
|
||||||
|
schema = form.get_schema()
|
||||||
|
self.assertEqual(len(schema.children), 2)
|
||||||
|
self.assertEqual(schema['foo'].name, 'foo')
|
||||||
|
|
||||||
|
# but auto-generating without fields is not supported
|
||||||
|
form = self.make_form()
|
||||||
self.assertIsNone(form.schema)
|
self.assertIsNone(form.schema)
|
||||||
self.assertRaises(NotImplementedError, form.get_schema)
|
self.assertRaises(NotImplementedError, form.get_schema)
|
||||||
|
|
||||||
def test_get_deform(self):
|
def test_get_deform(self):
|
||||||
schema = self.make_schema()
|
schema = self.make_schema()
|
||||||
|
|
||||||
|
# basic
|
||||||
form = self.make_form(schema=schema)
|
form = self.make_form(schema=schema)
|
||||||
self.assertFalse(hasattr(form, 'deform_form'))
|
self.assertFalse(hasattr(form, 'deform_form'))
|
||||||
dform = form.get_deform()
|
dform = form.get_deform()
|
||||||
self.assertIsInstance(dform, deform.Form)
|
self.assertIsInstance(dform, deform.Form)
|
||||||
self.assertIs(form.deform_form, dform)
|
self.assertIs(form.deform_form, dform)
|
||||||
|
|
||||||
|
# with model instance / cstruct
|
||||||
|
myobj = {'foo': 'one', 'bar': 'two'}
|
||||||
|
form = self.make_form(schema=schema, model_instance=myobj)
|
||||||
|
dform = form.get_deform()
|
||||||
|
self.assertEqual(dform.cstruct, myobj)
|
||||||
|
|
||||||
def test_get_label(self):
|
def test_get_label(self):
|
||||||
form = self.make_form(fields=['foo', 'bar'])
|
form = self.make_form(fields=['foo', 'bar'])
|
||||||
self.assertEqual(form.get_label('foo'), "Foo")
|
self.assertEqual(form.get_label('foo'), "Foo")
|
||||||
|
@ -193,6 +207,13 @@ class TestForm(TestCase):
|
||||||
# nb. no error message
|
# nb. no error message
|
||||||
self.assertNotIn('message', html)
|
self.assertNotIn('message', html)
|
||||||
|
|
||||||
|
# readonly
|
||||||
|
html = form.render_vue_field('foo', readonly=True)
|
||||||
|
self.assertIn('<b-field :horizontal="true" label="Foo">', html)
|
||||||
|
self.assertNotIn('<b-input name="foo"', html)
|
||||||
|
# nb. no error message
|
||||||
|
self.assertNotIn('message', html)
|
||||||
|
|
||||||
# with single "static" error
|
# with single "static" error
|
||||||
dform['foo'].error = MagicMock(msg="something is wrong")
|
dform['foo'].error = MagicMock(msg="something is wrong")
|
||||||
html = form.render_vue_field('foo')
|
html = form.render_vue_field('foo')
|
||||||
|
|
0
tests/grids/__init__.py
Normal file
0
tests/grids/__init__.py
Normal file
150
tests/grids/test_base.py
Normal file
150
tests/grids/test_base.py
Normal file
|
@ -0,0 +1,150 @@
|
||||||
|
# -*- coding: utf-8; -*-
|
||||||
|
|
||||||
|
from unittest import TestCase
|
||||||
|
|
||||||
|
from pyramid import testing
|
||||||
|
|
||||||
|
from wuttjamaican.conf import WuttaConfig
|
||||||
|
from wuttaweb.grids import base
|
||||||
|
from wuttaweb.forms import FieldList
|
||||||
|
|
||||||
|
|
||||||
|
class TestGrid(TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.config = WuttaConfig(defaults={
|
||||||
|
'wutta.web.menus.handler_spec': 'tests.utils:NullMenuHandler',
|
||||||
|
})
|
||||||
|
|
||||||
|
self.request = testing.DummyRequest(wutta_config=self.config, use_oruga=False)
|
||||||
|
|
||||||
|
self.pyramid_config = testing.setUp(request=self.request, settings={
|
||||||
|
'mako.directories': ['wuttaweb:templates'],
|
||||||
|
})
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
testing.tearDown()
|
||||||
|
|
||||||
|
def make_grid(self, request=None, **kwargs):
|
||||||
|
return base.Grid(request or self.request, **kwargs)
|
||||||
|
|
||||||
|
def test_constructor(self):
|
||||||
|
|
||||||
|
# empty
|
||||||
|
grid = self.make_grid()
|
||||||
|
self.assertIsNone(grid.key)
|
||||||
|
self.assertIsNone(grid.columns)
|
||||||
|
self.assertIsNone(grid.data)
|
||||||
|
|
||||||
|
# now with columns
|
||||||
|
grid = self.make_grid(columns=['foo', 'bar'])
|
||||||
|
self.assertIsInstance(grid.columns, FieldList)
|
||||||
|
self.assertEqual(grid.columns, ['foo', 'bar'])
|
||||||
|
|
||||||
|
def test_vue_tagname(self):
|
||||||
|
grid = self.make_grid()
|
||||||
|
self.assertEqual(grid.vue_tagname, 'wutta-grid')
|
||||||
|
|
||||||
|
def test_vue_component(self):
|
||||||
|
grid = self.make_grid()
|
||||||
|
self.assertEqual(grid.vue_component, 'WuttaGrid')
|
||||||
|
|
||||||
|
def test_render_vue_tag(self):
|
||||||
|
grid = self.make_grid(columns=['foo', 'bar'])
|
||||||
|
html = grid.render_vue_tag()
|
||||||
|
self.assertEqual(html, '<wutta-grid></wutta-grid>')
|
||||||
|
|
||||||
|
def test_render_vue_template(self):
|
||||||
|
self.pyramid_config.include('pyramid_mako')
|
||||||
|
self.pyramid_config.add_subscriber('wuttaweb.subscribers.before_render',
|
||||||
|
'pyramid.events.BeforeRender')
|
||||||
|
|
||||||
|
grid = self.make_grid(columns=['foo', 'bar'])
|
||||||
|
html = grid.render_vue_template()
|
||||||
|
self.assertIn('<script type="text/x-template" id="wutta-grid-template">', html)
|
||||||
|
|
||||||
|
def test_get_vue_columns(self):
|
||||||
|
|
||||||
|
# error if no columns are set
|
||||||
|
grid = self.make_grid()
|
||||||
|
self.assertRaises(ValueError, grid.get_vue_columns)
|
||||||
|
|
||||||
|
# otherwise get back field/label dicts
|
||||||
|
grid = self.make_grid(columns=['foo', 'bar'])
|
||||||
|
columns = grid.get_vue_columns()
|
||||||
|
first = columns[0]
|
||||||
|
self.assertEqual(first['field'], 'foo')
|
||||||
|
self.assertEqual(first['label'], 'Foo')
|
||||||
|
|
||||||
|
def test_get_vue_data(self):
|
||||||
|
|
||||||
|
# null by default
|
||||||
|
grid = self.make_grid()
|
||||||
|
data = grid.get_vue_data()
|
||||||
|
self.assertIsNone(data)
|
||||||
|
|
||||||
|
# is usually a list
|
||||||
|
mydata = [
|
||||||
|
{'foo': 'bar'},
|
||||||
|
]
|
||||||
|
grid = self.make_grid(data=mydata)
|
||||||
|
data = grid.get_vue_data()
|
||||||
|
self.assertIs(data, mydata)
|
||||||
|
self.assertEqual(data, [{'foo': 'bar'}])
|
||||||
|
|
||||||
|
# if grid has actions, that list may be supplemented
|
||||||
|
grid.actions.append(base.GridAction(self.request, 'view', url='/blarg'))
|
||||||
|
data = grid.get_vue_data()
|
||||||
|
self.assertIsNot(data, mydata)
|
||||||
|
self.assertEqual(data, [{'foo': 'bar', '_action_url_view': '/blarg'}])
|
||||||
|
|
||||||
|
|
||||||
|
class TestGridAction(TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.config = WuttaConfig()
|
||||||
|
self.request = testing.DummyRequest(wutta_config=self.config, use_oruga=False)
|
||||||
|
|
||||||
|
def make_action(self, key, **kwargs):
|
||||||
|
return base.GridAction(self.request, key, **kwargs)
|
||||||
|
|
||||||
|
def test_render_icon(self):
|
||||||
|
|
||||||
|
# icon is derived from key by default
|
||||||
|
action = self.make_action('blarg')
|
||||||
|
html = action.render_icon()
|
||||||
|
self.assertIn('<i class="fas fa-blarg">', html)
|
||||||
|
|
||||||
|
# oruga not yet supported
|
||||||
|
self.request.use_oruga = True
|
||||||
|
self.assertRaises(NotImplementedError, action.render_icon)
|
||||||
|
|
||||||
|
def test_render_label(self):
|
||||||
|
|
||||||
|
# label is derived from key by default
|
||||||
|
action = self.make_action('blarg')
|
||||||
|
label = action.render_label()
|
||||||
|
self.assertEqual(label, "Blarg")
|
||||||
|
|
||||||
|
# otherwise use what caller provides
|
||||||
|
action = self.make_action('foo', label="Bar")
|
||||||
|
label = action.render_label()
|
||||||
|
self.assertEqual(label, "Bar")
|
||||||
|
|
||||||
|
def test_get_url(self):
|
||||||
|
obj = {'foo': 'bar'}
|
||||||
|
|
||||||
|
# null by default
|
||||||
|
action = self.make_action('blarg')
|
||||||
|
url = action.get_url(obj)
|
||||||
|
self.assertIsNone(url)
|
||||||
|
|
||||||
|
# or can be "static"
|
||||||
|
action = self.make_action('blarg', url='/foo')
|
||||||
|
url = action.get_url(obj)
|
||||||
|
self.assertEqual(url, '/foo')
|
||||||
|
|
||||||
|
# or can be "dynamic"
|
||||||
|
action = self.make_action('blarg', url=lambda o, i: '/yeehaw')
|
||||||
|
url = action.get_url(obj)
|
||||||
|
self.assertEqual(url, '/yeehaw')
|
|
@ -8,6 +8,7 @@ from pyramid.httpexceptions import HTTPFound, HTTPForbidden, HTTPNotFound
|
||||||
from wuttjamaican.conf import WuttaConfig
|
from wuttjamaican.conf import WuttaConfig
|
||||||
from wuttaweb.views import base
|
from wuttaweb.views import base
|
||||||
from wuttaweb.forms import Form
|
from wuttaweb.forms import Form
|
||||||
|
from wuttaweb.grids import Grid
|
||||||
|
|
||||||
|
|
||||||
class TestView(TestCase):
|
class TestView(TestCase):
|
||||||
|
@ -31,6 +32,10 @@ class TestView(TestCase):
|
||||||
form = self.view.make_form()
|
form = self.view.make_form()
|
||||||
self.assertIsInstance(form, Form)
|
self.assertIsInstance(form, Form)
|
||||||
|
|
||||||
|
def test_make_grid(self):
|
||||||
|
grid = self.view.make_grid()
|
||||||
|
self.assertIsInstance(grid, Grid)
|
||||||
|
|
||||||
def test_notfound(self):
|
def test_notfound(self):
|
||||||
error = self.view.notfound()
|
error = self.view.notfound()
|
||||||
self.assertIsInstance(error, HTTPNotFound)
|
self.assertIsInstance(error, HTTPNotFound)
|
||||||
|
|
|
@ -19,8 +19,9 @@ class TestMasterView(WebTestCase):
|
||||||
|
|
||||||
def test_defaults(self):
|
def test_defaults(self):
|
||||||
master.MasterView.model_name = 'Widget'
|
master.MasterView.model_name = 'Widget'
|
||||||
# TODO: should inspect pyramid routes after this, to be certain
|
with patch.object(master.MasterView, 'viewable', new=False):
|
||||||
master.MasterView.defaults(self.pyramid_config)
|
# TODO: should inspect pyramid routes after this, to be certain
|
||||||
|
master.MasterView.defaults(self.pyramid_config)
|
||||||
del master.MasterView.model_name
|
del master.MasterView.model_name
|
||||||
|
|
||||||
##############################
|
##############################
|
||||||
|
@ -122,6 +123,16 @@ class TestMasterView(WebTestCase):
|
||||||
self.assertEqual(master.MasterView.get_model_title_plural(), "Dinosaurs")
|
self.assertEqual(master.MasterView.get_model_title_plural(), "Dinosaurs")
|
||||||
del master.MasterView.model_class
|
del master.MasterView.model_class
|
||||||
|
|
||||||
|
def test_get_model_key(self):
|
||||||
|
|
||||||
|
# error by default (since no model class)
|
||||||
|
self.assertRaises(AttributeError, master.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
|
||||||
|
|
||||||
def test_get_route_prefix(self):
|
def test_get_route_prefix(self):
|
||||||
|
|
||||||
# error by default (since no model class)
|
# error by default (since no model class)
|
||||||
|
@ -179,6 +190,25 @@ class TestMasterView(WebTestCase):
|
||||||
self.assertEqual(master.MasterView.get_url_prefix(), '/machines')
|
self.assertEqual(master.MasterView.get_url_prefix(), '/machines')
|
||||||
del master.MasterView.model_class
|
del master.MasterView.model_class
|
||||||
|
|
||||||
|
def test_get_instance_url_prefix(self):
|
||||||
|
|
||||||
|
# error by default (since no model class)
|
||||||
|
self.assertRaises(AttributeError, master.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
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
def test_get_template_prefix(self):
|
def test_get_template_prefix(self):
|
||||||
|
|
||||||
# error by default (since no model class)
|
# error by default (since no model class)
|
||||||
|
@ -215,6 +245,37 @@ class TestMasterView(WebTestCase):
|
||||||
self.assertEqual(master.MasterView.get_template_prefix(), '/machines')
|
self.assertEqual(master.MasterView.get_template_prefix(), '/machines')
|
||||||
del master.MasterView.model_class
|
del master.MasterView.model_class
|
||||||
|
|
||||||
|
def test_get_grid_key(self):
|
||||||
|
|
||||||
|
# error by default (since no model class)
|
||||||
|
self.assertRaises(AttributeError, master.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
|
||||||
|
|
||||||
|
# or it may specify route prefix
|
||||||
|
master.MasterView.route_prefix = 'trucks'
|
||||||
|
self.assertEqual(master.MasterView.get_grid_key(), 'trucks')
|
||||||
|
del master.MasterView.route_prefix
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# or it may specify model class
|
||||||
|
MyModel = MagicMock(__name__='Machine')
|
||||||
|
master.MasterView.model_class = MyModel
|
||||||
|
self.assertEqual(master.MasterView.get_grid_key(), 'machines')
|
||||||
|
del master.MasterView.model_class
|
||||||
|
|
||||||
def test_get_config_title(self):
|
def test_get_config_title(self):
|
||||||
|
|
||||||
# error by default (since no model class)
|
# error by default (since no model class)
|
||||||
|
@ -250,12 +311,6 @@ class TestMasterView(WebTestCase):
|
||||||
# support methods
|
# support methods
|
||||||
##############################
|
##############################
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
def test_render_to_response(self):
|
def test_render_to_response(self):
|
||||||
|
|
||||||
def widgets(request): return {}
|
def widgets(request): return {}
|
||||||
|
@ -286,21 +341,50 @@ class TestMasterView(WebTestCase):
|
||||||
self.assertRaises(IOError, view.render_to_response, 'nonexistent', {})
|
self.assertRaises(IOError, view.render_to_response, 'nonexistent', {})
|
||||||
del master.MasterView.model_name
|
del master.MasterView.model_name
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
def test_get_instance(self):
|
||||||
|
view = master.MasterView(self.request)
|
||||||
|
self.assertRaises(NotImplementedError, view.get_instance)
|
||||||
|
|
||||||
##############################
|
##############################
|
||||||
# view methods
|
# view methods
|
||||||
##############################
|
##############################
|
||||||
|
|
||||||
def test_index(self):
|
def test_index(self):
|
||||||
|
|
||||||
# basic sanity check using /appinfo
|
# sanity/coverage check using /settings/
|
||||||
master.MasterView.model_name = 'AppInfo'
|
master.MasterView.model_name = 'Setting'
|
||||||
master.MasterView.route_prefix = 'appinfo'
|
master.MasterView.model_key = 'name'
|
||||||
master.MasterView.template_prefix = '/appinfo'
|
master.MasterView.grid_columns = ['name', 'value']
|
||||||
view = master.MasterView(self.request)
|
view = master.MasterView(self.request)
|
||||||
response = view.index()
|
response = view.index()
|
||||||
|
# then again with data, to include view action url
|
||||||
|
data = [{'name': 'foo', 'value': 'bar'}]
|
||||||
|
with patch.object(view, 'index_get_grid_data', return_value=data):
|
||||||
|
response = view.index()
|
||||||
del master.MasterView.model_name
|
del master.MasterView.model_name
|
||||||
del master.MasterView.route_prefix
|
del master.MasterView.model_key
|
||||||
del master.MasterView.template_prefix
|
del master.MasterView.grid_columns
|
||||||
|
|
||||||
|
def test_view(self):
|
||||||
|
|
||||||
|
# sanity/coverage check using /settings/XXX
|
||||||
|
master.MasterView.model_name = 'Setting'
|
||||||
|
master.MasterView.grid_columns = ['name', 'value']
|
||||||
|
master.MasterView.form_fields = ['name', 'value']
|
||||||
|
view = master.MasterView(self.request)
|
||||||
|
setting = {'name': 'foo.bar', 'value': 'baz'}
|
||||||
|
self.request.matchdict = {'name': 'foo.bar'}
|
||||||
|
with patch.object(view, 'get_instance', return_value=setting):
|
||||||
|
response = view.view()
|
||||||
|
del master.MasterView.model_name
|
||||||
|
del master.MasterView.grid_columns
|
||||||
|
del master.MasterView.form_fields
|
||||||
|
|
||||||
def test_configure(self):
|
def test_configure(self):
|
||||||
model = self.app.model
|
model = self.app.model
|
||||||
|
|
|
@ -2,22 +2,66 @@
|
||||||
|
|
||||||
from tests.views.utils import WebTestCase
|
from tests.views.utils import WebTestCase
|
||||||
|
|
||||||
|
from pyramid.httpexceptions import HTTPNotFound
|
||||||
|
|
||||||
from wuttaweb.views import settings
|
from wuttaweb.views import settings
|
||||||
|
|
||||||
|
|
||||||
class TestAppInfoView(WebTestCase):
|
class TestAppInfoView(WebTestCase):
|
||||||
|
|
||||||
|
def make_view(self):
|
||||||
|
return settings.AppInfoView(self.request)
|
||||||
|
|
||||||
def test_index(self):
|
def test_index(self):
|
||||||
# sanity/coverage check
|
# sanity/coverage check
|
||||||
view = settings.AppInfoView(self.request)
|
view = self.make_view()
|
||||||
response = view.index()
|
response = view.index()
|
||||||
|
|
||||||
def test_configure_get_simple_settings(self):
|
def test_configure_get_simple_settings(self):
|
||||||
# sanity/coverage check
|
# sanity/coverage check
|
||||||
view = settings.AppInfoView(self.request)
|
view = self.make_view()
|
||||||
simple = view.configure_get_simple_settings()
|
simple = view.configure_get_simple_settings()
|
||||||
|
|
||||||
def test_configure_get_context(self):
|
def test_configure_get_context(self):
|
||||||
# sanity/coverage check
|
# sanity/coverage check
|
||||||
view = settings.AppInfoView(self.request)
|
view = self.make_view()
|
||||||
context = view.configure_get_context()
|
context = view.configure_get_context()
|
||||||
|
|
||||||
|
|
||||||
|
class TestSettingView(WebTestCase):
|
||||||
|
|
||||||
|
def make_view(self):
|
||||||
|
return settings.SettingView(self.request)
|
||||||
|
|
||||||
|
def test_index_get_grid_data(self):
|
||||||
|
|
||||||
|
# empty data by default
|
||||||
|
view = self.make_view()
|
||||||
|
data = view.index_get_grid_data(session=self.session)
|
||||||
|
self.assertEqual(len(data), 0)
|
||||||
|
|
||||||
|
# unless we save some settings
|
||||||
|
self.app.save_setting(self.session, 'foo', 'bar')
|
||||||
|
self.session.commit()
|
||||||
|
data = view.index_get_grid_data(session=self.session)
|
||||||
|
self.assertEqual(len(data), 1)
|
||||||
|
|
||||||
|
def test_get_instance(self):
|
||||||
|
view = self.make_view()
|
||||||
|
self.request.matchdict = {'name': 'foo'}
|
||||||
|
|
||||||
|
# setting not found
|
||||||
|
setting = view.get_instance(session=self.session)
|
||||||
|
self.assertIsInstance(setting, HTTPNotFound)
|
||||||
|
|
||||||
|
# setting is returned
|
||||||
|
self.app.save_setting(self.session, 'foo', 'bar')
|
||||||
|
self.session.commit()
|
||||||
|
setting = view.get_instance(session=self.session)
|
||||||
|
self.assertEqual(setting, {'name': 'foo', 'value': 'bar'})
|
||||||
|
|
||||||
|
def test_get_instance_title(self):
|
||||||
|
setting = {'name': 'foo', 'value': 'bar'}
|
||||||
|
view = self.make_view()
|
||||||
|
title = view.get_instance_title(setting)
|
||||||
|
self.assertEqual(title, 'foo')
|
||||||
|
|
Loading…
Reference in a new issue