Compare commits
No commits in common. "b4b72d92aa2d87d433973354cbd9d3e6e8947273" and "3d2aff7cc6309151e55076bae493e809ee918ac5" have entirely different histories.
b4b72d92aa
...
3d2aff7cc6
21
CHANGELOG.md
21
CHANGELOG.md
|
@ -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
|
||||||
|
|
|
@ -1,6 +0,0 @@
|
||||||
|
|
||||||
``wuttaweb.forms.schema``
|
|
||||||
=========================
|
|
||||||
|
|
||||||
.. automodule:: wuttaweb.forms.schema
|
|
||||||
:members:
|
|
|
@ -1,6 +0,0 @@
|
||||||
|
|
||||||
``wuttaweb.forms.widgets``
|
|
||||||
==========================
|
|
||||||
|
|
||||||
.. automodule:: wuttaweb.forms.widgets
|
|
||||||
:members:
|
|
|
@ -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
|
|
||||||
|
|
|
@ -1,6 +0,0 @@
|
||||||
|
|
||||||
``wuttaweb.views.people``
|
|
||||||
===========================
|
|
||||||
|
|
||||||
.. automodule:: wuttaweb.views.people
|
|
||||||
:members:
|
|
|
@ -1,6 +0,0 @@
|
||||||
|
|
||||||
``wuttaweb.views.roles``
|
|
||||||
========================
|
|
||||||
|
|
||||||
.. automodule:: wuttaweb.views.roles
|
|
||||||
:members:
|
|
|
@ -1,6 +0,0 @@
|
||||||
|
|
||||||
``wuttaweb.views.users``
|
|
||||||
========================
|
|
||||||
|
|
||||||
.. automodule:: wuttaweb.views.users
|
|
||||||
:members:
|
|
|
@ -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",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
@ -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,86 +306,17 @@ 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(
|
||||||
|
|
||||||
# use node override if present
|
|
||||||
if key in self.nodes:
|
|
||||||
node = self.nodes[key]
|
|
||||||
if not node:
|
|
||||||
|
|
||||||
# otherwise make simple string node
|
|
||||||
node = colander.SchemaNode(
|
|
||||||
colander.String(),
|
colander.String(),
|
||||||
name=key)
|
name=name))
|
||||||
|
|
||||||
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
|
self.schema = schema
|
||||||
|
|
||||||
|
else: # no fields
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
return self.schema
|
return self.schema
|
||||||
|
|
||||||
def get_deform(self):
|
def get_deform(self):
|
||||||
|
@ -701,16 +325,10 @@ 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() ?
|
|
||||||
# if hasattr(schema, 'dictify'):
|
|
||||||
if isinstance(self.model_instance, model.Base):
|
|
||||||
kwargs['appstruct'] = schema.dictify(self.model_instance)
|
|
||||||
else:
|
|
||||||
kwargs['appstruct'] = self.model_instance
|
kwargs['appstruct'] = self.model_instance
|
||||||
|
|
||||||
form = deform.Form(schema, **kwargs)
|
form = deform.Form(schema, **kwargs)
|
||||||
|
@ -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,35 +408,18 @@ 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:
|
if readonly is None:
|
||||||
readonly = self.is_readonly(fieldname)
|
|
||||||
if not readonly:
|
|
||||||
readonly = self.readonly
|
readonly = self.readonly
|
||||||
|
|
||||||
# but also, fields not in deform/schema must be readonly
|
|
||||||
dform = self.get_deform()
|
|
||||||
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()
|
||||||
|
|
||||||
# render proper widget if field is in deform/schema
|
|
||||||
field = dform[fieldname]
|
field = dform[fieldname]
|
||||||
kw = {}
|
kw = {}
|
||||||
if readonly:
|
if readonly:
|
||||||
kw['readonly'] = True
|
kw['readonly'] = True
|
||||||
html = field.serialize(**kw)
|
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
|
||||||
|
|
|
@ -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)
|
|
|
@ -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)
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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 |
|
@ -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,
|
||||||
|
|
|
@ -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%;">
|
||||||
|
|
|
@ -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()}
|
||||||
|
|
|
@ -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>
|
|
|
@ -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 ''}"
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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>
|
|
|
@ -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|{};" />
|
||||||
|
|
|
@ -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,15 +48,14 @@
|
||||||
|
|
||||||
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:
|
% if form.auto_disable_submit:
|
||||||
formSubmitting: false,
|
formSubmitting: false,
|
||||||
% endif
|
% endif
|
||||||
|
|
||||||
% endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -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']):
|
|
||||||
<a :href="props.row._action_url_view"
|
|
||||||
v-html="props.row.${column['field']}" />
|
|
||||||
% else:
|
|
||||||
<span v-html="props.row.${column['field']}"></span>
|
<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>
|
||||||
|
|
||||||
% endfor
|
% endfor
|
||||||
|
|
|
@ -1,7 +0,0 @@
|
||||||
## -*- coding: utf-8; -*-
|
|
||||||
<%inherit file="/master/form.mako" />
|
|
||||||
|
|
||||||
<%def name="title()">New ${model_title}</%def>
|
|
||||||
|
|
||||||
|
|
||||||
${parent.body()}
|
|
|
@ -1,18 +0,0 @@
|
||||||
## -*- coding: utf-8; -*-
|
|
||||||
<%inherit file="/master/form.mako" />
|
|
||||||
|
|
||||||
<%def name="title()">${index_title} » ${instance_title} » 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()}
|
|
|
@ -1,9 +0,0 @@
|
||||||
## -*- coding: utf-8; -*-
|
|
||||||
<%inherit file="/master/form.mako" />
|
|
||||||
|
|
||||||
<%def name="title()">${index_title} » ${instance_title} » Edit</%def>
|
|
||||||
|
|
||||||
<%def name="content_title()">Edit: ${instance_title}</%def>
|
|
||||||
|
|
||||||
|
|
||||||
${parent.body()}
|
|
|
@ -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>
|
|
|
@ -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
|
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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)
|
|
|
@ -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)
|
|
|
@ -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):
|
||||||
|
|
|
@ -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)
|
|
|
@ -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)
|
|
||||||
|
|
|
@ -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)
|
|
|
@ -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>')
|
|
|
@ -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'}
|
||||||
|
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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
11
tests/utils.py
Normal 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 []
|
|
@ -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',
|
|
||||||
creatable=False):
|
|
||||||
view = master.MasterView(self.request)
|
view = master.MasterView(self.request)
|
||||||
response = view.render_to_response('index', {})
|
response = view.render_to_response('index', {})
|
||||||
self.assertIsInstance(response, Response)
|
self.assertIsInstance(response, Response)
|
||||||
|
del master.MasterView.model_name
|
||||||
|
|
||||||
# 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',
|
|
||||||
creatable=False):
|
|
||||||
view = master.MasterView(self.request)
|
view = master.MasterView(self.request)
|
||||||
response = view.render_to_response('index', {})
|
response = view.render_to_response('index', {})
|
||||||
self.assertIsInstance(response, Response)
|
self.assertIsInstance(response, Response)
|
||||||
|
del master.MasterView.model_name
|
||||||
|
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,
|
|
||||||
model_name='Setting',
|
|
||||||
model_key='name',
|
|
||||||
grid_columns=['name', 'value'],
|
|
||||||
form_fields=['name', 'value']):
|
|
||||||
view = master.MasterView(self.request)
|
|
||||||
with patch.object(view, 'get_instance', return_value=setting):
|
with patch.object(view, 'get_instance', return_value=setting):
|
||||||
response = view.view()
|
response = view.view()
|
||||||
|
del master.MasterView.model_name
|
||||||
def test_edit(self):
|
del master.MasterView.grid_columns
|
||||||
model = self.app.model
|
del master.MasterView.form_fields
|
||||||
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',
|
||||||
|
return_value='/appinfo/configure'):
|
||||||
|
with patch.object(master.MasterView, 'configure_get_simple_settings',
|
||||||
|
return_value=settings):
|
||||||
with patch.object(master, 'Session', return_value=self.session):
|
with patch.object(master, 'Session', return_value=self.session):
|
||||||
with patch.multiple(master.MasterView, create=True,
|
|
||||||
model_name='AppInfo',
|
|
||||||
route_prefix='appinfo',
|
|
||||||
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
|
||||||
|
|
|
@ -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'])
|
|
|
@ -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'))
|
|
|
@ -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')
|
||||||
|
|
|
@ -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'))
|
|
|
@ -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 []
|
|
Loading…
Reference in a new issue