Compare commits
11 commits
3d2aff7cc6
...
b4b72d92aa
Author | SHA1 | Date | |
---|---|---|---|
b4b72d92aa | |||
7ad6a9d5a0 | |||
eac3b81918 | |||
33589f1cd8 | |||
fc01fa283a | |||
73014964cb | |||
c46b42f76d | |||
1a8fc8dd44 | |||
9e1fc6e57d | |||
e0de4e9a65 | |||
a361f07980 |
21
CHANGELOG.md
21
CHANGELOG.md
|
@ -5,6 +5,27 @@ 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/)
|
||||
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)
|
||||
|
||||
### Feat
|
||||
|
|
6
docs/api/wuttaweb/forms.schema.rst
Normal file
6
docs/api/wuttaweb/forms.schema.rst
Normal file
|
@ -0,0 +1,6 @@
|
|||
|
||||
``wuttaweb.forms.schema``
|
||||
=========================
|
||||
|
||||
.. automodule:: wuttaweb.forms.schema
|
||||
:members:
|
6
docs/api/wuttaweb/forms.widgets.rst
Normal file
6
docs/api/wuttaweb/forms.widgets.rst
Normal file
|
@ -0,0 +1,6 @@
|
|||
|
||||
``wuttaweb.forms.widgets``
|
||||
==========================
|
||||
|
||||
.. automodule:: wuttaweb.forms.widgets
|
||||
:members:
|
|
@ -12,6 +12,8 @@
|
|||
db
|
||||
forms
|
||||
forms.base
|
||||
forms.schema
|
||||
forms.widgets
|
||||
grids
|
||||
grids.base
|
||||
handler
|
||||
|
@ -26,4 +28,7 @@
|
|||
views.common
|
||||
views.essential
|
||||
views.master
|
||||
views.people
|
||||
views.roles
|
||||
views.settings
|
||||
views.users
|
||||
|
|
6
docs/api/wuttaweb/views.people.rst
Normal file
6
docs/api/wuttaweb/views.people.rst
Normal file
|
@ -0,0 +1,6 @@
|
|||
|
||||
``wuttaweb.views.people``
|
||||
===========================
|
||||
|
||||
.. automodule:: wuttaweb.views.people
|
||||
:members:
|
6
docs/api/wuttaweb/views.roles.rst
Normal file
6
docs/api/wuttaweb/views.roles.rst
Normal file
|
@ -0,0 +1,6 @@
|
|||
|
||||
``wuttaweb.views.roles``
|
||||
========================
|
||||
|
||||
.. automodule:: wuttaweb.views.roles
|
||||
:members:
|
6
docs/api/wuttaweb/views.users.rst
Normal file
6
docs/api/wuttaweb/views.users.rst
Normal file
|
@ -0,0 +1,6 @@
|
|||
|
||||
``wuttaweb.views.users``
|
||||
========================
|
||||
|
||||
.. automodule:: wuttaweb.views.users
|
||||
:members:
|
|
@ -6,7 +6,7 @@ build-backend = "hatchling.build"
|
|||
|
||||
[project]
|
||||
name = "WuttaWeb"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
description = "Web App for Wutta Framework"
|
||||
readme = "README.md"
|
||||
authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]
|
||||
|
@ -30,6 +30,7 @@ classifiers = [
|
|||
]
|
||||
requires-python = ">= 3.8"
|
||||
dependencies = [
|
||||
"ColanderAlchemy",
|
||||
"pyramid>=2",
|
||||
"pyramid_beaker",
|
||||
"pyramid_deform",
|
||||
|
@ -38,7 +39,7 @@ dependencies = [
|
|||
"pyramid_tm",
|
||||
"waitress",
|
||||
"WebHelpers2",
|
||||
"WuttJamaican[db]>=0.10.0",
|
||||
"WuttJamaican[db]>=0.11.0",
|
||||
"zope.sqlalchemy>=1.5",
|
||||
]
|
||||
|
||||
|
|
|
@ -24,65 +24,20 @@
|
|||
Base form classes
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
import colander
|
||||
import deform
|
||||
from colanderalchemy import SQLAlchemySchemaNode
|
||||
from pyramid.renderers import render
|
||||
from webhelpers2.html import HTML
|
||||
|
||||
from wuttaweb.util import get_form_data
|
||||
from wuttaweb.util import FieldList, get_form_data, get_model_fields, make_json_safe
|
||||
|
||||
|
||||
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:
|
||||
"""
|
||||
Base class for all forms.
|
||||
|
@ -112,23 +67,26 @@ class Form:
|
|||
|
||||
.. attribute:: fields
|
||||
|
||||
:class:`FieldList` instance containing string field names for
|
||||
the form. By default, fields will appear in the same order as
|
||||
they are in this list.
|
||||
:class:`~wuttaweb.util.FieldList` instance containing string
|
||||
field names for the form. By default, fields will appear in
|
||||
the same order as they are in this list.
|
||||
|
||||
See also :meth:`set_fields()`.
|
||||
|
||||
.. attribute:: schema
|
||||
|
||||
Colander-based schema object for the form. This is optional;
|
||||
if not specified an attempt will be made to construct one
|
||||
automatically.
|
||||
:class:`colander:colander.Schema` object for the form. This is
|
||||
optional; if not specified an attempt will be made to construct
|
||||
one automatically.
|
||||
|
||||
See also :meth:`get_schema()`.
|
||||
|
||||
.. attribute:: model_class
|
||||
|
||||
Optional "class" for the model. If set, this usually would be
|
||||
a SQLAlchemy mapped class. This may be used instead of
|
||||
specifying the :attr:`schema`.
|
||||
Model class for the form, if applicable. When set, this is
|
||||
usually a SQLAlchemy mapped class. This (or
|
||||
:attr:`model_instance`) may be used instead of specifying the
|
||||
:attr:`schema`.
|
||||
|
||||
.. attribute:: model_instance
|
||||
|
||||
|
@ -141,6 +99,27 @@ class Form:
|
|||
SQLAlchemy-mapped. (In that case :attr:`model_class` can be
|
||||
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
|
||||
|
||||
Boolean indicating the form does not allow submit. In practice
|
||||
|
@ -149,10 +128,48 @@ class Form:
|
|||
Default for this is ``False`` in which case the ``<form>`` tag
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
String name for Vue component tag. By default this is
|
||||
|
@ -177,9 +194,41 @@ class Form:
|
|||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
|
@ -196,40 +245,60 @@ class Form:
|
|||
schema=None,
|
||||
model_class=None,
|
||||
model_instance=None,
|
||||
nodes={},
|
||||
widgets={},
|
||||
validators={},
|
||||
readonly=False,
|
||||
readonly_fields=[],
|
||||
required_fields={},
|
||||
labels={},
|
||||
action_url=None,
|
||||
cancel_url=None,
|
||||
cancel_url_fallback=None,
|
||||
vue_tagname='wutta-form',
|
||||
align_buttons_right=False,
|
||||
auto_disable_submit=True,
|
||||
button_label_submit="Save",
|
||||
button_icon_submit='save',
|
||||
button_type_submit='is-primary',
|
||||
show_button_reset=False,
|
||||
show_button_cancel=True,
|
||||
button_label_cancel="Cancel",
|
||||
auto_disable_cancel=True,
|
||||
):
|
||||
self.request = request
|
||||
self.schema = schema
|
||||
self.nodes = nodes or {}
|
||||
self.widgets = widgets or {}
|
||||
self.validators = validators or {}
|
||||
self.readonly = readonly
|
||||
self.readonly_fields = set(readonly_fields or [])
|
||||
self.required_fields = required_fields or {}
|
||||
self.labels = labels or {}
|
||||
self.action_url = action_url
|
||||
self.cancel_url = cancel_url
|
||||
self.cancel_url_fallback = cancel_url_fallback
|
||||
self.vue_tagname = vue_tagname
|
||||
self.align_buttons_right = align_buttons_right
|
||||
self.auto_disable_submit = auto_disable_submit
|
||||
self.button_label_submit = button_label_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_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.app = self.config.get_app()
|
||||
|
||||
self.model_class = model_class
|
||||
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)
|
||||
|
||||
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
|
||||
self.set_fields(fields or self.get_fields())
|
||||
|
||||
def __contains__(self, name):
|
||||
"""
|
||||
|
@ -262,17 +331,216 @@ class Form:
|
|||
words = self.vue_tagname.split('-')
|
||||
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):
|
||||
"""
|
||||
Explicitly set the list of form fields.
|
||||
|
||||
This will overwrite :attr:`fields` with a new
|
||||
:class:`FieldList` instance.
|
||||
:class:`~wuttaweb.util.FieldList` instance.
|
||||
|
||||
:param fields: List of string field names.
|
||||
"""
|
||||
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):
|
||||
"""
|
||||
Set the label for given field name.
|
||||
|
@ -296,6 +564,45 @@ class Form:
|
|||
"""
|
||||
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):
|
||||
"""
|
||||
Return the :class:`colander:colander.Schema` object for the
|
||||
|
@ -306,17 +613,86 @@ class Form:
|
|||
"""
|
||||
if not self.schema:
|
||||
|
||||
if self.fields:
|
||||
schema = colander.Schema()
|
||||
for name in self.fields:
|
||||
schema.add(colander.SchemaNode(
|
||||
colander.String(),
|
||||
name=name))
|
||||
self.schema = schema
|
||||
##############################
|
||||
# create schema
|
||||
##############################
|
||||
|
||||
else: # no fields
|
||||
# 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()
|
||||
for key in fields:
|
||||
node = None
|
||||
|
||||
# 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(),
|
||||
name=key)
|
||||
|
||||
schema.add(node)
|
||||
|
||||
##############################
|
||||
# customize schema
|
||||
##############################
|
||||
|
||||
# apply widget overrides
|
||||
for key, widget in self.widgets.items():
|
||||
if key in schema:
|
||||
schema[key].widget = widget
|
||||
|
||||
# apply validator overrides
|
||||
for key, validator in self.validators.items():
|
||||
if key is None:
|
||||
# nb. this one is form-wide
|
||||
schema.validator = validator
|
||||
elif key in schema: # field-level
|
||||
schema[key].validator = validator
|
||||
|
||||
# apply required flags
|
||||
for key, required in self.required_fields.items():
|
||||
if key in schema:
|
||||
if required is False:
|
||||
schema[key].missing = colander.null
|
||||
|
||||
self.schema = schema
|
||||
|
||||
return self.schema
|
||||
|
||||
def get_deform(self):
|
||||
|
@ -325,10 +701,16 @@ class Form:
|
|||
generating it automatically if necessary.
|
||||
"""
|
||||
if not hasattr(self, 'deform_form'):
|
||||
model = self.app.model
|
||||
schema = self.get_schema()
|
||||
kwargs = {}
|
||||
|
||||
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
|
||||
|
||||
form = deform.Form(schema, **kwargs)
|
||||
|
@ -381,8 +763,10 @@ class Form:
|
|||
the output.
|
||||
"""
|
||||
context['form'] = self
|
||||
context['dform'] = self.get_deform()
|
||||
context.setdefault('form_attrs', {})
|
||||
context.setdefault('request', self.request)
|
||||
context['model_data'] = self.get_vue_model_data()
|
||||
|
||||
# auto disable button on submit
|
||||
if self.auto_disable_submit:
|
||||
|
@ -408,18 +792,35 @@ class Form:
|
|||
<!-- widget element(s) -->
|
||||
</b-field>
|
||||
"""
|
||||
|
||||
# readonly comes from: caller, field flag, or form flag
|
||||
if readonly is None:
|
||||
readonly = self.is_readonly(fieldname)
|
||||
if not readonly:
|
||||
readonly = self.readonly
|
||||
|
||||
# render the field widget or whatever
|
||||
# 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
|
||||
if fieldname in dform:
|
||||
|
||||
# render proper widget if field is in deform/schema
|
||||
field = dform[fieldname]
|
||||
kw = {}
|
||||
if readonly:
|
||||
kw['readonly'] = True
|
||||
html = field.serialize(**kw)
|
||||
|
||||
else:
|
||||
# render static text if field not in deform/schema
|
||||
# TODO: need to abstract this somehow
|
||||
if self.model_instance:
|
||||
html = str(self.model_instance[fieldname])
|
||||
else:
|
||||
html = ''
|
||||
|
||||
# mark all that as safe
|
||||
html = HTML.literal(html)
|
||||
|
||||
|
@ -463,6 +864,100 @@ class Form:
|
|||
|
||||
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):
|
||||
"""
|
||||
Return a list of error messages for the given field.
|
||||
|
@ -475,70 +970,3 @@ class Form:
|
|||
if error:
|
||||
return [error]
|
||||
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
|
||||
|
|
259
src/wuttaweb/forms/schema.py
Normal file
259
src/wuttaweb/forms/schema.py
Normal file
|
@ -0,0 +1,259 @@
|
|||
# -*- 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)
|
82
src/wuttaweb/forms/widgets.py
Normal file
82
src/wuttaweb/forms/widgets.py
Normal file
|
@ -0,0 +1,82 @@
|
|||
# -*- 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,10 +24,18 @@
|
|||
Base grid classes
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from pyramid.renderers import render
|
||||
from webhelpers2.html import HTML
|
||||
|
||||
from wuttaweb.forms import FieldList
|
||||
from wuttaweb.util import FieldList, get_model_fields, make_json_safe
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Grid:
|
||||
|
@ -52,13 +60,19 @@ class Grid:
|
|||
Presumably unique key for the grid; used to track per-grid
|
||||
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
|
||||
|
||||
:class:`~wuttaweb.forms.base.FieldList` instance containing
|
||||
string column names for the grid. Columns will appear in the
|
||||
same order as they are in this list.
|
||||
|
||||
See also :meth:`set_columns()`.
|
||||
See also :meth:`set_columns()` and :meth:`get_columns()`.
|
||||
|
||||
.. attribute:: data
|
||||
|
||||
|
@ -72,6 +86,13 @@ class Grid:
|
|||
List of :class:`GridAction` instances represenging action links
|
||||
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
|
||||
|
||||
String name for Vue component tag. By default this is
|
||||
|
@ -81,25 +102,59 @@ class Grid:
|
|||
def __init__(
|
||||
self,
|
||||
request,
|
||||
model_class=None,
|
||||
key=None,
|
||||
columns=None,
|
||||
data=None,
|
||||
actions=[],
|
||||
linked_columns=[],
|
||||
vue_tagname='wutta-grid',
|
||||
):
|
||||
self.request = request
|
||||
self.model_class = model_class
|
||||
self.key = key
|
||||
self.data = data
|
||||
self.actions = actions or []
|
||||
self.linked_columns = linked_columns or []
|
||||
self.vue_tagname = vue_tagname
|
||||
|
||||
self.config = self.request.wutta_config
|
||||
self.app = self.config.get_app()
|
||||
|
||||
if columns is not None:
|
||||
self.set_columns(columns)
|
||||
else:
|
||||
self.columns = None
|
||||
self.set_columns(columns or self.get_columns())
|
||||
|
||||
def get_columns(self):
|
||||
"""
|
||||
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
|
||||
def vue_component(self):
|
||||
|
@ -122,6 +177,68 @@ class Grid:
|
|||
"""
|
||||
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):
|
||||
"""
|
||||
Render the Vue component tag for the grid.
|
||||
|
@ -201,22 +318,52 @@ class Grid:
|
|||
Returns a list of Vue-compatible data records.
|
||||
|
||||
This uses :attr:`data` as the basis, but may add some extra
|
||||
values to each record for sake of action URLs etc.
|
||||
values to each record, e.g. URLs for :attr:`actions` etc.
|
||||
|
||||
See also :meth:`get_vue_columns()`.
|
||||
Importantly, this also ensures each value in the dict is
|
||||
JSON-serializable, using
|
||||
:func:`~wuttaweb.util.make_json_safe()`.
|
||||
|
||||
:returns: List of data record dicts for use with Vue table
|
||||
component.
|
||||
"""
|
||||
# use data as-is unless we have actions
|
||||
if not self.actions:
|
||||
return self.data
|
||||
original_data = self.data or []
|
||||
|
||||
# TODO: at some point i thought it was useful to wrangle the
|
||||
# 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
|
||||
data = []
|
||||
for i, record in enumerate(self.data):
|
||||
record = dict(record)
|
||||
for i, record in enumerate(original_data):
|
||||
|
||||
# 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:
|
||||
url = action.get_url(record, i)
|
||||
key = f'_action_url_{action.key}'
|
||||
record[key] = url
|
||||
|
||||
data.append(record)
|
||||
|
||||
return data
|
||||
|
@ -277,6 +424,10 @@ class GridAction:
|
|||
Name of icon to be shown for the action link.
|
||||
|
||||
See also :meth:`render_icon()`.
|
||||
|
||||
.. attribute:: link_class
|
||||
|
||||
Optional HTML class attribute for the action's ``<a>`` tag.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
|
@ -286,6 +437,7 @@ class GridAction:
|
|||
label=None,
|
||||
url=None,
|
||||
icon=None,
|
||||
link_class=None,
|
||||
):
|
||||
self.request = request
|
||||
self.config = self.request.wutta_config
|
||||
|
@ -294,6 +446,20 @@ class GridAction:
|
|||
self.url = url
|
||||
self.label = label or self.app.make_title(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):
|
||||
"""
|
||||
|
@ -305,6 +471,8 @@ class GridAction:
|
|||
.. code-block:: html
|
||||
|
||||
<i class="fas fa-trash"></i>
|
||||
|
||||
See also :meth:`render_icon_and_label()`.
|
||||
"""
|
||||
if self.request.use_oruga:
|
||||
raise NotImplementedError
|
||||
|
@ -316,6 +484,8 @@ class GridAction:
|
|||
Render the label text for the action link.
|
||||
|
||||
Default behavior is to return :attr:`label` as-is.
|
||||
|
||||
See also :meth:`render_icon_and_label()`.
|
||||
"""
|
||||
return self.label
|
||||
|
||||
|
|
|
@ -97,11 +97,41 @@ class MenuHandler(GenericHandler):
|
|||
is expected for most apps to override it.
|
||||
|
||||
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 [
|
||||
self.make_people_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):
|
||||
"""
|
||||
Generate a typical Admin menu.
|
||||
|
@ -111,12 +141,23 @@ class MenuHandler(GenericHandler):
|
|||
|
||||
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 above.
|
||||
dicts as described in :class:`MenuHandler`.
|
||||
"""
|
||||
return {
|
||||
'title': "Admin",
|
||||
'type': 'menu',
|
||||
'items': [
|
||||
{
|
||||
'title': "Users",
|
||||
'route': 'users',
|
||||
'perm': 'users.list',
|
||||
},
|
||||
{
|
||||
'title': "Roles",
|
||||
'route': 'roles',
|
||||
'perm': 'roles.list',
|
||||
},
|
||||
{'type': 'sep'},
|
||||
{
|
||||
'title': "App Info",
|
||||
'route': 'appinfo',
|
||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 13 KiB |
Binary file not shown.
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 594 KiB |
|
@ -64,7 +64,7 @@ def new_request(event):
|
|||
|
||||
Reference to the app :term:`config object`.
|
||||
|
||||
.. method:: request.get_referrer(default=None)
|
||||
.. function:: request.get_referrer(default=None)
|
||||
|
||||
Request method to get the "canonical" HTTP referrer value.
|
||||
This has logic to check for referrer in the request params,
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
<%def name="page_content()">
|
||||
|
||||
<nav class="panel item-panel">
|
||||
<nav class="panel">
|
||||
<p class="panel-heading">Application</p>
|
||||
<div class="panel-block">
|
||||
<div style="width: 100%;">
|
||||
|
@ -16,11 +16,14 @@
|
|||
<b-field horizontal label="App Title">
|
||||
<span>${app.get_title()}</span>
|
||||
</b-field>
|
||||
<b-field horizontal label="Production Mode">
|
||||
<span>${config.production()}</span>
|
||||
</b-field>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<nav class="panel item-panel">
|
||||
<nav class="panel">
|
||||
<p class="panel-heading">Configuration Files</p>
|
||||
<div class="panel-block">
|
||||
<div style="width: 100%;">
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
## -*- coding: utf-8; -*-
|
||||
<%namespace name="base_meta" file="/base_meta.mako" />
|
||||
<%namespace file="/wutta-components.mako" import="make_wutta_components" />
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
|
@ -213,13 +214,20 @@
|
|||
<div class="level-left">
|
||||
|
||||
## 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_url:
|
||||
<h1 class="title">${h.link_to(index_title, index_url)}</h1>
|
||||
% else:
|
||||
<h1 class="title">${index_title}</h1>
|
||||
% 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
|
||||
</div>
|
||||
|
||||
|
@ -230,13 +238,10 @@
|
|||
## TODO
|
||||
% if master and master.configurable and not master.configuring:
|
||||
<div class="level-item">
|
||||
<b-button type="is-primary"
|
||||
tag="a"
|
||||
href="${url(f'{route_prefix}.configure')}"
|
||||
icon-pack="fas"
|
||||
icon-left="cog">
|
||||
Configure
|
||||
</b-button>
|
||||
<wutta-button once type="is-primary"
|
||||
tag="a" href="${url(f'{route_prefix}.configure')}"
|
||||
icon-left="cog"
|
||||
label="Configure" />
|
||||
</div>
|
||||
% endif
|
||||
|
||||
|
@ -366,7 +371,38 @@
|
|||
${self.render_prevnext_header_buttons()}
|
||||
</%def>
|
||||
|
||||
<%def name="render_crud_header_buttons()"></%def>
|
||||
<%def name="render_crud_header_buttons()">
|
||||
% 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>
|
||||
|
||||
|
@ -432,6 +468,7 @@
|
|||
<%def name="finalize_whole_page_vars()"></%def>
|
||||
|
||||
<%def name="make_whole_page_component()">
|
||||
${make_wutta_components()}
|
||||
${self.render_whole_page_template()}
|
||||
${self.declare_whole_page_vars()}
|
||||
${self.modify_whole_page_vars()}
|
||||
|
|
11
src/wuttaweb/templates/deform/checkbox.pt
Normal file
11
src/wuttaweb/templates/deform/checkbox.pt
Normal file
|
@ -0,0 +1,11 @@
|
|||
<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,5 +1,6 @@
|
|||
<div tal:define="name name|field.name;
|
||||
vmodel vmodel|'model_'+name;">
|
||||
oid oid|field.oid;
|
||||
vmodel vmodel|'modelData.'+oid;">
|
||||
${field.start_mapping()}
|
||||
<b-input name="${name}"
|
||||
value="${field.widget.redisplay and cstruct or ''}"
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
<div tal:omit-tag=""
|
||||
tal:define="name name|field.name;
|
||||
vmodel vmodel|'model_'+name;">
|
||||
oid oid|field.oid;
|
||||
vmodel vmodel|'modelData.'+oid;">
|
||||
<b-input name="${name}"
|
||||
v-model="${vmodel}"
|
||||
type="password"
|
||||
|
|
50
src/wuttaweb/templates/deform/select.pt
Normal file
50
src/wuttaweb/templates/deform/select.pt
Normal file
|
@ -0,0 +1,50 @@
|
|||
<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,6 +1,7 @@
|
|||
<div tal:omit-tag=""
|
||||
tal:define="name name|field.name;
|
||||
vmodel vmodel|'model_'+name;">
|
||||
oid oid|field.oid;
|
||||
vmodel vmodel|'modelData.'+oid;">
|
||||
<b-input name="${name}"
|
||||
v-model="${vmodel}"
|
||||
tal:attributes="attributes|field.widget.attributes|{};" />
|
||||
|
|
|
@ -13,13 +13,19 @@
|
|||
% if not form.readonly:
|
||||
<div style="margin-top: 1.5rem; display: flex; gap: 0.5rem; justify-content: ${'end' if form.align_buttons_right else 'start'}; width: 100%; padding-left: 10rem;">
|
||||
|
||||
% if form.show_button_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:
|
||||
<b-button native-type="reset">
|
||||
Reset
|
||||
</b-button>
|
||||
% endif
|
||||
|
||||
<b-button type="is-primary"
|
||||
<b-button type="${form.button_type_submit}"
|
||||
native-type="submit"
|
||||
% if form.auto_disable_submit:
|
||||
:disabled="formSubmitting"
|
||||
|
@ -48,14 +54,15 @@
|
|||
|
||||
let ${form.vue_component}Data = {
|
||||
|
||||
## field model values
|
||||
% for key in form:
|
||||
model_${key}: ${form.get_vue_field_value(key)|n},
|
||||
% endfor
|
||||
% if not form.readonly:
|
||||
|
||||
modelData: ${json.dumps(model_data)|n},
|
||||
|
||||
% if form.auto_disable_submit:
|
||||
formSubmitting: false,
|
||||
% endif
|
||||
|
||||
% endif
|
||||
}
|
||||
|
||||
</script>
|
||||
|
|
|
@ -10,7 +10,12 @@
|
|||
label="${column['label']}"
|
||||
v-slot="props"
|
||||
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>
|
||||
% endif
|
||||
</${b}-table-column>
|
||||
% endfor
|
||||
|
||||
|
@ -19,9 +24,9 @@
|
|||
label="Actions"
|
||||
v-slot="props">
|
||||
% for action in grid.actions:
|
||||
<a :href="props.row._action_url_${action.key}">
|
||||
${action.render_icon()}
|
||||
${action.render_label()}
|
||||
<a :href="props.row._action_url_${action.key}"
|
||||
class="${action.link_class}">
|
||||
${action.render_icon_and_label()}
|
||||
</a>
|
||||
|
||||
% endfor
|
||||
|
|
7
src/wuttaweb/templates/master/create.mako
Normal file
7
src/wuttaweb/templates/master/create.mako
Normal file
|
@ -0,0 +1,7 @@
|
|||
## -*- coding: utf-8; -*-
|
||||
<%inherit file="/master/form.mako" />
|
||||
|
||||
<%def name="title()">New ${model_title}</%def>
|
||||
|
||||
|
||||
${parent.body()}
|
18
src/wuttaweb/templates/master/delete.mako
Normal file
18
src/wuttaweb/templates/master/delete.mako
Normal file
|
@ -0,0 +1,18 @@
|
|||
## -*- 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()}
|
9
src/wuttaweb/templates/master/edit.mako
Normal file
9
src/wuttaweb/templates/master/edit.mako
Normal file
|
@ -0,0 +1,9 @@
|
|||
## -*- 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()}
|
71
src/wuttaweb/templates/wutta-components.mako
Normal file
71
src/wuttaweb/templates/wutta-components.mako
Normal file
|
@ -0,0 +1,71 @@
|
|||
|
||||
<%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,10 +25,62 @@ Web Utilities
|
|||
"""
|
||||
|
||||
import importlib
|
||||
import json
|
||||
import logging
|
||||
|
||||
import colander
|
||||
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):
|
||||
"""
|
||||
Returns the effective form data for the given request.
|
||||
|
@ -357,3 +409,66 @@ def render_csrf_token(request, name='_csrf'):
|
|||
"""
|
||||
token = get_csrf_token(request)
|
||||
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,13 +59,11 @@ class AuthView(View):
|
|||
|
||||
form = self.make_form(schema=self.login_make_schema(),
|
||||
align_buttons_right=True,
|
||||
show_button_cancel=False,
|
||||
show_button_reset=True,
|
||||
button_label_submit="Login",
|
||||
button_icon_submit='user')
|
||||
|
||||
# TODO
|
||||
# form.show_cancel = False
|
||||
|
||||
# validate basic form data (sanity check)
|
||||
data = form.validate()
|
||||
if data:
|
||||
|
@ -155,6 +153,7 @@ class AuthView(View):
|
|||
return self.redirect(self.request.route_url('home'))
|
||||
|
||||
form = self.make_form(schema=self.change_password_make_schema(),
|
||||
show_button_cancel=False,
|
||||
show_button_reset=True)
|
||||
|
||||
data = form.validate()
|
||||
|
|
|
@ -31,6 +31,10 @@ That will in turn include the following modules:
|
|||
|
||||
* :mod:`wuttaweb.views.auth`
|
||||
* :mod:`wuttaweb.views.common`
|
||||
* :mod:`wuttaweb.views.settings`
|
||||
* :mod:`wuttaweb.views.people`
|
||||
* :mod:`wuttaweb.views.roles`
|
||||
* :mod:`wuttaweb.views.users`
|
||||
"""
|
||||
|
||||
|
||||
|
@ -40,6 +44,9 @@ def defaults(config, **kwargs):
|
|||
config.include(mod('wuttaweb.views.auth'))
|
||||
config.include(mod('wuttaweb.views.common'))
|
||||
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):
|
||||
|
|
|
@ -24,10 +24,13 @@
|
|||
Base Logic for Master Views
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import orm
|
||||
|
||||
from pyramid.renderers import render_to_response
|
||||
|
||||
from wuttaweb.views import View
|
||||
from wuttaweb.util import get_form_data
|
||||
from wuttaweb.util import get_form_data, get_model_fields
|
||||
from wuttaweb.db import Session
|
||||
|
||||
|
||||
|
@ -166,7 +169,13 @@ class MasterView(View):
|
|||
|
||||
List of columns for the :meth:`index()` view grid.
|
||||
|
||||
This is optional; see also :meth:`index_get_grid_columns()`.
|
||||
This is optional; see also :meth:`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
|
||||
|
||||
|
@ -174,6 +183,18 @@ class MasterView(View):
|
|||
i.e. it should have a :meth:`view()` view. Default value is
|
||||
``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
|
||||
|
||||
List of columns for the model form.
|
||||
|
@ -194,12 +215,18 @@ class MasterView(View):
|
|||
# features
|
||||
listable = True
|
||||
has_grid = True
|
||||
creatable = True
|
||||
viewable = True
|
||||
editable = True
|
||||
deletable = True
|
||||
configurable = False
|
||||
|
||||
# current action
|
||||
listing = False
|
||||
creating = False
|
||||
viewing = False
|
||||
editing = False
|
||||
deleting = False
|
||||
configuring = False
|
||||
|
||||
##############################
|
||||
|
@ -222,7 +249,7 @@ class MasterView(View):
|
|||
|
||||
See also related methods, which are called by this one:
|
||||
|
||||
* :meth:`index_make_grid()`
|
||||
* :meth:`make_model_grid()`
|
||||
"""
|
||||
self.listing = True
|
||||
|
||||
|
@ -231,110 +258,66 @@ class MasterView(View):
|
|||
}
|
||||
|
||||
if self.has_grid:
|
||||
context['grid'] = self.index_make_grid()
|
||||
context['grid'] = self.make_model_grid()
|
||||
|
||||
return self.render_to_response('index', context)
|
||||
|
||||
def index_make_grid(self, **kwargs):
|
||||
##############################
|
||||
# create methods
|
||||
##############################
|
||||
|
||||
def create(self):
|
||||
"""
|
||||
Create and return a :class:`~wuttaweb.grids.base.Grid`
|
||||
instance for use with the :meth:`index()` view.
|
||||
View to "create" a new model record.
|
||||
|
||||
See also related methods, which are called by this one:
|
||||
This usually corresponds to a URL like ``/widgets/new``.
|
||||
|
||||
* :meth:`get_grid_key()`
|
||||
* :meth:`index_get_grid_columns()`
|
||||
* :meth:`index_get_grid_data()`
|
||||
* :meth:`index_configure_grid()`
|
||||
By default, this view is included only if :attr:`creatable` is
|
||||
true.
|
||||
|
||||
The default "create" view logic will show a form with field
|
||||
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()`
|
||||
"""
|
||||
if 'key' not in kwargs:
|
||||
kwargs['key'] = self.get_grid_key()
|
||||
self.creating = True
|
||||
form = self.make_model_form(cancel_url_fallback=self.get_index_url())
|
||||
|
||||
if 'columns' not in kwargs:
|
||||
kwargs['columns'] = self.index_get_grid_columns()
|
||||
if form.validate():
|
||||
obj = self.create_save_form(form)
|
||||
Session.flush()
|
||||
return self.redirect(self.get_action_url('view', obj))
|
||||
|
||||
if 'data' not in kwargs:
|
||||
kwargs['data'] = self.index_get_grid_data()
|
||||
context = {
|
||||
'form': form,
|
||||
}
|
||||
return self.render_to_response('create', context)
|
||||
|
||||
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):
|
||||
def create_save_form(self, form):
|
||||
"""
|
||||
Returns the default list of grid column names, for the
|
||||
:meth:`index()` view.
|
||||
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:`create()`.
|
||||
|
||||
This is called by :meth:`index_make_grid()`; in the resulting
|
||||
:class:`~wuttaweb.grids.base.Grid` instance, this becomes
|
||||
:attr:`~wuttaweb.grids.base.Grid.columns`.
|
||||
Subclass may override this, or any of the related methods
|
||||
called by this one:
|
||||
|
||||
This method may return ``None``, in which case the grid may
|
||||
(try to) generate its own default list.
|
||||
* :meth:`objectify()`
|
||||
* :meth:`persist()`
|
||||
|
||||
Subclass may define :attr:`grid_columns` for simple cases, or
|
||||
can override this method if needed.
|
||||
|
||||
Also note that :meth:`index_configure_grid()` may be used to
|
||||
further modify the final column set, regardless of what this
|
||||
method returns. So a common pattern is to declare all
|
||||
"supported" columns by setting :attr:`grid_columns` but then
|
||||
optionally remove or replace some of those within
|
||||
:meth:`index_configure_grid()`.
|
||||
"""
|
||||
if hasattr(self, 'grid_columns'):
|
||||
return self.grid_columns
|
||||
|
||||
def index_get_grid_data(self):
|
||||
"""
|
||||
Returns the grid data for the :meth:`index()` view.
|
||||
|
||||
This is called by :meth:`index_make_grid()`; in the resulting
|
||||
:class:`~wuttaweb.grids.base.Grid` instance, this becomes
|
||||
:attr:`~wuttaweb.grids.base.Grid.data`.
|
||||
|
||||
As of now there is not yet a "sane" default for this method;
|
||||
it simply returns an empty list. Subclass should override as
|
||||
needed.
|
||||
"""
|
||||
return []
|
||||
|
||||
def get_action_url_view(self, obj, i):
|
||||
"""
|
||||
Returns the "view" grid action URL for the given object.
|
||||
|
||||
Most typically this is like ``/widgets/XXX`` where ``XXX``
|
||||
represents the object's key/ID.
|
||||
"""
|
||||
route_prefix = self.get_route_prefix()
|
||||
|
||||
kw = {}
|
||||
for key in self.get_model_key():
|
||||
kw[key] = obj[key]
|
||||
|
||||
return self.request.route_url(f'{route_prefix}.view', **kw)
|
||||
|
||||
def index_configure_grid(self, grid):
|
||||
"""
|
||||
Configure the grid for the :meth:`index()` view.
|
||||
|
||||
This is called by :meth:`index_make_grid()`.
|
||||
|
||||
There is no default logic here; subclass should override as
|
||||
needed. The ``grid`` param will already be "complete" and
|
||||
ready to use as-is, but this method can further modify it
|
||||
based on request details etc.
|
||||
:returns: Should return the resulting model instance, e.g. as
|
||||
produced by :meth:`objectify()`.
|
||||
"""
|
||||
obj = self.objectify(form)
|
||||
self.persist(obj)
|
||||
return obj
|
||||
|
||||
##############################
|
||||
# view methods
|
||||
|
@ -353,9 +336,12 @@ class MasterView(View):
|
|||
The default view logic will show a read-only form with field
|
||||
values displayed.
|
||||
|
||||
See also related methods, which are called by this one:
|
||||
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()`
|
||||
"""
|
||||
self.viewing = True
|
||||
instance = self.get_instance()
|
||||
|
@ -368,6 +354,148 @@ class MasterView(View):
|
|||
}
|
||||
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
|
||||
##############################
|
||||
|
@ -694,6 +822,7 @@ class MasterView(View):
|
|||
'route_prefix': self.get_route_prefix(),
|
||||
'index_title': self.get_index_title(),
|
||||
'index_url': self.get_index_url(),
|
||||
'model_title': self.get_model_title(),
|
||||
'config_title': self.get_config_title(),
|
||||
}
|
||||
|
||||
|
@ -758,7 +887,152 @@ class MasterView(View):
|
|||
route_prefix = self.get_route_prefix()
|
||||
return self.request.route_url(route_prefix, **kwargs)
|
||||
|
||||
def get_instance(self):
|
||||
def make_model_grid(self, session=None, **kwargs):
|
||||
"""
|
||||
Create and return a :class:`~wuttaweb.grids.base.Grid`
|
||||
instance for use with the :meth:`index()` view.
|
||||
|
||||
See also related methods, which are called by this one:
|
||||
|
||||
* :meth:`get_grid_key()`
|
||||
* :meth:`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
|
||||
request details (e.g. route kwargs).
|
||||
|
@ -769,6 +1043,27 @@ class MasterView(View):
|
|||
There is no "sane" default logic here; subclass *must*
|
||||
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 "
|
||||
f" for view class: {self.__class__}")
|
||||
|
||||
|
@ -782,6 +1077,60 @@ class MasterView(View):
|
|||
"""
|
||||
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):
|
||||
"""
|
||||
Create and return a :class:`~wuttaweb.forms.base.Form`
|
||||
|
@ -791,16 +1140,24 @@ class MasterView(View):
|
|||
e.g.:
|
||||
|
||||
* :meth:`view()`
|
||||
* :meth:`edit()`
|
||||
|
||||
See also related methods, which are called by this one:
|
||||
|
||||
* :meth:`get_form_fields()`
|
||||
* :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
|
||||
|
||||
if 'fields' not in kwargs:
|
||||
kwargs['fields'] = self.get_form_fields()
|
||||
if not kwargs.get('fields'):
|
||||
fields = self.get_form_fields()
|
||||
if fields:
|
||||
kwargs['fields'] = fields
|
||||
|
||||
form = self.make_form(**kwargs)
|
||||
self.configure_form(form)
|
||||
|
@ -834,13 +1191,77 @@ class MasterView(View):
|
|||
Configure the given model form, as needed.
|
||||
|
||||
This is called by :meth:`make_model_form()` - for multiple
|
||||
CRUD views.
|
||||
CRUD views (create, view, edit, delete, possibly others).
|
||||
|
||||
There is no default logic here; subclass should override if
|
||||
needed. The ``form`` param will already be "complete" and
|
||||
ready to use as-is, but this method can further modify it
|
||||
based on request details etc.
|
||||
The default logic here does just one thing: when "editing"
|
||||
(i.e. in :meth:`edit()` view) then all fields which are part
|
||||
of the :attr:`model_key` will be marked via
|
||||
:meth:`set_readonly()` so the user cannot change primary key
|
||||
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
|
||||
|
@ -961,6 +1382,11 @@ class MasterView(View):
|
|||
keys = [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}")
|
||||
|
||||
@classmethod
|
||||
|
@ -1066,7 +1492,7 @@ class MasterView(View):
|
|||
grid in the :meth:`index()` view. This key may also be used
|
||||
as the basis (key prefix) for secondary grids.
|
||||
|
||||
This is called from :meth:`index_make_grid()`; in the
|
||||
This is called from :meth:`make_model_grid()`; in the
|
||||
resulting :class:`~wuttaweb.grids.base.Grid` instance, this
|
||||
becomes :attr:`~wuttaweb.grids.base.Grid.key`.
|
||||
|
||||
|
@ -1135,6 +1561,13 @@ class MasterView(View):
|
|||
config.add_view(cls, attr='index',
|
||||
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
|
||||
if cls.viewable:
|
||||
instance_url_prefix = cls.get_instance_url_prefix()
|
||||
|
@ -1142,6 +1575,22 @@ class MasterView(View):
|
|||
config.add_view(cls, attr='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
|
||||
if cls.configurable:
|
||||
config.add_route(f'{route_prefix}.configure',
|
||||
|
|
95
src/wuttaweb/views/people.py
Normal file
95
src/wuttaweb/views/people.py
Normal file
|
@ -0,0 +1,95 @@
|
|||
# -*- 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)
|
100
src/wuttaweb/views/roles.py
Normal file
100
src/wuttaweb/views/roles.py
Normal file
|
@ -0,0 +1,100 @@
|
|||
# -*- 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,10 +27,8 @@ Views for app settings
|
|||
from collections import OrderedDict
|
||||
|
||||
from wuttjamaican.db.model import Setting
|
||||
|
||||
from wuttaweb.views import MasterView
|
||||
from wuttaweb.util import get_libver, get_liburl
|
||||
from wuttaweb.db import Session
|
||||
|
||||
|
||||
class AppInfoView(MasterView):
|
||||
|
@ -48,6 +46,7 @@ class AppInfoView(MasterView):
|
|||
model_title_plural = "App Info"
|
||||
route_prefix = 'appinfo'
|
||||
has_grid = False
|
||||
creatable = False
|
||||
viewable = False
|
||||
editable = False
|
||||
deletable = False
|
||||
|
@ -147,55 +146,18 @@ class SettingView(MasterView):
|
|||
model_class = Setting
|
||||
model_title = "Raw Setting"
|
||||
|
||||
# TODO: this should be deduced by master
|
||||
model_key = 'name'
|
||||
|
||||
# TODO: try removing these
|
||||
grid_columns = [
|
||||
'name',
|
||||
'value',
|
||||
]
|
||||
form_fields = list(grid_columns)
|
||||
|
||||
# TODO: should define query, let master handle the rest
|
||||
def index_get_grid_data(self, session=None):
|
||||
# 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.Setting.name)
|
||||
|
||||
session = session or Session()
|
||||
query = session.query(model.Setting)\
|
||||
.order_by(model.Setting.name)
|
||||
|
||||
settings = []
|
||||
for setting in query:
|
||||
settings.append(self.normalize_setting(setting))
|
||||
|
||||
return settings
|
||||
|
||||
# TODO: master should handle this (but not as dict)
|
||||
def normalize_setting(self, setting):
|
||||
# TODO: master should handle this (per column nullable)
|
||||
def configure_form(self, f):
|
||||
""" """
|
||||
return {
|
||||
'name': setting.name,
|
||||
'value': setting.value,
|
||||
}
|
||||
|
||||
# TODO: master should handle this
|
||||
def get_instance(self, session=None):
|
||||
""" """
|
||||
model = self.app.model
|
||||
session = session or Session()
|
||||
name = self.request.matchdict['name']
|
||||
setting = session.query(model.Setting).get(name)
|
||||
if setting:
|
||||
return self.normalize_setting(setting)
|
||||
|
||||
return self.notfound()
|
||||
|
||||
# TODO: master should handle this
|
||||
def get_instance_title(self, setting):
|
||||
""" """
|
||||
return setting['name']
|
||||
super().configure_form(f)
|
||||
f.set_required('value', False)
|
||||
|
||||
|
||||
def defaults(config, **kwargs):
|
||||
|
|
117
src/wuttaweb/views/users.py
Normal file
117
src/wuttaweb/views/users.py
Normal file
|
@ -0,0 +1,117 @@
|
|||
# -*- 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,54 +1,24 @@
|
|||
# -*- coding: utf-8; -*-
|
||||
|
||||
from unittest import TestCase
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import colander
|
||||
import deform
|
||||
from pyramid import testing
|
||||
|
||||
from wuttjamaican.conf import WuttaConfig
|
||||
from wuttaweb.forms import base
|
||||
from wuttaweb.forms import base, widgets
|
||||
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):
|
||||
|
||||
def setUp(self):
|
||||
self.config = WuttaConfig(defaults={
|
||||
'wutta.web.menus.handler_spec': 'tests.utils:NullMenuHandler',
|
||||
'wutta.web.menus.handler_spec': 'tests.util:NullMenuHandler',
|
||||
})
|
||||
self.app = self.config.get_app()
|
||||
self.request = testing.DummyRequest(wutta_config=self.config, use_oruga=False)
|
||||
|
||||
self.pyramid_config = testing.setUp(request=self.request, settings={
|
||||
|
@ -73,7 +43,7 @@ class TestForm(TestCase):
|
|||
|
||||
def test_init_with_none(self):
|
||||
form = self.make_form()
|
||||
self.assertIsNone(form.fields)
|
||||
self.assertEqual(form.fields, [])
|
||||
|
||||
def test_init_with_fields(self):
|
||||
form = self.make_form(fields=['foo', 'bar'])
|
||||
|
@ -114,7 +84,81 @@ class TestForm(TestCase):
|
|||
form.set_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):
|
||||
model = self.app.model
|
||||
form = self.make_form()
|
||||
self.assertIsNone(form.schema)
|
||||
|
||||
|
@ -135,7 +179,62 @@ class TestForm(TestCase):
|
|||
self.assertIsNone(form.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):
|
||||
model = self.app.model
|
||||
schema = self.make_schema()
|
||||
|
||||
# basic
|
||||
|
@ -145,12 +244,52 @@ class TestForm(TestCase):
|
|||
self.assertIsInstance(dform, deform.Form)
|
||||
self.assertIs(form.deform_form, dform)
|
||||
|
||||
# with model instance / cstruct
|
||||
# with model instance as dict
|
||||
myobj = {'foo': 'one', 'bar': 'two'}
|
||||
form = self.make_form(schema=schema, model_instance=myobj)
|
||||
dform = form.get_deform()
|
||||
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):
|
||||
form = self.make_form(fields=['foo', 'bar'])
|
||||
self.assertEqual(form.get_label('foo'), "Foo")
|
||||
|
@ -170,6 +309,46 @@ class TestForm(TestCase):
|
|||
self.assertEqual(form.get_label('foo'), "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):
|
||||
schema = self.make_schema()
|
||||
form = self.make_form(schema=schema)
|
||||
|
@ -183,13 +362,13 @@ class TestForm(TestCase):
|
|||
|
||||
# form button is disabled on @submit by default
|
||||
schema = self.make_schema()
|
||||
form = self.make_form(schema=schema)
|
||||
form = self.make_form(schema=schema, cancel_url='/')
|
||||
html = form.render_vue_template()
|
||||
self.assertIn('<script type="text/x-template" id="wutta-form-template">', html)
|
||||
self.assertIn('@submit', html)
|
||||
|
||||
# but not if form is configured otherwise
|
||||
form = self.make_form(schema=schema, auto_disable_submit=False)
|
||||
form = self.make_form(schema=schema, auto_disable_submit=False, cancel_url='/')
|
||||
html = form.render_vue_template()
|
||||
self.assertIn('<script type="text/x-template" id="wutta-form-template">', html)
|
||||
self.assertNotIn('@submit', html)
|
||||
|
@ -224,6 +403,25 @@ class TestForm(TestCase):
|
|||
html = form.render_vue_field('foo')
|
||||
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):
|
||||
schema = self.make_schema()
|
||||
form = self.make_form(schema=schema)
|
||||
|
@ -239,34 +437,6 @@ class TestForm(TestCase):
|
|||
self.assertEqual(len(errors), 1)
|
||||
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):
|
||||
schema = self.make_schema()
|
||||
form = self.make_form(schema=schema)
|
||||
|
@ -280,7 +450,7 @@ class TestForm(TestCase):
|
|||
data = form.validate()
|
||||
self.assertEqual(data, {'foo': 'blarg', 'bar': 'baz'})
|
||||
|
||||
# validating a second type updates form.validated
|
||||
# validating a second time updates form.validated
|
||||
self.request.POST = {'foo': 'BLARG', 'bar': 'BAZ'}
|
||||
data = form.validate()
|
||||
self.assertEqual(data, {'foo': 'BLARG', 'bar': 'BAZ'})
|
||||
|
@ -292,3 +462,17 @@ class TestForm(TestCase):
|
|||
dform = form.get_deform()
|
||||
self.assertEqual(len(dform.error.children), 2)
|
||||
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)
|
||||
|
|
199
tests/forms/test_schema.py
Normal file
199
tests/forms/test_schema.py
Normal file
|
@ -0,0 +1,199 @@
|
|||
# -*- 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)
|
32
tests/forms/test_widgets.py
Normal file
32
tests/forms/test_widgets.py
Normal file
|
@ -0,0 +1,32 @@
|
|||
# -*- 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,6 +1,7 @@
|
|||
# -*- coding: utf-8; -*-
|
||||
|
||||
from unittest import TestCase
|
||||
from unittest.mock import patch
|
||||
|
||||
from pyramid import testing
|
||||
|
||||
|
@ -13,8 +14,9 @@ class TestGrid(TestCase):
|
|||
|
||||
def setUp(self):
|
||||
self.config = WuttaConfig(defaults={
|
||||
'wutta.web.menus.handler_spec': 'tests.utils:NullMenuHandler',
|
||||
'wutta.web.menus.handler_spec': 'tests.util:NullMenuHandler',
|
||||
})
|
||||
self.app = self.config.get_app()
|
||||
|
||||
self.request = testing.DummyRequest(wutta_config=self.config, use_oruga=False)
|
||||
|
||||
|
@ -33,7 +35,7 @@ class TestGrid(TestCase):
|
|||
# empty
|
||||
grid = self.make_grid()
|
||||
self.assertIsNone(grid.key)
|
||||
self.assertIsNone(grid.columns)
|
||||
self.assertEqual(grid.columns, [])
|
||||
self.assertIsNone(grid.data)
|
||||
|
||||
# now with columns
|
||||
|
@ -49,6 +51,50 @@ class TestGrid(TestCase):
|
|||
grid = self.make_grid()
|
||||
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):
|
||||
grid = self.make_grid(columns=['foo', 'bar'])
|
||||
html = grid.render_vue_tag()
|
||||
|
@ -78,18 +124,17 @@ class TestGrid(TestCase):
|
|||
|
||||
def test_get_vue_data(self):
|
||||
|
||||
# null by default
|
||||
# empty if no columns defined
|
||||
grid = self.make_grid()
|
||||
data = grid.get_vue_data()
|
||||
self.assertIsNone(data)
|
||||
self.assertEqual(data, [])
|
||||
|
||||
# is usually a list
|
||||
# typical data is a list
|
||||
mydata = [
|
||||
{'foo': 'bar'},
|
||||
]
|
||||
grid = self.make_grid(data=mydata)
|
||||
grid = self.make_grid(columns=['foo'], data=mydata)
|
||||
data = grid.get_vue_data()
|
||||
self.assertIs(data, mydata)
|
||||
self.assertEqual(data, [{'foo': 'bar'}])
|
||||
|
||||
# if grid has actions, that list may be supplemented
|
||||
|
@ -131,6 +176,14 @@ class TestGridAction(TestCase):
|
|||
label = action.render_label()
|
||||
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):
|
||||
obj = {'foo': 'bar'}
|
||||
|
||||
|
|
|
@ -215,7 +215,7 @@ class TestBeforeRender(TestCase):
|
|||
|
||||
def setUp(self):
|
||||
self.config = WuttaConfig(defaults={
|
||||
'wutta.web.menus.handler_spec': 'tests.utils:NullMenuHandler',
|
||||
'wutta.web.menus.handler_spec': 'tests.util:NullMenuHandler',
|
||||
})
|
||||
|
||||
def make_request(self):
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
# -*- coding: utf-8; -*-
|
||||
|
||||
import json
|
||||
from unittest import TestCase
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import colander
|
||||
from fanstatic import Library, Resource
|
||||
from pyramid import testing
|
||||
|
||||
|
@ -10,6 +12,37 @@ from wuttjamaican.conf import WuttaConfig
|
|||
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):
|
||||
|
||||
def setUp(self):
|
||||
|
@ -403,6 +436,18 @@ class TestGetFormData(TestCase):
|
|||
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):
|
||||
|
||||
def setUp(self):
|
||||
|
@ -443,3 +488,40 @@ class TestRenderCsrfToken(TestCase):
|
|||
self.assertIn('name="_csrf"', html)
|
||||
token = util.get_csrf_token(self.request)
|
||||
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",
|
||||
})
|
||||
|
|
|
@ -7,9 +7,36 @@ from pyramid import testing
|
|||
|
||||
from wuttjamaican.conf import WuttaConfig
|
||||
from wuttaweb import subscribers
|
||||
from wuttaweb.menus import MenuHandler
|
||||
|
||||
|
||||
class WebTestCase(TestCase):
|
||||
class DataTestCase(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.
|
||||
"""
|
||||
|
@ -18,24 +45,15 @@ class WebTestCase(TestCase):
|
|||
self.setup_web()
|
||||
|
||||
def setup_web(self):
|
||||
self.config = WuttaConfig(defaults={
|
||||
'wutta.db.default.url': 'sqlite://',
|
||||
'wutta.web.menus.handler_spec': 'tests.utils:NullMenuHandler',
|
||||
})
|
||||
|
||||
self.setup_db()
|
||||
self.request = testing.DummyRequest()
|
||||
|
||||
self.pyramid_config = testing.setUp(request=self.request, settings={
|
||||
'wutta_config': self.config,
|
||||
'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
|
||||
self.pyramid_config.include('pyramid_mako')
|
||||
self.pyramid_config.include('wuttaweb.static')
|
||||
|
@ -55,3 +73,12 @@ class WebTestCase(TestCase):
|
|||
|
||||
def teardown_web(self):
|
||||
testing.tearDown()
|
||||
self.teardown_db()
|
||||
|
||||
|
||||
class NullMenuHandler(MenuHandler):
|
||||
"""
|
||||
Dummy menu handler for testing.
|
||||
"""
|
||||
def make_menus(self, request, **kwargs):
|
||||
return []
|
|
@ -1,11 +0,0 @@
|
|||
# -*- 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.response import Response
|
||||
from pyramid.httpexceptions import HTTPFound
|
||||
from pyramid.httpexceptions import HTTPFound, HTTPNotFound
|
||||
|
||||
from wuttjamaican.conf import WuttaConfig
|
||||
from wuttaweb.views import master
|
||||
from wuttaweb.subscribers import new_request_set_user
|
||||
|
||||
from tests.views.utils import WebTestCase
|
||||
from tests.util import WebTestCase
|
||||
|
||||
|
||||
class TestMasterView(WebTestCase):
|
||||
|
||||
def test_defaults(self):
|
||||
master.MasterView.model_name = 'Widget'
|
||||
with patch.object(master.MasterView, 'viewable', new=False):
|
||||
# TODO: should inspect pyramid routes after this, to be certain
|
||||
with patch.multiple(master.MasterView, create=True,
|
||||
model_name='Widget',
|
||||
viewable=False,
|
||||
editable=False,
|
||||
deletable=False):
|
||||
master.MasterView.defaults(self.pyramid_config)
|
||||
del master.MasterView.model_name
|
||||
|
||||
##############################
|
||||
# class methods
|
||||
|
@ -35,9 +35,9 @@ class TestMasterView(WebTestCase):
|
|||
|
||||
# subclass may specify
|
||||
MyModel = MagicMock()
|
||||
master.MasterView.model_class = MyModel
|
||||
with patch.multiple(master.MasterView, create=True,
|
||||
model_class=MyModel):
|
||||
self.assertIs(master.MasterView.get_model_class(), MyModel)
|
||||
del master.MasterView.model_class
|
||||
|
||||
def test_get_model_name(self):
|
||||
|
||||
|
@ -51,9 +51,9 @@ class TestMasterView(WebTestCase):
|
|||
|
||||
# or it may specify model class
|
||||
MyModel = MagicMock(__name__='Blaster')
|
||||
master.MasterView.model_class = MyModel
|
||||
with patch.multiple(master.MasterView, create=True,
|
||||
model_class=MyModel):
|
||||
self.assertEqual(master.MasterView.get_model_name(), 'Blaster')
|
||||
del master.MasterView.model_class
|
||||
|
||||
def test_get_model_name_normalized(self):
|
||||
|
||||
|
@ -72,9 +72,9 @@ class TestMasterView(WebTestCase):
|
|||
|
||||
# or it may specify model class
|
||||
MyModel = MagicMock(__name__='Dinosaur')
|
||||
master.MasterView.model_class = MyModel
|
||||
with patch.multiple(master.MasterView, create=True,
|
||||
model_class=MyModel):
|
||||
self.assertEqual(master.MasterView.get_model_name_normalized(), 'dinosaur')
|
||||
del master.MasterView.model_class
|
||||
|
||||
def test_get_model_title(self):
|
||||
|
||||
|
@ -93,9 +93,9 @@ class TestMasterView(WebTestCase):
|
|||
|
||||
# or it may specify model class
|
||||
MyModel = MagicMock(__name__='Dinosaur')
|
||||
master.MasterView.model_class = MyModel
|
||||
with patch.multiple(master.MasterView, create=True,
|
||||
model_class=MyModel):
|
||||
self.assertEqual(master.MasterView.get_model_title(), "Dinosaur")
|
||||
del master.MasterView.model_class
|
||||
|
||||
def test_get_model_title_plural(self):
|
||||
|
||||
|
@ -119,9 +119,9 @@ class TestMasterView(WebTestCase):
|
|||
|
||||
# or it may specify model class
|
||||
MyModel = MagicMock(__name__='Dinosaur')
|
||||
master.MasterView.model_class = MyModel
|
||||
with patch.multiple(master.MasterView, create=True,
|
||||
model_class=MyModel):
|
||||
self.assertEqual(master.MasterView.get_model_title_plural(), "Dinosaurs")
|
||||
del master.MasterView.model_class
|
||||
|
||||
def test_get_model_key(self):
|
||||
|
||||
|
@ -155,9 +155,9 @@ class TestMasterView(WebTestCase):
|
|||
|
||||
# or it may specify model class
|
||||
MyModel = MagicMock(__name__='Truck')
|
||||
master.MasterView.model_class = MyModel
|
||||
with patch.multiple(master.MasterView, create=True,
|
||||
model_class=MyModel):
|
||||
self.assertEqual(master.MasterView.get_route_prefix(), 'trucks')
|
||||
del master.MasterView.model_class
|
||||
|
||||
def test_get_url_prefix(self):
|
||||
|
||||
|
@ -186,9 +186,9 @@ class TestMasterView(WebTestCase):
|
|||
|
||||
# or it may specify model class
|
||||
MyModel = MagicMock(__name__='Machine')
|
||||
master.MasterView.model_class = MyModel
|
||||
with patch.multiple(master.MasterView, create=True,
|
||||
model_class=MyModel):
|
||||
self.assertEqual(master.MasterView.get_url_prefix(), '/machines')
|
||||
del master.MasterView.model_class
|
||||
|
||||
def test_get_instance_url_prefix(self):
|
||||
|
||||
|
@ -241,9 +241,9 @@ class TestMasterView(WebTestCase):
|
|||
|
||||
# or it may specify model class
|
||||
MyModel = MagicMock(__name__='Machine')
|
||||
master.MasterView.model_class = MyModel
|
||||
with patch.multiple(master.MasterView, create=True,
|
||||
model_class=MyModel):
|
||||
self.assertEqual(master.MasterView.get_template_prefix(), '/machines')
|
||||
del master.MasterView.model_class
|
||||
|
||||
def test_get_grid_key(self):
|
||||
|
||||
|
@ -272,9 +272,9 @@ class TestMasterView(WebTestCase):
|
|||
|
||||
# or it may specify model class
|
||||
MyModel = MagicMock(__name__='Machine')
|
||||
master.MasterView.model_class = MyModel
|
||||
with patch.multiple(master.MasterView, create=True,
|
||||
model_class=MyModel):
|
||||
self.assertEqual(master.MasterView.get_grid_key(), 'machines')
|
||||
del master.MasterView.model_class
|
||||
|
||||
def test_get_config_title(self):
|
||||
|
||||
|
@ -303,9 +303,9 @@ class TestMasterView(WebTestCase):
|
|||
|
||||
# or it may specify model class
|
||||
MyModel = MagicMock(__name__='Dinosaur')
|
||||
master.MasterView.model_class = MyModel
|
||||
with patch.multiple(master.MasterView, create=True,
|
||||
model_class=MyModel):
|
||||
self.assertEqual(master.MasterView.get_config_title(), "Dinosaurs")
|
||||
del master.MasterView.model_class
|
||||
|
||||
##############################
|
||||
# support methods
|
||||
|
@ -319,22 +319,22 @@ class TestMasterView(WebTestCase):
|
|||
|
||||
# basic sanity check using /master/index.mako
|
||||
# (nb. it skips /widgets/index.mako since that doesn't exist)
|
||||
master.MasterView.model_name = 'Widget'
|
||||
with patch.multiple(master.MasterView, create=True,
|
||||
model_name='Widget',
|
||||
creatable=False):
|
||||
view = master.MasterView(self.request)
|
||||
response = view.render_to_response('index', {})
|
||||
self.assertIsInstance(response, Response)
|
||||
del master.MasterView.model_name
|
||||
|
||||
# basic sanity check using /appinfo/index.mako
|
||||
master.MasterView.model_name = 'AppInfo'
|
||||
master.MasterView.route_prefix = 'appinfo'
|
||||
master.MasterView.url_prefix = '/appinfo'
|
||||
with patch.multiple(master.MasterView, create=True,
|
||||
model_name='AppInfo',
|
||||
route_prefix='appinfo',
|
||||
url_prefix='/appinfo',
|
||||
creatable=False):
|
||||
view = master.MasterView(self.request)
|
||||
response = view.render_to_response('index', {})
|
||||
self.assertIsInstance(response, Response)
|
||||
del master.MasterView.model_name
|
||||
del master.MasterView.route_prefix
|
||||
del master.MasterView.url_prefix
|
||||
|
||||
# bad template name causes error
|
||||
master.MasterView.model_name = 'Widget'
|
||||
|
@ -347,10 +347,168 @@ class TestMasterView(WebTestCase):
|
|||
self.assertEqual(view.get_index_title(), "Wutta Widgets")
|
||||
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):
|
||||
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)
|
||||
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
|
||||
##############################
|
||||
|
@ -365,35 +523,190 @@ class TestMasterView(WebTestCase):
|
|||
response = view.index()
|
||||
# then again with data, to include view action url
|
||||
data = [{'name': 'foo', 'value': 'bar'}]
|
||||
with patch.object(view, 'index_get_grid_data', return_value=data):
|
||||
with patch.object(view, 'get_grid_data', return_value=data):
|
||||
response = view.index()
|
||||
del master.MasterView.model_name
|
||||
del master.MasterView.model_key
|
||||
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):
|
||||
|
||||
# sanity/coverage check using /settings/XXX
|
||||
master.MasterView.model_name = 'Setting'
|
||||
master.MasterView.grid_columns = ['name', 'value']
|
||||
master.MasterView.form_fields = ['name', 'value']
|
||||
view = master.MasterView(self.request)
|
||||
setting = {'name': 'foo.bar', 'value': 'baz'}
|
||||
self.request.matchdict = {'name': 'foo.bar'}
|
||||
with patch.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):
|
||||
response = view.view()
|
||||
del master.MasterView.model_name
|
||||
del master.MasterView.grid_columns
|
||||
del master.MasterView.form_fields
|
||||
|
||||
def test_edit(self):
|
||||
model = self.app.model
|
||||
self.app.save_setting(self.session, 'foo.bar', 'frazzle')
|
||||
self.session.commit()
|
||||
|
||||
def get_instance():
|
||||
setting = self.session.query(model.Setting).get('foo.bar')
|
||||
return {
|
||||
'name': setting.name,
|
||||
'value': setting.value,
|
||||
}
|
||||
|
||||
# sanity/coverage check using /settings/XXX/edit
|
||||
self.request.matchdict = {'name': 'foo.bar'}
|
||||
with patch.multiple(master.MasterView, create=True,
|
||||
model_name='Setting',
|
||||
model_key='name',
|
||||
form_fields=['name', 'value']):
|
||||
view = master.MasterView(self.request)
|
||||
with patch.object(view, 'get_instance', new=get_instance):
|
||||
|
||||
# get the form page
|
||||
response = view.edit()
|
||||
self.assertIsInstance(response, Response)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn('frazzle', response.text)
|
||||
# nb. no error
|
||||
self.assertNotIn('Required', response.text)
|
||||
|
||||
def persist(setting):
|
||||
self.app.save_setting(self.session, 'foo.bar', setting['value'])
|
||||
self.session.commit()
|
||||
|
||||
# post request to save settings
|
||||
self.request.method = 'POST'
|
||||
self.request.POST = {
|
||||
'name': 'foo.bar',
|
||||
'value': 'froogle',
|
||||
}
|
||||
with patch.object(view, 'persist', new=persist):
|
||||
response = view.edit()
|
||||
# nb. should get redirect back to view page
|
||||
self.assertEqual(response.status_code, 302)
|
||||
# setting should be updated in DB
|
||||
self.assertEqual(self.app.get_setting(self.session, 'foo.bar'), 'froogle')
|
||||
|
||||
# try another post with invalid data (value is required)
|
||||
self.request.method = 'POST'
|
||||
self.request.POST = {}
|
||||
with patch.object(view, 'persist', new=persist):
|
||||
response = view.edit()
|
||||
# nb. should get a form with errors
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn('Required', response.text)
|
||||
# setting did not change in DB
|
||||
self.assertEqual(self.app.get_setting(self.session, 'foo.bar'), 'froogle')
|
||||
|
||||
def test_delete(self):
|
||||
model = self.app.model
|
||||
self.app.save_setting(self.session, 'foo.bar', 'frazzle')
|
||||
self.session.commit()
|
||||
self.assertEqual(self.session.query(model.Setting).count(), 1)
|
||||
|
||||
def get_instance():
|
||||
setting = self.session.query(model.Setting).get('foo.bar')
|
||||
return {
|
||||
'name': setting.name,
|
||||
'value': setting.value,
|
||||
}
|
||||
|
||||
# sanity/coverage check using /settings/XXX/delete
|
||||
self.request.matchdict = {'name': 'foo.bar'}
|
||||
with patch.multiple(master.MasterView, create=True,
|
||||
model_name='Setting',
|
||||
model_key='name',
|
||||
form_fields=['name', 'value']):
|
||||
view = master.MasterView(self.request)
|
||||
with patch.object(view, 'get_instance', new=get_instance):
|
||||
|
||||
# get the form page
|
||||
response = view.delete()
|
||||
self.assertIsInstance(response, Response)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn('frazzle', response.text)
|
||||
|
||||
def delete_instance(setting):
|
||||
self.app.delete_setting(self.session, setting['name'])
|
||||
|
||||
# post request to save settings
|
||||
self.request.method = 'POST'
|
||||
self.request.POST = {}
|
||||
with patch.object(view, 'delete_instance', new=delete_instance):
|
||||
response = view.delete()
|
||||
# nb. should get redirect back to view page
|
||||
self.assertEqual(response.status_code, 302)
|
||||
# setting should be gone from DB
|
||||
self.assertEqual(self.session.query(model.Setting).count(), 0)
|
||||
|
||||
def test_delete_instance(self):
|
||||
model = self.app.model
|
||||
self.app.save_setting(self.session, 'foo.bar', 'frazzle')
|
||||
self.session.commit()
|
||||
setting = self.session.query(model.Setting).one()
|
||||
|
||||
with patch.multiple(master.MasterView, create=True,
|
||||
model_class=model.Setting,
|
||||
form_fields=['name', 'value']):
|
||||
view = master.MasterView(self.request)
|
||||
view.delete_instance(setting)
|
||||
self.session.commit()
|
||||
self.assertEqual(self.session.query(model.Setting).count(), 0)
|
||||
|
||||
def test_configure(self):
|
||||
model = self.app.model
|
||||
|
||||
# setup
|
||||
master.MasterView.model_name = 'AppInfo'
|
||||
master.MasterView.route_prefix = 'appinfo'
|
||||
master.MasterView.template_prefix = '/appinfo'
|
||||
|
||||
# mock settings
|
||||
settings = [
|
||||
{'name': 'wutta.app_title'},
|
||||
|
@ -405,11 +718,14 @@ class TestMasterView(WebTestCase):
|
|||
]
|
||||
|
||||
view = master.MasterView(self.request)
|
||||
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(self.request, 'current_route_url', return_value='/appinfo/configure'):
|
||||
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
|
||||
response = view.configure()
|
||||
|
@ -447,8 +763,3 @@ class TestMasterView(WebTestCase):
|
|||
# should now have 0 settings
|
||||
count = self.session.query(model.Setting).count()
|
||||
self.assertEqual(count, 0)
|
||||
|
||||
# teardown
|
||||
del master.MasterView.model_name
|
||||
del master.MasterView.route_prefix
|
||||
del master.MasterView.template_prefix
|
||||
|
|
39
tests/views/test_people.py
Normal file
39
tests/views/test_people.py
Normal file
|
@ -0,0 +1,39 @@
|
|||
# -*- 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'])
|
57
tests/views/test_roles.py
Normal file
57
tests/views/test_roles.py
Normal file
|
@ -0,0 +1,57 @@
|
|||
# -*- 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,10 +1,11 @@
|
|||
# -*- coding: utf-8; -*-
|
||||
|
||||
from tests.views.utils import WebTestCase
|
||||
from unittest.mock import patch
|
||||
|
||||
from pyramid.httpexceptions import HTTPNotFound
|
||||
|
||||
from wuttaweb.views import settings
|
||||
from tests.util import WebTestCase
|
||||
|
||||
|
||||
class TestAppInfoView(WebTestCase):
|
||||
|
@ -33,35 +34,23 @@ class TestSettingView(WebTestCase):
|
|||
def make_view(self):
|
||||
return settings.SettingView(self.request)
|
||||
|
||||
def test_index_get_grid_data(self):
|
||||
def test_get_grid_data(self):
|
||||
|
||||
# empty data by default
|
||||
view = self.make_view()
|
||||
data = view.index_get_grid_data(session=self.session)
|
||||
data = view.get_grid_data(session=self.session)
|
||||
self.assertEqual(len(data), 0)
|
||||
|
||||
# unless we save some settings
|
||||
self.app.save_setting(self.session, 'foo', 'bar')
|
||||
self.session.commit()
|
||||
data = view.index_get_grid_data(session=self.session)
|
||||
data = view.get_grid_data(session=self.session)
|
||||
self.assertEqual(len(data), 1)
|
||||
|
||||
def test_get_instance(self):
|
||||
def test_configure_form(self):
|
||||
view = self.make_view()
|
||||
self.request.matchdict = {'name': 'foo'}
|
||||
|
||||
# setting not found
|
||||
setting = view.get_instance(session=self.session)
|
||||
self.assertIsInstance(setting, HTTPNotFound)
|
||||
|
||||
# setting is returned
|
||||
self.app.save_setting(self.session, 'foo', 'bar')
|
||||
self.session.commit()
|
||||
setting = view.get_instance(session=self.session)
|
||||
self.assertEqual(setting, {'name': 'foo', 'value': 'bar'})
|
||||
|
||||
def test_get_instance_title(self):
|
||||
setting = {'name': 'foo', 'value': 'bar'}
|
||||
view = self.make_view()
|
||||
title = view.get_instance_title(setting)
|
||||
self.assertEqual(title, 'foo')
|
||||
form = view.make_form(fields=view.get_form_fields())
|
||||
self.assertNotIn('value', form.required_fields)
|
||||
view.configure_form(form)
|
||||
self.assertIn('value', form.required_fields)
|
||||
self.assertFalse(form.required_fields['value'])
|
||||
|
|
57
tests/views/test_users.py
Normal file
57
tests/views/test_users.py
Normal file
|
@ -0,0 +1,57 @@
|
|||
# -*- 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'))
|
Loading…
Reference in a new issue