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: 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
@ -161,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',
@ -172,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
@ -184,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:
@ -260,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
@ -273,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
@ -333,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.
@ -350,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

@ -28,4 +28,4 @@ The ``wuttaweb.grids`` namespace contains the following:
* :class:`~wuttaweb.grids.base.Grid` * :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 model records) or else an object capable of producing such a
list, e.g. SQLAlchemy query. 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 .. attribute:: vue_tagname
String name for Vue component tag. By default this is String name for Vue component tag. By default this is
@ -79,11 +84,13 @@ class Grid:
key=None, key=None,
columns=None, columns=None,
data=None, data=None,
actions=[],
vue_tagname='wutta-grid', vue_tagname='wutta-grid',
): ):
self.request = request self.request = request
self.key = key self.key = key
self.data = data self.data = data
self.actions = actions or []
self.vue_tagname = vue_tagname self.vue_tagname = vue_tagname
self.config = self.request.wutta_config self.config = self.request.wutta_config
@ -193,11 +200,145 @@ class Grid:
""" """
Returns a list of Vue-compatible data records. Returns a list of Vue-compatible data records.
This uses :attr:`data` as the basis. This uses :attr:`data` as the basis, but may add some extra
values to each record for sake of action URLs etc.
TODO: not clear yet how/where "non-simple" data should be
converted?
See also :meth:`get_vue_columns()`. 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 % 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

@ -2,6 +2,7 @@
<script type="text/x-template" id="${grid.vue_tagname}-template"> <script type="text/x-template" id="${grid.vue_tagname}-template">
<${b}-table :data="data" <${b}-table :data="data"
hoverable
:loading="loading"> :loading="loading">
% for column in grid.get_vue_columns(): % for column in grid.get_vue_columns():
@ -13,6 +14,20 @@
</${b}-table-column> </${b}-table-column>
% endfor % 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> </${b}-table>
</script> </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` Make and return a new :class:`~wuttaweb.forms.base.Form`
instance, per the given ``kwargs``. 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. constructor.
""" """
return forms.Form(self.request, **kwargs) return forms.Form(self.request, **kwargs)
@ -78,11 +78,21 @@ class View:
Make and return a new :class:`~wuttaweb.grids.base.Grid` Make and return a new :class:`~wuttaweb.grids.base.Grid`
instance, per the given ``kwargs``. 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. constructor.
""" """
return grids.Grid(self.request, **kwargs) 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,18 @@ 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 .. attribute:: grid_key
Optional override for the view's grid key, e.g. ``'widgets'``. 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()`. 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
@ -170,10 +194,12 @@ class MasterView(View):
# features # features
listable = True listable = True
has_grid = True has_grid = True
viewable = True
configurable = False configurable = False
# current action # current action
listing = False listing = False
viewing = False
configuring = False configuring = False
############################## ##############################
@ -230,6 +256,16 @@ class MasterView(View):
if 'data' not in kwargs: if 'data' not in kwargs:
kwargs['data'] = self.index_get_grid_data() 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) grid = self.make_grid(**kwargs)
self.index_configure_grid(grid) self.index_configure_grid(grid)
return grid return grid
@ -273,6 +309,21 @@ class MasterView(View):
""" """
return [] 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): def index_configure_grid(self, grid):
""" """
Configure the grid for the :meth:`index()` view. Configure the grid for the :meth:`index()` view.
@ -285,6 +336,38 @@ class MasterView(View):
based on request details etc. 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
############################## ##############################
@ -560,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()
@ -579,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
@ -677,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
############################## ##############################
@ -772,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):
""" """
@ -822,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):
""" """
@ -923,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

@ -48,6 +48,9 @@ class AppInfoView(MasterView):
model_title_plural = "App Info" model_title_plural = "App Info"
route_prefix = 'appinfo' route_prefix = 'appinfo'
has_grid = False 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):
@ -144,11 +147,15 @@ class SettingView(MasterView):
model_class = Setting model_class = Setting
model_title = "Raw Setting" model_title = "Raw Setting"
# TODO: this should be deduced by master
model_key = 'name'
# TODO: try removing these # TODO: try removing these
grid_columns = [ grid_columns = [
'name', 'name',
'value', 'value',
] ]
form_fields = list(grid_columns)
# TODO: should define query, let master handle the rest # TODO: should define query, let master handle the rest
def index_get_grid_data(self, session=None): def index_get_grid_data(self, session=None):
@ -161,13 +168,35 @@ class SettingView(MasterView):
settings = [] settings = []
for setting in query: for setting in query:
settings.append({ settings.append(self.normalize_setting(setting))
'name': setting.name,
'value': setting.value,
})
return settings 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()

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

View file

@ -75,3 +75,76 @@ class TestGrid(TestCase):
first = columns[0] first = columns[0]
self.assertEqual(first['field'], 'foo') self.assertEqual(first['field'], 'foo')
self.assertEqual(first['label'], '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): 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)
@ -281,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 {}
@ -317,24 +341,51 @@ 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']
master.MasterView.grid_columns = ['foo', 'bar']
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 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,6 +2,8 @@
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
@ -43,3 +45,23 @@ class TestSettingView(WebTestCase):
self.session.commit() self.session.commit()
data = view.index_get_grid_data(session=self.session) data = view.index_get_grid_data(session=self.session)
self.assertEqual(len(data), 1) 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')