3
0
Fork 0

Compare commits

...

2 commits

Author SHA1 Message Date
Lance Edgar 4c467f5267 feat: add basic support for "view" part of CRUD
still no SQLAlchemy yet, view must be explicit about data/model.  but
should support simple dict records, which will be needed in a few
places anyway
2024-08-07 19:47:24 -05:00
Lance Edgar 754e0989e4 feat: add basic Grid class, and /settings master view 2024-08-07 14:00:53 -05:00
22 changed files with 1372 additions and 81 deletions

View file

@ -0,0 +1,6 @@
``wuttaweb.grids.base``
=======================
.. automodule:: wuttaweb.grids.base
:members:

View file

@ -0,0 +1,6 @@
``wuttaweb.grids``
==================
.. automodule:: wuttaweb.grids
:members:

View file

@ -12,6 +12,8 @@
db db
forms forms
forms.base forms.base
grids
grids.base
handler handler
helpers helpers
menus menus

View file

@ -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

View file

@ -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

View 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
View 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

View file

@ -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',
},
], ],
} }

View file

@ -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>

View 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>
&nbsp;
% 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>

View file

@ -0,0 +1,5 @@
## -*- coding: utf-8; -*-
<%inherit file="/form.mako" />
${parent.body()}

View file

@ -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()}

View file

@ -0,0 +1,9 @@
## -*- coding: utf-8; -*-
<%inherit file="/master/form.mako" />
<%def name="title()">${index_title} &raquo; ${instance_title}</%def>
<%def name="content_title()">${instance_title}</%def>
${parent.body()}

View file

@ -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::

View file

@ -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',

View file

@ -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)

View file

@ -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
View file

150
tests/grids/test_base.py Normal file
View 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')

View file

@ -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)

View file

@ -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

View file

@ -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')