3
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

@ -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, get_model_fields
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,9 +67,11 @@ 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
@ -142,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
@ -267,6 +245,9 @@ class Form:
schema=None,
model_class=None,
model_instance=None,
nodes={},
widgets={},
validators={},
readonly=False,
readonly_fields=[],
required_fields={},
@ -287,6 +268,9 @@ class Form:
):
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 {}
@ -311,13 +295,10 @@ class Form:
self.model_class = model_class
self.model_instance = model_instance
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()
if fields:
self.set_fields(fields)
else:
self.fields = None
self.set_fields(fields or self.get_fields())
def __contains__(self, name):
"""
@ -388,12 +369,108 @@ class Form:
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.
@ -512,6 +589,8 @@ class Form:
if fields:
return fields
return []
def get_model_fields(self, model_class=None):
"""
This method is a shortcut which calls
@ -534,25 +613,83 @@ class Form:
"""
if not self.schema:
##############################
# create schema
##############################
# get fields
fields = self.get_fields()
if not fields:
raise NotImplementedError
# make basic schema
schema = colander.Schema()
for name in fields:
schema.add(colander.SchemaNode(
colander.String(),
name=name))
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:
# TODO: (why) should we not use colander.null here?
#schema[key].missing = colander.null
schema[key].missing = None
schema[key].missing = colander.null
self.schema = schema
@ -569,16 +706,13 @@ class Form:
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'] = dict(self.model_instance)
kwargs['appstruct'] = schema.dictify(self.model_instance)
else:
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)
self.deform_form = form
@ -632,6 +766,7 @@ class Form:
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:
@ -729,50 +864,44 @@ class Form:
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()
if field in dform:
error = dform[field].errormsg
if error:
return [error]
return []
model_data = {}
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.
def assign(field):
model_data[field.oid] = make_json_safe(field.cstruct)
Again, this must return a *string* such as:
for key in self.fields:
* ``'null'``
* ``'{"foo": "bar"}'``
# TODO: i thought commented code was useful, but no longer sure?
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]
# 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
return self.jsonify_value(field.cstruct)
field = dform[key]
def jsonify_value(self, value):
"""
Convert a Python value to JSON string.
# if hasattr(field, 'children'):
# for subfield in field.children:
# assign(subfield)
See also :meth:`get_vue_field_value()`.
"""
if value is colander.null:
return 'null'
assign(field)
return json.dumps(value)
return model_data
def validate(self):
"""
@ -824,6 +953,20 @@ class Form:
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.
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
"""
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 get_model_fields
from wuttaweb.util import FieldList, get_model_fields, make_json_safe
log = logging.getLogger(__name__)
class Grid:
@ -114,11 +121,7 @@ class Grid:
self.config = self.request.wutta_config
self.app = self.config.get_app()
columns = columns or self.get_columns()
if columns:
self.set_columns(columns)
else:
self.columns = None
self.set_columns(columns or self.get_columns())
def get_columns(self):
"""
@ -139,6 +142,8 @@ class Grid:
if columns:
return columns
return []
def get_model_columns(self, model_class=None):
"""
This method is a shortcut which calls
@ -172,6 +177,23 @@ 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
@ -296,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

View file

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

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%;">
@ -20,7 +20,7 @@
</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

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

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

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.
@ -376,3 +428,47 @@ def get_model_fields(config, model_class=None):
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

@ -33,6 +33,7 @@ That will in turn include the following modules:
* :mod:`wuttaweb.views.common`
* :mod:`wuttaweb.views.settings`
* :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.settings'))
config.include(mod('wuttaweb.views.people'))
config.include(mod('wuttaweb.views.users'))
def includeme(config):

View file

@ -1026,9 +1026,11 @@ class MasterView(View):
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)
# print("set link:", key)
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
can further modify it based on request details etc.
"""
model_keys = self.get_model_key()
if 'uuid' in form:
form.fields.remove('uuid')
form.remove('uuid')
if self.editing:
for key in model_keys:
for key in self.get_model_key():
form.set_readonly(key)
def objectify(self, form):
@ -1228,22 +1227,15 @@ class MasterView(View):
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):
# update instance attrs for sqlalchemy model
if self.creating:
obj = model_class()
else:
obj = form.model_instance
data = form.validated
for key in form.fields:
if key in data:
setattr(obj, key, data[key])
return obj
# 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):

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)