1
0
Fork 0

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
This commit is contained in:
Lance Edgar 2024-08-07 19:47:24 -05:00
parent 754e0989e4
commit 4c467f5267
14 changed files with 745 additions and 82 deletions

View file

@ -106,15 +106,48 @@ class Form:
Form instances contain the following attributes:
.. attribute:: request
Reference to current :term:`request` object.
.. attribute:: fields
:class:`FieldList` instance containing string field names for
the form. By default, fields will appear in the same order as
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
@ -161,6 +194,9 @@ class Form:
request,
fields=None,
schema=None,
model_class=None,
model_instance=None,
readonly=False,
labels={},
action_url=None,
vue_tagname='wutta-form',
@ -172,6 +208,7 @@ class Form:
):
self.request = request
self.schema = schema
self.readonly = readonly
self.labels = labels or {}
self.action_url = action_url
self.vue_tagname = vue_tagname
@ -184,6 +221,9 @@ class Form:
self.config = self.request.wutta_config
self.app = self.config.get_app()
self.model_class = model_class
self.model_instance = model_instance
if fields is not None:
self.set_fields(fields)
elif self.schema:
@ -260,9 +300,22 @@ class Form:
"""
Return the :class:`colander:colander.Schema` object for the
form, generating it automatically if necessary.
Note that if :attr:`schema` is already set, that will be
returned as-is.
"""
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
@ -273,7 +326,12 @@ class Form:
"""
if not hasattr(self, 'deform_form'):
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
return self.deform_form
@ -333,7 +391,7 @@ class Form:
output = render(template, context)
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
with label and containing a widget.
@ -350,11 +408,19 @@ class Form:
<!-- widget element(s) -->
</b-field>
"""
dform = self.get_deform()
field = dform[fieldname]
if readonly is None:
readonly = self.readonly
# 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)
# render field label

View file

