1
0
Fork 0

Compare commits

...

11 commits

Author SHA1 Message Date
Lance Edgar b4b72d92aa bump: version 0.5.0 → 0.6.0 2024-08-13 10:54:51 -05:00
Lance Edgar 7ad6a9d5a0 feat: add basic Roles view
can't edit user/role/perm mappings yet, just minimal CRUD
2024-08-13 10:52:30 -05:00
Lance Edgar eac3b81918 feat: add Users view; improve CRUD master for SQLAlchemy models 2024-08-12 21:17:08 -05:00
Lance Edgar 33589f1cd8 feat: add People view; improve CRUD master for SQLAlchemy models 2024-08-11 18:21:02 -05:00
Lance Edgar fc01fa283a feat: add basic support for SQLAlchemy model in master view
must more to be done for this yet, but basics are in place for the
Setting view
2024-08-11 16:52:47 -05:00
Lance Edgar 73014964cb feat: add basic Create support for CRUD master view 2024-08-11 12:43:48 -05:00
Lance Edgar c46b42f76d feat: add basic Delete support for CRUD master view 2024-08-11 09:57:01 -05:00
Lance Edgar 1a8fc8dd44 feat: add basic Edit support for CRUD master view 2024-08-10 21:07:38 -05:00
Lance Edgar 9e1fc6e57d fix: rename MasterView method to configure_grid()
still not 100% sure about this, `index_configure_grid()` is more
explicit, but this will be a common method to override so probably the
shorter name is better
2024-08-10 16:45:37 -05:00
Lance Edgar e0de4e9a65 feat: add auto-link (to "View") behavior for grid columns 2024-08-10 16:45:12 -05:00
Lance Edgar a361f07980 fix: replace default logo, favicon images
these files are based on one found in Wikipedia at
https://commons.wikimedia.org/wiki/File:Aelse_7.jpg

as of writing it was licensed under the "Creative Commons Attribution
2.0 Generic" license,
cf. https://creativecommons.org/licenses/by/2.0/deed.en

author listed as Josef F. Stuefer, cf.
https://www.flickr.com/photos/20375052@N00
2024-08-10 13:28:22 -05:00
50 changed files with 3659 additions and 538 deletions

View file

@ -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

View file

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

View file

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

View file

@ -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

View file

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

View file

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

View file

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

View file

@ -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",
]

View file

@ -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

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

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

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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%;">

View file

@ -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()}

View 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>

View file

@ -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 ''}"

View file

@ -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"

View 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>

View file

@ -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|{};" />

View file

@ -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>

View file

@ -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>
&nbsp;
% endfor

View file

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

View file

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

View file

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

View file

@ -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>

View file

@ -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

View file

@ -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()

View file

@ -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):

View file

@ -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',

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

View file

@ -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
View 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)

View file

@ -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
View 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)

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

View file

@ -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'}

View file

@ -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):

View file

@ -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",
})

View file

@ -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 []

View file

@ -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 []

View file

@ -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

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

View file

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