2
0
Fork 0

Compare commits

..

No commits in common. "b4b72d92aa2d87d433973354cbd9d3e6e8947273" and "3d2aff7cc6309151e55076bae493e809ee918ac5" have entirely different histories.

50 changed files with 538 additions and 3659 deletions

View file

@ -5,27 +5,6 @@ All notable changes to wuttaweb will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
## v0.6.0 (2024-08-13)
### Feat
- add basic Roles view
- add Users view; improve CRUD master for SQLAlchemy models
- add People view; improve CRUD master for SQLAlchemy models
- add basic support for SQLAlchemy model in master view
- add basic Create support for CRUD master view
- add basic Delete support for CRUD master view
- add basic Edit support for CRUD master view
- add auto-link (to "View") behavior for grid columns
- add basic support for "view" part of CRUD
- add basic `Grid` class, and /settings master view
### Fix
- rename MasterView method to `configure_grid()`
- replace default logo, favicon images
- tweak labels for Web Libraries config
## v0.5.0 (2024-08-06) ## v0.5.0 (2024-08-06)
### Feat ### Feat

View file

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

View file

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

View file

@ -12,8 +12,6 @@
db db
forms forms
forms.base forms.base
forms.schema
forms.widgets
grids grids
grids.base grids.base
handler handler
@ -28,7 +26,4 @@
views.common views.common
views.essential views.essential
views.master views.master
views.people
views.roles
views.settings views.settings
views.users

View file

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

View file

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

View file

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

View file

@ -6,7 +6,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "WuttaWeb" name = "WuttaWeb"
version = "0.6.0" version = "0.5.0"
description = "Web App for Wutta Framework" description = "Web App for Wutta Framework"
readme = "README.md" readme = "README.md"
authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]
@ -30,7 +30,6 @@ classifiers = [
] ]
requires-python = ">= 3.8" requires-python = ">= 3.8"
dependencies = [ dependencies = [
"ColanderAlchemy",
"pyramid>=2", "pyramid>=2",
"pyramid_beaker", "pyramid_beaker",
"pyramid_deform", "pyramid_deform",
@ -39,7 +38,7 @@ dependencies = [
"pyramid_tm", "pyramid_tm",
"waitress", "waitress",
"WebHelpers2", "WebHelpers2",
"WuttJamaican[db]>=0.11.0", "WuttJamaican[db]>=0.10.0",
"zope.sqlalchemy>=1.5", "zope.sqlalchemy>=1.5",
] ]

View file

@ -24,20 +24,65 @@
Base form classes Base form classes
""" """
import json
import logging import logging
import colander import colander
import deform import deform
from colanderalchemy import SQLAlchemySchemaNode
from pyramid.renderers import render from pyramid.renderers import render
from webhelpers2.html import HTML from webhelpers2.html import HTML
from wuttaweb.util import FieldList, get_form_data, get_model_fields, make_json_safe from wuttaweb.util import get_form_data
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
class FieldList(list):
"""
Convenience wrapper for a form's field list. This is a subclass
of :class:`python:list`.
You normally would not need to instantiate this yourself, but it
is used under the hood for :attr:`Form.fields` as well as
:attr:`~wuttaweb.grids.base.Grid.columns`.
"""
def insert_before(self, field, newfield):
"""
Insert a new field, before an existing field.
:param field: String name for the existing field.
:param newfield: String name for the new field, to be inserted
just before the existing ``field``.
"""
if field in self:
i = self.index(field)
self.insert(i, newfield)
else:
log.warning("field '%s' not found, will append new field: %s",
field, newfield)
self.append(newfield)
def insert_after(self, field, newfield):
"""
Insert a new field, after an existing field.
:param field: String name for the existing field.
:param newfield: String name for the new field, to be inserted
just after the existing ``field``.
"""
if field in self:
i = self.index(field)
self.insert(i + 1, newfield)
else:
log.warning("field '%s' not found, will append new field: %s",
field, newfield)
self.append(newfield)
class Form: class Form:
""" """
Base class for all forms. Base class for all forms.
@ -67,26 +112,23 @@ class Form:
.. attribute:: fields .. attribute:: fields
:class:`~wuttaweb.util.FieldList` instance containing string :class:`FieldList` instance containing string field names for
field names for the form. By default, fields will appear in the form. By default, fields will appear in the same order as
the same order as they are in this list. they are in this list.
See also :meth:`set_fields()`.
.. attribute:: schema .. attribute:: schema
:class:`colander:colander.Schema` object for the form. This is Colander-based schema object for the form. This is optional;
optional; if not specified an attempt will be made to construct if not specified an attempt will be made to construct one
one automatically. automatically.
See also :meth:`get_schema()`. See also :meth:`get_schema()`.
.. attribute:: model_class .. attribute:: model_class
Model class for the form, if applicable. When set, this is Optional "class" for the model. If set, this usually would be
usually a SQLAlchemy mapped class. This (or a SQLAlchemy mapped class. This may be used instead of
:attr:`model_instance`) may be used instead of specifying the specifying the :attr:`schema`.
:attr:`schema`.
.. attribute:: model_instance .. attribute:: model_instance
@ -99,27 +141,6 @@ class Form:
SQLAlchemy-mapped. (In that case :attr:`model_class` can be SQLAlchemy-mapped. (In that case :attr:`model_class` can be
determined automatically.) determined automatically.)
.. attribute:: nodes
Dict of node overrides, used to construct the form in
:meth:`get_schema()`.
See also :meth:`set_node()`.
.. attribute:: widgets
Dict of widget overrides, used to construct the form in
:meth:`get_schema()`.
See also :meth:`set_widget()`.
.. attribute:: validators
Dict of node validators, used to construct the form in
:meth:`get_schema()`.
See also :meth:`set_validator()`.
.. attribute:: readonly .. attribute:: readonly
Boolean indicating the form does not allow submit. In practice Boolean indicating the form does not allow submit. In practice
@ -128,48 +149,10 @@ class Form:
Default for this is ``False`` in which case the ``<form>`` tag Default for this is ``False`` in which case the ``<form>`` tag
will exist and submit is allowed. will exist and submit is allowed.
.. attribute:: readonly_fields
A :class:`~python:set` of field names which should be readonly.
Each will still be rendered but with static value text and no
widget.
This is only applicable if :attr:`readonly` is ``False``.
See also :meth:`set_readonly()` and :meth:`is_readonly()`.
.. attribute:: required_fields
A dict of "required" field flags. Keys are field names, and
values are boolean flags indicating whether the field is
required.
Depending on :attr:`schema`, some fields may be "(not)
required" by default. However ``required_fields`` keeps track
of any "overrides" per field.
See also :meth:`set_required()` and :meth:`is_required()`.
.. attribute:: action_url .. attribute:: action_url
String URL to which the form should be submitted, if applicable. String URL to which the form should be submitted, if applicable.
.. attribute:: cancel_url
String URL to which the Cancel button should "always" redirect,
if applicable.
Code should not access this directly, but instead call
:meth:`get_cancel_url()`.
.. attribute:: cancel_url_fallback
String URL to which the Cancel button should redirect, if
referrer cannot be determined from request.
Code should not access this directly, but instead call
:meth:`get_cancel_url()`.
.. 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
@ -194,41 +177,9 @@ class Form:
String icon name for the form submit button. Default is ``'save'``. String icon name for the form submit button. Default is ``'save'``.
.. attribute:: button_type_submit
Buefy type for the submit button. Default is ``'is-primary'``,
so for example:
.. code-block:: html
<b-button type="is-primary"
native-type="submit">
Save
</b-button>
See also the `Buefy docs
<https://buefy.org/documentation/button/#api-view>`_.
.. attribute:: show_button_reset .. attribute:: show_button_reset
Flag indicating whether a Reset button should be shown. Flag indicating whether a Reset button should be shown.
Default is ``False``.
.. attribute:: show_button_cancel
Flag indicating whether a Cancel button should be shown.
Default is ``True``.
.. attribute:: button_label_cancel
String label for the form cancel button. Default is
``"Cancel"``.
.. attribute:: auto_disable_cancel
Flag indicating whether the cancel button should be
auto-disabled, whenever the button is clicked. Default is
``True``.
.. attribute:: validated .. attribute:: validated
@ -245,60 +196,40 @@ class Form:
schema=None, schema=None,
model_class=None, model_class=None,
model_instance=None, model_instance=None,
nodes={},
widgets={},
validators={},
readonly=False, readonly=False,
readonly_fields=[],
required_fields={},
labels={}, labels={},
action_url=None, action_url=None,
cancel_url=None,
cancel_url_fallback=None,
vue_tagname='wutta-form', vue_tagname='wutta-form',
align_buttons_right=False, align_buttons_right=False,
auto_disable_submit=True, auto_disable_submit=True,
button_label_submit="Save", button_label_submit="Save",
button_icon_submit='save', button_icon_submit='save',
button_type_submit='is-primary',
show_button_reset=False, show_button_reset=False,
show_button_cancel=True,
button_label_cancel="Cancel",
auto_disable_cancel=True,
): ):
self.request = request self.request = request
self.schema = schema self.schema = schema
self.nodes = nodes or {}
self.widgets = widgets or {}
self.validators = validators or {}
self.readonly = readonly self.readonly = readonly
self.readonly_fields = set(readonly_fields or [])
self.required_fields = required_fields or {}
self.labels = labels or {} self.labels = labels or {}
self.action_url = action_url self.action_url = action_url
self.cancel_url = cancel_url
self.cancel_url_fallback = cancel_url_fallback
self.vue_tagname = vue_tagname self.vue_tagname = vue_tagname
self.align_buttons_right = align_buttons_right self.align_buttons_right = align_buttons_right
self.auto_disable_submit = auto_disable_submit self.auto_disable_submit = auto_disable_submit
self.button_label_submit = button_label_submit self.button_label_submit = button_label_submit
self.button_icon_submit = button_icon_submit self.button_icon_submit = button_icon_submit
self.button_type_submit = button_type_submit
self.show_button_reset = show_button_reset self.show_button_reset = show_button_reset
self.show_button_cancel = show_button_cancel
self.button_label_cancel = button_label_cancel
self.auto_disable_cancel = auto_disable_cancel
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_class = model_class
self.model_instance = model_instance self.model_instance = model_instance
if self.model_instance and not self.model_class:
if type(self.model_instance) is not dict:
self.model_class = type(self.model_instance)
self.set_fields(fields or self.get_fields()) if fields is not None:
self.set_fields(fields)
elif self.schema:
self.set_fields([f.name for f in self.schema])
else:
self.fields = None
def __contains__(self, name): def __contains__(self, name):
""" """
@ -331,216 +262,17 @@ class Form:
words = self.vue_tagname.split('-') words = self.vue_tagname.split('-')
return ''.join([word.capitalize() for word in words]) return ''.join([word.capitalize() for word in words])
def get_cancel_url(self):
"""
Returns the URL for the Cancel button.
If :attr:`cancel_url` is set, its value is returned.
Or, if the referrer can be deduced from the request, that is
returned.
Or, if :attr:`cancel_url_fallback` is set, that value is
returned.
As a last resort the "default" URL from
:func:`~wuttaweb.subscribers.request.get_referrer()` is
returned.
"""
# use "permanent" URL if set
if self.cancel_url:
return self.cancel_url
# nb. use fake default to avoid normal default logic;
# that way if we get something it's a real referrer
url = self.request.get_referrer(default='NOPE')
if url and url != 'NOPE':
return url
# use fallback URL if set
if self.cancel_url_fallback:
return self.cancel_url_fallback
# okay, home page then (or whatever is the default URL)
return self.request.get_referrer()
def set_fields(self, fields): def set_fields(self, fields):
""" """
Explicitly set the list of form fields. Explicitly set the list of form fields.
This will overwrite :attr:`fields` with a new This will overwrite :attr:`fields` with a new
:class:`~wuttaweb.util.FieldList` instance. :class:`FieldList` instance.
:param fields: List of string field names. :param fields: List of string field names.
""" """
self.fields = FieldList(fields) self.fields = FieldList(fields)
def remove(self, *keys):
"""
Remove some fields(s) from the form.
This is a convenience to allow removal of multiple fields at
once::
form.remove('first_field',
'second_field',
'third_field')
It will remove each field from :attr:`fields`.
"""
for key in keys:
if key in self.fields:
self.fields.remove(key)
def set_node(self, key, nodeinfo, **kwargs):
"""
Set/override the node for a field.
:param key: Name of field.
:param nodeinfo: Should be either a
:class:`colander:colander.SchemaNode` instance, or else a
:class:`colander:colander.SchemaType` instance.
If ``nodeinfo`` is a proper node instance, it will be used
as-is. Otherwise an
:class:`~wuttaweb.forms.schema.ObjectNode` instance will be
constructed using ``nodeinfo`` as the type (``typ``).
Node overrides are tracked via :attr:`nodes`.
"""
if isinstance(nodeinfo, colander.SchemaNode):
# assume nodeinfo is a complete node
node = nodeinfo
else: # assume nodeinfo is a schema type
kwargs.setdefault('name', key)
from wuttaweb.forms.schema import ObjectNode
# node = colander.SchemaNode(nodeinfo, **kwargs)
node = ObjectNode(nodeinfo, **kwargs)
self.nodes[key] = node
# must explicitly replace node, if we already have a schema
if self.schema:
self.schema[key] = node
def set_widget(self, key, widget):
"""
Set/override the widget for a field.
:param key: Name of field.
:param widget: Instance of
:class:`deform:deform.widget.Widget`.
Widget overrides are tracked via :attr:`widgets`.
"""
self.widgets[key] = widget
# update schema if necessary
if self.schema and key in self.schema:
self.schema[key].widget = widget
def set_validator(self, key, validator):
"""
Set/override the validator for a field, or the form.
:param key: Name of field. This may also be ``None`` in which
case the validator will apply to the whole form instead of
a field.
:param validator: Callable which accepts ``(node, value)``
args. For instance::
def validate_foo(node, value):
if value == 42:
node.raise_invalid("42 is not allowed!")
form = Form(fields=['foo', 'bar'])
form.set_validator('foo', validate_foo)
Validator overrides are tracked via :attr:`validators`.
"""
self.validators[key] = validator
# nb. must apply to existing schema if present
if self.schema and key in self.schema:
self.schema[key].validator = validator
def set_readonly(self, key, readonly=True):
"""
Enable or disable the "readonly" flag for a given field.
When a field is marked readonly, it will be shown in the form
but there will be no editable widget. The field is skipped
over (not saved) when form is submitted.
See also :meth:`is_readonly()`; this is tracked via
:attr:`readonly_fields`.
:param key: String key (fieldname) for the field.
:param readonly: New readonly flag for the field.
"""
if readonly:
self.readonly_fields.add(key)
else:
if key in self.readonly_fields:
self.readonly_fields.remove(key)
def is_readonly(self, key):
"""
Returns boolean indicating if the given field is marked as
readonly.
See also :meth:`set_readonly()`.
:param key: Field key/name as string.
"""
if self.readonly_fields:
if key in self.readonly_fields:
return True
return False
def set_required(self, key, required=True):
"""
Enable or disable the "required" flag for a given field.
When a field is marked required, a value must be provided
or else it fails validation.
In practice if a field is "not required" then a default
"empty" value is assumed, should the user not provide one.
See also :meth:`is_required()`; this is tracked via
:attr:`required_fields`.
:param key: String key (fieldname) for the field.
:param required: New required flag for the field. Usually a
boolean, but may also be ``None`` to remove any flag and
revert to default behavior for the field.
"""
self.required_fields[key] = required
def is_required(self, key):
"""
Returns boolean indicating if the given field is marked as
required.
See also :meth:`set_required()`.
:param key: Field key/name as string.
:returns: Value for the flag from :attr:`required_fields` if
present; otherwise ``None``.
"""
return self.required_fields.get(key, None)
def set_label(self, key, label): def set_label(self, key, label):
""" """
Set the label for given field name. Set the label for given field name.
@ -564,45 +296,6 @@ class Form:
""" """
return self.labels.get(key, self.app.make_title(key)) return self.labels.get(key, self.app.make_title(key))
def get_fields(self):
"""
Returns the official list of field names for the form, or
``None``.
If :attr:`fields` is set and non-empty, it is returned.
Or, if :attr:`schema` is set, the field list is derived
from that.
Or, if :attr:`model_class` is set, the field list is derived
from that, via :meth:`get_model_fields()`.
Otherwise ``None`` is returned.
"""
if hasattr(self, 'fields') and self.fields:
return self.fields
if self.schema:
return [field.name for field in self.schema]
fields = self.get_model_fields()
if fields:
return fields
return []
def get_model_fields(self, model_class=None):
"""
This method is a shortcut which calls
:func:`~wuttaweb.util.get_model_fields()`.
:param model_class: Optional model class for which to return
fields. If not set, the form's :attr:`model_class` is
assumed.
"""
return get_model_fields(self.config,
model_class=model_class or self.model_class)
def get_schema(self): def get_schema(self):
""" """
Return the :class:`colander:colander.Schema` object for the Return the :class:`colander:colander.Schema` object for the
@ -613,85 +306,16 @@ class Form:
""" """
if not self.schema: if not self.schema:
############################## if self.fields:
# create schema
##############################
# get fields
fields = self.get_fields()
if not fields:
raise NotImplementedError
if self.model_class:
# first define full list of 'includes' - final schema
# should contain all of these fields
includes = list(fields)
# determine which we want ColanderAlchemy to handle
auto_includes = []
for key in includes:
# skip if we already have a node defined
if key in self.nodes:
continue
# we want the magic for this field
auto_includes.append(key)
# make initial schema with ColanderAlchemy magic
schema = SQLAlchemySchemaNode(self.model_class,
includes=auto_includes)
# now fill in the blanks for non-magic fields
for key in includes:
if key not in auto_includes:
node = self.nodes[key]
schema.add(node)
else:
# make basic schema
schema = colander.Schema() schema = colander.Schema()
for key in fields: for name in self.fields:
node = None schema.add(colander.SchemaNode(
colander.String(),
name=name))
self.schema = schema
# use node override if present else: # no fields
if key in self.nodes: raise NotImplementedError
node = self.nodes[key]
if not node:
# otherwise make simple string node
node = colander.SchemaNode(
colander.String(),
name=key)
schema.add(node)
##############################
# customize schema
##############################
# apply widget overrides
for key, widget in self.widgets.items():
if key in schema:
schema[key].widget = widget
# apply validator overrides
for key, validator in self.validators.items():
if key is None:
# nb. this one is form-wide
schema.validator = validator
elif key in schema: # field-level
schema[key].validator = validator
# apply required flags
for key, required in self.required_fields.items():
if key in schema:
if required is False:
schema[key].missing = colander.null
self.schema = schema
return self.schema return self.schema
@ -701,17 +325,11 @@ class Form:
generating it automatically if necessary. generating it automatically if necessary.
""" """
if not hasattr(self, 'deform_form'): if not hasattr(self, 'deform_form'):
model = self.app.model
schema = self.get_schema() schema = self.get_schema()
kwargs = {} kwargs = {}
if self.model_instance: if self.model_instance:
# TODO: would it be smarter to test with hasattr() ? kwargs['appstruct'] = self.model_instance
# if hasattr(schema, 'dictify'):
if isinstance(self.model_instance, model.Base):
kwargs['appstruct'] = schema.dictify(self.model_instance)
else:
kwargs['appstruct'] = self.model_instance
form = deform.Form(schema, **kwargs) form = deform.Form(schema, **kwargs)
self.deform_form = form self.deform_form = form
@ -763,10 +381,8 @@ class Form:
the output. the output.
""" """
context['form'] = self context['form'] = self
context['dform'] = self.get_deform()
context.setdefault('form_attrs', {}) context.setdefault('form_attrs', {})
context.setdefault('request', self.request) context.setdefault('request', self.request)
context['model_data'] = self.get_vue_model_data()
# auto disable button on submit # auto disable button on submit
if self.auto_disable_submit: if self.auto_disable_submit:
@ -792,34 +408,17 @@ class Form:
<!-- widget element(s) --> <!-- widget element(s) -->
</b-field> </b-field>
""" """
# readonly comes from: caller, field flag, or form flag
if readonly is None:
readonly = self.is_readonly(fieldname)
if not readonly:
readonly = self.readonly
# but also, fields not in deform/schema must be readonly if readonly is None:
dform = self.get_deform() readonly = self.readonly
if not readonly and fieldname not in dform:
readonly = True
# render the field widget or whatever # render the field widget or whatever
if fieldname in dform: dform = self.get_deform()
field = dform[fieldname]
# render proper widget if field is in deform/schema kw = {}
field = dform[fieldname] if readonly:
kw = {} kw['readonly'] = True
if readonly: html = field.serialize(**kw)
kw['readonly'] = True
html = field.serialize(**kw)
else:
# render static text if field not in deform/schema
# TODO: need to abstract this somehow
if self.model_instance:
html = str(self.model_instance[fieldname])
else:
html = ''
# mark all that as safe # mark all that as safe
html = HTML.literal(html) html = HTML.literal(html)
@ -864,100 +463,6 @@ class Form:
return HTML.tag('b-field', c=[html], **attrs) return HTML.tag('b-field', c=[html], **attrs)
def get_vue_model_data(self):
"""
Returns a dict with form model data. Values may be nested
depending on the types of fields contained in the form.
Note that the values need not be "converted" (to be
JSON-compatible) at this stage, for instance ``colander.null``
is not a problem here. The point is to collect the raw data.
The dict should have a key/value for each field in the form.
This method is called by :meth:`render_vue_model_data()` which
is responsible for ensuring JSON compatibility.
"""
dform = self.get_deform()
model_data = {}
def assign(field):
model_data[field.oid] = make_json_safe(field.cstruct)
for key in self.fields:
# TODO: i thought commented code was useful, but no longer sure?
# TODO: need to describe the scenario when this is true
if key not in dform:
# log.warning("field '%s' is missing from deform", key)
continue
field = dform[key]
# if hasattr(field, 'children'):
# for subfield in field.children:
# assign(subfield)
assign(field)
return model_data
def validate(self):
"""
Try to validate the form, using data from the :attr:`request`.
Uses :func:`~wuttaweb.util.get_form_data()` to retrieve the
form data from POST or JSON body.
If the form data is valid, the data dict is returned. This
data dict is also made available on the form object via the
:attr:`validated` attribute.
However if the data is not valid, ``False`` is returned, and
there will be no :attr:`validated` attribute. In that case
you should inspect the form errors to learn/display what went
wrong for the user's sake. See also
:meth:`get_field_errors()`.
This uses :meth:`deform:deform.Field.validate()` under the
hood.
.. warning::
Calling ``validate()`` on some forms will cause the
underlying Deform and Colander structures to mutate. In
particular, all :attr:`readonly_fields` will be *removed*
from the :attr:`schema` to ensure they are not involved in
the validation.
:returns: Data dict, or ``False``.
"""
if hasattr(self, 'validated'):
del self.validated
if self.request.method != 'POST':
return False
# remove all readonly fields from deform / schema
dform = self.get_deform()
if self.readonly_fields:
schema = self.get_schema()
for field in self.readonly_fields:
if field in schema:
del schema[field]
dform.children.remove(dform[field])
# let deform do real validation
controls = get_form_data(self.request).items()
try:
self.validated = dform.validate(controls)
except deform.ValidationFailure:
log.debug("form not valid: %s", dform.error)
return False
return self.validated
def get_field_errors(self, field): def get_field_errors(self, field):
""" """
Return a list of error messages for the given field. Return a list of error messages for the given field.
@ -970,3 +475,70 @@ class Form:
if error: if error:
return [error] return [error]
return [] return []
def get_vue_field_value(self, field):
"""
This method returns a JSON string which will be assigned as
the initial model value for the given field. This JSON will
be written as part of the overall response, to be interpreted
on the client side.
Again, this must return a *string* such as:
* ``'null'``
* ``'{"foo": "bar"}'``
In practice this calls :meth:`jsonify_value()` to convert the
``field.cstruct`` value to string.
"""
if isinstance(field, str):
dform = self.get_deform()
field = dform[field]
return self.jsonify_value(field.cstruct)
def jsonify_value(self, value):
"""
Convert a Python value to JSON string.
See also :meth:`get_vue_field_value()`.
"""
if value is colander.null:
return 'null'
return json.dumps(value)
def validate(self):
"""
Try to validate the form.
This should work whether request data was submitted as classic
POST data, or as JSON body.
If the form data is valid, this method returns the data dict.
This data dict is also then available on the form object via
the :attr:`validated` attribute.
However if the data is not valid, ``False`` is returned, and
there will be no :attr:`validated` attribute. In that case
you should inspect the form errors to learn/display what went
wrong for the user's sake. See also
:meth:`get_field_errors()`.
:returns: Data dict, or ``False``.
"""
if hasattr(self, 'validated'):
del self.validated
if self.request.method != 'POST':
return False
dform = self.get_deform()
controls = get_form_data(self.request).items()
try:
self.validated = dform.validate(controls)
except deform.ValidationFailure:
return False
return self.validated