@ -28,4 +28,4 @@ The ``wuttaweb.grids`` namespace contains the following:
* :class:`~wuttaweb.grids.base.Grid`
"""
from .base import Grid
from .base import Grid, GridAction

View file

@ -67,6 +67,11 @@ class Grid:
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
@ -79,11 +84,13 @@ class Grid:
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
@ -193,11 +200,145 @@ class Grid:
"""
Returns a list of Vue-compatible data records.
This uses :attr:`data` as the basis.
TODO: not clear yet how/where "non-simple" data should be
converted?
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()`.
"""
return self.data # TODO
# 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

@ -10,29 +10,31 @@
% endfor
</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:
<b-button native-type="reset">
Reset
% if form.show_button_reset:
<b-button native-type="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>
% 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>
</div>
</div>
% endif
${h.end_form()}
</script>

View file

@ -2,6 +2,7 @@
<script type="text/x-template" id="${grid.vue_tagname}-template">
<${b}-table :data="data"
hoverable
:loading="loading">
% for column in grid.get_vue_columns():
@ -13,6 +14,20 @@
</${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>

View file

@ -0,0 +1,5 @@
## -*- coding: utf-8; -*-
<%inherit file="/form.mako" />
${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

@ -68,7 +68,7 @@ class View:
Make and return a new :class:`~wuttaweb.forms.base.Form`
instance, per the given ``kwargs``.
This is the "base" form factory which merely invokes the
This is the "base" factory which merely invokes the
constructor.
"""
return forms.Form(self.request, **kwargs)
@ -78,11 +78,21 @@ class View:
Make and return a new :class:`~wuttaweb.grids.base.Grid`
instance, per the given ``kwargs``.
This is the "base" grid factory which merely invokes the
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):
"""
Convenience method, to raise a HTTP 404 Not Found exception::

View file

@ -100,6 +100,18 @@ class MasterView(View):
Code should not access this directly but instead call
: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'``.
@ -156,6 +168,18 @@ class MasterView(View):
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
Boolean indicating whether the master view supports
@ -170,10 +194,12 @@ class MasterView(View):
# features
listable = True
has_grid = True
viewable = True
configurable = False
# current action
listing = False
viewing = False
configuring = False
##############################
@ -230,6 +256,16 @@ class MasterView(View):
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
@ -273,6 +309,21 @@ class MasterView(View):
"""
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.
@ -285,6 +336,38 @@ class MasterView(View):
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
##############################
@ -560,14 +643,12 @@ class MasterView(View):
Save the given settings to the DB; this is called by
:meth:`configure()`.
This method expected a list of name/value dicts and will
simply save each to the DB, with no "conversion" logic.
This method expects a list of name/value dicts and will simply
save each to the DB, with no "conversion" logic.
:param settings: List of normalized setting definitions, as
returned by :meth:`configure_gather_settings()`.
"""
# app = self.get_rattail_app()
# nb. must avoid self.Session here in case that does not point
# to our primary app DB
session = Session()
@ -579,26 +660,6 @@ class MasterView(View):
# 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):
"""
Locate and render an appropriate template, with the given
@ -677,6 +738,110 @@ class MasterView(View):
"""
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
##############################
@ -772,6 +937,32 @@ class MasterView(View):
model_title = cls.get_model_title()
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
def get_route_prefix(cls):
"""
@ -822,6 +1013,27 @@ class MasterView(View):
route_prefix = cls.get_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
def get_template_prefix(cls):
"""
@ -923,6 +1135,13 @@ class MasterView(View):
config.add_view(cls, attr='index',
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
if cls.configurable:
config.add_route(f'{route_prefix}.configure',

View file

@ -48,6 +48,9 @@ class AppInfoView(MasterView):
model_title_plural = "App Info"
route_prefix = 'appinfo'
has_grid = False
viewable = False
editable = False
deletable = False
configurable = True
def configure_get_simple_settings(self):
@ -144,11 +147,15 @@ class SettingView(MasterView):
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):
@ -161,13 +168,35 @@ class SettingView(MasterView):
settings = []
for setting in query:
settings.append({
'name': setting.name,
'value': setting.value,
})
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):
base = globals()

View file

@ -59,8 +59,8 @@ class TestForm(TestCase):
def tearDown(self):
testing.tearDown()
def make_form(self, request=None, **kwargs):
return base.Form(request or self.request, **kwargs)
def make_form(self, **kwargs):
return base.Form(self.request, **kwargs)
def make_schema(self):
schema = colander.Schema(children=[
@ -124,19 +124,33 @@ class TestForm(TestCase):
self.assertIs(form.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'])
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.assertRaises(NotImplementedError, form.get_schema)
def test_get_deform(self):
schema = self.make_schema()
# basic
form = self.make_form(schema=schema)
self.assertFalse(hasattr(form, 'deform_form'))
dform = form.get_deform()
self.assertIsInstance(dform, deform.Form)
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):
form = self.make_form(fields=['foo', 'bar'])
self.assertEqual(form.get_label('foo'), "Foo")
@ -193,6 +207,13 @@ class TestForm(TestCase):
# nb. no error message
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
dform['foo'].error = MagicMock(msg="something is wrong")
html = form.render_vue_field('foo')

View file

@ -75,3 +75,76 @@ class TestGrid(TestCase):
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

@ -19,8 +19,9 @@ class TestMasterView(WebTestCase):
def test_defaults(self):
master.MasterView.model_name = 'Widget'
# TODO: should inspect pyramid routes after this, to be certain
master.MasterView.defaults(self.pyramid_config)
with patch.object(master.MasterView, 'viewable', new=False):
# TODO: should inspect pyramid routes after this, to be certain
master.MasterView.defaults(self.pyramid_config)
del master.MasterView.model_name
##############################
@ -122,6 +123,16 @@ class TestMasterView(WebTestCase):
self.assertEqual(master.MasterView.get_model_title_plural(), "Dinosaurs")
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):
# error by default (since no model class)
@ -179,6 +190,25 @@ class TestMasterView(WebTestCase):
self.assertEqual(master.MasterView.get_url_prefix(), '/machines')
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):
# error by default (since no model class)
@ -281,12 +311,6 @@ class TestMasterView(WebTestCase):
# 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 widgets(request): return {}
@ -317,24 +341,51 @@ class TestMasterView(WebTestCase):
self.assertRaises(IOError, view.render_to_response, 'nonexistent', {})
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
##############################
def test_index(self):
# basic sanity check using /appinfo
master.MasterView.model_name = 'AppInfo'
master.MasterView.route_prefix = 'appinfo'
master.MasterView.template_prefix = '/appinfo'
master.MasterView.grid_columns = ['foo', 'bar']
# sanity/coverage check using /settings/
master.MasterView.model_name = 'Setting'
master.MasterView.model_key = 'name'
master.MasterView.grid_columns = ['name', 'value']
view = master.MasterView(self.request)
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.route_prefix
del master.MasterView.template_prefix
del master.MasterView.model_key
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):
model = self.app.model

View file

@ -2,6 +2,8 @@
from tests.views.utils import WebTestCase
from pyramid.httpexceptions import HTTPNotFound
from wuttaweb.views import settings
@ -43,3 +45,23 @@ class TestSettingView(WebTestCase):
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')