1
0
Fork 0

feat: add Users view; improve CRUD master for SQLAlchemy models

This commit is contained in:
Lance Edgar 2024-08-12 21:17:08 -05:00
parent 33589f1cd8
commit eac3b81918
33 changed files with 1510 additions and 253 deletions

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 db
forms forms
forms.base forms.base
forms.schema
forms.widgets
grids grids
grids.base grids.base
handler handler
@ -28,3 +30,4 @@
views.master views.master
views.people views.people
views.settings views.settings
views.users

View file

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

View file

@ -30,6 +30,7 @@ classifiers = [
] ]
requires-python = ">= 3.8" requires-python = ">= 3.8"
dependencies = [ dependencies = [
"ColanderAlchemy",
"pyramid>=2", "pyramid>=2",
"pyramid_beaker", "pyramid_beaker",
"pyramid_deform", "pyramid_deform",

View file

@ -24,65 +24,20 @@
Base form classes Base form classes
""" """
import json
import logging import logging
import colander import colander
import deform import deform
from colanderalchemy import SQLAlchemySchemaNode
from pyramid.renderers import render from pyramid.renderers import render
from webhelpers2.html import HTML from webhelpers2.html import HTML
from wuttaweb.util import get_form_data, get_model_fields from wuttaweb.util import FieldList, get_form_data, get_model_fields, make_json_safe
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
class FieldList(list):
"""
Convenience wrapper for a form's field list. This is a subclass
of :class:`python:list`.
You normally would not need to instantiate this yourself, but it
is used under the hood for :attr:`Form.fields` as well as
:attr:`~wuttaweb.grids.base.Grid.columns`.
"""
def insert_before(self, field, newfield):
"""
Insert a new field, before an existing field.
:param field: String name for the existing field.
:param newfield: String name for the new field, to be inserted
just before the existing ``field``.
"""
if field in self:
i = self.index(field)
self.insert(i, newfield)
else:
log.warning("field '%s' not found, will append new field: %s",
field, newfield)
self.append(newfield)
def insert_after(self, field, newfield):
"""
Insert a new field, after an existing field.
:param field: String name for the existing field.
:param newfield: String name for the new field, to be inserted
just after the existing ``field``.
"""
if field in self:
i = self.index(field)
self.insert(i + 1, newfield)
else:
log.warning("field '%s' not found, will append new field: %s",
field, newfield)
self.append(newfield)
class Form: class Form:
""" """
Base class for all forms. Base class for all forms.
@ -112,9 +67,11 @@ class Form:
.. attribute:: fields .. attribute:: fields
:class:`FieldList` instance containing string field names for :class:`~wuttaweb.util.FieldList` instance containing string
the form. By default, fields will appear in the same order as field names for the form. By default, fields will appear in
they are in this list. the same order as they are in this list.
See also :meth:`set_fields()`.
.. attribute:: schema .. attribute:: schema
@ -142,6 +99,27 @@ class Form:
SQLAlchemy-mapped. (In that case :attr:`model_class` can be SQLAlchemy-mapped. (In that case :attr:`model_class` can be
determined automatically.) determined automatically.)
.. attribute:: nodes
Dict of node overrides, used to construct the form in
:meth:`get_schema()`.
See also :meth:`set_node()`.
.. attribute:: widgets
Dict of widget overrides, used to construct the form in
:meth:`get_schema()`.
See also :meth:`set_widget()`.
.. attribute:: validators
Dict of node validators, used to construct the form in
:meth:`get_schema()`.
See also :meth:`set_validator()`.
.. attribute:: readonly .. attribute:: readonly
Boolean indicating the form does not allow submit. In practice Boolean indicating the form does not allow submit. In practice
@ -267,6 +245,9 @@ class Form:
schema=None, schema=None,
model_class=None, model_class=None,
model_instance=None, model_instance=None,
nodes={},
widgets={},
validators={},
readonly=False, readonly=False,
readonly_fields=[], readonly_fields=[],
required_fields={}, required_fields={},
@ -287,6 +268,9 @@ class Form:
): ):
self.request = request self.request = request
self.schema = schema self.schema = schema
self.nodes = nodes or {}
self.widgets = widgets or {}
self.validators = validators or {}
self.readonly = readonly self.readonly = readonly
self.readonly_fields = set(readonly_fields or []) self.readonly_fields = set(readonly_fields or [])
self.required_fields = required_fields or {} self.required_fields = required_fields or {}
@ -311,13 +295,10 @@ class Form:
self.model_class = model_class self.model_class = model_class
self.model_instance = model_instance self.model_instance = model_instance
if self.model_instance and not self.model_class: if self.model_instance and not self.model_class:
self.model_class = type(self.model_instance) if type(self.model_instance) is not dict:
self.model_class = type(self.model_instance)
fields = fields or self.get_fields() self.set_fields(fields or self.get_fields())
if fields:
self.set_fields(fields)
else:
self.fields = None
def __contains__(self, name): def __contains__(self, name):
""" """
@ -388,12 +369,108 @@ class Form:
Explicitly set the list of form fields. Explicitly set the list of form fields.
This will overwrite :attr:`fields` with a new This will overwrite :attr:`fields` with a new
:class:`FieldList` instance. :class:`~wuttaweb.util.FieldList` instance.
:param fields: List of string field names. :param fields: List of string field names.
""" """
self.fields = FieldList(fields) self.fields = FieldList(fields)
def remove(self, *keys):
"""
Remove some fields(s) from the form.
This is a convenience to allow removal of multiple fields at
once::
form.remove('first_field',
'second_field',
'third_field')
It will remove each field from :attr:`fields`.
"""
for key in keys:
if key in self.fields:
self.fields.remove(key)
def set_node(self, key, nodeinfo, **kwargs):
"""
Set/override the node for a field.
:param key: Name of field.
:param nodeinfo: Should be either a
:class:`colander:colander.SchemaNode` instance, or else a
:class:`colander:colander.SchemaType` instance.
If ``nodeinfo`` is a proper node instance, it will be used
as-is. Otherwise an
:class:`~wuttaweb.forms.schema.ObjectNode` instance will be
constructed using ``nodeinfo`` as the type (``typ``).
Node overrides are tracked via :attr:`nodes`.
"""
if isinstance(nodeinfo, colander.SchemaNode):
# assume nodeinfo is a complete node
node = nodeinfo
else: # assume nodeinfo is a schema type
kwargs.setdefault('name', key)
from wuttaweb.forms.schema import ObjectNode
# node = colander.SchemaNode(nodeinfo, **kwargs)
node = ObjectNode(nodeinfo, **kwargs)
self.nodes[key] = node
# must explicitly replace node, if we already have a schema
if self.schema:
self.schema[key] = node
def set_widget(self, key, widget):
"""
Set/override the widget for a field.
:param key: Name of field.
:param widget: Instance of
:class:`deform:deform.widget.Widget`.
Widget overrides are tracked via :attr:`widgets`.
"""
self.widgets[key] = widget
# update schema if necessary
if self.schema and key in self.schema:
self.schema[key].widget = widget
def set_validator(self, key, validator):
"""
Set/override the validator for a field, or the form.
:param key: Name of field. This may also be ``None`` in which
case the validator will apply to the whole form instead of
a field.
:param validator: Callable which accepts ``(node, value)``
args. For instance::
def validate_foo(node, value):
if value == 42:
node.raise_invalid("42 is not allowed!")
form = Form(fields=['foo', 'bar'])
form.set_validator('foo', validate_foo)
Validator overrides are tracked via :attr:`validators`.
"""
self.validators[key] = validator
# nb. must apply to existing schema if present
if self.schema and key in self.schema:
self.schema[key].validator = validator
def set_readonly(self, key, readonly=True): def set_readonly(self, key, readonly=True):
""" """
Enable or disable the "readonly" flag for a given field. Enable or disable the "readonly" flag for a given field.
@ -512,6 +589,8 @@ class Form:
if fields: if fields:
return fields return fields
return []
def get_model_fields(self, model_class=None): def get_model_fields(self, model_class=None):
""" """
This method is a shortcut which calls This method is a shortcut which calls
@ -534,25 +613,83 @@ class Form:
""" """
if not self.schema: if not self.schema:
##############################
# create schema
##############################
# get fields # get fields
fields = self.get_fields() fields = self.get_fields()
if not fields: if not fields:
raise NotImplementedError raise NotImplementedError
# make basic schema if self.model_class:
schema = colander.Schema()
for name in fields: # first define full list of 'includes' - final schema
schema.add(colander.SchemaNode( # should contain all of these fields
colander.String(), includes = list(fields)
name=name))
# 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 # apply required flags
for key, required in self.required_fields.items(): for key, required in self.required_fields.items():
if key in schema: if key in schema:
if required is False: if required is False:
# TODO: (why) should we not use colander.null here? schema[key].missing = colander.null
#schema[key].missing = colander.null
schema[key].missing = None
self.schema = schema self.schema = schema
@ -569,16 +706,13 @@ class Form:
kwargs = {} kwargs = {}
if self.model_instance: if self.model_instance:
# TODO: would it be smarter to test with hasattr() ?
# if hasattr(schema, 'dictify'):
if isinstance(self.model_instance, model.Base): if isinstance(self.model_instance, model.Base):
kwargs['appstruct'] = dict(self.model_instance) kwargs['appstruct'] = schema.dictify(self.model_instance)
else: else:
kwargs['appstruct'] = self.model_instance kwargs['appstruct'] = self.model_instance
# TODO: ugh why is this necessary?
for key, value in list(kwargs['appstruct'].items()):
if value is None:
kwargs['appstruct'][key] = colander.null
form = deform.Form(schema, **kwargs) form = deform.Form(schema, **kwargs)
self.deform_form = form self.deform_form = form
@ -632,6 +766,7 @@ class Form:
context['dform'] = self.get_deform() context['dform'] = self.get_deform()
context.setdefault('form_attrs', {}) context.setdefault('form_attrs', {})
context.setdefault('request', self.request) context.setdefault('request', self.request)
context['model_data'] = self.get_vue_model_data()
# auto disable button on submit # auto disable button on submit
if self.auto_disable_submit: if self.auto_disable_submit:
@ -729,50 +864,44 @@ class Form:
return HTML.tag('b-field', c=[html], **attrs) return HTML.tag('b-field', c=[html], **attrs)
def get_field_errors(self, field): def get_vue_model_data(self):
""" """
Return a list of error messages for the given field. Returns a dict with form model data. Values may be nested
depending on the types of fields contained in the form.
Not useful unless a call to :meth:`validate()` failed. 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() dform = self.get_deform()
if field in dform: model_data = {}
error = dform[field].errormsg
if error:
return [error]
return []
def get_vue_field_value(self, field): def assign(field):
""" model_data[field.oid] = make_json_safe(field.cstruct)
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: for key in self.fields:
* ``'null'`` # TODO: i thought commented code was useful, but no longer sure?
* ``'{"foo": "bar"}'``
In practice this calls :meth:`jsonify_value()` to convert the # TODO: need to describe the scenario when this is true
``field.cstruct`` value to string. if key not in dform:
""" # log.warning("field '%s' is missing from deform", key)
if isinstance(field, str): continue
dform = self.get_deform()
field = dform[field]
return self.jsonify_value(field.cstruct) field = dform[key]
def jsonify_value(self, value): # if hasattr(field, 'children'):
""" # for subfield in field.children:
Convert a Python value to JSON string. # assign(subfield)
See also :meth:`get_vue_field_value()`. assign(field)
"""
if value is colander.null:
return 'null'
return json.dumps(value) return model_data
def validate(self): def validate(self):
""" """
@ -824,6 +953,20 @@ class Form:
try: try:
self.validated = dform.validate(controls) self.validated = dform.validate(controls)
except deform.ValidationFailure: except deform.ValidationFailure:
log.debug("form not valid: %s", dform.error)
return False return False
return self.validated return self.validated
def get_field_errors(self, field):
"""
Return a list of error messages for the given field.
Not useful unless a call to :meth:`validate()` failed.
"""
dform = self.get_deform()
if field in dform:
error = dform[field].errormsg
if error:
return [error]
return []

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,11 +24,18 @@
Base grid classes Base grid classes
""" """
import json
import logging
import sqlalchemy as sa
from pyramid.renderers import render from pyramid.renderers import render
from webhelpers2.html import HTML from webhelpers2.html import HTML
from wuttaweb.forms import FieldList from wuttaweb.util import FieldList, get_model_fields, make_json_safe
from wuttaweb.util import get_model_fields
log = logging.getLogger(__name__)
class Grid: class Grid:
@ -114,11 +121,7 @@ class Grid:
self.config = self.request.wutta_config self.config = self.request.wutta_config
self.app = self.config.get_app() self.app = self.config.get_app()
columns = columns or self.get_columns() self.set_columns(columns or self.get_columns())
if columns:
self.set_columns(columns)
else:
self.columns = None
def get_columns(self): def get_columns(self):
""" """
@ -139,6 +142,8 @@ class Grid:
if columns: if columns:
return columns return columns
return []
def get_model_columns(self, model_class=None): def get_model_columns(self, model_class=None):
""" """
This method is a shortcut which calls This method is a shortcut which calls
@ -172,6 +177,23 @@ class Grid:
""" """
self.columns = FieldList(columns) self.columns = FieldList(columns)
def remove(self, *keys):
"""
Remove some column(s) from the grid.
This is a convenience to allow removal of multiple columns at
once::
grid.remove('first_field',
'second_field',
'third_field')
It will remove each column from :attr:`columns`.
"""
for key in keys:
if key in self.columns:
self.columns.remove(key)
def set_link(self, key, link=True): def set_link(self, key, link=True):
""" """
Explicitly enable or disable auto-link behavior for a given Explicitly enable or disable auto-link behavior for a given
@ -296,22 +318,52 @@ class Grid:
Returns a list of Vue-compatible data records. Returns a list of Vue-compatible data records.
This uses :attr:`data` as the basis, but may add some extra This uses :attr:`data` as the basis, but may add some extra
values to each record 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 original_data = self.data or []
if not self.actions:
return self.data # 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 # we have action(s), so add URL(s) for each record in data
data = [] data = []
for i, record in enumerate(self.data): for i, record in enumerate(original_data):
record = dict(record)
# convert data if needed, for json compat
record = make_json_safe(record,
# TODO: is this a good idea?
warn=False)
# add action urls to each record
for action in self.actions: for action in self.actions:
url = action.get_url(record, i) url = action.get_url(record, i)
key = f'_action_url_{action.key}' key = f'_action_url_{action.key}'
record[key] = url record[key] = url
data.append(record) data.append(record)
return data return data