View file

@ -1,259 +0,0 @@
# -*- 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/>.
#
################################################################################
"""
Form schema types
"""
import colander
from wuttaweb.db import Session
from wuttaweb.forms import widgets
from wuttjamaican.db.model import Person
class ObjectNode(colander.SchemaNode):
"""
Custom schema node class which adds methods for compatibility with
ColanderAlchemy. This is a direct subclass of
:class:`colander:colander.SchemaNode`.
ColanderAlchemy will call certain methods on any node found in the
schema. However these methods are not "standard" and only exist
for ColanderAlchemy nodes.
So we must add nodes using this class, to ensure the node has all
methods needed by ColanderAlchemy.
"""
def dictify(self, obj):
"""
This method is called by ColanderAlchemy when translating the
in-app Python object to a value suitable for use in the form
data dict.
The logic here will look for a ``dictify()`` method on the
node's "type" instance (``self.typ``; see also
:class:`colander:colander.SchemaNode`) and invoke it if found.
For an example type which is supported in this way, see
:class:`ObjectRef`.
If the node's type does not have a ``dictify()`` method, this
will raise ``NotImplementeError``.
"""
if hasattr(self.typ, 'dictify'):
return self.typ.dictify(obj)
class_name = self.typ.__class__.__name__
raise NotImplementedError(f"you must define {class_name}.dictify()")
def objectify(self, value):
"""
This method is called by ColanderAlchemy when translating form
data to the final Python representation.
The logic here will look for an ``objectify()`` method on the
node's "type" instance (``self.typ``; see also
:class:`colander:colander.SchemaNode`) and invoke it if found.
For an example type which is supported in this way, see
:class:`ObjectRef`.
If the node's type does not have an ``objectify()`` method,
this will raise ``NotImplementeError``.
"""
if hasattr(self.typ, 'objectify'):
return self.typ.objectify(value)
class_name = self.typ.__class__.__name__
raise NotImplementedError(f"you must define {class_name}.objectify()")
class ObjectRef(colander.SchemaType):
"""
Custom schema type for a model class reference field.
This expects the incoming ``appstruct`` to be either a model
record instance, or ``None``.
Serializes to the instance UUID as string, or ``colander.null``;
form data should be of the same nature.
This schema type is not useful directly, but various other types
will subclass it. Each should define (at least) the
:attr:`model_class` attribute or property.
:param request: Current :term:`request` object.
:param empty_option: If a select widget is used, this determines
whether an empty option is included for the dropdown. Set
this to one of the following to add an empty option:
* ``True`` to add the default empty option
* label text for the empty option
* tuple of ``(value, label)`` for the empty option
Note that in the latter, ``value`` must be a string.
"""
default_empty_option = ('', "(none)")
def __init__(
self,
request,
empty_option=None,
session=None,
*args,
**kwargs,
):
super().__init__(*args, **kwargs)
self.request = request
self.config = self.request.wutta_config
self.app = self.config.get_app()
self.model_instance = None
self.session = session or Session()
if empty_option:
if empty_option is True:
self.empty_option = self.default_empty_option
elif isinstance(empty_option, tuple) and len(empty_option) == 2:
self.empty_option = empty_option
else:
self.empty_option = ('', str(empty_option))
else:
self.empty_option = None
@property
def model_class(self):
"""
Should be a reference to the model class to which this schema
type applies
(e.g. :class:`~wuttjamaican:wuttjamaican.db.model.base.Person`).
"""
class_name = self.__class__.__name__
raise NotImplementedError(f"you must define {class_name}.model_class")
def serialize(self, node, appstruct):
""" """
if appstruct is colander.null:
return colander.null
# nb. keep a ref to this for later use
node.model_instance = appstruct
# serialize to uuid
return appstruct.uuid
def deserialize(self, node, cstruct):
""" """
if not cstruct:
return colander.null
# nb. use shortcut to fetch model instance from DB
return self.objectify(cstruct)
def dictify(self, obj):
""" """
# TODO: would we ever need to do something else?
return obj
def objectify(self, value):
"""
For the given UUID value, returns the object it represents
(based on :attr:`model_class`).
If the value is empty, returns ``None``.
If the value is not empty but object cannot be found, raises
``colander.Invalid``.
"""
if not value:
return
if isinstance(value, self.model_class):
return value
# fetch object from DB
model = self.app.model
obj = self.session.query(self.model_class).get(value)
# raise error if not found
if not obj:
class_name = self.model_class.__name__
raise ValueError(f"{class_name} not found: {value}")
return obj
def get_query(self):
"""
Returns the main SQLAlchemy query responsible for locating the
dropdown choices for the select widget.
This is called by :meth:`widget_maker()`.
"""
query = self.session.query(self.model_class)
query = self.sort_query(query)
return query
def sort_query(self, query):
"""
TODO
"""
return query
def widget_maker(self, **kwargs):
"""
This method is responsible for producing the default widget
for the schema node.
Deform calls this method automatically when constructing the
default widget for a field.
:returns: Instance of
:class:`~wuttaweb.forms.widgets.ObjectRefWidget`.
"""
if 'values' not in kwargs:
query = self.get_query()
objects = query.all()
values = [(obj.uuid, str(obj))
for obj in objects]
if self.empty_option:
values.insert(0, self.empty_option)
kwargs['values'] = values
return widgets.ObjectRefWidget(self.request, **kwargs)
class PersonRef(ObjectRef):
"""
Custom schema type for a ``Person`` reference field.
This is a subclass of :class:`ObjectRef`.
"""
model_class = Person
def sort_query(self, query):
""" """
return query.order_by(self.model_class.full_name)

View file

@ -1,82 +0,0 @@
# -*- 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/>.
#
################################################################################
"""
Form widgets
This module defines some custom widgets for use with WuttaWeb.
However for convenience it also makes other Deform widgets available
in the namespace:
* :class:`deform:deform.widget.Widget` (base class)
* :class:`deform:deform.widget.TextInputWidget`
* :class:`deform:deform.widget.SelectWidget`
"""
from deform.widget import Widget, TextInputWidget, SelectWidget
from webhelpers2.html import HTML
class ObjectRefWidget(SelectWidget):
"""
Widget for use with model "object reference" fields, e.g. foreign
key UUID => TargetModel instance.
While you may create instances of this widget directly, it
normally happens automatically when schema nodes of the
:class:`~wuttaweb.forms.schema.ObjectRef` (sub)type are part of
the form schema; via
:meth:`~wuttaweb.forms.schema.ObjectRef.widget_maker()`.
.. attribute:: model_instance
Reference to the model record instance, i.e. the "far side" of
the foreign key relationship.
.. note::
You do not need to provide the ``model_instance`` when
constructing the widget. Rather, it is set automatically
when the :class:`~wuttaweb.forms.schema.ObjectRef` type
instance (associated with the node) is serialized.
"""
def __init__(self, request, *args, **kwargs):
super().__init__(*args, **kwargs)
self.request = request
def serialize(self, field, cstruct, **kw):
"""
Serialize the widget.
In readonly mode, returns a ``<span>`` tag around the
:attr:`model_instance` rendered as string.
Otherwise renders via the ``deform/select`` template.
"""
readonly = kw.get('readonly', self.readonly)
if readonly:
obj = field.schema.model_instance
return HTML.tag('span', c=str(obj or ''))
return super().serialize(field, cstruct, **kw)

View file

@ -24,18 +24,10 @@
Base grid classes Base grid classes
""" """
import json
import logging
import sqlalchemy as sa
from pyramid.renderers import render from pyramid.renderers import render
from webhelpers2.html import HTML from webhelpers2.html import HTML
from wuttaweb.util import FieldList, get_model_fields, make_json_safe from wuttaweb.forms import FieldList
log = logging.getLogger(__name__)
class Grid: class Grid:
@ -60,19 +52,13 @@ class Grid:
Presumably unique key for the grid; used to track per-grid Presumably unique key for the grid; used to track per-grid
sort/filter settings etc. sort/filter settings etc.
.. attribute:: model_class
Model class for the grid, if applicable. When set, this is
usually a SQLAlchemy mapped class. This may be used for
deriving the default :attr:`columns` among other things.
.. attribute:: columns .. attribute:: columns
:class:`~wuttaweb.forms.base.FieldList` instance containing :class:`~wuttaweb.forms.base.FieldList` instance containing
string column names for the grid. Columns will appear in the string column names for the grid. Columns will appear in the
same order as they are in this list. same order as they are in this list.
See also :meth:`set_columns()` and :meth:`get_columns()`. See also :meth:`set_columns()`.
.. attribute:: data .. attribute:: data
@ -86,13 +72,6 @@ class Grid:
List of :class:`GridAction` instances represenging action links List of :class:`GridAction` instances represenging action links
to be shown for each record in the grid. to be shown for each record in the grid.
.. attribute:: linked_columns
List of column names for which auto-link behavior should be
applied.
See also :meth:`set_link()` and :meth:`is_linked()`.
.. 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
@ -102,59 +81,25 @@ class Grid:
def __init__( def __init__(
self, self,
request, request,
model_class=None,
key=None, key=None,
columns=None, columns=None,
data=None, data=None,
actions=[], actions=[],
linked_columns=[],
vue_tagname='wutta-grid', vue_tagname='wutta-grid',
): ):
self.request = request self.request = request
self.model_class = model_class
self.key = key self.key = key
self.data = data self.data = data
self.actions = actions or [] self.actions = actions or []
self.linked_columns = linked_columns or []
self.vue_tagname = vue_tagname self.vue_tagname = vue_tagname
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.set_columns(columns or self.get_columns()) if columns is not None:
self.set_columns(columns)
def get_columns(self): else:
""" self.columns = None
Returns the official list of column names for the grid, or
``None``.
If :attr:`columns` is set and non-empty, it is returned.
Or, if :attr:`model_class` is set, the field list is derived
from that, via :meth:`get_model_columns()`.
Otherwise ``None`` is returned.
"""
if hasattr(self, 'columns') and self.columns:
return self.columns
columns = self.get_model_columns()
if columns:
return columns
return []
def get_model_columns(self, model_class=None):
"""
This method is a shortcut which calls
:func:`~wuttaweb.util.get_model_fields()`.
:param model_class: Optional model class for which to return
fields. If not set, the grid's :attr:`model_class` is
assumed.
"""
return get_model_fields(self.config,
model_class=model_class or self.model_class)
@property @property
def vue_component(self): def vue_component(self):
@ -177,68 +122,6 @@ class Grid:
""" """
self.columns = FieldList(columns) self.columns = FieldList(columns)
def remove(self, *keys):
"""
Remove some column(s) from the grid.
This is a convenience to allow removal of multiple columns at
once::
grid.remove('first_field',
'second_field',
'third_field')
It will remove each column from :attr:`columns`.
"""
for key in keys:
if key in self.columns:
self.columns.remove(key)
def set_link(self, key, link=True):
"""
Explicitly enable or disable auto-link behavior for a given
column.
If a column has auto-link enabled, then each of its cell
contents will automatically be wrapped with a hyperlink. The
URL for this will be the same as for the "View"
:class:`GridAction`
(aka. :meth:`~wuttaweb.views.master.MasterView.view()`).
Although of course each cell gets a different link depending
on which data record it points to.
It is typical to enable auto-link for fields relating to ID,
description etc. or some may prefer to auto-link all columns.
See also :meth:`is_linked()`; the list is tracked via
:attr:`linked_columns`.
:param key: Column key as string.
:param link: Boolean indicating whether column's cell contents
should be auto-linked.
"""
if link:
if key not in self.linked_columns:
self.linked_columns.append(key)
else: # unlink
if self.linked_columns and key in self.linked_columns:
self.linked_columns.remove(key)
def is_linked(self, key):
"""
Returns boolean indicating if auto-link behavior is enabled
for a given column.
See also :meth:`set_link()` which describes auto-link behavior.
:param key: Column key as string.
"""
if self.linked_columns:
if key in self.linked_columns:
return True
return False
def render_vue_tag(self, **kwargs): def render_vue_tag(self, **kwargs):
""" """
Render the Vue component tag for the grid. Render the Vue component tag for the grid.
@ -318,52 +201,22 @@ 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, but may add some extra This uses :attr:`data` as the basis, but may add some extra
values to each record, e.g. URLs for :attr:`actions` etc. values to each record for sake of action URLs etc.
Importantly, this also ensures each value in the dict is See also :meth:`get_vue_columns()`.
JSON-serializable, using
:func:`~wuttaweb.util.make_json_safe()`.
:returns: List of data record dicts for use with Vue table
component.
""" """
original_data = self.data or [] # use data as-is unless we have actions
if not self.actions:
# TODO: at some point i thought it was useful to wrangle the return self.data
# columns here, but now i can't seem to figure out why..?
# # determine which columns are relevant for data set
# columns = None
# if not columns:
# columns = self.get_columns()
# if not columns:
# raise ValueError("cannot determine columns for the grid")
# columns = set(columns)
# if self.model_class:
# mapper = sa.inspect(self.model_class)
# for column in mapper.primary_key:
# columns.add(column.key)
# # prune data fields for which no column is defined
# for i, record in enumerate(original_data):
# original_data[i]= dict([(key, record[key])
# for key in columns])
# we have action(s), so add URL(s) for each record in data # we have action(s), so add URL(s) for each record in data
data = [] data = []
for i, record in enumerate(original_data): for i, record in enumerate(self.data):
record = dict(record)
# convert data if needed, for json compat
record = make_json_safe(record,
# TODO: is this a good idea?
warn=False)
# add action urls to each record
for action in self.actions: for action in self.actions:
url = action.get_url(record, i) url = action.get_url(record, i)
key = f'_action_url_{action.key}' key = f'_action_url_{action.key}'
record[key] = url record[key] = url
data.append(record) data.append(record)
return data return data
@ -424,10 +277,6 @@ class GridAction:
Name of icon to be shown for the action link. Name of icon to be shown for the action link.
See also :meth:`render_icon()`. See also :meth:`render_icon()`.
.. attribute:: link_class
Optional HTML class attribute for the action's ``<a>`` tag.
""" """
def __init__( def __init__(
@ -437,7 +286,6 @@ class GridAction:
label=None, label=None,
url=None, url=None,
icon=None, icon=None,
link_class=None,
): ):
self.request = request self.request = request
self.config = self.request.wutta_config self.config = self.request.wutta_config
@ -446,20 +294,6 @@ class GridAction:
self.url = url self.url = url
self.label = label or self.app.make_title(key) self.label = label or self.app.make_title(key)
self.icon = icon or key self.icon = icon or key
self.link_class = link_class or ''
def render_icon_and_label(self):
"""
Render the HTML snippet for action link icon and label.
Default logic returns the output from :meth:`render_icon()`
and :meth:`render_label()`.
"""
html = [
self.render_icon(),
self.render_label(),
]
return HTML.literal(' ').join(html)
def render_icon(self): def render_icon(self):
""" """
@ -471,8 +305,6 @@ class GridAction:
.. code-block:: html .. code-block:: html
<i class="fas fa-trash"></i> <i class="fas fa-trash"></i>
See also :meth:`render_icon_and_label()`.
""" """
if self.request.use_oruga: if self.request.use_oruga:
raise NotImplementedError raise NotImplementedError
@ -484,8 +316,6 @@ class GridAction:
Render the label text for the action link. Render the label text for the action link.
Default behavior is to return :attr:`label` as-is. Default behavior is to return :attr:`label` as-is.
See also :meth:`render_icon_and_label()`.
""" """
return self.label return self.label