View file

@ -147,6 +147,12 @@ class MenuHandler(GenericHandler):
'title': "Admin", 'title': "Admin",
'type': 'menu', 'type': 'menu',
'items': [ 'items': [
{
'title': "Users",
'route': 'users',
'perm': 'users.list',
},
{'type': 'sep'},
{ {
'title': "App Info", 'title': "App Info",
'route': 'appinfo', 'route': 'appinfo',

View file

@ -3,7 +3,7 @@
<%def name="page_content()"> <%def name="page_content()">
<nav class="panel item-panel"> <nav class="panel">
<p class="panel-heading">Application</p> <p class="panel-heading">Application</p>
<div class="panel-block"> <div class="panel-block">
<div style="width: 100%;"> <div style="width: 100%;">
@ -20,7 +20,7 @@
</div> </div>
</nav> </nav>
<nav class="panel item-panel"> <nav class="panel">
<p class="panel-heading">Configuration Files</p> <p class="panel-heading">Configuration Files</p>
<div class="panel-block"> <div class="panel-block">
<div style="width: 100%;"> <div style="width: 100%;">

View file

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

View file

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

View file

@ -56,12 +56,7 @@
% if not form.readonly: % if not form.readonly:
## field model values modelData: ${json.dumps(model_data)|n},
% for key in form:
% if key in dform:
model_${key}: ${form.get_vue_field_value(key)|n},
% endif
% endfor
% if form.auto_disable_submit: % if form.auto_disable_submit:
formSubmitting: false, formSubmitting: false,

View file

@ -25,10 +25,62 @@ Web Utilities
""" """
import importlib import importlib
import json
import logging
import colander
from webhelpers2.html import HTML, tags from webhelpers2.html import HTML, tags
log = logging.getLogger(__name__)
class FieldList(list):
"""
Convenience wrapper for a form's field list. This is a subclass
of :class:`python:list`.
You normally would not need to instantiate this yourself, but it
is used under the hood for
:attr:`~wuttaweb.forms.base.Form.fields` as well as
:attr:`~wuttaweb.grids.base.Grid.columns`.
"""
def insert_before(self, field, newfield):
"""
Insert a new field, before an existing field.
:param field: String name for the existing field.
:param newfield: String name for the new field, to be inserted
just before the existing ``field``.
"""
if field in self:
i = self.index(field)
self.insert(i, newfield)
else:
log.warning("field '%s' not found, will append new field: %s",
field, newfield)
self.append(newfield)
def insert_after(self, field, newfield):
"""
Insert a new field, after an existing field.
:param field: String name for the existing field.
:param newfield: String name for the new field, to be inserted
just after the existing ``field``.
"""
if field in self:
i = self.index(field)
self.insert(i + 1, newfield)
else:
log.warning("field '%s' not found, will append new field: %s",
field, newfield)
self.append(newfield)
def get_form_data(request): def get_form_data(request):
""" """
Returns the effective form data for the given request. Returns the effective form data for the given request.
@ -376,3 +428,47 @@ def get_model_fields(config, model_class=None):
mapper = sa.inspect(model_class) mapper = sa.inspect(model_class)
fields = list([prop.key for prop in mapper.iterate_properties]) fields = list([prop.key for prop in mapper.iterate_properties])
return fields 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

@ -33,6 +33,7 @@ That will in turn include the following modules:
* :mod:`wuttaweb.views.common` * :mod:`wuttaweb.views.common`
* :mod:`wuttaweb.views.settings` * :mod:`wuttaweb.views.settings`
* :mod:`wuttaweb.views.people` * :mod:`wuttaweb.views.people`
* :mod:`wuttaweb.views.users`
""" """
@ -43,6 +44,7 @@ def defaults(config, **kwargs):
config.include(mod('wuttaweb.views.common')) config.include(mod('wuttaweb.views.common'))
config.include(mod('wuttaweb.views.settings')) config.include(mod('wuttaweb.views.settings'))
config.include(mod('wuttaweb.views.people')) config.include(mod('wuttaweb.views.people'))
config.include(mod('wuttaweb.views.users'))
def includeme(config): def includeme(config):

View file

@ -1026,9 +1026,11 @@ class MasterView(View):
ready to use as-is, but this method can further modify it ready to use as-is, but this method can further modify it
based on request details etc. based on request details etc.
""" """
if 'uuid' in grid.columns:
grid.columns.remove('uuid')
for key in self.get_model_key(): for key in self.get_model_key():
grid.set_link(key) grid.set_link(key)
# print("set link:", key)
def get_instance(self, session=None): def get_instance(self, session=None):
""" """
@ -1201,13 +1203,10 @@ class MasterView(View):
already be "complete" and ready to use as-is, but this method already be "complete" and ready to use as-is, but this method
can further modify it based on request details etc. can further modify it based on request details etc.
""" """
model_keys = self.get_model_key() form.remove('uuid')
if 'uuid' in form:
form.fields.remove('uuid')
if self.editing: if self.editing:
for key in model_keys: for key in self.get_model_key():
form.set_readonly(key) form.set_readonly(key)
def objectify(self, form): def objectify(self, form):
@ -1228,22 +1227,15 @@ class MasterView(View):
See also :meth:`edit_save_form()` which calls this method. See also :meth:`edit_save_form()` which calls this method.
""" """
model = self.app.model
model_class = self.get_model_class() # use ColanderAlchemy magic if possible
if model_class and issubclass(model_class, model.Base): schema = form.get_schema()
if hasattr(schema, 'objectify'):
# update instance attrs for sqlalchemy model # this returns a model instance
if self.creating: return schema.objectify(form.validated,
obj = model_class() context=form.model_instance)
else:
obj = form.model_instance
data = form.validated
for key in form.fields:
if key in data:
setattr(obj, key, data[key])
return obj
# otherwise return data dict as-is
return form.validated return form.validated
def persist(self, obj, session=None): def persist(self, obj, session=None):

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

@ -8,46 +8,15 @@ import deform
from pyramid import testing from pyramid import testing
from wuttjamaican.conf import WuttaConfig from wuttjamaican.conf import WuttaConfig
from wuttaweb.forms import base from wuttaweb.forms import base, widgets
from wuttaweb import helpers from wuttaweb import helpers
class TestFieldList(TestCase):
def test_insert_before(self):
fields = base.FieldList(['f1', 'f2'])
self.assertEqual(fields, ['f1', 'f2'])
# typical
fields.insert_before('f1', 'XXX')
self.assertEqual(fields, ['XXX', 'f1', 'f2'])
fields.insert_before('f2', 'YYY')
self.assertEqual(fields, ['XXX', 'f1', 'YYY', 'f2'])
# appends new field if reference field is invalid
fields.insert_before('f3', 'ZZZ')
self.assertEqual(fields, ['XXX', 'f1', 'YYY', 'f2', 'ZZZ'])
def test_insert_after(self):
fields = base.FieldList(['f1', 'f2'])
self.assertEqual(fields, ['f1', 'f2'])
# typical
fields.insert_after('f1', 'XXX')
self.assertEqual(fields, ['f1', 'XXX', 'f2'])
fields.insert_after('XXX', 'YYY')
self.assertEqual(fields, ['f1', 'XXX', 'YYY', 'f2'])
# appends new field if reference field is invalid
fields.insert_after('f3', 'ZZZ')
self.assertEqual(fields, ['f1', 'XXX', 'YYY', 'f2', 'ZZZ'])
class TestForm(TestCase): class TestForm(TestCase):
def setUp(self): def setUp(self):
self.config = WuttaConfig(defaults={ self.config = WuttaConfig(defaults={
'wutta.web.menus.handler_spec': 'tests.utils:NullMenuHandler', 'wutta.web.menus.handler_spec': 'tests.util:NullMenuHandler',
}) })
self.app = self.config.get_app() self.app = self.config.get_app()
self.request = testing.DummyRequest(wutta_config=self.config, use_oruga=False) self.request = testing.DummyRequest(wutta_config=self.config, use_oruga=False)
@ -74,7 +43,7 @@ class TestForm(TestCase):
def test_init_with_none(self): def test_init_with_none(self):
form = self.make_form() form = self.make_form()
self.assertIsNone(form.fields) self.assertEqual(form.fields, [])
def test_init_with_fields(self): def test_init_with_fields(self):
form = self.make_form(fields=['foo', 'bar']) form = self.make_form(fields=['foo', 'bar'])
@ -115,6 +84,79 @@ class TestForm(TestCase):
form.set_fields(['baz']) form.set_fields(['baz'])
self.assertEqual(form.fields, ['baz']) self.assertEqual(form.fields, ['baz'])
def test_remove(self):
form = self.make_form(fields=['one', 'two', 'three', 'four'])
self.assertEqual(form.fields, ['one', 'two', 'three', 'four'])
form.remove('two', 'three')
self.assertEqual(form.fields, ['one', 'four'])
def test_set_node(self):
form = self.make_form(fields=['foo', 'bar'])
self.assertEqual(form.nodes, {})
# complete node
node = colander.SchemaNode(colander.Bool(), name='foo')
form.set_node('foo', node)
self.assertIs(form.nodes['foo'], node)
# type only
typ = colander.Bool()
form.set_node('foo', typ)
node = form.nodes['foo']
self.assertIsInstance(node, colander.SchemaNode)
self.assertIsInstance(node.typ, colander.Bool)
self.assertEqual(node.name, 'foo')
# schema is updated if already present
schema = form.get_schema()
self.assertIsNotNone(schema)
typ = colander.Date()
form.set_node('foo', typ)
node = form.nodes['foo']
self.assertIsInstance(node, colander.SchemaNode)
self.assertIsInstance(node.typ, colander.Date)
self.assertEqual(node.name, 'foo')
def test_set_widget(self):
form = self.make_form(fields=['foo', 'bar'])
self.assertEqual(form.widgets, {})
# basic
widget = widgets.SelectWidget()
form.set_widget('foo', widget)
self.assertIs(form.widgets['foo'], widget)
# schema is updated if already present
schema = form.get_schema()
self.assertIsNotNone(schema)
self.assertIs(schema['foo'].widget, widget)
new_widget = widgets.TextInputWidget()
form.set_widget('foo', new_widget)
self.assertIs(form.widgets['foo'], new_widget)
self.assertIs(schema['foo'].widget, new_widget)
def test_set_validator(self):
form = self.make_form(fields=['foo', 'bar'])
self.assertEqual(form.validators, {})
def validate1(node, value):
pass
# basic
form.set_validator('foo', validate1)
self.assertIs(form.validators['foo'], validate1)
def validate2(node, value):
pass
# schema is updated if already present
schema = form.get_schema()
self.assertIsNotNone(schema)
self.assertIs(schema['foo'].validator, validate1)
form.set_validator('foo', validate2)
self.assertIs(form.validators['foo'], validate2)
self.assertIs(schema['foo'].validator, validate2)
def test_get_schema(self): def test_get_schema(self):
model = self.app.model model = self.app.model
form = self.make_form() form = self.make_form()
@ -144,6 +186,13 @@ class TestForm(TestCase):
self.assertIn('name', schema) self.assertIn('name', schema)
self.assertIn('value', 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 # schema is auto-generated if model_instance provided
form = self.make_form(model_instance=model.Setting(name='uhoh')) form = self.make_form(model_instance=model.Setting(name='uhoh'))
self.assertEqual(form.fields, ['name', 'value']) self.assertEqual(form.fields, ['name', 'value'])
@ -166,7 +215,23 @@ class TestForm(TestCase):
form.set_required('bar', False) form.set_required('bar', False)
schema = form.get_schema() schema = form.get_schema()
self.assertIs(schema['foo'].missing, colander.required) self.assertIs(schema['foo'].missing, colander.required)
self.assertIsNone(schema['bar'].missing) self.assertIs(schema['bar'].missing, colander.null)
# validator overrides are honored
def validate(node, value): pass
form = self.make_form(model_class=model.Setting)
form.set_validator('name', validate)
schema = form.get_schema()
self.assertIs(schema['name'].validator, validate)
# validator can be set for whole form
form = self.make_form(model_class=model.Setting)
schema = form.get_schema()
self.assertIsNone(schema.validator)
form = self.make_form(model_class=model.Setting)
form.set_validator(None, validate)
schema = form.get_schema()
self.assertIs(schema.validator, validate)
def test_get_deform(self): def test_get_deform(self):
model = self.app.model model = self.app.model
@ -372,34 +437,6 @@ class TestForm(TestCase):
self.assertEqual(len(errors), 1) self.assertEqual(len(errors), 1)
self.assertEqual(errors[0], "something is wrong") self.assertEqual(errors[0], "something is wrong")
def test_get_vue_field_value(self):
schema = self.make_schema()
form = self.make_form(schema=schema)
# null field value
value = form.get_vue_field_value('foo')
self.assertEqual(value, 'null')
# non-default / explicit value
# TODO: surely need a different approach to set value
dform = form.get_deform()
dform['foo'].cstruct = 'blarg'
value = form.get_vue_field_value('foo')
self.assertEqual(value, '"blarg"')
def test_jsonify_value(self):
form = self.make_form()
# null field value
value = form.jsonify_value(colander.null)
self.assertEqual(value, 'null')
value = form.jsonify_value(None)
self.assertEqual(value, 'null')
# string value
value = form.jsonify_value('blarg')
self.assertEqual(value, '"blarg"')
def test_validate(self): def test_validate(self):
schema = self.make_schema() schema = self.make_schema()
form = self.make_form(schema=schema) form = self.make_form(schema=schema)

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

@ -14,7 +14,7 @@ class TestGrid(TestCase):
def setUp(self): def setUp(self):
self.config = WuttaConfig(defaults={ 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.app = self.config.get_app()
@ -35,7 +35,7 @@ class TestGrid(TestCase):
# empty # empty
grid = self.make_grid() grid = self.make_grid()
self.assertIsNone(grid.key) self.assertIsNone(grid.key)
self.assertIsNone(grid.columns) self.assertEqual(grid.columns, [])
self.assertIsNone(grid.data) self.assertIsNone(grid.data)
# now with columns # now with columns
@ -56,8 +56,8 @@ class TestGrid(TestCase):
# empty # empty
grid = self.make_grid() grid = self.make_grid()
self.assertIsNone(grid.columns) self.assertEqual(grid.columns, [])
self.assertIsNone(grid.get_columns()) self.assertEqual(grid.get_columns(), [])
# explicit # explicit
grid = self.make_grid(columns=['foo', 'bar']) grid = self.make_grid(columns=['foo', 'bar'])
@ -69,6 +69,12 @@ class TestGrid(TestCase):
self.assertEqual(grid.columns, ['name', 'value']) self.assertEqual(grid.columns, ['name', 'value'])
self.assertEqual(grid.get_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): def test_linked_columns(self):
grid = self.make_grid(columns=['foo', 'bar']) grid = self.make_grid(columns=['foo', 'bar'])
self.assertEqual(grid.linked_columns, []) self.assertEqual(grid.linked_columns, [])
@ -118,18 +124,17 @@ class TestGrid(TestCase):
def test_get_vue_data(self): def test_get_vue_data(self):
# null by default # empty if no columns defined
grid = self.make_grid() grid = self.make_grid()
data = grid.get_vue_data() data = grid.get_vue_data()
self.assertIsNone(data) self.assertEqual(data, [])
# is usually a list # typical data is a list
mydata = [ mydata = [
{'foo': 'bar'}, {'foo': 'bar'},
] ]
grid = self.make_grid(data=mydata) grid = self.make_grid(columns=['foo'], data=mydata)
data = grid.get_vue_data() data = grid.get_vue_data()
self.assertIs(data, mydata)
self.assertEqual(data, [{'foo': 'bar'}]) self.assertEqual(data, [{'foo': 'bar'}])
# if grid has actions, that list may be supplemented # if grid has actions, that list may be supplemented

View file

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

View file

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

View file

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

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

@ -11,8 +11,7 @@ from pyramid.httpexceptions import HTTPFound, HTTPNotFound
from wuttjamaican.conf import WuttaConfig from wuttjamaican.conf import WuttaConfig
from wuttaweb.views import master from wuttaweb.views import master
from wuttaweb.subscribers import new_request_set_user from wuttaweb.subscribers import new_request_set_user
from tests.util import WebTestCase
from tests.views.utils import WebTestCase
class TestMasterView(WebTestCase): class TestMasterView(WebTestCase):
@ -387,6 +386,19 @@ class TestMasterView(WebTestCase):
with patch.object(view, 'get_query', new=get_query): with patch.object(view, 'get_query', new=get_query):
self.assertRaises(ValueError, view.get_grid_data, session=self.session) self.assertRaises(ValueError, view.get_grid_data, session=self.session)
def test_configure_grid(self):
model = self.app.model
# uuid field is pruned
with patch.multiple(master.MasterView, create=True,
model_class=model.Setting):
view = master.MasterView(self.request)
grid = view.make_grid(model_class=model.Setting,
columns=['uuid', 'name', 'value'])
self.assertIn('uuid', grid.columns)
view.configure_grid(grid)
self.assertNotIn('uuid', grid.columns)
def test_get_instance(self): def test_get_instance(self):
model = self.app.model model = self.app.model
self.app.save_setting(self.session, 'foo', 'bar') self.app.save_setting(self.session, 'foo', 'bar')
@ -454,7 +466,7 @@ class TestMasterView(WebTestCase):
model_name='Widget', model_name='Widget',
model_key='uuid'): model_key='uuid'):
view = master.MasterView(self.request) view = master.MasterView(self.request)
form = view.make_model_form() form = view.make_model_form(fields=['name', 'description'])
form.validated = {'name': 'first'} form.validated = {'name': 'first'}
obj = view.objectify(form) obj = view.objectify(form)
self.assertIs(obj, form.validated) self.assertIs(obj, form.validated)
@ -666,7 +678,6 @@ class TestMasterView(WebTestCase):
self.assertIn('frazzle', response.text) self.assertIn('frazzle', response.text)
def delete_instance(setting): def delete_instance(setting):
print(setting) # TODO
self.app.delete_setting(self.session, setting['name']) self.app.delete_setting(self.session, setting['name'])
# post request to save settings # post request to save settings

View file

@ -7,7 +7,7 @@ from sqlalchemy import orm
from pyramid.httpexceptions import HTTPNotFound from pyramid.httpexceptions import HTTPNotFound
from wuttaweb.views import people from wuttaweb.views import people
from tests.views.utils import WebTestCase from tests.util import WebTestCase
class TestPersonView(WebTestCase): class TestPersonView(WebTestCase):

View file

@ -2,11 +2,10 @@
from unittest.mock import patch from unittest.mock import patch
from tests.views.utils import WebTestCase
from pyramid.httpexceptions import HTTPNotFound from pyramid.httpexceptions import HTTPNotFound
from wuttaweb.views import settings from wuttaweb.views import settings
from tests.util import WebTestCase
class TestAppInfoView(WebTestCase): class TestAppInfoView(WebTestCase):

58
tests/views/test_users.py Normal file
View file

@ -0,0 +1,58 @@
# -*- coding: utf-8; -*-
from unittest.mock import patch
from sqlalchemy import orm
import colander
from pyramid.httpexceptions import HTTPNotFound
from wuttaweb.views import users
from tests.util import WebTestCase
class TestPersonView(WebTestCase):
def make_view(self):
return users.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(users, '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'))