View file

@ -97,41 +97,11 @@ class MenuHandler(GenericHandler):
is expected for most apps to override it. is expected for most apps to override it.
The return value should be a list of dicts as described above. The return value should be a list of dicts as described above.
The default logic returns a list of menus obtained from
calling these methods:
* :meth:`make_people_menu()`
* :meth:`make_admin_menu()`
""" """
return [ return [
self.make_people_menu(request),
self.make_admin_menu(request), self.make_admin_menu(request),
] ]
def make_people_menu(self, request, **kwargs):
"""
Generate a typical People menu.
This method provides a semi-sane menu set by default, but it
is expected for most apps to override it.
The return value for this method should be a *single* dict,
which will ultimately be one element of the final list of
dicts as described in :class:`MenuHandler`.
"""
return {
'title': "People",
'type': 'menu',
'items': [
{
'title': "All People",
'route': 'people',
'perm': 'people.list',
},
],
}
def make_admin_menu(self, request, **kwargs): def make_admin_menu(self, request, **kwargs):
""" """
Generate a typical Admin menu. Generate a typical Admin menu.
@ -141,23 +111,12 @@ class MenuHandler(GenericHandler):
The return value for this method should be a *single* dict, The return value for this method should be a *single* dict,
which will ultimately be one element of the final list of which will ultimately be one element of the final list of
dicts as described in :class:`MenuHandler`. dicts as described above.
""" """
return { return {
'title': "Admin", 'title': "Admin",
'type': 'menu', 'type': 'menu',
'items': [ 'items': [
{
'title': "Users",
'route': 'users',
'perm': 'users.list',
},
{
'title': "Roles",
'route': 'roles',
'perm': 'roles.list',
},
{'type': 'sep'},
{ {
'title': "App Info", 'title': "App Info",
'route': 'appinfo', 'route': 'appinfo',

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 594 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View file

@ -64,7 +64,7 @@ def new_request(event):
Reference to the app :term:`config object`. Reference to the app :term:`config object`.
.. function:: request.get_referrer(default=None) .. method:: request.get_referrer(default=None)
Request method to get the "canonical" HTTP referrer value. Request method to get the "canonical" HTTP referrer value.
This has logic to check for referrer in the request params, This has logic to check for referrer in the request params,

View file

@ -3,7 +3,7 @@
<%def name="page_content()"> <%def name="page_content()">
<nav class="panel"> <nav class="panel item-panel">
<p class="panel-heading">Application</p> <p class="panel-heading">Application</p>
<div class="panel-block"> <div class="panel-block">
<div style="width: 100%;"> <div style="width: 100%;">
@ -16,14 +16,11 @@
<b-field horizontal label="App Title"> <b-field horizontal label="App Title">
<span>${app.get_title()}</span> <span>${app.get_title()}</span>
</b-field> </b-field>
<b-field horizontal label="Production Mode">
<span>${config.production()}</span>
</b-field>
</div> </div>
</div> </div>
</nav> </nav>
<nav class="panel"> <nav class="panel item-panel">
<p class="panel-heading">Configuration Files</p> <p class="panel-heading">Configuration Files</p>
<div class="panel-block"> <div class="panel-block">
<div style="width: 100%;"> <div style="width: 100%;">

View file

@ -1,6 +1,5 @@
## -*- coding: utf-8; -*- ## -*- coding: utf-8; -*-
<%namespace name="base_meta" file="/base_meta.mako" /> <%namespace name="base_meta" file="/base_meta.mako" />
<%namespace file="/wutta-components.mako" import="make_wutta_components" />
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
@ -214,20 +213,13 @@
<div class="level-left"> <div class="level-left">
## Current Context ## Current Context
<div id="current-context" class="level-item" <div id="current-context" class="level-item">
style="display: flex; gap: 1.5rem;">
% if index_title: % if index_title:
% if index_url: % if index_url:
<h1 class="title">${h.link_to(index_title, index_url)}</h1> <h1 class="title">${h.link_to(index_title, index_url)}</h1>
% else: % else:
<h1 class="title">${index_title}</h1> <h1 class="title">${index_title}</h1>
% endif % endif
% if master and master.creatable and not master.creating:
<wutta-button once type="is-primary"
tag="a" href="${url(f'{route_prefix}.create')}"
icon-left="plus"
label="Create New" />
% endif
% endif % endif
</div> </div>
@ -238,10 +230,13 @@
## TODO ## TODO
% if master and master.configurable and not master.configuring: % if master and master.configurable and not master.configuring:
<div class="level-item"> <div class="level-item">
<wutta-button once type="is-primary" <b-button type="is-primary"
tag="a" href="${url(f'{route_prefix}.configure')}" tag="a"
icon-left="cog" href="${url(f'{route_prefix}.configure')}"
label="Configure" /> icon-pack="fas"
icon-left="cog">
Configure
</b-button>
</div> </div>
% endif % endif
@ -371,38 +366,7 @@
${self.render_prevnext_header_buttons()} ${self.render_prevnext_header_buttons()}
</%def> </%def>
<%def name="render_crud_header_buttons()"> <%def name="render_crud_header_buttons()"></%def>
% if master:
% if master.viewing:
<wutta-button once
tag="a" href="${master.get_action_url('edit', instance)}"
icon-left="edit"
label="Edit This" />
<wutta-button once type="is-danger"
tag="a" href="${master.get_action_url('delete', instance)}"
icon-left="trash"
label="Delete This" />
% elif master.editing:
<wutta-button once
tag="a" href="${master.get_action_url('view', instance)}"
icon-left="eye"
label="View This" />
<wutta-button once type="is-danger"
tag="a" href="${master.get_action_url('delete', instance)}"
icon-left="trash"
label="Delete This" />
% elif master.deleting:
<wutta-button once
tag="a" href="${master.get_action_url('view', instance)}"
icon-left="eye"
label="View This" />
<wutta-button once
tag="a" href="${master.get_action_url('edit', instance)}"
icon-left="edit"
label="Edit This" />
% endif
% endif
</%def>
<%def name="render_prevnext_header_buttons()"></%def> <%def name="render_prevnext_header_buttons()"></%def>
@ -468,7 +432,6 @@
<%def name="finalize_whole_page_vars()"></%def> <%def name="finalize_whole_page_vars()"></%def>
<%def name="make_whole_page_component()"> <%def name="make_whole_page_component()">
${make_wutta_components()}
${self.render_whole_page_template()} ${self.render_whole_page_template()}
${self.declare_whole_page_vars()} ${self.declare_whole_page_vars()}
${self.modify_whole_page_vars()} ${self.modify_whole_page_vars()}

View file

@ -1,11 +0,0 @@
<div tal:omit-tag=""
tal:define="name name|field.name;
oid oid|field.oid;
vmodel vmodel|'modelData.'+oid;">
<b-checkbox name="${name}"
v-model="${vmodel}"
native-value="true"
tal:attributes="attributes|field.widget.attributes|{};">
{{ ${vmodel} }}
</b-checkbox>
</div>

View file

@ -1,6 +1,5 @@
<div tal:define="name name|field.name; <div tal:define="name name|field.name;
oid oid|field.oid; vmodel vmodel|'model_'+name;">
vmodel vmodel|'modelData.'+oid;">
${field.start_mapping()} ${field.start_mapping()}
<b-input name="${name}" <b-input name="${name}"
value="${field.widget.redisplay and cstruct or ''}" value="${field.widget.redisplay and cstruct or ''}"

View file

@ -1,7 +1,6 @@
<div tal:omit-tag="" <div tal:omit-tag=""
tal:define="name name|field.name; tal:define="name name|field.name;
oid oid|field.oid; vmodel vmodel|'model_'+name;">
vmodel vmodel|'modelData.'+oid;">
<b-input name="${name}" <b-input name="${name}"
v-model="${vmodel}" v-model="${vmodel}"
type="password" type="password"

View file

@ -1,50 +0,0 @@
<div tal:define="
name name|field.name;
oid oid|field.oid;
style style|field.widget.style;
size size|field.widget.size;
css_class css_class|field.widget.css_class;
unicode unicode|str;
optgroup_class optgroup_class|field.widget.optgroup_class;
multiple multiple|field.widget.multiple;
autofocus autofocus|field.autofocus;
vmodel vmodel|'modelData.'+oid;"
tal:omit-tag="">
<input type="hidden" name="__start__" value="${name}:sequence"
tal:condition="multiple" />
<b-select tal:attributes="
name name;
v-model vmodel;
id oid;
class string: ${css_class or ''};
multiple multiple;
size size;
style style;
autofocus autofocus;
attributes|field.widget.attributes|{};">
<tal:loop tal:repeat="item values">
<optgroup tal:condition="isinstance(item, optgroup_class)"
tal:attributes="label item.label">
<option tal:repeat="(value, description) item.options"
tal:attributes="
selected python:field.widget.get_select_value(cstruct, value);
readonly 'readonly' in getattr(field.widget, 'attributes', {}) and field.widget.get_select_value(cstruct, item[0]);
disabled 'readonly' in getattr(field.widget, 'attributes', {}) and not field.widget.get_select_value(cstruct, item[0]);
class css_class;
label field.widget.long_label_generator and description;
value value"
tal:content="field.widget.long_label_generator and field.widget.long_label_generator(item.label, description) or description"/>
</optgroup>
<option tal:condition="not isinstance(item, optgroup_class)"
tal:attributes="
selected python:field.widget.get_select_value(cstruct, item[0]);
readonly 'readonly' in getattr(field.widget, 'attributes', {}) and field.widget.get_select_value(cstruct, item[0]);
disabled 'readonly' in getattr(field.widget, 'attributes', {}) and not field.widget.get_select_value(cstruct, item[0]);
class css_class;
value item[0];">${item[1]}</option>
</tal:loop>
</b-select>
<input type="hidden" name="__end__" value="${name}:sequence"
tal:condition="multiple" />
</div>

View file

@ -1,7 +1,6 @@
<div tal:omit-tag="" <div tal:omit-tag=""
tal:define="name name|field.name; tal:define="name name|field.name;
oid oid|field.oid; vmodel vmodel|'model_'+name;">
vmodel vmodel|'modelData.'+oid;">
<b-input name="${name}" <b-input name="${name}"
v-model="${vmodel}" v-model="${vmodel}"
tal:attributes="attributes|field.widget.attributes|{};" /> tal:attributes="attributes|field.widget.attributes|{};" />

View file

@ -13,19 +13,13 @@
% if not form.readonly: % 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;"> <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_cancel:
<wutta-button ${'once' if form.auto_disable_cancel else ''}
tag="a" href="${form.get_cancel_url()}"
label="${form.button_label_cancel}" />
% endif
% if form.show_button_reset: % if form.show_button_reset:
<b-button native-type="reset"> <b-button native-type="reset">
Reset Reset
</b-button> </b-button>
% endif % endif
<b-button type="${form.button_type_submit}" <b-button type="is-primary"
native-type="submit" native-type="submit"
% if form.auto_disable_submit: % if form.auto_disable_submit:
:disabled="formSubmitting" :disabled="formSubmitting"
@ -54,14 +48,13 @@
let ${form.vue_component}Data = { let ${form.vue_component}Data = {
% if not form.readonly: ## field model values
% for key in form:
modelData: ${json.dumps(model_data)|n}, model_${key}: ${form.get_vue_field_value(key)|n},
% endfor
% if form.auto_disable_submit:
formSubmitting: false,
% endif
% if form.auto_disable_submit:
formSubmitting: false,
% endif % endif
} }

View file

@ -10,12 +10,7 @@
label="${column['label']}" label="${column['label']}"
v-slot="props" v-slot="props"
cell-class="c_${column['field']}"> cell-class="c_${column['field']}">
% if grid.is_linked(column['field']): <span v-html="props.row.${column['field']}"></span>
<a :href="props.row._action_url_view"
v-html="props.row.${column['field']}" />
% else:
<span v-html="props.row.${column['field']}"></span>
% endif
</${b}-table-column> </${b}-table-column>
% endfor % endfor
@ -24,9 +19,9 @@
label="Actions" label="Actions"
v-slot="props"> v-slot="props">
% for action in grid.actions: % for action in grid.actions:
<a :href="props.row._action_url_${action.key}" <a :href="props.row._action_url_${action.key}">
class="${action.link_class}"> ${action.render_icon()}
${action.render_icon_and_label()} ${action.render_label()}
</a> </a>
&nbsp; &nbsp;
% endfor % endfor

View file

@ -1,7 +0,0 @@
## -*- coding: utf-8; -*-
<%inherit file="/master/form.mako" />
<%def name="title()">New ${model_title}</%def>
${parent.body()}

View file

@ -1,18 +0,0 @@
## -*- coding: utf-8; -*-
<%inherit file="/master/form.mako" />
<%def name="title()">${index_title} &raquo; ${instance_title} &raquo; Delete</%def>
<%def name="content_title()">Delete: ${instance_title}</%def>
<%def name="page_content()">
<br />
<b-notification type="is-danger" :closable="false"
style="width: 50%;">
Really DELETE this ${model_title}?
</b-notification>
${parent.page_content()}
</%def>
${parent.body()}

View file

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

View file

@ -1,71 +0,0 @@
<%def name="make_wutta_components()">
${self.make_wutta_button_component()}
</%def>
<%def name="make_wutta_button_component()">
<script type="text/x-template" id="wutta-button-template">
<b-button :type="type"
:native-type="nativeType"
:tag="tag"
:href="href"
:title="title"
:disabled="buttonDisabled"
@click="clicked"
icon-pack="fas"
:icon-left="iconLeft">
{{ buttonLabel }}
</b-button>
</script>
<script>
const WuttaButton = {
template: '#wutta-button-template',
props: {
type: String,
nativeType: String,
tag: String,
href: String,
label: String,
title: String,
iconLeft: String,
working: String,
workingLabel: String,
disabled: Boolean,
once: Boolean,
},
data() {
return {
currentLabel: null,
currentDisabled: null,
}
},
computed: {
buttonLabel: function() {
return this.currentLabel || this.label
},
buttonDisabled: function() {
if (this.currentDisabled !== null) {
return this.currentDisabled
}
return this.disabled
},
},
methods: {
clicked(event) {
if (this.once) {
this.currentDisabled = true
if (this.workingLabel) {
this.currentLabel = this.workingLabel
} else if (this.working) {
this.currentLabel = this.working + ", please wait..."
} else {
this.currentLabel = "Working, please wait..."
}
}
}
},
}
Vue.component('wutta-button', WuttaButton)
</script>
</%def>

View file

@ -25,62 +25,10 @@ Web Utilities
""" """
import importlib import importlib
import json
import logging
import colander
from webhelpers2.html import HTML, tags from webhelpers2.html import HTML, tags
log = logging.getLogger(__name__)
class FieldList(list):
"""
Convenience wrapper for a form's field list. This is a subclass
of :class:`python:list`.
You normally would not need to instantiate this yourself, but it
is used under the hood for
:attr:`~wuttaweb.forms.base.Form.fields` as well as
:attr:`~wuttaweb.grids.base.Grid.columns`.
"""
def insert_before(self, field, newfield):
"""
Insert a new field, before an existing field.
:param field: String name for the existing field.
:param newfield: String name for the new field, to be inserted
just before the existing ``field``.
"""
if field in self:
i = self.index(field)
self.insert(i, newfield)
else:
log.warning("field '%s' not found, will append new field: %s",
field, newfield)
self.append(newfield)
def insert_after(self, field, newfield):
"""
Insert a new field, after an existing field.
:param field: String name for the existing field.
:param newfield: String name for the new field, to be inserted
just after the existing ``field``.
"""
if field in self:
i = self.index(field)
self.insert(i + 1, newfield)
else:
log.warning("field '%s' not found, will append new field: %s",
field, newfield)
self.append(newfield)
def get_form_data(request): def get_form_data(request):
""" """
Returns the effective form data for the given request. Returns the effective form data for the given request.
@ -409,66 +357,3 @@ def render_csrf_token(request, name='_csrf'):
""" """
token = get_csrf_token(request) token = get_csrf_token(request)
return HTML.tag('div', tags.hidden(name, value=token), style='display:none;') return HTML.tag('div', tags.hidden(name, value=token), style='display:none;')
def get_model_fields(config, model_class=None):
"""
Convenience function to return a list of field names for the given
model class.
This logic only supports SQLAlchemy mapped classes and will use
that to determine the field listing if applicable. Otherwise this
returns ``None``.
"""
if model_class:
import sqlalchemy as sa
app = config.get_app()
model = app.model
if model_class and issubclass(model_class, model.Base):
mapper = sa.inspect(model_class)
fields = list([prop.key for prop in mapper.iterate_properties])
return fields
def make_json_safe(value, key=None, warn=True):
"""
Convert a Python value as needed, to ensure it is compatible with
:func:`python:json.dumps()`.
:param value: Python value.
:param key: Optional key for the value, if known. This is used
when logging warnings, if applicable.
:param warn: Whether warnings should be logged if the value is not
already JSON-compatible.
:returns: A (possibly new) Python value which is guaranteed to be
JSON-serializable.
"""
# convert null => None
if value is colander.null:
return None
# recursively convert dict
if isinstance(value, dict):
parent = dict(value)
for key, value in parent.items():
parent[key] = make_json_safe(value, key=key, warn=warn)
value = parent
# ensure JSON-compatibility, warn if problems
try:
json.dumps(value)
except TypeError as error:
if warn:
prefix = "value"
if key:
prefix += f" for '{key}'"
log.warning("%s is not json-friendly: %s", prefix, repr(value))
value = str(value)
if warn:
log.warning("forced value to: %s", value)
return value

View file

@ -59,11 +59,13 @@ class AuthView(View):
form = self.make_form(schema=self.login_make_schema(), form = self.make_form(schema=self.login_make_schema(),
align_buttons_right=True, align_buttons_right=True,
show_button_cancel=False,
show_button_reset=True, show_button_reset=True,
button_label_submit="Login", button_label_submit="Login",
button_icon_submit='user') button_icon_submit='user')
# TODO
# form.show_cancel = False
# validate basic form data (sanity check) # validate basic form data (sanity check)
data = form.validate() data = form.validate()
if data: if data:
@ -153,7 +155,6 @@ class AuthView(View):
return self.redirect(self.request.route_url('home')) return self.redirect(self.request.route_url('home'))
form = self.make_form(schema=self.change_password_make_schema(), form = self.make_form(schema=self.change_password_make_schema(),
show_button_cancel=False,
show_button_reset=True) show_button_reset=True)
data = form.validate() data = form.validate()

View file

@ -31,10 +31,6 @@ That will in turn include the following modules:
* :mod:`wuttaweb.views.auth` * :mod:`wuttaweb.views.auth`
* :mod:`wuttaweb.views.common` * :mod:`wuttaweb.views.common`
* :mod:`wuttaweb.views.settings`
* :mod:`wuttaweb.views.people`
* :mod:`wuttaweb.views.roles`
* :mod:`wuttaweb.views.users`
""" """
@ -44,9 +40,6 @@ def defaults(config, **kwargs):
config.include(mod('wuttaweb.views.auth')) config.include(mod('wuttaweb.views.auth'))
config.include(mod('wuttaweb.views.common')) config.include(mod('wuttaweb.views.common'))
config.include(mod('wuttaweb.views.settings')) config.include(mod('wuttaweb.views.settings'))
config.include(mod('wuttaweb.views.people'))
config.include(mod('wuttaweb.views.roles'))
config.include(mod('wuttaweb.views.users'))
def includeme(config): def includeme(config):

View file

@ -24,13 +24,10 @@
Base Logic for Master Views Base Logic for Master Views
""" """
import sqlalchemy as sa
from sqlalchemy import orm
from pyramid.renderers import render_to_response from pyramid.renderers import render_to_response
from wuttaweb.views import View from wuttaweb.views import View
from wuttaweb.util import get_form_data, get_model_fields from wuttaweb.util import get_form_data
from wuttaweb.db import Session from wuttaweb.db import Session
@ -169,13 +166,7 @@ class MasterView(View):
List of columns for the :meth:`index()` view grid. List of columns for the :meth:`index()` view grid.
This is optional; see also :meth:`get_grid_columns()`. This is optional; see also :meth:`index_get_grid_columns()`.
.. attribute:: creatable
Boolean indicating whether the view model supports "creating" -
i.e. it should have a :meth:`create()` view. Default value is
``True``.
.. attribute:: viewable .. attribute:: viewable
@ -183,18 +174,6 @@ class MasterView(View):
i.e. it should have a :meth:`view()` view. Default value is i.e. it should have a :meth:`view()` view. Default value is
``True``. ``True``.
.. attribute:: editable
Boolean indicating whether the view model supports "editing" -
i.e. it should have an :meth:`edit()` view. Default value is
``True``.
.. attribute:: deletable
Boolean indicating whether the view model supports "deleting" -
i.e. it should have a :meth:`delete()` view. Default value is
``True``.
.. attribute:: form_fields .. attribute:: form_fields
List of columns for the model form. List of columns for the model form.
@ -215,18 +194,12 @@ class MasterView(View):
# features # features
listable = True listable = True
has_grid = True has_grid = True
creatable = True
viewable = True viewable = True
editable = True
deletable = True
configurable = False configurable = False
# current action # current action
listing = False listing = False
creating = False
viewing = False viewing = False
editing = False
deleting = False
configuring = False configuring = False
############################## ##############################
@ -249,7 +222,7 @@ class MasterView(View):
See also related methods, which are called by this one: See also related methods, which are called by this one:
* :meth:`make_model_grid()` * :meth:`index_make_grid()`
""" """
self.listing = True self.listing = True
@ -258,66 +231,110 @@ class MasterView(View):
} }
if self.has_grid: if self.has_grid:
context['grid'] = self.make_model_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 methods
##############################
def create(self):
""" """
View to "create" a new model record. Create and return a :class:`~wuttaweb.grids.base.Grid`
instance for use with the :meth:`index()` view.
This usually corresponds to a URL like ``/widgets/new``. See also related methods, which are called by this one:
By default, this view is included only if :attr:`creatable` is * :meth:`get_grid_key()`
true. * :meth:`index_get_grid_columns()`
* :meth:`index_get_grid_data()`
The default "create" view logic will show a form with field * :meth:`index_configure_grid()`
widgets, allowing user to submit new values which are then
persisted to the DB (assuming typical SQLAlchemy model).
Subclass normally should not override this method, but rather
one of the related methods which are called (in)directly by
this one:
* :meth:`make_model_form()`
* :meth:`configure_form()`
* :meth:`create_save_form()`
""" """
self.creating = True if 'key' not in kwargs:
form = self.make_model_form(cancel_url_fallback=self.get_index_url()) kwargs['key'] = self.get_grid_key()
if form.validate(): if 'columns' not in kwargs:
obj = self.create_save_form(form) kwargs['columns'] = self.index_get_grid_columns()
Session.flush()
return self.redirect(self.get_action_url('view', obj))
context = { if 'data' not in kwargs:
'form': form, kwargs['data'] = self.index_get_grid_data()
}
return self.render_to_response('create', context)
def create_save_form(self, form): 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):
""" """
This method is responsible for "converting" the validated form Returns the default list of grid column names, for the
data to a model instance, and then "saving" the result, :meth:`index()` view.
e.g. to DB. It is called by :meth:`create()`.
Subclass may override this, or any of the related methods This is called by :meth:`index_make_grid()`; in the resulting
called by this one: :class:`~wuttaweb.grids.base.Grid` instance, this becomes
:attr:`~wuttaweb.grids.base.Grid.columns`.
* :meth:`objectify()` This method may return ``None``, in which case the grid may
* :meth:`persist()` (try to) generate its own default list.
:returns: Should return the resulting model instance, e.g. as Subclass may define :attr:`grid_columns` for simple cases, or
produced by :meth:`objectify()`. 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.
""" """
obj = self.objectify(form)
self.persist(obj)
return obj
############################## ##############################
# view methods # view methods
@ -336,12 +353,9 @@ class MasterView(View):
The default view logic will show a read-only form with field The default view logic will show a read-only form with field
values displayed. values displayed.
Subclass normally should not override this method, but rather See also related methods, which are called by this one:
one of the related methods which are called (in)directly by
this one:
* :meth:`make_model_form()` * :meth:`make_model_form()`
* :meth:`configure_form()`
""" """
self.viewing = True self.viewing = True
instance = self.get_instance() instance = self.get_instance()
@ -354,148 +368,6 @@ class MasterView(View):
} }
return self.render_to_response('view', context) return self.render_to_response('view', context)
##############################
# edit methods
##############################
def edit(self):
"""
View to "edit" details of an existing model record.
This usually corresponds to a URL like ``/widgets/XXX/edit``
where ``XXX`` represents the key/ID for the record.
By default, this view is included only if :attr:`editable` is
true.
The default "edit" view logic will show a form with field
widgets, allowing user to modify and submit new values which
are then persisted to the DB (assuming typical SQLAlchemy
model).
Subclass normally should not override this method, but rather
one of the related methods which are called (in)directly by
this one:
* :meth:`make_model_form()`
* :meth:`configure_form()`
* :meth:`edit_save_form()`
"""
self.editing = True
instance = self.get_instance()
instance_title = self.get_instance_title(instance)
form = self.make_model_form(instance,
cancel_url_fallback=self.get_action_url('view', instance))
if form.validate():
self.edit_save_form(form)
return self.redirect(self.get_action_url('view', instance))
context = {
'instance': instance,
'instance_title': instance_title,
'form': form,
}
return self.render_to_response('edit', context)
def edit_save_form(self, form):
"""
This method is responsible for "converting" the validated form
data to a model instance, and then "saving" the result,
e.g. to DB. It is called by :meth:`edit()`.
Subclass may override this, or any of the related methods
called by this one:
* :meth:`objectify()`
* :meth:`persist()`
:returns: Should return the resulting model instance, e.g. as
produced by :meth:`objectify()`.
"""
obj = self.objectify(form)
self.persist(obj)
return obj
##############################
# delete methods
##############################
def delete(self):
"""
View to delete an existing model instance.
This usually corresponds to a URL like ``/widgets/XXX/delete``
where ``XXX`` represents the key/ID for the record.
By default, this view is included only if :attr:`deletable` is
true.
The default "delete" view logic will show a "psuedo-readonly"
form with no fields editable, but with a submit button so user
must confirm, before deletion actually occurs.
Subclass normally should not override this method, but rather
one of the related methods which are called (in)directly by
this one:
* :meth:`make_model_form()`
* :meth:`configure_form()`
* :meth:`delete_save_form()`
* :meth:`delete_instance()`
"""
self.deleting = True
instance = self.get_instance()
instance_title = self.get_instance_title(instance)
# nb. this form proper is not readonly..
form = self.make_model_form(instance,
cancel_url_fallback=self.get_action_url('view', instance),
button_label_submit="DELETE Forever",
button_icon_submit='trash',
button_type_submit='is-danger')
# ..but *all* fields are readonly
form.readonly_fields = set(form.fields)
# nb. validate() often returns empty dict here
if form.validate() is not False:
self.delete_save_form(form)
return self.redirect(self.get_index_url())
context = {
'instance': instance,
'instance_title': instance_title,
'form': form,
}
return self.render_to_response('delete', context)
def delete_save_form(self, form):
"""
Perform the delete operation(s) based on the given form data.
Default logic simply calls :meth:`delete_instance()` on the
form's :attr:`~wuttaweb.forms.base.Form.model_instance`.
This method is called by :meth:`delete()` after it has
validated the form.
"""
obj = form.model_instance
self.delete_instance(obj)
def delete_instance(self, obj):
"""
Delete the given model instance.
As of yet there is no default logic for this method; it will
raise ``NotImplementedError``. Subclass should override if
needed.
This method is called by :meth:`delete_save_form()`.
"""
session = self.app.get_session(obj)
session.delete(obj)
############################## ##############################
# configure methods # configure methods
############################## ##############################
@ -822,7 +694,6 @@ class MasterView(View):
'route_prefix': self.get_route_prefix(), 'route_prefix': self.get_route_prefix(),
'index_title': self.get_index_title(), 'index_title': self.get_index_title(),
'index_url': self.get_index_url(), 'index_url': self.get_index_url(),
'model_title': self.get_model_title(),
'config_title': self.get_config_title(), 'config_title': self.get_config_title(),
} }
@ -887,152 +758,7 @@ class MasterView(View):
route_prefix = self.get_route_prefix() route_prefix = self.get_route_prefix()
return self.request.route_url(route_prefix, **kwargs) return self.request.route_url(route_prefix, **kwargs)
def make_model_grid(self, session=None, **kwargs): def get_instance(self):
"""
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:`get_grid_columns()`
* :meth:`get_grid_data()`
* :meth:`configure_grid()`
"""
if 'key' not in kwargs:
kwargs['key'] = self.get_grid_key()
if 'model_class' not in kwargs:
model_class = self.get_model_class()
if model_class:
kwargs['model_class'] = model_class
if 'columns' not in kwargs:
kwargs['columns'] = self.get_grid_columns()
if 'data' not in kwargs:
kwargs['data'] = self.get_grid_data(columns=kwargs['columns'],
session=session)
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))
if self.editable:
actions.append(self.make_grid_action('edit', icon='edit',
url=self.get_action_url_edit))
if self.deletable:
actions.append(self.make_grid_action('delete', icon='trash',
url=self.get_action_url_delete,
link_class='has-text-danger'))
kwargs['actions'] = actions
grid = self.make_grid(**kwargs)
self.configure_grid(grid)
return grid
def get_grid_columns(self):
"""
Returns the default list of grid column names, for the
:meth:`index()` view.
This is called by :meth:`make_model_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:`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:`configure_grid()`.
"""
if hasattr(self, 'grid_columns'):
return self.grid_columns
def get_grid_data(self, columns=None, session=None):
"""
Returns the grid data for the :meth:`index()` view.
This is called by :meth:`make_model_grid()`; in the resulting
:class:`~wuttaweb.grids.base.Grid` instance, this becomes
:attr:`~wuttaweb.grids.base.Grid.data`.
Default logic will call :meth:`get_query()` and if successful,
return the list from ``query.all()``. Otherwise returns an
empty list. Subclass should override as needed.
"""
query = self.get_query(session=session)
if query:
data = query.all()
# determine which columns are relevant for data set
if not columns:
columns = self.get_grid_columns()
if not columns:
model_class = self.get_model_class()
if model_class:
columns = get_model_fields(self.config, model_class)
if not columns:
raise ValueError("cannot determine columns for the grid")
columns = set(columns)
columns.update(self.get_model_key())
# prune data fields for which no column is defined
for i, record in enumerate(data):
data[i]= dict([(key, record[key])
for key in columns])
return data
return []
def get_query(self, session=None):
"""
Returns the main SQLAlchemy query object for the
:meth:`index()` view. This is called by
:meth:`get_grid_data()`.
Default logic for this method returns a "plain" query on the
:attr:`model_class` if that is defined; otherwise ``None``.
"""
model = self.app.model
model_class = self.get_model_class()
if model_class and issubclass(model_class, model.Base):
session = session or Session()
return session.query(model_class)
def configure_grid(self, grid):
"""
Configure the grid for the :meth:`index()` view.
This is called by :meth:`make_model_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.
"""
if 'uuid' in grid.columns:
grid.columns.remove('uuid')
for key in self.get_model_key():
grid.set_link(key)
def get_instance(self, session=None):
""" """
This should return the "current" model instance based on the This should return the "current" model instance based on the
request details (e.g. route kwargs). request details (e.g. route kwargs).
@ -1043,27 +769,6 @@ class MasterView(View):
There is no "sane" default logic here; subclass *must* There is no "sane" default logic here; subclass *must*
override or else a ``NotImplementedError`` is raised. override or else a ``NotImplementedError`` is raised.
""" """
model_class = self.get_model_class()
if model_class:
session = session or Session()
def filtr(query, model_key):
key = self.request.matchdict[model_key]
query = query.filter(getattr(self.model_class, model_key) == key)
return query
query = session.query(model_class)
for key in self.get_model_key():
query = filtr(query, key)
try:
return query.one()
except orm.exc.NoResultFound:
pass
raise self.notfound()
raise NotImplementedError("you must define get_instance() method " raise NotImplementedError("you must define get_instance() method "
f" for view class: {self.__class__}") f" for view class: {self.__class__}")
@ -1077,60 +782,6 @@ class MasterView(View):
""" """
return str(instance) return str(instance)
def get_action_url(self, action, obj, **kwargs):
"""
Generate an "action" URL for the given model instance.
This is a shortcut which generates a route name based on
:meth:`get_route_prefix()` and the ``action`` param.
It returns the URL based on generated route name and object's
model key values.
:param action: String name for the action, which corresponds
to part of some named route, e.g. ``'view'`` or ``'edit'``.
:param obj: Model instance object.
"""
route_prefix = self.get_route_prefix()
kw = dict([(key, obj[key])
for key in self.get_model_key()])
kw.update(kwargs)
return self.request.route_url(f'{route_prefix}.{action}', **kw)
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.
Calls :meth:`get_action_url()` under the hood.
"""
return self.get_action_url('view', obj)
def get_action_url_edit(self, obj, i):
"""
Returns the "edit" grid action URL for the given object.
Most typically this is like ``/widgets/XXX/edit`` where
``XXX`` represents the object's key/ID.
Calls :meth:`get_action_url()` under the hood.
"""
return self.get_action_url('edit', obj)
def get_action_url_delete(self, obj, i):
"""
Returns the "delete" grid action URL for the given object.
Most typically this is like ``/widgets/XXX/delete`` where
``XXX`` represents the object's key/ID.
Calls :meth:`get_action_url()` under the hood.
"""
return self.get_action_url('delete', obj)
def make_model_form(self, model_instance=None, **kwargs): def make_model_form(self, model_instance=None, **kwargs):
""" """
Create and return a :class:`~wuttaweb.forms.base.Form` Create and return a :class:`~wuttaweb.forms.base.Form`
@ -1140,24 +791,16 @@ class MasterView(View):
e.g.: e.g.:
* :meth:`view()` * :meth:`view()`
* :meth:`edit()`
See also related methods, which are called by this one: See also related methods, which are called by this one:
* :meth:`get_form_fields()` * :meth:`get_form_fields()`
* :meth:`configure_form()` * :meth:`configure_form()`
""" """
if 'model_class' not in kwargs:
model_class = self.get_model_class()
if model_class:
kwargs['model_class'] = model_class
kwargs['model_instance'] = model_instance kwargs['model_instance'] = model_instance
if not kwargs.get('fields'): if 'fields' not in kwargs:
fields = self.get_form_fields() kwargs['fields'] = self.get_form_fields()
if fields:
kwargs['fields'] = fields
form = self.make_form(**kwargs) form = self.make_form(**kwargs)
self.configure_form(form) self.configure_form(form)
@ -1191,77 +834,13 @@ class MasterView(View):
Configure the given model form, as needed. Configure the given model form, as needed.
This is called by :meth:`make_model_form()` - for multiple This is called by :meth:`make_model_form()` - for multiple
CRUD views (create, view, edit, delete, possibly others). CRUD views.
The default logic here does just one thing: when "editing" There is no default logic here; subclass should override if
(i.e. in :meth:`edit()` view) then all fields which are part needed. The ``form`` param will already be "complete" and
of the :attr:`model_key` will be marked via ready to use as-is, but this method can further modify it
:meth:`set_readonly()` so the user cannot change primary key based on request details etc.
values for a record.
Subclass may override as 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.
""" """
form.remove('uuid')
if self.editing:
for key in self.get_model_key():
form.set_readonly(key)
def objectify(self, form):
"""
Must return a "model instance" object which reflects the
validated form data.
In simple cases this may just return the
:attr:`~wuttaweb.forms.base.Form.validated` data dict.
When dealing with SQLAlchemy models it would return a proper
mapped instance, creating it if necessary.
:param form: Reference to the *already validated*
:class:`~wuttaweb.forms.base.Form` object. See the form's
:attr:`~wuttaweb.forms.base.Form.validated` attribute for
the data.
See also :meth:`edit_save_form()` which calls this method.
"""
# use ColanderAlchemy magic if possible
schema = form.get_schema()
if hasattr(schema, 'objectify'):
# this returns a model instance
return schema.objectify(form.validated,
context=form.model_instance)
# otherwise return data dict as-is
return form.validated
def persist(self, obj, session=None):
"""
If applicable, this method should persist ("save") the given
object's data (e.g. to DB), creating or updating it as needed.
This is part of the "submit form" workflow; ``obj`` should be
a model instance which already reflects the validated form
data.
Note that there is no default logic here, subclass must
override if needed.
:param obj: Model instance object as produced by
:meth:`objectify()`.
See also :meth:`edit_save_form()` which calls this method.
"""
model = self.app.model
model_class = self.get_model_class()
if model_class and issubclass(model_class, model.Base):
# add sqlalchemy model to session
session = session or Session()
session.add(obj)
############################## ##############################
# class methods # class methods
@ -1382,11 +961,6 @@ class MasterView(View):
keys = [keys] keys = [keys]
return tuple(keys) return tuple(keys)
model_class = cls.get_model_class()
if model_class:
mapper = sa.inspect(model_class)
return tuple([column.key for column in mapper.primary_key])
raise AttributeError(f"you must define model_key for view class: {cls}") raise AttributeError(f"you must define model_key for view class: {cls}")
@classmethod @classmethod
@ -1492,7 +1066,7 @@ class MasterView(View):
grid in the :meth:`index()` view. This key may also be used grid in the :meth:`index()` view. This key may also be used
as the basis (key prefix) for secondary grids. as the basis (key prefix) for secondary grids.
This is called from :meth:`make_model_grid()`; in the This is called from :meth:`index_make_grid()`; in the
resulting :class:`~wuttaweb.grids.base.Grid` instance, this resulting :class:`~wuttaweb.grids.base.Grid` instance, this
becomes :attr:`~wuttaweb.grids.base.Grid.key`. becomes :attr:`~wuttaweb.grids.base.Grid.key`.
@ -1561,13 +1135,6 @@ class MasterView(View):
config.add_view(cls, attr='index', config.add_view(cls, attr='index',
route_name=route_prefix) route_name=route_prefix)
# create
if cls.creatable:
config.add_route(f'{route_prefix}.create',
f'{url_prefix}/new')
config.add_view(cls, attr='create',
route_name=f'{route_prefix}.create')
# view # view
if cls.viewable: if cls.viewable:
instance_url_prefix = cls.get_instance_url_prefix() instance_url_prefix = cls.get_instance_url_prefix()
@ -1575,22 +1142,6 @@ class MasterView(View):
config.add_view(cls, attr='view', config.add_view(cls, attr='view',
route_name=f'{route_prefix}.view') route_name=f'{route_prefix}.view')
# edit
if cls.editable:
instance_url_prefix = cls.get_instance_url_prefix()
config.add_route(f'{route_prefix}.edit',
f'{instance_url_prefix}/edit')
config.add_view(cls, attr='edit',
route_name=f'{route_prefix}.edit')
# delete
if cls.deletable:
instance_url_prefix = cls.get_instance_url_prefix()
config.add_route(f'{route_prefix}.delete',
f'{instance_url_prefix}/delete')
config.add_view(cls, attr='delete',
route_name=f'{route_prefix}.delete')
# configure # configure
if cls.configurable: if cls.configurable:
config.add_route(f'{route_prefix}.configure', config.add_route(f'{route_prefix}.configure',

View file

@ -1,95 +0,0 @@
# -*- 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/>.
#
################################################################################
"""
Views for people
"""
from wuttjamaican.db.model import Person
from wuttaweb.views import MasterView
class PersonView(MasterView):
"""
Master view for people.
Notable URLs provided by this class:
* ``/people/``
* ``/people/new``
* ``/people/XXX``
* ``/people/XXX/edit``
* ``/people/XXX/delete``
"""
model_class = Person
model_title_plural = "People"
route_prefix = 'people'
grid_columns = [
'full_name',
'first_name',
'middle_name',
'last_name',
]
# TODO: master should handle this, possibly via configure_form()
def get_query(self, session=None):
""" """
model = self.app.model
query = super().get_query(session=session)
return query.order_by(model.Person.full_name)
def configure_grid(self, g):
""" """
super().configure_grid(g)
# full_name
g.set_link('full_name')
# TODO: master should handle this?
def configure_form(self, f):
""" """
super().configure_form(f)
# first_name
f.set_required('first_name', False)
# middle_name
f.set_required('middle_name', False)
# last_name
f.set_required('last_name', False)
# users
if 'users' in f:
f.fields.remove('users')
def defaults(config, **kwargs):
base = globals()
PersonView = kwargs.get('PersonView', base['PersonView'])
PersonView.defaults(config)
def includeme(config):
defaults(config)

View file

@ -1,100 +0,0 @@
# -*- 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/>.
#
################################################################################
"""
Views for roles
"""
from wuttjamaican.db.model import Role
from wuttaweb.views import MasterView
from wuttaweb.db import Session
class RoleView(MasterView):
"""
Master view for roles.
Notable URLs provided by this class:
* ``/roles/``
* ``/roles/new``
* ``/roles/XXX``
* ``/roles/XXX/edit``
* ``/roles/XXX/delete``
"""
model_class = Role
grid_columns = [
'name',
'notes',
]
# TODO: master should handle this, possibly via configure_form()
def get_query(self, session=None):
""" """
model = self.app.model
query = super().get_query(session=session)
return query.order_by(model.Role.name)
def configure_grid(self, g):
""" """
super().configure_grid(g)
# name
g.set_link('name')
def configure_form(self, f):
""" """
super().configure_form(f)
# never show these
f.remove('permission_refs',
'user_refs')
# name
f.set_validator('name', self.unique_name)
def unique_name(self, node, value):
""" """
model = self.app.model
session = Session()
query = session.query(model.Role)\
.filter(model.Role.name == value)
if self.editing:
uuid = self.request.matchdict['uuid']
query = query.filter(model.Role.uuid != uuid)
if query.count():
node.raise_invalid("Name must be unique")
def defaults(config, **kwargs):
base = globals()
RoleView = kwargs.get('RoleView', base['RoleView'])
RoleView.defaults(config)
def includeme(config):
defaults(config)

View file

@ -27,8 +27,10 @@ Views for app settings
from collections import OrderedDict from collections import OrderedDict
from wuttjamaican.db.model import Setting 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):
@ -46,7 +48,6 @@ 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
creatable = False
viewable = False viewable = False
editable = False editable = False
deletable = False deletable = False
@ -146,18 +147,55 @@ class SettingView(MasterView):
model_class = Setting model_class = Setting
model_title = "Raw Setting" model_title = "Raw Setting"
# TODO: master should handle this, possibly via configure_form() # TODO: this should be deduced by master
def get_query(self, session=None): 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 model = self.app.model
query = super().get_query(session=session)
return query.order_by(model.Setting.name)
# TODO: master should handle this (per column nullable) session = session or Session()
def configure_form(self, f): 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):
""" """ """ """
super().configure_form(f) return {
f.set_required('value', False) '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):

View file

@ -1,117 +0,0 @@
# -*- 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/>.
#
################################################################################
"""
Views for users
"""
import colander
from wuttjamaican.db.model import User
from wuttaweb.views import MasterView
from wuttaweb.forms.schema import PersonRef
from wuttaweb.db import Session
class UserView(MasterView):
"""
Master view for users.
Notable URLs provided by this class:
* ``/users/``
* ``/users/new``
* ``/users/XXX``
* ``/users/XXX/edit``
* ``/users/XXX/delete``
"""
model_class = User
grid_columns = [
'username',
'person',
'active',
]
# TODO: master should handle this, possibly via configure_form()
def get_query(self, session=None):
""" """
model = self.app.model
query = super().get_query(session=session)
return query.order_by(model.User.username)
def configure_grid(self, g):
""" """
super().configure_grid(g)
# never show these
g.remove('person_uuid',
'role_refs',
'password')
# username
g.set_link('username')
# person
g.set_link('person')
def configure_form(self, f):
""" """
super().configure_form(f)
# never show these
f.remove('person_uuid',
'password',
'role_refs')
# person
f.set_node('person', PersonRef(self.request, empty_option=True))
f.set_required('person', False)
# username
f.set_validator('username', self.unique_username)
def unique_username(self, node, value):
""" """
model = self.app.model
session = Session()
query = session.query(model.User)\
.filter(model.User.username == value)
if self.editing:
uuid = self.request.matchdict['uuid']
query = query.filter(model.User.uuid != uuid)
if query.count():
node.raise_invalid("Username must be unique")
def defaults(config, **kwargs):
base = globals()
UserView = kwargs.get('UserView', base['UserView'])
UserView.defaults(config)
def includeme(config):
defaults(config)

View file

@ -1,24 +1,54 @@
# -*- coding: utf-8; -*- # -*- coding: utf-8; -*-
from unittest import TestCase from unittest import TestCase
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock
import colander import colander
import deform import deform
from pyramid import testing from pyramid import testing
from wuttjamaican.conf import WuttaConfig from wuttjamaican.conf import WuttaConfig
from wuttaweb.forms import base, widgets from wuttaweb.forms import base
from wuttaweb import helpers from wuttaweb import helpers
class TestFieldList(TestCase):
def test_insert_before(self):
fields = base.FieldList(['f1', 'f2'])
self.assertEqual(fields, ['f1', 'f2'])
# typical
fields.insert_before('f1', 'XXX')
self.assertEqual(fields, ['XXX', 'f1', 'f2'])
fields.insert_before('f2', 'YYY')
self.assertEqual(fields, ['XXX', 'f1', 'YYY', 'f2'])
# appends new field if reference field is invalid
fields.insert_before('f3', 'ZZZ')
self.assertEqual(fields, ['XXX', 'f1', 'YYY', 'f2', 'ZZZ'])
def test_insert_after(self):
fields = base.FieldList(['f1', 'f2'])
self.assertEqual(fields, ['f1', 'f2'])
# typical
fields.insert_after('f1', 'XXX')
self.assertEqual(fields, ['f1', 'XXX', 'f2'])
fields.insert_after('XXX', 'YYY')
self.assertEqual(fields, ['f1', 'XXX', 'YYY', 'f2'])
# appends new field if reference field is invalid
fields.insert_after('f3', 'ZZZ')
self.assertEqual(fields, ['f1', 'XXX', 'YYY', 'f2', 'ZZZ'])
class TestForm(TestCase): class TestForm(TestCase):
def setUp(self): def setUp(self):
self.config = WuttaConfig(defaults={ self.config = WuttaConfig(defaults={
'wutta.web.menus.handler_spec': 'tests.util:NullMenuHandler', 'wutta.web.menus.handler_spec': 'tests.utils:NullMenuHandler',
}) })
self.app = self.config.get_app()
self.request = testing.DummyRequest(wutta_config=self.config, use_oruga=False) self.request = testing.DummyRequest(wutta_config=self.config, use_oruga=False)
self.pyramid_config = testing.setUp(request=self.request, settings={ self.pyramid_config = testing.setUp(request=self.request, settings={
@ -43,7 +73,7 @@ class TestForm(TestCase):
def test_init_with_none(self): def test_init_with_none(self):
form = self.make_form() form = self.make_form()
self.assertEqual(form.fields, []) self.assertIsNone(form.fields)
def test_init_with_fields(self): def test_init_with_fields(self):
form = self.make_form(fields=['foo', 'bar']) form = self.make_form(fields=['foo', 'bar'])
@ -84,81 +114,7 @@ class TestForm(TestCase):
form.set_fields(['baz']) form.set_fields(['baz'])
self.assertEqual(form.fields, ['baz']) self.assertEqual(form.fields, ['baz'])
def test_remove(self):
form = self.make_form(fields=['one', 'two', 'three', 'four'])
self.assertEqual(form.fields, ['one', 'two', 'three', 'four'])
form.remove('two', 'three')
self.assertEqual(form.fields, ['one', 'four'])
def test_set_node(self):
form = self.make_form(fields=['foo', 'bar'])
self.assertEqual(form.nodes, {})
# complete node
node = colander.SchemaNode(colander.Bool(), name='foo')
form.set_node('foo', node)
self.assertIs(form.nodes['foo'], node)
# type only
typ = colander.Bool()
form.set_node('foo', typ)
node = form.nodes['foo']
self.assertIsInstance(node, colander.SchemaNode)
self.assertIsInstance(node.typ, colander.Bool)
self.assertEqual(node.name, 'foo')
# schema is updated if already present
schema = form.get_schema()
self.assertIsNotNone(schema)
typ = colander.Date()
form.set_node('foo', typ)
node = form.nodes['foo']
self.assertIsInstance(node, colander.SchemaNode)
self.assertIsInstance(node.typ, colander.Date)
self.assertEqual(node.name, 'foo')
def test_set_widget(self):
form = self.make_form(fields=['foo', 'bar'])
self.assertEqual(form.widgets, {})
# basic
widget = widgets.SelectWidget()
form.set_widget('foo', widget)
self.assertIs(form.widgets['foo'], widget)
# schema is updated if already present
schema = form.get_schema()
self.assertIsNotNone(schema)
self.assertIs(schema['foo'].widget, widget)
new_widget = widgets.TextInputWidget()
form.set_widget('foo', new_widget)
self.assertIs(form.widgets['foo'], new_widget)
self.assertIs(schema['foo'].widget, new_widget)
def test_set_validator(self):
form = self.make_form(fields=['foo', 'bar'])
self.assertEqual(form.validators, {})
def validate1(node, value):
pass
# basic
form.set_validator('foo', validate1)
self.assertIs(form.validators['foo'], validate1)
def validate2(node, value):
pass
# schema is updated if already present
schema = form.get_schema()
self.assertIsNotNone(schema)
self.assertIs(schema['foo'].validator, validate1)
form.set_validator('foo', validate2)
self.assertIs(form.validators['foo'], validate2)
self.assertIs(schema['foo'].validator, validate2)
def test_get_schema(self): def test_get_schema(self):
model = self.app.model
form = self.make_form() form = self.make_form()
self.assertIsNone(form.schema) self.assertIsNone(form.schema)
@ -179,62 +135,7 @@ class TestForm(TestCase):
self.assertIsNone(form.schema) self.assertIsNone(form.schema)
self.assertRaises(NotImplementedError, form.get_schema) self.assertRaises(NotImplementedError, form.get_schema)
# schema is auto-generated if model_class provided
form = self.make_form(model_class=model.Setting)
schema = form.get_schema()
self.assertEqual(len(schema.children), 2)
self.assertIn('name', schema)
self.assertIn('value', schema)
# but node overrides are honored when auto-generating
form = self.make_form(model_class=model.Setting)
value_node = colander.SchemaNode(colander.Bool(), name='value')
form.set_node('value', value_node)
schema = form.get_schema()
self.assertIs(schema['value'], value_node)
# schema is auto-generated if model_instance provided
form = self.make_form(model_instance=model.Setting(name='uhoh'))
self.assertEqual(form.fields, ['name', 'value'])
self.assertIsNone(form.schema)
# nb. force method to get new fields
del form.fields
schema = form.get_schema()
self.assertEqual(len(schema.children), 2)
self.assertIn('name', schema)
self.assertIn('value', schema)
# schema nodes are required by default
form = self.make_form(fields=['foo', 'bar'])
schema = form.get_schema()
self.assertIs(schema['foo'].missing, colander.required)
self.assertIs(schema['bar'].missing, colander.required)
# but fields can be marked *not* required
form = self.make_form(fields=['foo', 'bar'])
form.set_required('bar', False)
schema = form.get_schema()
self.assertIs(schema['foo'].missing, colander.required)
self.assertIs(schema['bar'].missing, colander.null)
# validator overrides are honored
def validate(node, value): pass
form = self.make_form(model_class=model.Setting)
form.set_validator('name', validate)
schema = form.get_schema()
self.assertIs(schema['name'].validator, validate)
# validator can be set for whole form
form = self.make_form(model_class=model.Setting)
schema = form.get_schema()
self.assertIsNone(schema.validator)
form = self.make_form(model_class=model.Setting)
form.set_validator(None, validate)
schema = form.get_schema()
self.assertIs(schema.validator, validate)
def test_get_deform(self): def test_get_deform(self):
model = self.app.model
schema = self.make_schema() schema = self.make_schema()
# basic # basic
@ -244,52 +145,12 @@ class TestForm(TestCase):
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 as dict # with model instance / cstruct
myobj = {'foo': 'one', 'bar': 'two'} myobj = {'foo': 'one', 'bar': 'two'}
form = self.make_form(schema=schema, model_instance=myobj) form = self.make_form(schema=schema, model_instance=myobj)
dform = form.get_deform() dform = form.get_deform()
self.assertEqual(dform.cstruct, myobj) self.assertEqual(dform.cstruct, myobj)
# with sqlalchemy model instance
myobj = model.Setting(name='foo', value='bar')
form = self.make_form(model_instance=myobj)
dform = form.get_deform()
self.assertEqual(dform.cstruct, {'name': 'foo', 'value': 'bar'})
# sqlalchemy instance with null value
myobj = model.Setting(name='foo', value=None)
form = self.make_form(model_instance=myobj)
dform = form.get_deform()
self.assertEqual(dform.cstruct, {'name': 'foo', 'value': colander.null})
def test_get_cancel_url(self):
# is referrer by default
form = self.make_form()
self.request.get_referrer = MagicMock(return_value='/cancel-default')
self.assertEqual(form.get_cancel_url(), '/cancel-default')
del self.request.get_referrer
# or can be static URL
form = self.make_form(cancel_url='/cancel-static')
self.assertEqual(form.get_cancel_url(), '/cancel-static')
# or can be fallback URL (nb. 'NOPE' indicates no referrer)
form = self.make_form(cancel_url_fallback='/cancel-fallback')
self.request.get_referrer = MagicMock(return_value='NOPE')
self.assertEqual(form.get_cancel_url(), '/cancel-fallback')
del self.request.get_referrer
# or can be referrer fallback, i.e. home page
form = self.make_form()
def get_referrer(default=None):
if default == 'NOPE':
return 'NOPE'
return '/home-page'
self.request.get_referrer = get_referrer
self.assertEqual(form.get_cancel_url(), '/home-page')
del self.request.get_referrer
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")
@ -309,46 +170,6 @@ class TestForm(TestCase):
self.assertEqual(form.get_label('foo'), "Woohoo") self.assertEqual(form.get_label('foo'), "Woohoo")
self.assertEqual(schema['foo'].title, "Woohoo") self.assertEqual(schema['foo'].title, "Woohoo")
def test_readonly_fields(self):
form = self.make_form(fields=['foo', 'bar'])
self.assertEqual(form.readonly_fields, set())
self.assertFalse(form.is_readonly('foo'))
form.set_readonly('foo')
self.assertEqual(form.readonly_fields, {'foo'})
self.assertTrue(form.is_readonly('foo'))
self.assertFalse(form.is_readonly('bar'))
form.set_readonly('bar')
self.assertEqual(form.readonly_fields, {'foo', 'bar'})
self.assertTrue(form.is_readonly('foo'))
self.assertTrue(form.is_readonly('bar'))
form.set_readonly('foo', False)
self.assertEqual(form.readonly_fields, {'bar'})
self.assertFalse(form.is_readonly('foo'))
self.assertTrue(form.is_readonly('bar'))
def test_required_fields(self):
form = self.make_form(fields=['foo', 'bar'])
self.assertEqual(form.required_fields, {})
self.assertIsNone(form.is_required('foo'))
form.set_required('foo')
self.assertEqual(form.required_fields, {'foo': True})
self.assertTrue(form.is_required('foo'))
self.assertIsNone(form.is_required('bar'))
form.set_required('bar')
self.assertEqual(form.required_fields, {'foo': True, 'bar': True})
self.assertTrue(form.is_required('foo'))
self.assertTrue(form.is_required('bar'))
form.set_required('foo', False)
self.assertEqual(form.required_fields, {'foo': False, 'bar': True})
self.assertFalse(form.is_required('foo'))
self.assertTrue(form.is_required('bar'))
def test_render_vue_tag(self): def test_render_vue_tag(self):
schema = self.make_schema() schema = self.make_schema()
form = self.make_form(schema=schema) form = self.make_form(schema=schema)
@ -362,13 +183,13 @@ class TestForm(TestCase):
# form button is disabled on @submit by default # form button is disabled on @submit by default
schema = self.make_schema() schema = self.make_schema()
form = self.make_form(schema=schema, cancel_url='/') form = self.make_form(schema=schema)
html = form.render_vue_template() html = form.render_vue_template()
self.assertIn('<script type="text/x-template" id="wutta-form-template">', html) self.assertIn('<script type="text/x-template" id="wutta-form-template">', html)
self.assertIn('@submit', html) self.assertIn('@submit', html)
# but not if form is configured otherwise # but not if form is configured otherwise
form = self.make_form(schema=schema, auto_disable_submit=False, cancel_url='/') form = self.make_form(schema=schema, auto_disable_submit=False)
html = form.render_vue_template() html = form.render_vue_template()
self.assertIn('<script type="text/x-template" id="wutta-form-template">', html) self.assertIn('<script type="text/x-template" id="wutta-form-template">', html)
self.assertNotIn('@submit', html) self.assertNotIn('@submit', html)
@ -403,25 +224,6 @@ class TestForm(TestCase):
html = form.render_vue_field('foo') html = form.render_vue_field('foo')
self.assertIn(':message="`something is wrong`"', html) self.assertIn(':message="`something is wrong`"', html)
# add another field, but not to deform, so it should still
# display but with no widget
form.fields.append('zanzibar')
html = form.render_vue_field('zanzibar')
self.assertIn('<b-field :horizontal="true" label="Zanzibar">', html)
self.assertNotIn('<b-input', html)
# nb. no error message
self.assertNotIn('message', html)
# try that once more but with a model record instance
with patch.object(form, 'model_instance', new={'zanzibar': 'omgwtfbbq'}):
html = form.render_vue_field('zanzibar')
self.assertIn('<b-field', html)
self.assertIn('label="Zanzibar"', html)
self.assertNotIn('<b-input', html)
self.assertIn('>omgwtfbbq<', html)
# nb. no error message
self.assertNotIn('message', html)
def test_get_field_errors(self): def test_get_field_errors(self):
schema = self.make_schema() schema = self.make_schema()
form = self.make_form(schema=schema) form = self.make_form(schema=schema)
@ -437,6 +239,34 @@ class TestForm(TestCase):
self.assertEqual(len(errors), 1) self.assertEqual(len(errors), 1)
self.assertEqual(errors[0], "something is wrong") self.assertEqual(errors[0], "something is wrong")
def test_get_vue_field_value(self):
schema = self.make_schema()
form = self.make_form(schema=schema)
# null field value
value = form.get_vue_field_value('foo')
self.assertEqual(value, 'null')
# non-default / explicit value
# TODO: surely need a different approach to set value
dform = form.get_deform()
dform['foo'].cstruct = 'blarg'
value = form.get_vue_field_value('foo')
self.assertEqual(value, '"blarg"')
def test_jsonify_value(self):
form = self.make_form()
# null field value
value = form.jsonify_value(colander.null)
self.assertEqual(value, 'null')
value = form.jsonify_value(None)
self.assertEqual(value, 'null')
# string value
value = form.jsonify_value('blarg')
self.assertEqual(value, '"blarg"')
def test_validate(self): def test_validate(self):
schema = self.make_schema() schema = self.make_schema()
form = self.make_form(schema=schema) form = self.make_form(schema=schema)
@ -450,7 +280,7 @@ class TestForm(TestCase):
data = form.validate() data = form.validate()
self.assertEqual(data, {'foo': 'blarg', 'bar': 'baz'}) self.assertEqual(data, {'foo': 'blarg', 'bar': 'baz'})
# validating a second time updates form.validated # validating a second type updates form.validated
self.request.POST = {'foo': 'BLARG', 'bar': 'BAZ'} self.request.POST = {'foo': 'BLARG', 'bar': 'BAZ'}
data = form.validate() data = form.validate()
self.assertEqual(data, {'foo': 'BLARG', 'bar': 'BAZ'}) self.assertEqual(data, {'foo': 'BLARG', 'bar': 'BAZ'})
@ -462,17 +292,3 @@ class TestForm(TestCase):
dform = form.get_deform() dform = form.get_deform()
self.assertEqual(len(dform.error.children), 2) self.assertEqual(len(dform.error.children), 2)
self.assertEqual(dform['foo'].errormsg, "Pstruct is not a string") self.assertEqual(dform['foo'].errormsg, "Pstruct is not a string")
# when a form has readonly fields, validating it will *remove*
# those fields from deform/schema as well as final data dict
schema = self.make_schema()
form = self.make_form(schema=schema)
form.set_readonly('foo')
self.request.POST = {'foo': 'one', 'bar': 'two'}
data = form.validate()
self.assertEqual(data, {'bar': 'two'})
dform = form.get_deform()
self.assertNotIn('foo', schema)
self.assertNotIn('foo', dform)
self.assertIn('bar', schema)
self.assertIn('bar', dform)

View file

@ -1,199 +0,0 @@
# -*- coding: utf-8; -*-
from unittest.mock import patch
import colander
from pyramid import testing
from sqlalchemy import orm
from wuttjamaican.conf import WuttaConfig
from wuttaweb.forms import schema as mod
from tests.util import DataTestCase
class TestObjectNode(DataTestCase):
def setUp(self):
self.setup_db()
self.request = testing.DummyRequest(wutta_config=self.config)
def test_dictify(self):
model = self.app.model
person = model.Person(full_name="Betty Boop")
# unsupported type raises error
node = mod.ObjectNode(colander.String())
self.assertRaises(NotImplementedError, node.dictify, person)
# but supported type can dictify
node = mod.ObjectNode(mod.PersonRef(self.request))
value = node.dictify(person)
self.assertIs(value, person)
def test_objectify(self):
model = self.app.model
person = model.Person(full_name="Betty Boop")
# unsupported type raises error
node = mod.ObjectNode(colander.String())
self.assertRaises(NotImplementedError, node.objectify, person)
# but supported type can objectify
node = mod.ObjectNode(mod.PersonRef(self.request))
value = node.objectify(person)
self.assertIs(value, person)
class TestObjectRef(DataTestCase):
def setUp(self):
self.setup_db()
self.request = testing.DummyRequest(wutta_config=self.config)
def test_empty_option(self):
# null by default
typ = mod.ObjectRef(self.request)
self.assertIsNone(typ.empty_option)
# passing true yields default empty option
typ = mod.ObjectRef(self.request, empty_option=True)
self.assertEqual(typ.empty_option, ('', "(none)"))
# can set explicitly
typ = mod.ObjectRef(self.request, empty_option=('foo', 'bar'))
self.assertEqual(typ.empty_option, ('foo', 'bar'))
# can set just a label
typ = mod.ObjectRef(self.request, empty_option="(empty)")
self.assertEqual(typ.empty_option, ('', "(empty)"))
def test_model_class(self):
typ = mod.ObjectRef(self.request)
self.assertRaises(NotImplementedError, getattr, typ, 'model_class')
def test_serialize(self):
model = self.app.model
node = colander.SchemaNode(colander.String())
# null
typ = mod.ObjectRef(self.request)
value = typ.serialize(node, colander.null)
self.assertIs(value, colander.null)
# model instance
person = model.Person(full_name="Betty Boop")
self.session.add(person)
self.session.commit()
self.assertIsNotNone(person.uuid)
typ = mod.ObjectRef(self.request)
value = typ.serialize(node, person)
self.assertEqual(value, person.uuid)
def test_deserialize(self):
model = self.app.model
node = colander.SchemaNode(colander.String())
# null
typ = mod.ObjectRef(self.request)
value = typ.deserialize(node, colander.null)
self.assertIs(value, colander.null)
# model instance
person = model.Person(full_name="Betty Boop")
self.session.add(person)
self.session.commit()
self.assertIsNotNone(person.uuid)
with patch.object(mod.ObjectRef, 'model_class', new=model.Person):
typ = mod.ObjectRef(self.request, session=self.session)
value = typ.deserialize(node, person.uuid)
self.assertIs(value, person)
def test_dictify(self):
model = self.app.model
node = colander.SchemaNode(colander.String())
# model instance
person = model.Person(full_name="Betty Boop")
self.session.add(person)
self.session.commit()
self.assertIsNotNone(person.uuid)
typ = mod.ObjectRef(self.request)
value = typ.dictify(person)
self.assertIs(value, person)
def test_objectify(self):
model = self.app.model
node = colander.SchemaNode(colander.String())
# null
typ = mod.ObjectRef(self.request)
value = typ.objectify(None)
self.assertIsNone(value)
# model instance
person = model.Person(full_name="Betty Boop")
self.session.add(person)
self.session.commit()
self.assertIsNotNone(person.uuid)
with patch.object(mod.ObjectRef, 'model_class', new=model.Person):
typ = mod.ObjectRef(self.request, session=self.session)
value = typ.objectify(person.uuid)
self.assertIs(value, person)
# error if not found
with patch.object(mod.ObjectRef, 'model_class', new=model.Person):
typ = mod.ObjectRef(self.request, session=self.session)
self.assertRaises(ValueError, typ.objectify, 'WRONG-UUID')
def test_get_query(self):
model = self.app.model
with patch.object(mod.ObjectRef, 'model_class', new=model.Person):
typ = mod.ObjectRef(self.request, session=self.session)
query = typ.get_query()
self.assertIsInstance(query, orm.Query)
def test_sort_query(self):
model = self.app.model
with patch.object(mod.ObjectRef, 'model_class', new=model.Person):
typ = mod.ObjectRef(self.request, session=self.session)
query = typ.get_query()
sorted_query = typ.sort_query(query)
self.assertIs(sorted_query, query)
def test_widget_maker(self):
model = self.app.model
person = model.Person(full_name="Betty Boop")
self.session.add(person)
self.session.commit()
# basic
with patch.object(mod.ObjectRef, 'model_class', new=model.Person):
typ = mod.ObjectRef(self.request, session=self.session)
widget = typ.widget_maker()
self.assertEqual(len(widget.values), 1)
self.assertEqual(widget.values[0][1], "Betty Boop")
# empty option
with patch.object(mod.ObjectRef, 'model_class', new=model.Person):
typ = mod.ObjectRef(self.request, session=self.session, empty_option=True)
widget = typ.widget_maker()
self.assertEqual(len(widget.values), 2)
self.assertEqual(widget.values[0][1], "(none)")
self.assertEqual(widget.values[1][1], "Betty Boop")
class TestPersonRef(DataTestCase):
def setUp(self):
self.setup_db()
self.request = testing.DummyRequest(wutta_config=self.config)
def test_sort_query(self):
typ = mod.PersonRef(self.request, session=self.session)
query = typ.get_query()
self.assertIsInstance(query, orm.Query)
sorted_query = typ.sort_query(query)
self.assertIsInstance(sorted_query, orm.Query)
self.assertIsNot(sorted_query, query)

View file

@ -1,32 +0,0 @@
# -*- coding: utf-8; -*-
import colander
import deform
from pyramid import testing
from wuttaweb.forms import widgets
from wuttaweb.forms.schema import PersonRef
from tests.util import WebTestCase
class TestObjectRefWidget(WebTestCase):
def test_serialize(self):
model = self.app.model
person = model.Person(full_name="Betty Boop")
self.session.add(person)
self.session.commit()
# standard (editable)
node = colander.SchemaNode(PersonRef(self.request, session=self.session))
widget = widgets.ObjectRefWidget(self.request)
field = deform.Field(node)
html = widget.serialize(field, person.uuid)
self.assertIn('<select ', html)
# readonly
node = colander.SchemaNode(PersonRef(self.request, session=self.session))
node.model_instance = person
widget = widgets.ObjectRefWidget(self.request)
field = deform.Field(node)
html = widget.serialize(field, person.uuid, readonly=True)
self.assertEqual(html, '<span>Betty Boop</span>')

View file

@ -1,7 +1,6 @@
# -*- coding: utf-8; -*- # -*- coding: utf-8; -*-
from unittest import TestCase from unittest import TestCase
from unittest.mock import patch
from pyramid import testing from pyramid import testing
@ -14,9 +13,8 @@ class TestGrid(TestCase):
def setUp(self): def setUp(self):
self.config = WuttaConfig(defaults={ self.config = WuttaConfig(defaults={
'wutta.web.menus.handler_spec': 'tests.util:NullMenuHandler', 'wutta.web.menus.handler_spec': 'tests.utils:NullMenuHandler',
}) })
self.app = self.config.get_app()
self.request = testing.DummyRequest(wutta_config=self.config, use_oruga=False) self.request = testing.DummyRequest(wutta_config=self.config, use_oruga=False)
@ -35,7 +33,7 @@ class TestGrid(TestCase):
# empty # empty
grid = self.make_grid() grid = self.make_grid()
self.assertIsNone(grid.key) self.assertIsNone(grid.key)
self.assertEqual(grid.columns, []) self.assertIsNone(grid.columns)
self.assertIsNone(grid.data) self.assertIsNone(grid.data)
# now with columns # now with columns
@ -51,50 +49,6 @@ class TestGrid(TestCase):
grid = self.make_grid() grid = self.make_grid()
self.assertEqual(grid.vue_component, 'WuttaGrid') self.assertEqual(grid.vue_component, 'WuttaGrid')
def test_get_columns(self):
model = self.app.model
# empty
grid = self.make_grid()
self.assertEqual(grid.columns, [])
self.assertEqual(grid.get_columns(), [])
# explicit
grid = self.make_grid(columns=['foo', 'bar'])
self.assertEqual(grid.columns, ['foo', 'bar'])
self.assertEqual(grid.get_columns(), ['foo', 'bar'])
# derived from model
grid = self.make_grid(model_class=model.Setting)
self.assertEqual(grid.columns, ['name', 'value'])
self.assertEqual(grid.get_columns(), ['name', 'value'])
def test_remove(self):
grid = self.make_grid(columns=['one', 'two', 'three', 'four'])
self.assertEqual(grid.columns, ['one', 'two', 'three', 'four'])
grid.remove('two', 'three')
self.assertEqual(grid.columns, ['one', 'four'])
def test_linked_columns(self):
grid = self.make_grid(columns=['foo', 'bar'])
self.assertEqual(grid.linked_columns, [])
self.assertFalse(grid.is_linked('foo'))
grid.set_link('foo')
self.assertEqual(grid.linked_columns, ['foo'])
self.assertTrue(grid.is_linked('foo'))
self.assertFalse(grid.is_linked('bar'))
grid.set_link('bar')
self.assertEqual(grid.linked_columns, ['foo', 'bar'])
self.assertTrue(grid.is_linked('foo'))
self.assertTrue(grid.is_linked('bar'))
grid.set_link('foo', False)
self.assertEqual(grid.linked_columns, ['bar'])
self.assertFalse(grid.is_linked('foo'))
self.assertTrue(grid.is_linked('bar'))
def test_render_vue_tag(self): def test_render_vue_tag(self):
grid = self.make_grid(columns=['foo', 'bar']) grid = self.make_grid(columns=['foo', 'bar'])
html = grid.render_vue_tag() html = grid.render_vue_tag()
@ -124,17 +78,18 @@ class TestGrid(TestCase):
def test_get_vue_data(self): def test_get_vue_data(self):
# empty if no columns defined # null by default
grid = self.make_grid() grid = self.make_grid()
data = grid.get_vue_data() data = grid.get_vue_data()
self.assertEqual(data, []) self.assertIsNone(data)
# typical data is a list # is usually a list
mydata = [ mydata = [
{'foo': 'bar'}, {'foo': 'bar'},
] ]
grid = self.make_grid(columns=['foo'], data=mydata) grid = self.make_grid(data=mydata)
data = grid.get_vue_data() data = grid.get_vue_data()
self.assertIs(data, mydata)
self.assertEqual(data, [{'foo': 'bar'}]) self.assertEqual(data, [{'foo': 'bar'}])
# if grid has actions, that list may be supplemented # if grid has actions, that list may be supplemented
@ -176,14 +131,6 @@ class TestGridAction(TestCase):
label = action.render_label() label = action.render_label()
self.assertEqual(label, "Bar") self.assertEqual(label, "Bar")
def test_render_icon_and_label(self):
action = self.make_action('blarg')
with patch.multiple(action,
render_icon=lambda: 'ICON',
render_label=lambda: 'LABEL'):
html = action.render_icon_and_label()
self.assertEqual('ICON LABEL', html)
def test_get_url(self): def test_get_url(self):
obj = {'foo': 'bar'} obj = {'foo': 'bar'}

View file

@ -215,7 +215,7 @@ class TestBeforeRender(TestCase):
def setUp(self): def setUp(self):
self.config = WuttaConfig(defaults={ self.config = WuttaConfig(defaults={
'wutta.web.menus.handler_spec': 'tests.util:NullMenuHandler', 'wutta.web.menus.handler_spec': 'tests.utils:NullMenuHandler',
}) })
def make_request(self): def make_request(self):

View file

@ -1,10 +1,8 @@
# -*- coding: utf-8; -*- # -*- coding: utf-8; -*-
import json
from unittest import TestCase from unittest import TestCase
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
import colander
from fanstatic import Library, Resource from fanstatic import Library, Resource
from pyramid import testing from pyramid import testing
@ -12,37 +10,6 @@ from wuttjamaican.conf import WuttaConfig
from wuttaweb import util from wuttaweb import util
class TestFieldList(TestCase):
def test_insert_before(self):
fields = util.FieldList(['f1', 'f2'])
self.assertEqual(fields, ['f1', 'f2'])
# typical
fields.insert_before('f1', 'XXX')
self.assertEqual(fields, ['XXX', 'f1', 'f2'])
fields.insert_before('f2', 'YYY')
self.assertEqual(fields, ['XXX', 'f1', 'YYY', 'f2'])
# appends new field if reference field is invalid
fields.insert_before('f3', 'ZZZ')
self.assertEqual(fields, ['XXX', 'f1', 'YYY', 'f2', 'ZZZ'])
def test_insert_after(self):
fields = util.FieldList(['f1', 'f2'])
self.assertEqual(fields, ['f1', 'f2'])
# typical
fields.insert_after('f1', 'XXX')
self.assertEqual(fields, ['f1', 'XXX', 'f2'])
fields.insert_after('XXX', 'YYY')
self.assertEqual(fields, ['f1', 'XXX', 'YYY', 'f2'])
# appends new field if reference field is invalid
fields.insert_after('f3', 'ZZZ')
self.assertEqual(fields, ['f1', 'XXX', 'YYY', 'f2', 'ZZZ'])
class TestGetLibVer(TestCase): class TestGetLibVer(TestCase):
def setUp(self): def setUp(self):
@ -436,18 +403,6 @@ class TestGetFormData(TestCase):
self.assertEqual(data, {'foo2': 'baz'}) self.assertEqual(data, {'foo2': 'baz'})
class TestGetModelFields(TestCase):
def setUp(self):
self.config = WuttaConfig()
self.app = self.config.get_app()
def test_basic(self):
model = self.app.model
fields = util.get_model_fields(self.config, model.Setting)
self.assertEqual(fields, ['name', 'value'])
class TestGetCsrfToken(TestCase): class TestGetCsrfToken(TestCase):
def setUp(self): def setUp(self):
@ -488,40 +443,3 @@ class TestRenderCsrfToken(TestCase):
self.assertIn('name="_csrf"', html) self.assertIn('name="_csrf"', html)
token = util.get_csrf_token(self.request) token = util.get_csrf_token(self.request)
self.assertIn(f'value="{token}"', html) self.assertIn(f'value="{token}"', html)
class TestMakeJsonSafe(TestCase):
def setUp(self):
self.config = WuttaConfig()
self.app = self.config.get_app()
def test_null(self):
value = util.make_json_safe(colander.null)
self.assertIsNone(value)
value = util.make_json_safe(None)
self.assertIsNone(value)
def test_invalid(self):
model = self.app.model
person = model.Person(full_name="Betty Boop")
self.assertRaises(TypeError, json.dumps, person)
value = util.make_json_safe(person, key='person')
self.assertEqual(value, "Betty Boop")
def test_dict(self):
model = self.app.model
person = model.Person(full_name="Betty Boop")
data = {
'foo': 'bar',
'person': person,
}
self.assertRaises(TypeError, json.dumps, data)
value = util.make_json_safe(data)
self.assertEqual(value, {
'foo': 'bar',
'person': "Betty Boop",
})

11
tests/utils.py Normal file
View file

@ -0,0 +1,11 @@
# -*- coding: utf-8; -*-
from wuttaweb.menus import MenuHandler
class NullMenuHandler(MenuHandler):
"""
Dummy menu handler for testing.
"""
def make_menus(self, request, **kwargs):
return []

View file

@ -6,23 +6,23 @@ from unittest.mock import MagicMock, patch
from pyramid import testing from pyramid import testing
from pyramid.response import Response from pyramid.response import Response
from pyramid.httpexceptions import HTTPFound, HTTPNotFound from pyramid.httpexceptions import HTTPFound
from wuttjamaican.conf import WuttaConfig from wuttjamaican.conf import WuttaConfig
from wuttaweb.views import master from wuttaweb.views import master
from wuttaweb.subscribers import new_request_set_user from wuttaweb.subscribers import new_request_set_user
from tests.util import WebTestCase
from tests.views.utils import WebTestCase
class TestMasterView(WebTestCase): class TestMasterView(WebTestCase):
def test_defaults(self): def test_defaults(self):
with patch.multiple(master.MasterView, create=True, master.MasterView.model_name = 'Widget'
model_name='Widget', with patch.object(master.MasterView, 'viewable', new=False):
viewable=False, # TODO: should inspect pyramid routes after this, to be certain
editable=False,
deletable=False):
master.MasterView.defaults(self.pyramid_config) master.MasterView.defaults(self.pyramid_config)
del master.MasterView.model_name
############################## ##############################
# class methods # class methods
@ -35,9 +35,9 @@ class TestMasterView(WebTestCase):
# subclass may specify # subclass may specify
MyModel = MagicMock() MyModel = MagicMock()
with patch.multiple(master.MasterView, create=True, master.MasterView.model_class = MyModel
model_class=MyModel): self.assertIs(master.MasterView.get_model_class(), MyModel)
self.assertIs(master.MasterView.get_model_class(), MyModel) del master.MasterView.model_class
def test_get_model_name(self): def test_get_model_name(self):
@ -51,9 +51,9 @@ class TestMasterView(WebTestCase):
# or it may specify model class # or it may specify model class
MyModel = MagicMock(__name__='Blaster') MyModel = MagicMock(__name__='Blaster')
with patch.multiple(master.MasterView, create=True, master.MasterView.model_class = MyModel
model_class=MyModel): self.assertEqual(master.MasterView.get_model_name(), 'Blaster')
self.assertEqual(master.MasterView.get_model_name(), 'Blaster') del master.MasterView.model_class
def test_get_model_name_normalized(self): def test_get_model_name_normalized(self):
@ -72,9 +72,9 @@ class TestMasterView(WebTestCase):
# or it may specify model class # or it may specify model class
MyModel = MagicMock(__name__='Dinosaur') MyModel = MagicMock(__name__='Dinosaur')
with patch.multiple(master.MasterView, create=True, master.MasterView.model_class = MyModel
model_class=MyModel): self.assertEqual(master.MasterView.get_model_name_normalized(), 'dinosaur')
self.assertEqual(master.MasterView.get_model_name_normalized(), 'dinosaur') del master.MasterView.model_class
def test_get_model_title(self): def test_get_model_title(self):
@ -93,9 +93,9 @@ class TestMasterView(WebTestCase):
# or it may specify model class # or it may specify model class
MyModel = MagicMock(__name__='Dinosaur') MyModel = MagicMock(__name__='Dinosaur')
with patch.multiple(master.MasterView, create=True, master.MasterView.model_class = MyModel
model_class=MyModel): self.assertEqual(master.MasterView.get_model_title(), "Dinosaur")
self.assertEqual(master.MasterView.get_model_title(), "Dinosaur") del master.MasterView.model_class
def test_get_model_title_plural(self): def test_get_model_title_plural(self):
@ -119,9 +119,9 @@ class TestMasterView(WebTestCase):
# or it may specify model class # or it may specify model class
MyModel = MagicMock(__name__='Dinosaur') MyModel = MagicMock(__name__='Dinosaur')
with patch.multiple(master.MasterView, create=True, master.MasterView.model_class = MyModel
model_class=MyModel): self.assertEqual(master.MasterView.get_model_title_plural(), "Dinosaurs")
self.assertEqual(master.MasterView.get_model_title_plural(), "Dinosaurs") del master.MasterView.model_class
def test_get_model_key(self): def test_get_model_key(self):
@ -155,9 +155,9 @@ class TestMasterView(WebTestCase):
# or it may specify model class # or it may specify model class
MyModel = MagicMock(__name__='Truck') MyModel = MagicMock(__name__='Truck')
with patch.multiple(master.MasterView, create=True, master.MasterView.model_class = MyModel
model_class=MyModel): self.assertEqual(master.MasterView.get_route_prefix(), 'trucks')
self.assertEqual(master.MasterView.get_route_prefix(), 'trucks') del master.MasterView.model_class
def test_get_url_prefix(self): def test_get_url_prefix(self):
@ -186,9 +186,9 @@ class TestMasterView(WebTestCase):
# or it may specify model class # or it may specify model class
MyModel = MagicMock(__name__='Machine') MyModel = MagicMock(__name__='Machine')
with patch.multiple(master.MasterView, create=True, master.MasterView.model_class = MyModel
model_class=MyModel): self.assertEqual(master.MasterView.get_url_prefix(), '/machines')
self.assertEqual(master.MasterView.get_url_prefix(), '/machines') del master.MasterView.model_class
def test_get_instance_url_prefix(self): def test_get_instance_url_prefix(self):
@ -241,9 +241,9 @@ class TestMasterView(WebTestCase):
# or it may specify model class # or it may specify model class
MyModel = MagicMock(__name__='Machine') MyModel = MagicMock(__name__='Machine')
with patch.multiple(master.MasterView, create=True, master.MasterView.model_class = MyModel
model_class=MyModel): self.assertEqual(master.MasterView.get_template_prefix(), '/machines')
self.assertEqual(master.MasterView.get_template_prefix(), '/machines') del master.MasterView.model_class
def test_get_grid_key(self): def test_get_grid_key(self):
@ -272,9 +272,9 @@ class TestMasterView(WebTestCase):
# or it may specify model class # or it may specify model class
MyModel = MagicMock(__name__='Machine') MyModel = MagicMock(__name__='Machine')
with patch.multiple(master.MasterView, create=True, master.MasterView.model_class = MyModel
model_class=MyModel): self.assertEqual(master.MasterView.get_grid_key(), 'machines')
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):
@ -303,9 +303,9 @@ class TestMasterView(WebTestCase):
# or it may specify model class # or it may specify model class
MyModel = MagicMock(__name__='Dinosaur') MyModel = MagicMock(__name__='Dinosaur')
with patch.multiple(master.MasterView, create=True, master.MasterView.model_class = MyModel
model_class=MyModel): self.assertEqual(master.MasterView.get_config_title(), "Dinosaurs")
self.assertEqual(master.MasterView.get_config_title(), "Dinosaurs") del master.MasterView.model_class
############################## ##############################
# support methods # support methods
@ -319,22 +319,22 @@ class TestMasterView(WebTestCase):
# basic sanity check using /master/index.mako # basic sanity check using /master/index.mako
# (nb. it skips /widgets/index.mako since that doesn't exist) # (nb. it skips /widgets/index.mako since that doesn't exist)
with patch.multiple(master.MasterView, create=True, master.MasterView.model_name = 'Widget'
model_name='Widget', view = master.MasterView(self.request)
creatable=False): response = view.render_to_response('index', {})
view = master.MasterView(self.request) self.assertIsInstance(response, Response)
response = view.render_to_response('index', {}) del master.MasterView.model_name
self.assertIsInstance(response, Response)
# basic sanity check using /appinfo/index.mako # basic sanity check using /appinfo/index.mako
with patch.multiple(master.MasterView, create=True, master.MasterView.model_name = 'AppInfo'
model_name='AppInfo', master.MasterView.route_prefix = 'appinfo'
route_prefix='appinfo', master.MasterView.url_prefix = '/appinfo'
url_prefix='/appinfo', view = master.MasterView(self.request)
creatable=False): response = view.render_to_response('index', {})
view = master.MasterView(self.request) self.assertIsInstance(response, Response)
response = view.render_to_response('index', {}) del master.MasterView.model_name
self.assertIsInstance(response, Response) del master.MasterView.route_prefix
del master.MasterView.url_prefix
# bad template name causes error # bad template name causes error
master.MasterView.model_name = 'Widget' master.MasterView.model_name = 'Widget'
@ -347,168 +347,10 @@ class TestMasterView(WebTestCase):
self.assertEqual(view.get_index_title(), "Wutta Widgets") self.assertEqual(view.get_index_title(), "Wutta Widgets")
del master.MasterView.model_title_plural del master.MasterView.model_title_plural
def test_make_model_grid(self):
model = self.app.model
# no model class
with patch.multiple(master.MasterView, create=True,
model_name='Widget',
model_key='uuid'):
view = master.MasterView(self.request)
grid = view.make_model_grid()
self.assertIsNone(grid.model_class)
# explicit model class
with patch.multiple(master.MasterView, create=True,
model_class=model.Setting):
grid = view.make_model_grid(session=self.session)
self.assertIs(grid.model_class, model.Setting)
def test_get_grid_data(self):
model = self.app.model
self.app.save_setting(self.session, 'foo', 'bar')
self.session.commit()
# basic logic with Setting model
with patch.multiple(master.MasterView, create=True,
model_class=model.Setting):
view = master.MasterView(self.request)
data = view.get_grid_data(session=self.session)
self.assertEqual(len(data), 1)
self.assertEqual(data[0], {'name': 'foo', 'value': 'bar'})
# error if model not known
view = master.MasterView(self.request)
self.assertFalse(hasattr(master.MasterView, 'model_class'))
def get_query(session=None):
session = session or self.session
return session.query(model.Setting)
with patch.object(view, 'get_query', new=get_query):
self.assertRaises(ValueError, view.get_grid_data, session=self.session)
def test_configure_grid(self):
model = self.app.model
# uuid field is pruned
with patch.multiple(master.MasterView, create=True,
model_class=model.Setting):
view = master.MasterView(self.request)
grid = view.make_grid(model_class=model.Setting,
columns=['uuid', 'name', 'value'])
self.assertIn('uuid', grid.columns)
view.configure_grid(grid)
self.assertNotIn('uuid', grid.columns)
def test_get_instance(self): def test_get_instance(self):
model = self.app.model
self.app.save_setting(self.session, 'foo', 'bar')
self.session.commit()
self.assertEqual(self.session.query(model.Setting).count(), 1)
# default not implemented
view = master.MasterView(self.request) view = master.MasterView(self.request)
self.assertRaises(NotImplementedError, view.get_instance) self.assertRaises(NotImplementedError, view.get_instance)
# fetch from DB if model class is known
with patch.multiple(master.MasterView, create=True,
model_class=model.Setting):
view = master.MasterView(self.request)
# existing setting is returned
self.request.matchdict = {'name': 'foo'}
setting = view.get_instance(session=self.session)
self.assertIsInstance(setting, model.Setting)
self.assertEqual(setting.name, 'foo')
self.assertEqual(setting.value, 'bar')
# missing setting not found
self.request.matchdict = {'name': 'blarg'}
self.assertRaises(HTTPNotFound, view.get_instance, session=self.session)
def test_make_model_form(self):
model = self.app.model
# no model class
with patch.multiple(master.MasterView, create=True,
model_name='Widget',
model_key='uuid'):
view = master.MasterView(self.request)
form = view.make_model_form()
self.assertIsNone(form.model_class)
# explicit model class
with patch.multiple(master.MasterView, create=True,
model_class=model.Setting):
form = view.make_model_form()
self.assertIs(form.model_class, model.Setting)
def test_configure_form(self):
model = self.app.model
# uuid field is pruned
with patch.multiple(master.MasterView, create=True,
model_class=model.Setting):
view = master.MasterView(self.request)
form = view.make_form(model_class=model.Setting,
fields=['uuid', 'name', 'value'])
self.assertIn('uuid', form.fields)
view.configure_form(form)
self.assertNotIn('uuid', form.fields)
def test_objectify(self):
model = self.app.model
self.app.save_setting(self.session, 'foo', 'bar')
self.session.commit()
self.assertEqual(self.session.query(model.Setting).count(), 1)
# no model class
with patch.multiple(master.MasterView, create=True,
model_name='Widget',
model_key='uuid'):
view = master.MasterView(self.request)
form = view.make_model_form(fields=['name', 'description'])
form.validated = {'name': 'first'}
obj = view.objectify(form)
self.assertIs(obj, form.validated)
# explicit model class (editing)
with patch.multiple(master.MasterView, create=True,
model_class=model.Setting,
editing=True):
form = view.make_model_form()
form.validated = {'name': 'foo', 'value': 'blarg'}
form.model_instance = self.session.query(model.Setting).one()
obj = view.objectify(form)
self.assertIsInstance(obj, model.Setting)
self.assertEqual(obj.name, 'foo')
self.assertEqual(obj.value, 'blarg')
# explicit model class (creating)
with patch.multiple(master.MasterView, create=True,
model_class=model.Setting,
creating=True):
form = view.make_model_form()
form.validated = {'name': 'another', 'value': 'whatever'}
obj = view.objectify(form)
self.assertIsInstance(obj, model.Setting)
self.assertEqual(obj.name, 'another')
self.assertEqual(obj.value, 'whatever')
def test_persist(self):
model = self.app.model
with patch.multiple(master.MasterView, create=True,
model_class=model.Setting):
view = master.MasterView(self.request)
# new instance is persisted
setting = model.Setting(name='foo', value='bar')
self.assertEqual(self.session.query(model.Setting).count(), 0)
view.persist(setting, session=self.session)
self.session.commit()
setting = self.session.query(model.Setting).one()
self.assertEqual(setting.name, 'foo')
self.assertEqual(setting.value, 'bar')
############################## ##############################
# view methods # view methods
############################## ##############################
@ -523,190 +365,35 @@ class TestMasterView(WebTestCase):
response = view.index() response = view.index()
# then again with data, to include view action url # then again with data, to include view action url
data = [{'name': 'foo', 'value': 'bar'}] data = [{'name': 'foo', 'value': 'bar'}]
with patch.object(view, 'get_grid_data', return_value=data): with patch.object(view, 'index_get_grid_data', return_value=data):
response = view.index() response = view.index()
del master.MasterView.model_name del master.MasterView.model_name
del master.MasterView.model_key del master.MasterView.model_key
del master.MasterView.grid_columns del master.MasterView.grid_columns
def test_create(self):
model = self.app.model
# sanity/coverage check using /settings/new
with patch.multiple(master.MasterView, create=True,
model_name='Setting',
model_key='name',
form_fields=['name', 'value']):
view = master.MasterView(self.request)
# no setting yet
self.assertIsNone(self.app.get_setting(self.session, 'foo.bar'))
# get the form page
response = view.create()
self.assertIsInstance(response, Response)
self.assertEqual(response.status_code, 200)
# self.assertIn('frazzle', response.text)
# nb. no error
self.assertNotIn('Required', response.text)
def persist(setting):
self.app.save_setting(self.session, setting['name'], setting['value'])
self.session.commit()
# post request to save setting
self.request.method = 'POST'
self.request.POST = {
'name': 'foo.bar',
'value': 'fraggle',
}
with patch.object(view, 'persist', new=persist):
response = view.create()
# nb. should get redirect back to view page
self.assertEqual(response.status_code, 302)
# setting should now be in DB
self.assertEqual(self.app.get_setting(self.session, 'foo.bar'), 'fraggle')
# try another post with invalid data (value is required)
self.request.method = 'POST'
self.request.POST = {}
with patch.object(view, 'persist', new=persist):
response = view.create()
# nb. should get a form with errors
self.assertEqual(response.status_code, 200)
self.assertIn('Required', response.text)
# setting did not change in DB
self.assertEqual(self.app.get_setting(self.session, 'foo.bar'), 'fraggle')
def test_view(self): def test_view(self):
# sanity/coverage check using /settings/XXX # 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'} setting = {'name': 'foo.bar', 'value': 'baz'}
self.request.matchdict = {'name': 'foo.bar'} self.request.matchdict = {'name': 'foo.bar'}
with patch.multiple(master.MasterView, create=True, with patch.object(view, 'get_instance', return_value=setting):
model_name='Setting', response = view.view()
model_key='name', del master.MasterView.model_name
grid_columns=['name', 'value'], del master.MasterView.grid_columns
form_fields=['name', 'value']): del master.MasterView.form_fields
view = master.MasterView(self.request)
with patch.object(view, 'get_instance', return_value=setting):
response = view.view()
def test_edit(self):
model = self.app.model
self.app.save_setting(self.session, 'foo.bar', 'frazzle')
self.session.commit()
def get_instance():
setting = self.session.query(model.Setting).get('foo.bar')
return {
'name': setting.name,
'value': setting.value,
}
# sanity/coverage check using /settings/XXX/edit
self.request.matchdict = {'name': 'foo.bar'}
with patch.multiple(master.MasterView, create=True,
model_name='Setting',
model_key='name',
form_fields=['name', 'value']):
view = master.MasterView(self.request)
with patch.object(view, 'get_instance', new=get_instance):
# get the form page
response = view.edit()
self.assertIsInstance(response, Response)
self.assertEqual(response.status_code, 200)
self.assertIn('frazzle', response.text)
# nb. no error
self.assertNotIn('Required', response.text)
def persist(setting):
self.app.save_setting(self.session, 'foo.bar', setting['value'])
self.session.commit()
# post request to save settings
self.request.method = 'POST'
self.request.POST = {
'name': 'foo.bar',
'value': 'froogle',
}
with patch.object(view, 'persist', new=persist):
response = view.edit()
# nb. should get redirect back to view page
self.assertEqual(response.status_code, 302)
# setting should be updated in DB
self.assertEqual(self.app.get_setting(self.session, 'foo.bar'), 'froogle')
# try another post with invalid data (value is required)
self.request.method = 'POST'
self.request.POST = {}
with patch.object(view, 'persist', new=persist):
response = view.edit()
# nb. should get a form with errors
self.assertEqual(response.status_code, 200)
self.assertIn('Required', response.text)
# setting did not change in DB
self.assertEqual(self.app.get_setting(self.session, 'foo.bar'), 'froogle')
def test_delete(self):
model = self.app.model
self.app.save_setting(self.session, 'foo.bar', 'frazzle')
self.session.commit()
self.assertEqual(self.session.query(model.Setting).count(), 1)
def get_instance():
setting = self.session.query(model.Setting).get('foo.bar')
return {
'name': setting.name,
'value': setting.value,
}
# sanity/coverage check using /settings/XXX/delete
self.request.matchdict = {'name': 'foo.bar'}
with patch.multiple(master.MasterView, create=True,
model_name='Setting',
model_key='name',
form_fields=['name', 'value']):
view = master.MasterView(self.request)
with patch.object(view, 'get_instance', new=get_instance):
# get the form page
response = view.delete()
self.assertIsInstance(response, Response)
self.assertEqual(response.status_code, 200)
self.assertIn('frazzle', response.text)
def delete_instance(setting):
self.app.delete_setting(self.session, setting['name'])
# post request to save settings
self.request.method = 'POST'
self.request.POST = {}
with patch.object(view, 'delete_instance', new=delete_instance):
response = view.delete()
# nb. should get redirect back to view page
self.assertEqual(response.status_code, 302)
# setting should be gone from DB
self.assertEqual(self.session.query(model.Setting).count(), 0)
def test_delete_instance(self):
model = self.app.model
self.app.save_setting(self.session, 'foo.bar', 'frazzle')
self.session.commit()
setting = self.session.query(model.Setting).one()
with patch.multiple(master.MasterView, create=True,
model_class=model.Setting,
form_fields=['name', 'value']):
view = master.MasterView(self.request)
view.delete_instance(setting)
self.session.commit()
self.assertEqual(self.session.query(model.Setting).count(), 0)
def test_configure(self): def test_configure(self):
model = self.app.model model = self.app.model
# setup
master.MasterView.model_name = 'AppInfo'
master.MasterView.route_prefix = 'appinfo'
master.MasterView.template_prefix = '/appinfo'
# mock settings # mock settings
settings = [ settings = [
{'name': 'wutta.app_title'}, {'name': 'wutta.app_title'},
@ -718,14 +405,11 @@ class TestMasterView(WebTestCase):
] ]
view = master.MasterView(self.request) view = master.MasterView(self.request)
with patch.object(self.request, 'current_route_url', return_value='/appinfo/configure'): with patch.object(self.request, 'current_route_url',
with patch.object(master, 'Session', return_value=self.session): return_value='/appinfo/configure'):
with patch.multiple(master.MasterView, create=True, with patch.object(master.MasterView, 'configure_get_simple_settings',
model_name='AppInfo', return_value=settings):
route_prefix='appinfo', with patch.object(master, 'Session', return_value=self.session):
template_prefix='/appinfo',
creatable=False,
configure_get_simple_settings=MagicMock(return_value=settings)):
# get the form page # get the form page
response = view.configure() response = view.configure()
@ -763,3 +447,8 @@ class TestMasterView(WebTestCase):
# should now have 0 settings # should now have 0 settings
count = self.session.query(model.Setting).count() count = self.session.query(model.Setting).count()
self.assertEqual(count, 0) self.assertEqual(count, 0)
# teardown
del master.MasterView.model_name
del master.MasterView.route_prefix
del master.MasterView.template_prefix

View file

@ -1,39 +0,0 @@
# -*- coding: utf-8; -*-
from unittest.mock import patch
from sqlalchemy import orm
from pyramid.httpexceptions import HTTPNotFound
from wuttaweb.views import people
from tests.util import WebTestCase
class TestPersonView(WebTestCase):
def make_view(self):
return people.PersonView(self.request)
def test_get_query(self):
view = self.make_view()
query = view.get_query(session=self.session)
self.assertIsInstance(query, orm.Query)
def test_configure_grid(self):
model = self.app.model
view = self.make_view()
grid = view.make_grid(model_class=model.Setting)
self.assertEqual(grid.linked_columns, [])
view.configure_grid(grid)
self.assertIn('full_name', grid.linked_columns)
def test_configure_form(self):
model = self.app.model
view = self.make_view()
form = view.make_form(model_class=model.Person)
form.set_fields(form.get_model_fields())
self.assertEqual(form.required_fields, {})
view.configure_form(form)
self.assertTrue(form.required_fields)
self.assertFalse(form.required_fields['middle_name'])

View file

@ -1,57 +0,0 @@
# -*- coding: utf-8; -*-
from unittest.mock import patch
from sqlalchemy import orm
import colander
from wuttaweb.views import roles as mod
from tests.util import WebTestCase
class TestRoleView(WebTestCase):
def make_view(self):
return mod.RoleView(self.request)
def test_get_query(self):
view = self.make_view()
query = view.get_query(session=self.session)
self.assertIsInstance(query, orm.Query)
def test_configure_grid(self):
model = self.app.model
view = self.make_view()
grid = view.make_grid(model_class=model.Role)
self.assertFalse(grid.is_linked('name'))
view.configure_grid(grid)
self.assertTrue(grid.is_linked('name'))
def test_configure_form(self):
model = self.app.model
view = self.make_view()
form = view.make_form(model_class=model.Person)
self.assertNotIn('name', form.validators)
view.configure_form(form)
self.assertIsNotNone(form.validators['name'])
def test_unique_name(self):
model = self.app.model
view = self.make_view()
role = model.Role(name='Foo')
self.session.add(role)
self.session.commit()
with patch.object(mod, 'Session', return_value=self.session):
# invalid if same name in data
node = colander.SchemaNode(colander.String(), name='name')
self.assertRaises(colander.Invalid, view.unique_name, node, 'Foo')
# but not if name belongs to current role
view.editing = True
self.request.matchdict = {'uuid': role.uuid}
node = colander.SchemaNode(colander.String(), name='name')
self.assertIsNone(view.unique_name(node, 'Foo'))

View file

@ -1,11 +1,10 @@
# -*- coding: utf-8; -*- # -*- coding: utf-8; -*-
from unittest.mock import patch from tests.views.utils import WebTestCase
from pyramid.httpexceptions import HTTPNotFound from pyramid.httpexceptions import HTTPNotFound
from wuttaweb.views import settings from wuttaweb.views import settings
from tests.util import WebTestCase
class TestAppInfoView(WebTestCase): class TestAppInfoView(WebTestCase):
@ -34,23 +33,35 @@ class TestSettingView(WebTestCase):
def make_view(self): def make_view(self):
return settings.SettingView(self.request) return settings.SettingView(self.request)
def test_get_grid_data(self): def test_index_get_grid_data(self):
# empty data by default # empty data by default
view = self.make_view() view = self.make_view()
data = view.get_grid_data(session=self.session) data = view.index_get_grid_data(session=self.session)
self.assertEqual(len(data), 0) self.assertEqual(len(data), 0)
# unless we save some settings # unless we save some settings
self.app.save_setting(self.session, 'foo', 'bar') self.app.save_setting(self.session, 'foo', 'bar')
self.session.commit() self.session.commit()
data = view.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_configure_form(self): def test_get_instance(self):
view = self.make_view() view = self.make_view()
form = view.make_form(fields=view.get_form_fields()) self.request.matchdict = {'name': 'foo'}
self.assertNotIn('value', form.required_fields)
view.configure_form(form) # setting not found
self.assertIn('value', form.required_fields) setting = view.get_instance(session=self.session)
self.assertFalse(form.required_fields['value']) 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')

View file

@ -1,57 +0,0 @@
# -*- coding: utf-8; -*-
from unittest.mock import patch
from sqlalchemy import orm
import colander
from wuttaweb.views import users as mod
from tests.util import WebTestCase
class TestUserView(WebTestCase):
def make_view(self):
return mod.UserView(self.request)
def test_get_query(self):
view = self.make_view()
query = view.get_query(session=self.session)
self.assertIsInstance(query, orm.Query)
def test_configure_grid(self):
model = self.app.model
view = self.make_view()
grid = view.make_grid(model_class=model.User)
self.assertFalse(grid.is_linked('person'))
view.configure_grid(grid)
self.assertTrue(grid.is_linked('person'))
def test_configure_form(self):
model = self.app.model
view = self.make_view()
form = view.make_form(model_class=model.Person)
self.assertIsNone(form.is_required('person'))
view.configure_form(form)
self.assertFalse(form.is_required('person'))
def test_unique_username(self):
model = self.app.model
view = self.make_view()
user = model.User(username='foo')
self.session.add(user)
self.session.commit()
with patch.object(mod, 'Session', return_value=self.session):
# invalid if same username in data
node = colander.SchemaNode(colander.String(), name='username')
self.assertRaises(colander.Invalid, view.unique_username, node, 'foo')
# but not if username belongs to current user
view.editing = True
self.request.matchdict = {'uuid': user.uuid}
node = colander.SchemaNode(colander.String(), name='username')
self.assertIsNone(view.unique_username(node, 'foo'))

View file

@ -7,36 +7,9 @@ from pyramid import testing
from wuttjamaican.conf import WuttaConfig from wuttjamaican.conf import WuttaConfig
from wuttaweb import subscribers from wuttaweb import subscribers
from wuttaweb.menus import MenuHandler
class DataTestCase(TestCase): class WebTestCase(TestCase):
"""
Base class for test suites requiring a full (typical) database.
"""
def setUp(self):
self.setup_db()
def setup_db(self):
self.config = WuttaConfig(defaults={
'wutta.db.default.url': 'sqlite://',
})
self.app = self.config.get_app()
# init db
model = self.app.model
model.Base.metadata.create_all(bind=self.config.appdb_engine)
self.session = self.app.make_session()
def tearDown(self):
self.teardown_db()
def teardown_db(self):
pass
class WebTestCase(DataTestCase):
""" """
Base class for test suites requiring a full (typical) web app. Base class for test suites requiring a full (typical) web app.
""" """
@ -45,15 +18,24 @@ class WebTestCase(DataTestCase):
self.setup_web() self.setup_web()
def setup_web(self): def setup_web(self):
self.setup_db() self.config = WuttaConfig(defaults={
'wutta.db.default.url': 'sqlite://',
'wutta.web.menus.handler_spec': 'tests.utils:NullMenuHandler',
})
self.request = testing.DummyRequest() self.request = testing.DummyRequest()
self.pyramid_config = testing.setUp(request=self.request, settings={ self.pyramid_config = testing.setUp(request=self.request, settings={
'wutta_config': self.config, 'wutta_config': self.config,
'mako.directories': ['wuttaweb:templates'], 'mako.directories': ['wuttaweb:templates'],
# TODO: have not need this yet, but will?
# 'pyramid_deform.template_search_path': 'wuttaweb:templates/deform',
}) })
# init db
self.app = self.config.get_app()
model = self.app.model
model.Base.metadata.create_all(bind=self.config.appdb_engine)
self.session = self.app.make_session()
# init web # init web
self.pyramid_config.include('pyramid_mako') self.pyramid_config.include('pyramid_mako')
self.pyramid_config.include('wuttaweb.static') self.pyramid_config.include('wuttaweb.static')
@ -73,12 +55,3 @@ class WebTestCase(DataTestCase):
def teardown_web(self): def teardown_web(self):
testing.tearDown() testing.tearDown()
self.teardown_db()
class NullMenuHandler(MenuHandler):
"""
Dummy menu handler for testing.
"""
def make_menus(self, request, **kwargs):
return []