+
% if index_title:
% if index_url:
${h.link_to(index_title, index_url)}
% else:
${index_title}
% endif
+ % if master and master.creatable and not master.creating:
+
+ % endif
% endif
@@ -230,13 +238,10 @@
## TODO
% if master and master.configurable and not master.configuring:
-
- Configure
-
+
% endif
@@ -366,7 +371,38 @@
${self.render_prevnext_header_buttons()}
%def>
-<%def name="render_crud_header_buttons()">%def>
+<%def name="render_crud_header_buttons()">
+ % if master:
+ % if master.viewing:
+
+
+ % elif master.editing:
+
+
+ % elif master.deleting:
+
+
+ % endif
+ % endif
+%def>
<%def name="render_prevnext_header_buttons()">%def>
@@ -432,6 +468,7 @@
<%def name="finalize_whole_page_vars()">%def>
<%def name="make_whole_page_component()">
+ ${make_wutta_components()}
${self.render_whole_page_template()}
${self.declare_whole_page_vars()}
${self.modify_whole_page_vars()}
diff --git a/src/wuttaweb/templates/deform/checkbox.pt b/src/wuttaweb/templates/deform/checkbox.pt
new file mode 100644
index 0000000..92c9f62
--- /dev/null
+++ b/src/wuttaweb/templates/deform/checkbox.pt
@@ -0,0 +1,11 @@
+
+
+ {{ ${vmodel} }}
+
+
diff --git a/src/wuttaweb/templates/deform/checked_password.pt b/src/wuttaweb/templates/deform/checked_password.pt
index 624f8a8..8a009df 100644
--- a/src/wuttaweb/templates/deform/checked_password.pt
+++ b/src/wuttaweb/templates/deform/checked_password.pt
@@ -1,5 +1,6 @@
+ oid oid|field.oid;
+ vmodel vmodel|'modelData.'+oid;">
${field.start_mapping()}
+ oid oid|field.oid;
+ vmodel vmodel|'modelData.'+oid;">
+
+
+
+
+
+
+
+ ${item[1]}
+
+
+
+
diff --git a/src/wuttaweb/templates/deform/textinput.pt b/src/wuttaweb/templates/deform/textinput.pt
index 4f09fc6..89c8c0f 100644
--- a/src/wuttaweb/templates/deform/textinput.pt
+++ b/src/wuttaweb/templates/deform/textinput.pt
@@ -1,6 +1,7 @@
+ oid oid|field.oid;
+ vmodel vmodel|'modelData.'+oid;">
diff --git a/src/wuttaweb/templates/forms/vue_template.mako b/src/wuttaweb/templates/forms/vue_template.mako
index a4e21ca..70540d0 100644
--- a/src/wuttaweb/templates/forms/vue_template.mako
+++ b/src/wuttaweb/templates/forms/vue_template.mako
@@ -13,13 +13,19 @@
% if not form.readonly:
+ % if form.show_button_cancel:
+
+ % endif
+
% if form.show_button_reset:
Reset
% endif
-
-
+ % if grid.is_linked(column['field']):
+
+ % else:
+
+ % endif
${b}-table-column>
% endfor
@@ -19,9 +24,9 @@
label="Actions"
v-slot="props">
% for action in grid.actions:
-
- ${action.render_icon()}
- ${action.render_label()}
+
+ ${action.render_icon_and_label()}
% endfor
diff --git a/src/wuttaweb/templates/master/create.mako b/src/wuttaweb/templates/master/create.mako
new file mode 100644
index 0000000..5aebc1a
--- /dev/null
+++ b/src/wuttaweb/templates/master/create.mako
@@ -0,0 +1,7 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/master/form.mako" />
+
+<%def name="title()">New ${model_title}%def>
+
+
+${parent.body()}
diff --git a/src/wuttaweb/templates/master/delete.mako b/src/wuttaweb/templates/master/delete.mako
new file mode 100644
index 0000000..59a6f63
--- /dev/null
+++ b/src/wuttaweb/templates/master/delete.mako
@@ -0,0 +1,18 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/master/form.mako" />
+
+<%def name="title()">${index_title} » ${instance_title} » Delete%def>
+
+<%def name="content_title()">Delete: ${instance_title}%def>
+
+<%def name="page_content()">
+
+
+ Really DELETE this ${model_title}?
+
+ ${parent.page_content()}
+%def>
+
+
+${parent.body()}
diff --git a/src/wuttaweb/templates/master/edit.mako b/src/wuttaweb/templates/master/edit.mako
new file mode 100644
index 0000000..8e21fa7
--- /dev/null
+++ b/src/wuttaweb/templates/master/edit.mako
@@ -0,0 +1,9 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/master/form.mako" />
+
+<%def name="title()">${index_title} » ${instance_title} » Edit%def>
+
+<%def name="content_title()">Edit: ${instance_title}%def>
+
+
+${parent.body()}
diff --git a/src/wuttaweb/templates/wutta-components.mako b/src/wuttaweb/templates/wutta-components.mako
new file mode 100644
index 0000000..6619eed
--- /dev/null
+++ b/src/wuttaweb/templates/wutta-components.mako
@@ -0,0 +1,71 @@
+
+<%def name="make_wutta_components()">
+ ${self.make_wutta_button_component()}
+%def>
+
+<%def name="make_wutta_button_component()">
+
+
+%def>
diff --git a/src/wuttaweb/util.py b/src/wuttaweb/util.py
index 6d1d5f2..36942c3 100644
--- a/src/wuttaweb/util.py
+++ b/src/wuttaweb/util.py
@@ -25,10 +25,62 @@ Web Utilities
"""
import importlib
+import json
+import logging
+import colander
from webhelpers2.html import HTML, tags
+log = logging.getLogger(__name__)
+
+
+class FieldList(list):
+ """
+ Convenience wrapper for a form's field list. This is a subclass
+ of :class:`python:list`.
+
+ You normally would not need to instantiate this yourself, but it
+ is used under the hood for
+ :attr:`~wuttaweb.forms.base.Form.fields` as well as
+ :attr:`~wuttaweb.grids.base.Grid.columns`.
+ """
+
+ def insert_before(self, field, newfield):
+ """
+ Insert a new field, before an existing field.
+
+ :param field: String name for the existing field.
+
+ :param newfield: String name for the new field, to be inserted
+ just before the existing ``field``.
+ """
+ if field in self:
+ i = self.index(field)
+ self.insert(i, newfield)
+ else:
+ log.warning("field '%s' not found, will append new field: %s",
+ field, newfield)
+ self.append(newfield)
+
+ def insert_after(self, field, newfield):
+ """
+ Insert a new field, after an existing field.
+
+ :param field: String name for the existing field.
+
+ :param newfield: String name for the new field, to be inserted
+ just after the existing ``field``.
+ """
+ if field in self:
+ i = self.index(field)
+ self.insert(i + 1, newfield)
+ else:
+ log.warning("field '%s' not found, will append new field: %s",
+ field, newfield)
+ self.append(newfield)
+
+
def get_form_data(request):
"""
Returns the effective form data for the given request.
@@ -357,3 +409,66 @@ def render_csrf_token(request, name='_csrf'):
"""
token = get_csrf_token(request)
return HTML.tag('div', tags.hidden(name, value=token), style='display:none;')
+
+
+def get_model_fields(config, model_class=None):
+ """
+ Convenience function to return a list of field names for the given
+ model class.
+
+ This logic only supports SQLAlchemy mapped classes and will use
+ that to determine the field listing if applicable. Otherwise this
+ returns ``None``.
+ """
+ if model_class:
+ import sqlalchemy as sa
+ app = config.get_app()
+ model = app.model
+ if model_class and issubclass(model_class, model.Base):
+ mapper = sa.inspect(model_class)
+ fields = list([prop.key for prop in mapper.iterate_properties])
+ return fields
+
+
+def make_json_safe(value, key=None, warn=True):
+ """
+ Convert a Python value as needed, to ensure it is compatible with
+ :func:`python:json.dumps()`.
+
+ :param value: Python value.
+
+ :param key: Optional key for the value, if known. This is used
+ when logging warnings, if applicable.
+
+ :param warn: Whether warnings should be logged if the value is not
+ already JSON-compatible.
+
+ :returns: A (possibly new) Python value which is guaranteed to be
+ JSON-serializable.
+ """
+
+ # convert null => None
+ if value is colander.null:
+ return None
+
+ # recursively convert dict
+ if isinstance(value, dict):
+ parent = dict(value)
+ for key, value in parent.items():
+ parent[key] = make_json_safe(value, key=key, warn=warn)
+ value = parent
+
+ # ensure JSON-compatibility, warn if problems
+ try:
+ json.dumps(value)
+ except TypeError as error:
+ if warn:
+ prefix = "value"
+ if key:
+ prefix += f" for '{key}'"
+ log.warning("%s is not json-friendly: %s", prefix, repr(value))
+ value = str(value)
+ if warn:
+ log.warning("forced value to: %s", value)
+
+ return value
diff --git a/src/wuttaweb/views/auth.py b/src/wuttaweb/views/auth.py
index 389271b..894752a 100644
--- a/src/wuttaweb/views/auth.py
+++ b/src/wuttaweb/views/auth.py
@@ -59,13 +59,11 @@ class AuthView(View):
form = self.make_form(schema=self.login_make_schema(),
align_buttons_right=True,
+ show_button_cancel=False,
show_button_reset=True,
button_label_submit="Login",
button_icon_submit='user')
- # TODO
- # form.show_cancel = False
-
# validate basic form data (sanity check)
data = form.validate()
if data:
@@ -155,6 +153,7 @@ class AuthView(View):
return self.redirect(self.request.route_url('home'))
form = self.make_form(schema=self.change_password_make_schema(),
+ show_button_cancel=False,
show_button_reset=True)
data = form.validate()
diff --git a/src/wuttaweb/views/essential.py b/src/wuttaweb/views/essential.py
index 0d4ec35..1387a99 100644
--- a/src/wuttaweb/views/essential.py
+++ b/src/wuttaweb/views/essential.py
@@ -31,6 +31,10 @@ That will in turn include the following modules:
* :mod:`wuttaweb.views.auth`
* :mod:`wuttaweb.views.common`
+* :mod:`wuttaweb.views.settings`
+* :mod:`wuttaweb.views.people`
+* :mod:`wuttaweb.views.roles`
+* :mod:`wuttaweb.views.users`
"""
@@ -40,6 +44,9 @@ def defaults(config, **kwargs):
config.include(mod('wuttaweb.views.auth'))
config.include(mod('wuttaweb.views.common'))
config.include(mod('wuttaweb.views.settings'))
+ config.include(mod('wuttaweb.views.people'))
+ config.include(mod('wuttaweb.views.roles'))
+ config.include(mod('wuttaweb.views.users'))
def includeme(config):
diff --git a/src/wuttaweb/views/master.py b/src/wuttaweb/views/master.py
index f884f7a..2cd816a 100644
--- a/src/wuttaweb/views/master.py
+++ b/src/wuttaweb/views/master.py
@@ -24,10 +24,13 @@
Base Logic for Master Views
"""
+import sqlalchemy as sa
+from sqlalchemy import orm
+
from pyramid.renderers import render_to_response
from wuttaweb.views import View
-from wuttaweb.util import get_form_data
+from wuttaweb.util import get_form_data, get_model_fields
from wuttaweb.db import Session
@@ -166,7 +169,13 @@ class MasterView(View):
List of columns for the :meth:`index()` view grid.
- This is optional; see also :meth:`index_get_grid_columns()`.
+ This is optional; see also :meth:`get_grid_columns()`.
+
+ .. attribute:: creatable
+
+ Boolean indicating whether the view model supports "creating" -
+ i.e. it should have a :meth:`create()` view. Default value is
+ ``True``.
.. attribute:: viewable
@@ -174,6 +183,18 @@ class MasterView(View):
i.e. it should have a :meth:`view()` view. Default value is
``True``.
+ .. attribute:: editable
+
+ Boolean indicating whether the view model supports "editing" -
+ i.e. it should have an :meth:`edit()` view. Default value is
+ ``True``.
+
+ .. attribute:: deletable
+
+ Boolean indicating whether the view model supports "deleting" -
+ i.e. it should have a :meth:`delete()` view. Default value is
+ ``True``.
+
.. attribute:: form_fields
List of columns for the model form.
@@ -194,12 +215,18 @@ class MasterView(View):
# features
listable = True
has_grid = True
+ creatable = True
viewable = True
+ editable = True
+ deletable = True
configurable = False
# current action
listing = False
+ creating = False
viewing = False
+ editing = False
+ deleting = False
configuring = False
##############################
@@ -222,7 +249,7 @@ class MasterView(View):
See also related methods, which are called by this one:
- * :meth:`index_make_grid()`
+ * :meth:`make_model_grid()`
"""
self.listing = True
@@ -231,110 +258,66 @@ class MasterView(View):
}
if self.has_grid:
- context['grid'] = self.index_make_grid()
+ context['grid'] = self.make_model_grid()
return self.render_to_response('index', context)
- def index_make_grid(self, **kwargs):
+ ##############################
+ # create methods
+ ##############################
+
+ def create(self):
"""
- Create and return a :class:`~wuttaweb.grids.base.Grid`
- instance for use with the :meth:`index()` view.
+ View to "create" a new model record.
- See also related methods, which are called by this one:
+ This usually corresponds to a URL like ``/widgets/new``.
- * :meth:`get_grid_key()`
- * :meth:`index_get_grid_columns()`
- * :meth:`index_get_grid_data()`
- * :meth:`index_configure_grid()`
+ By default, this view is included only if :attr:`creatable` is
+ true.
+
+ The default "create" view logic will show a form with field
+ widgets, allowing user to submit new values which are then
+ persisted to the DB (assuming typical SQLAlchemy model).
+
+ Subclass normally should not override this method, but rather
+ one of the related methods which are called (in)directly by
+ this one:
+
+ * :meth:`make_model_form()`
+ * :meth:`configure_form()`
+ * :meth:`create_save_form()`
"""
- if 'key' not in kwargs:
- kwargs['key'] = self.get_grid_key()
+ self.creating = True
+ form = self.make_model_form(cancel_url_fallback=self.get_index_url())
- if 'columns' not in kwargs:
- kwargs['columns'] = self.index_get_grid_columns()
+ if form.validate():
+ obj = self.create_save_form(form)
+ Session.flush()
+ return self.redirect(self.get_action_url('view', obj))
- if 'data' not in kwargs:
- kwargs['data'] = self.index_get_grid_data()
+ context = {
+ 'form': form,
+ }
+ return self.render_to_response('create', context)
- if 'actions' not in kwargs:
- actions = []
-
- # TODO: should split this off into index_get_grid_actions() ?
- if self.viewable:
- actions.append(self.make_grid_action('view', icon='eye',
- url=self.get_action_url_view))
-
- kwargs['actions'] = actions
-
- grid = self.make_grid(**kwargs)
- self.index_configure_grid(grid)
- return grid
-
- def index_get_grid_columns(self):
+ def create_save_form(self, form):
"""
- Returns the default list of grid column names, for the
- :meth:`index()` view.
+ This method is responsible for "converting" the validated form
+ data to a model instance, and then "saving" the result,
+ e.g. to DB. It is called by :meth:`create()`.
- This is called by :meth:`index_make_grid()`; in the resulting
- :class:`~wuttaweb.grids.base.Grid` instance, this becomes
- :attr:`~wuttaweb.grids.base.Grid.columns`.
+ Subclass may override this, or any of the related methods
+ called by this one:
- This method may return ``None``, in which case the grid may
- (try to) generate its own default list.
+ * :meth:`objectify()`
+ * :meth:`persist()`
- Subclass may define :attr:`grid_columns` for simple cases, or
- can override this method if needed.
-
- Also note that :meth:`index_configure_grid()` may be used to
- further modify the final column set, regardless of what this
- method returns. So a common pattern is to declare all
- "supported" columns by setting :attr:`grid_columns` but then
- optionally remove or replace some of those within
- :meth:`index_configure_grid()`.
- """
- if hasattr(self, 'grid_columns'):
- return self.grid_columns
-
- def index_get_grid_data(self):
- """
- Returns the grid data for the :meth:`index()` view.
-
- This is called by :meth:`index_make_grid()`; in the resulting
- :class:`~wuttaweb.grids.base.Grid` instance, this becomes
- :attr:`~wuttaweb.grids.base.Grid.data`.
-
- As of now there is not yet a "sane" default for this method;
- it simply returns an empty list. Subclass should override as
- needed.
- """
- return []
-
- def get_action_url_view(self, obj, i):
- """
- Returns the "view" grid action URL for the given object.
-
- Most typically this is like ``/widgets/XXX`` where ``XXX``
- represents the object's key/ID.
- """
- route_prefix = self.get_route_prefix()
-
- kw = {}
- for key in self.get_model_key():
- kw[key] = obj[key]
-
- return self.request.route_url(f'{route_prefix}.view', **kw)
-
- def index_configure_grid(self, grid):
- """
- Configure the grid for the :meth:`index()` view.
-
- This is called by :meth:`index_make_grid()`.
-
- There is no default logic here; subclass should override as
- needed. The ``grid`` param will already be "complete" and
- ready to use as-is, but this method can further modify it
- based on request details etc.
+ :returns: Should return the resulting model instance, e.g. as
+ produced by :meth:`objectify()`.
"""
+ obj = self.objectify(form)
+ self.persist(obj)
+ return obj
##############################
# view methods
@@ -353,9 +336,12 @@ class MasterView(View):
The default view logic will show a read-only form with field
values displayed.
- See also related methods, which are called by this one:
+ Subclass normally should not override this method, but rather
+ one of the related methods which are called (in)directly by
+ this one:
* :meth:`make_model_form()`
+ * :meth:`configure_form()`
"""
self.viewing = True
instance = self.get_instance()
@@ -368,6 +354,148 @@ class MasterView(View):
}
return self.render_to_response('view', context)
+ ##############################
+ # edit methods
+ ##############################
+
+ def edit(self):
+ """
+ View to "edit" details of an existing model record.
+
+ This usually corresponds to a URL like ``/widgets/XXX/edit``
+ where ``XXX`` represents the key/ID for the record.
+
+ By default, this view is included only if :attr:`editable` is
+ true.
+
+ The default "edit" view logic will show a form with field
+ widgets, allowing user to modify and submit new values which
+ are then persisted to the DB (assuming typical SQLAlchemy
+ model).
+
+ Subclass normally should not override this method, but rather
+ one of the related methods which are called (in)directly by
+ this one:
+
+ * :meth:`make_model_form()`
+ * :meth:`configure_form()`
+ * :meth:`edit_save_form()`
+ """
+ self.editing = True
+ instance = self.get_instance()
+ instance_title = self.get_instance_title(instance)
+
+ form = self.make_model_form(instance,
+ cancel_url_fallback=self.get_action_url('view', instance))
+
+ if form.validate():
+ self.edit_save_form(form)
+ return self.redirect(self.get_action_url('view', instance))
+
+ context = {
+ 'instance': instance,
+ 'instance_title': instance_title,
+ 'form': form,
+ }
+ return self.render_to_response('edit', context)
+
+ def edit_save_form(self, form):
+ """
+ This method is responsible for "converting" the validated form
+ data to a model instance, and then "saving" the result,
+ e.g. to DB. It is called by :meth:`edit()`.
+
+ Subclass may override this, or any of the related methods
+ called by this one:
+
+ * :meth:`objectify()`
+ * :meth:`persist()`
+
+ :returns: Should return the resulting model instance, e.g. as
+ produced by :meth:`objectify()`.
+ """
+ obj = self.objectify(form)
+ self.persist(obj)
+ return obj
+
+ ##############################
+ # delete methods
+ ##############################
+
+ def delete(self):
+ """
+ View to delete an existing model instance.
+
+ This usually corresponds to a URL like ``/widgets/XXX/delete``
+ where ``XXX`` represents the key/ID for the record.
+
+ By default, this view is included only if :attr:`deletable` is
+ true.
+
+ The default "delete" view logic will show a "psuedo-readonly"
+ form with no fields editable, but with a submit button so user
+ must confirm, before deletion actually occurs.
+
+ Subclass normally should not override this method, but rather
+ one of the related methods which are called (in)directly by
+ this one:
+
+ * :meth:`make_model_form()`
+ * :meth:`configure_form()`
+ * :meth:`delete_save_form()`
+ * :meth:`delete_instance()`
+ """
+ self.deleting = True
+ instance = self.get_instance()
+ instance_title = self.get_instance_title(instance)
+
+ # nb. this form proper is not readonly..
+ form = self.make_model_form(instance,
+ cancel_url_fallback=self.get_action_url('view', instance),
+ button_label_submit="DELETE Forever",
+ button_icon_submit='trash',
+ button_type_submit='is-danger')
+ # ..but *all* fields are readonly
+ form.readonly_fields = set(form.fields)
+
+ # nb. validate() often returns empty dict here
+ if form.validate() is not False:
+ self.delete_save_form(form)
+ return self.redirect(self.get_index_url())
+
+ context = {
+ 'instance': instance,
+ 'instance_title': instance_title,
+ 'form': form,
+ }
+ return self.render_to_response('delete', context)
+
+ def delete_save_form(self, form):
+ """
+ Perform the delete operation(s) based on the given form data.
+
+ Default logic simply calls :meth:`delete_instance()` on the
+ form's :attr:`~wuttaweb.forms.base.Form.model_instance`.
+
+ This method is called by :meth:`delete()` after it has
+ validated the form.
+ """
+ obj = form.model_instance
+ self.delete_instance(obj)
+
+ def delete_instance(self, obj):
+ """
+ Delete the given model instance.
+
+ As of yet there is no default logic for this method; it will
+ raise ``NotImplementedError``. Subclass should override if
+ needed.
+
+ This method is called by :meth:`delete_save_form()`.
+ """
+ session = self.app.get_session(obj)
+ session.delete(obj)
+
##############################
# configure methods
##############################
@@ -694,6 +822,7 @@ class MasterView(View):
'route_prefix': self.get_route_prefix(),
'index_title': self.get_index_title(),
'index_url': self.get_index_url(),
+ 'model_title': self.get_model_title(),
'config_title': self.get_config_title(),
}
@@ -758,7 +887,152 @@ class MasterView(View):
route_prefix = self.get_route_prefix()
return self.request.route_url(route_prefix, **kwargs)
- def get_instance(self):
+ def make_model_grid(self, session=None, **kwargs):
+ """
+ Create and return a :class:`~wuttaweb.grids.base.Grid`
+ instance for use with the :meth:`index()` view.
+
+ See also related methods, which are called by this one:
+
+ * :meth:`get_grid_key()`
+ * :meth:`get_grid_columns()`
+ * :meth:`get_grid_data()`
+ * :meth:`configure_grid()`
+ """
+ if 'key' not in kwargs:
+ kwargs['key'] = self.get_grid_key()
+
+ if 'model_class' not in kwargs:
+ model_class = self.get_model_class()
+ if model_class:
+ kwargs['model_class'] = model_class
+
+ if 'columns' not in kwargs:
+ kwargs['columns'] = self.get_grid_columns()
+
+ if 'data' not in kwargs:
+ kwargs['data'] = self.get_grid_data(columns=kwargs['columns'],
+ session=session)
+
+ if 'actions' not in kwargs:
+ actions = []
+
+ # TODO: should split this off into index_get_grid_actions() ?
+
+ if self.viewable:
+ actions.append(self.make_grid_action('view', icon='eye',
+ url=self.get_action_url_view))
+
+ if self.editable:
+ actions.append(self.make_grid_action('edit', icon='edit',
+ url=self.get_action_url_edit))
+
+ if self.deletable:
+ actions.append(self.make_grid_action('delete', icon='trash',
+ url=self.get_action_url_delete,
+ link_class='has-text-danger'))
+
+ kwargs['actions'] = actions
+
+ grid = self.make_grid(**kwargs)
+ self.configure_grid(grid)
+ return grid
+
+ def get_grid_columns(self):
+ """
+ Returns the default list of grid column names, for the
+ :meth:`index()` view.
+
+ This is called by :meth:`make_model_grid()`; in the resulting
+ :class:`~wuttaweb.grids.base.Grid` instance, this becomes
+ :attr:`~wuttaweb.grids.base.Grid.columns`.
+
+ This method may return ``None``, in which case the grid may
+ (try to) generate its own default list.
+
+ Subclass may define :attr:`grid_columns` for simple cases, or
+ can override this method if needed.
+
+ Also note that :meth:`configure_grid()` may be used to further
+ modify the final column set, regardless of what this method
+ returns. So a common pattern is to declare all "supported"
+ columns by setting :attr:`grid_columns` but then optionally
+ remove or replace some of those within
+ :meth:`configure_grid()`.
+ """
+ if hasattr(self, 'grid_columns'):
+ return self.grid_columns
+
+ def get_grid_data(self, columns=None, session=None):
+ """
+ Returns the grid data for the :meth:`index()` view.
+
+ This is called by :meth:`make_model_grid()`; in the resulting
+ :class:`~wuttaweb.grids.base.Grid` instance, this becomes
+ :attr:`~wuttaweb.grids.base.Grid.data`.
+
+ Default logic will call :meth:`get_query()` and if successful,
+ return the list from ``query.all()``. Otherwise returns an
+ empty list. Subclass should override as needed.
+ """
+ query = self.get_query(session=session)
+ if query:
+ data = query.all()
+
+ # determine which columns are relevant for data set
+ if not columns:
+ columns = self.get_grid_columns()
+ if not columns:
+ model_class = self.get_model_class()
+ if model_class:
+ columns = get_model_fields(self.config, model_class)
+ if not columns:
+ raise ValueError("cannot determine columns for the grid")
+ columns = set(columns)
+ columns.update(self.get_model_key())
+
+ # prune data fields for which no column is defined
+ for i, record in enumerate(data):
+ data[i]= dict([(key, record[key])
+ for key in columns])
+
+ return data
+
+ return []
+
+ def get_query(self, session=None):
+ """
+ Returns the main SQLAlchemy query object for the
+ :meth:`index()` view. This is called by
+ :meth:`get_grid_data()`.
+
+ Default logic for this method returns a "plain" query on the
+ :attr:`model_class` if that is defined; otherwise ``None``.
+ """
+ model = self.app.model
+ model_class = self.get_model_class()
+ if model_class and issubclass(model_class, model.Base):
+ session = session or Session()
+ return session.query(model_class)
+
+ def configure_grid(self, grid):
+ """
+ Configure the grid for the :meth:`index()` view.
+
+ This is called by :meth:`make_model_grid()`.
+
+ There is no default logic here; subclass should override as
+ needed. The ``grid`` param will already be "complete" and
+ ready to use as-is, but this method can further modify it
+ based on request details etc.
+ """
+ if 'uuid' in grid.columns:
+ grid.columns.remove('uuid')
+
+ for key in self.get_model_key():
+ grid.set_link(key)
+
+ def get_instance(self, session=None):
"""
This should return the "current" model instance based on the
request details (e.g. route kwargs).
@@ -769,6 +1043,27 @@ class MasterView(View):
There is no "sane" default logic here; subclass *must*
override or else a ``NotImplementedError`` is raised.
"""
+ model_class = self.get_model_class()
+ if model_class:
+ session = session or Session()
+
+ def filtr(query, model_key):
+ key = self.request.matchdict[model_key]
+ query = query.filter(getattr(self.model_class, model_key) == key)
+ return query
+
+ query = session.query(model_class)
+
+ for key in self.get_model_key():
+ query = filtr(query, key)
+
+ try:
+ return query.one()
+ except orm.exc.NoResultFound:
+ pass
+
+ raise self.notfound()
+
raise NotImplementedError("you must define get_instance() method "
f" for view class: {self.__class__}")
@@ -782,6 +1077,60 @@ class MasterView(View):
"""
return str(instance)
+ def get_action_url(self, action, obj, **kwargs):
+ """
+ Generate an "action" URL for the given model instance.
+
+ This is a shortcut which generates a route name based on
+ :meth:`get_route_prefix()` and the ``action`` param.
+
+ It returns the URL based on generated route name and object's
+ model key values.
+
+ :param action: String name for the action, which corresponds
+ to part of some named route, e.g. ``'view'`` or ``'edit'``.
+
+ :param obj: Model instance object.
+ """
+ route_prefix = self.get_route_prefix()
+ kw = dict([(key, obj[key])
+ for key in self.get_model_key()])
+ kw.update(kwargs)
+ return self.request.route_url(f'{route_prefix}.{action}', **kw)
+
+ def get_action_url_view(self, obj, i):
+ """
+ Returns the "view" grid action URL for the given object.
+
+ Most typically this is like ``/widgets/XXX`` where ``XXX``
+ represents the object's key/ID.
+
+ Calls :meth:`get_action_url()` under the hood.
+ """
+ return self.get_action_url('view', obj)
+
+ def get_action_url_edit(self, obj, i):
+ """
+ Returns the "edit" grid action URL for the given object.
+
+ Most typically this is like ``/widgets/XXX/edit`` where
+ ``XXX`` represents the object's key/ID.
+
+ Calls :meth:`get_action_url()` under the hood.
+ """
+ return self.get_action_url('edit', obj)
+
+ def get_action_url_delete(self, obj, i):
+ """
+ Returns the "delete" grid action URL for the given object.
+
+ Most typically this is like ``/widgets/XXX/delete`` where
+ ``XXX`` represents the object's key/ID.
+
+ Calls :meth:`get_action_url()` under the hood.
+ """
+ return self.get_action_url('delete', obj)
+
def make_model_form(self, model_instance=None, **kwargs):
"""
Create and return a :class:`~wuttaweb.forms.base.Form`
@@ -791,16 +1140,24 @@ class MasterView(View):
e.g.:
* :meth:`view()`
+ * :meth:`edit()`
See also related methods, which are called by this one:
* :meth:`get_form_fields()`
* :meth:`configure_form()`
"""
+ if 'model_class' not in kwargs:
+ model_class = self.get_model_class()
+ if model_class:
+ kwargs['model_class'] = model_class
+
kwargs['model_instance'] = model_instance
- if 'fields' not in kwargs:
- kwargs['fields'] = self.get_form_fields()
+ if not kwargs.get('fields'):
+ fields = self.get_form_fields()
+ if fields:
+ kwargs['fields'] = fields
form = self.make_form(**kwargs)
self.configure_form(form)
@@ -834,13 +1191,77 @@ class MasterView(View):
Configure the given model form, as needed.
This is called by :meth:`make_model_form()` - for multiple
- CRUD views.
+ CRUD views (create, view, edit, delete, possibly others).
- There is no default logic here; subclass should override if
- needed. The ``form`` param will already be "complete" and
- ready to use as-is, but this method can further modify it
- based on request details etc.
+ The default logic here does just one thing: when "editing"
+ (i.e. in :meth:`edit()` view) then all fields which are part
+ of the :attr:`model_key` will be marked via
+ :meth:`set_readonly()` so the user cannot change primary key
+ values for a record.
+
+ Subclass may override as needed. The ``form`` param will
+ already be "complete" and ready to use as-is, but this method
+ can further modify it based on request details etc.
"""
+ form.remove('uuid')
+
+ if self.editing:
+ for key in self.get_model_key():
+ form.set_readonly(key)
+
+ def objectify(self, form):
+ """
+ Must return a "model instance" object which reflects the
+ validated form data.
+
+ In simple cases this may just return the
+ :attr:`~wuttaweb.forms.base.Form.validated` data dict.
+
+ When dealing with SQLAlchemy models it would return a proper
+ mapped instance, creating it if necessary.
+
+ :param form: Reference to the *already validated*
+ :class:`~wuttaweb.forms.base.Form` object. See the form's
+ :attr:`~wuttaweb.forms.base.Form.validated` attribute for
+ the data.
+
+ See also :meth:`edit_save_form()` which calls this method.
+ """
+
+ # use ColanderAlchemy magic if possible
+ schema = form.get_schema()
+ if hasattr(schema, 'objectify'):
+ # this returns a model instance
+ return schema.objectify(form.validated,
+ context=form.model_instance)
+
+ # otherwise return data dict as-is
+ return form.validated
+
+ def persist(self, obj, session=None):
+ """
+ If applicable, this method should persist ("save") the given
+ object's data (e.g. to DB), creating or updating it as needed.
+
+ This is part of the "submit form" workflow; ``obj`` should be
+ a model instance which already reflects the validated form
+ data.
+
+ Note that there is no default logic here, subclass must
+ override if needed.
+
+ :param obj: Model instance object as produced by
+ :meth:`objectify()`.
+
+ See also :meth:`edit_save_form()` which calls this method.
+ """
+ model = self.app.model
+ model_class = self.get_model_class()
+ if model_class and issubclass(model_class, model.Base):
+
+ # add sqlalchemy model to session
+ session = session or Session()
+ session.add(obj)
##############################
# class methods
@@ -961,6 +1382,11 @@ class MasterView(View):
keys = [keys]
return tuple(keys)
+ model_class = cls.get_model_class()
+ if model_class:
+ mapper = sa.inspect(model_class)
+ return tuple([column.key for column in mapper.primary_key])
+
raise AttributeError(f"you must define model_key for view class: {cls}")
@classmethod
@@ -1066,7 +1492,7 @@ class MasterView(View):
grid in the :meth:`index()` view. This key may also be used
as the basis (key prefix) for secondary grids.
- This is called from :meth:`index_make_grid()`; in the
+ This is called from :meth:`make_model_grid()`; in the
resulting :class:`~wuttaweb.grids.base.Grid` instance, this
becomes :attr:`~wuttaweb.grids.base.Grid.key`.
@@ -1135,6 +1561,13 @@ class MasterView(View):
config.add_view(cls, attr='index',
route_name=route_prefix)
+ # create
+ if cls.creatable:
+ config.add_route(f'{route_prefix}.create',
+ f'{url_prefix}/new')
+ config.add_view(cls, attr='create',
+ route_name=f'{route_prefix}.create')
+
# view
if cls.viewable:
instance_url_prefix = cls.get_instance_url_prefix()
@@ -1142,6 +1575,22 @@ class MasterView(View):
config.add_view(cls, attr='view',
route_name=f'{route_prefix}.view')
+ # edit
+ if cls.editable:
+ instance_url_prefix = cls.get_instance_url_prefix()
+ config.add_route(f'{route_prefix}.edit',
+ f'{instance_url_prefix}/edit')
+ config.add_view(cls, attr='edit',
+ route_name=f'{route_prefix}.edit')
+
+ # delete
+ if cls.deletable:
+ instance_url_prefix = cls.get_instance_url_prefix()
+ config.add_route(f'{route_prefix}.delete',
+ f'{instance_url_prefix}/delete')
+ config.add_view(cls, attr='delete',
+ route_name=f'{route_prefix}.delete')
+
# configure
if cls.configurable:
config.add_route(f'{route_prefix}.configure',
diff --git a/src/wuttaweb/views/people.py b/src/wuttaweb/views/people.py
new file mode 100644
index 0000000..cd56c83
--- /dev/null
+++ b/src/wuttaweb/views/people.py
@@ -0,0 +1,95 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+# wuttaweb -- Web App for Wutta Framework
+# Copyright © 2024 Lance Edgar
+#
+# This file is part of Wutta Framework.
+#
+# Wutta Framework is free software: you can redistribute it and/or modify it
+# under the terms of the GNU General Public License as published by the Free
+# Software Foundation, either version 3 of the License, or (at your option) any
+# later version.
+#
+# Wutta Framework is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+# more details.
+#
+# You should have received a copy of the GNU General Public License along with
+# Wutta Framework. If not, see .
+#
+################################################################################
+"""
+Views for people
+"""
+
+from wuttjamaican.db.model import Person
+from wuttaweb.views import MasterView
+
+
+class PersonView(MasterView):
+ """
+ Master view for people.
+
+ Notable URLs provided by this class:
+
+ * ``/people/``
+ * ``/people/new``
+ * ``/people/XXX``
+ * ``/people/XXX/edit``
+ * ``/people/XXX/delete``
+ """
+ model_class = Person
+ model_title_plural = "People"
+ route_prefix = 'people'
+
+ grid_columns = [
+ 'full_name',
+ 'first_name',
+ 'middle_name',
+ 'last_name',
+ ]
+
+ # TODO: master should handle this, possibly via configure_form()
+ def get_query(self, session=None):
+ """ """
+ model = self.app.model
+ query = super().get_query(session=session)
+ return query.order_by(model.Person.full_name)
+
+ def configure_grid(self, g):
+ """ """
+ super().configure_grid(g)
+
+ # full_name
+ g.set_link('full_name')
+
+ # TODO: master should handle this?
+ def configure_form(self, f):
+ """ """
+ super().configure_form(f)
+
+ # first_name
+ f.set_required('first_name', False)
+
+ # middle_name
+ f.set_required('middle_name', False)
+
+ # last_name
+ f.set_required('last_name', False)
+
+ # users
+ if 'users' in f:
+ f.fields.remove('users')
+
+
+def defaults(config, **kwargs):
+ base = globals()
+
+ PersonView = kwargs.get('PersonView', base['PersonView'])
+ PersonView.defaults(config)
+
+
+def includeme(config):
+ defaults(config)
diff --git a/src/wuttaweb/views/roles.py b/src/wuttaweb/views/roles.py
new file mode 100644
index 0000000..51111e0
--- /dev/null
+++ b/src/wuttaweb/views/roles.py
@@ -0,0 +1,100 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+# wuttaweb -- Web App for Wutta Framework
+# Copyright © 2024 Lance Edgar
+#
+# This file is part of Wutta Framework.
+#
+# Wutta Framework is free software: you can redistribute it and/or modify it
+# under the terms of the GNU General Public License as published by the Free
+# Software Foundation, either version 3 of the License, or (at your option) any
+# later version.
+#
+# Wutta Framework is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+# more details.
+#
+# You should have received a copy of the GNU General Public License along with
+# Wutta Framework. If not, see .
+#
+################################################################################
+"""
+Views for roles
+"""
+
+from wuttjamaican.db.model import Role
+from wuttaweb.views import MasterView
+from wuttaweb.db import Session
+
+
+class RoleView(MasterView):
+ """
+ Master view for roles.
+
+ Notable URLs provided by this class:
+
+ * ``/roles/``
+ * ``/roles/new``
+ * ``/roles/XXX``
+ * ``/roles/XXX/edit``
+ * ``/roles/XXX/delete``
+ """
+ model_class = Role
+
+ grid_columns = [
+ 'name',
+ 'notes',
+ ]
+
+ # TODO: master should handle this, possibly via configure_form()
+ def get_query(self, session=None):
+ """ """
+ model = self.app.model
+ query = super().get_query(session=session)
+ return query.order_by(model.Role.name)
+
+ def configure_grid(self, g):
+ """ """
+ super().configure_grid(g)
+
+ # name
+ g.set_link('name')
+
+ def configure_form(self, f):
+ """ """
+ super().configure_form(f)
+
+ # never show these
+ f.remove('permission_refs',
+ 'user_refs')
+
+ # name
+ f.set_validator('name', self.unique_name)
+
+ def unique_name(self, node, value):
+ """ """
+ model = self.app.model
+ session = Session()
+
+ query = session.query(model.Role)\
+ .filter(model.Role.name == value)
+
+ if self.editing:
+ uuid = self.request.matchdict['uuid']
+ query = query.filter(model.Role.uuid != uuid)
+
+ if query.count():
+ node.raise_invalid("Name must be unique")
+
+
+def defaults(config, **kwargs):
+ base = globals()
+
+ RoleView = kwargs.get('RoleView', base['RoleView'])
+ RoleView.defaults(config)
+
+
+def includeme(config):
+ defaults(config)
diff --git a/src/wuttaweb/views/settings.py b/src/wuttaweb/views/settings.py
index 17f3c42..a85be38 100644
--- a/src/wuttaweb/views/settings.py
+++ b/src/wuttaweb/views/settings.py
@@ -27,10 +27,8 @@ Views for app settings
from collections import OrderedDict
from wuttjamaican.db.model import Setting
-
from wuttaweb.views import MasterView
from wuttaweb.util import get_libver, get_liburl
-from wuttaweb.db import Session
class AppInfoView(MasterView):
@@ -48,6 +46,7 @@ class AppInfoView(MasterView):
model_title_plural = "App Info"
route_prefix = 'appinfo'
has_grid = False
+ creatable = False
viewable = False
editable = False
deletable = False
@@ -147,55 +146,18 @@ class SettingView(MasterView):
model_class = Setting
model_title = "Raw Setting"
- # TODO: this should be deduced by master
- model_key = 'name'
-
- # TODO: try removing these
- grid_columns = [
- 'name',
- 'value',
- ]
- form_fields = list(grid_columns)
-
- # TODO: should define query, let master handle the rest
- def index_get_grid_data(self, session=None):
+ # TODO: master should handle this, possibly via configure_form()
+ def get_query(self, session=None):
""" """
model = self.app.model
+ query = super().get_query(session=session)
+ return query.order_by(model.Setting.name)
- session = session or Session()
- query = session.query(model.Setting)\
- .order_by(model.Setting.name)
-
- settings = []
- for setting in query:
- settings.append(self.normalize_setting(setting))
-
- return settings
-
- # TODO: master should handle this (but not as dict)
- def normalize_setting(self, setting):
+ # TODO: master should handle this (per column nullable)
+ def configure_form(self, f):
""" """
- return {
- 'name': setting.name,
- 'value': setting.value,
- }
-
- # TODO: master should handle this
- def get_instance(self, session=None):
- """ """
- model = self.app.model
- session = session or Session()
- name = self.request.matchdict['name']
- setting = session.query(model.Setting).get(name)
- if setting:
- return self.normalize_setting(setting)
-
- return self.notfound()
-
- # TODO: master should handle this
- def get_instance_title(self, setting):
- """ """
- return setting['name']
+ super().configure_form(f)
+ f.set_required('value', False)
def defaults(config, **kwargs):
diff --git a/src/wuttaweb/views/users.py b/src/wuttaweb/views/users.py
new file mode 100644
index 0000000..124aa59
--- /dev/null
+++ b/src/wuttaweb/views/users.py
@@ -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 .
+#
+################################################################################
+"""
+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)
diff --git a/tests/forms/test_base.py b/tests/forms/test_base.py
index 5270f0f..b3aade2 100644
--- a/tests/forms/test_base.py
+++ b/tests/forms/test_base.py
@@ -1,54 +1,24 @@
# -*- coding: utf-8; -*-
from unittest import TestCase
-from unittest.mock import MagicMock
+from unittest.mock import MagicMock, patch
import colander
import deform
from pyramid import testing
from wuttjamaican.conf import WuttaConfig
-from wuttaweb.forms import base
+from wuttaweb.forms import base, widgets
from wuttaweb import helpers
-class TestFieldList(TestCase):
-
- def test_insert_before(self):
- fields = base.FieldList(['f1', 'f2'])
- self.assertEqual(fields, ['f1', 'f2'])
-
- # typical
- fields.insert_before('f1', 'XXX')
- self.assertEqual(fields, ['XXX', 'f1', 'f2'])
- fields.insert_before('f2', 'YYY')
- self.assertEqual(fields, ['XXX', 'f1', 'YYY', 'f2'])
-
- # appends new field if reference field is invalid
- fields.insert_before('f3', 'ZZZ')
- self.assertEqual(fields, ['XXX', 'f1', 'YYY', 'f2', 'ZZZ'])
-
- def test_insert_after(self):
- fields = base.FieldList(['f1', 'f2'])
- self.assertEqual(fields, ['f1', 'f2'])
-
- # typical
- fields.insert_after('f1', 'XXX')
- self.assertEqual(fields, ['f1', 'XXX', 'f2'])
- fields.insert_after('XXX', 'YYY')
- self.assertEqual(fields, ['f1', 'XXX', 'YYY', 'f2'])
-
- # appends new field if reference field is invalid
- fields.insert_after('f3', 'ZZZ')
- self.assertEqual(fields, ['f1', 'XXX', 'YYY', 'f2', 'ZZZ'])
-
-
class TestForm(TestCase):
def setUp(self):
self.config = WuttaConfig(defaults={
- 'wutta.web.menus.handler_spec': 'tests.utils:NullMenuHandler',
+ 'wutta.web.menus.handler_spec': 'tests.util:NullMenuHandler',
})
+ self.app = self.config.get_app()
self.request = testing.DummyRequest(wutta_config=self.config, use_oruga=False)
self.pyramid_config = testing.setUp(request=self.request, settings={
@@ -73,7 +43,7 @@ class TestForm(TestCase):
def test_init_with_none(self):
form = self.make_form()
- self.assertIsNone(form.fields)
+ self.assertEqual(form.fields, [])
def test_init_with_fields(self):
form = self.make_form(fields=['foo', 'bar'])
@@ -114,7 +84,81 @@ class TestForm(TestCase):
form.set_fields(['baz'])
self.assertEqual(form.fields, ['baz'])
+ def test_remove(self):
+ form = self.make_form(fields=['one', 'two', 'three', 'four'])
+ self.assertEqual(form.fields, ['one', 'two', 'three', 'four'])
+ form.remove('two', 'three')
+ self.assertEqual(form.fields, ['one', 'four'])
+
+ def test_set_node(self):
+ form = self.make_form(fields=['foo', 'bar'])
+ self.assertEqual(form.nodes, {})
+
+ # complete node
+ node = colander.SchemaNode(colander.Bool(), name='foo')
+ form.set_node('foo', node)
+ self.assertIs(form.nodes['foo'], node)
+
+ # type only
+ typ = colander.Bool()
+ form.set_node('foo', typ)
+ node = form.nodes['foo']
+ self.assertIsInstance(node, colander.SchemaNode)
+ self.assertIsInstance(node.typ, colander.Bool)
+ self.assertEqual(node.name, 'foo')
+
+ # schema is updated if already present
+ schema = form.get_schema()
+ self.assertIsNotNone(schema)
+ typ = colander.Date()
+ form.set_node('foo', typ)
+ node = form.nodes['foo']
+ self.assertIsInstance(node, colander.SchemaNode)
+ self.assertIsInstance(node.typ, colander.Date)
+ self.assertEqual(node.name, 'foo')
+
+ def test_set_widget(self):
+ form = self.make_form(fields=['foo', 'bar'])
+ self.assertEqual(form.widgets, {})
+
+ # basic
+ widget = widgets.SelectWidget()
+ form.set_widget('foo', widget)
+ self.assertIs(form.widgets['foo'], widget)
+
+ # schema is updated if already present
+ schema = form.get_schema()
+ self.assertIsNotNone(schema)
+ self.assertIs(schema['foo'].widget, widget)
+ new_widget = widgets.TextInputWidget()
+ form.set_widget('foo', new_widget)
+ self.assertIs(form.widgets['foo'], new_widget)
+ self.assertIs(schema['foo'].widget, new_widget)
+
+ def test_set_validator(self):
+ form = self.make_form(fields=['foo', 'bar'])
+ self.assertEqual(form.validators, {})
+
+ def validate1(node, value):
+ pass
+
+ # basic
+ form.set_validator('foo', validate1)
+ self.assertIs(form.validators['foo'], validate1)
+
+ def validate2(node, value):
+ pass
+
+ # schema is updated if already present
+ schema = form.get_schema()
+ self.assertIsNotNone(schema)
+ self.assertIs(schema['foo'].validator, validate1)
+ form.set_validator('foo', validate2)
+ self.assertIs(form.validators['foo'], validate2)
+ self.assertIs(schema['foo'].validator, validate2)
+
def test_get_schema(self):
+ model = self.app.model
form = self.make_form()
self.assertIsNone(form.schema)
@@ -135,7 +179,62 @@ class TestForm(TestCase):
self.assertIsNone(form.schema)
self.assertRaises(NotImplementedError, form.get_schema)
+ # schema is auto-generated if model_class provided
+ form = self.make_form(model_class=model.Setting)
+ schema = form.get_schema()
+ self.assertEqual(len(schema.children), 2)
+ self.assertIn('name', schema)
+ self.assertIn('value', schema)
+
+ # but node overrides are honored when auto-generating
+ form = self.make_form(model_class=model.Setting)
+ value_node = colander.SchemaNode(colander.Bool(), name='value')
+ form.set_node('value', value_node)
+ schema = form.get_schema()
+ self.assertIs(schema['value'], value_node)
+
+ # schema is auto-generated if model_instance provided
+ form = self.make_form(model_instance=model.Setting(name='uhoh'))
+ self.assertEqual(form.fields, ['name', 'value'])
+ self.assertIsNone(form.schema)
+ # nb. force method to get new fields
+ del form.fields
+ schema = form.get_schema()
+ self.assertEqual(len(schema.children), 2)
+ self.assertIn('name', schema)
+ self.assertIn('value', schema)
+
+ # schema nodes are required by default
+ form = self.make_form(fields=['foo', 'bar'])
+ schema = form.get_schema()
+ self.assertIs(schema['foo'].missing, colander.required)
+ self.assertIs(schema['bar'].missing, colander.required)
+
+ # but fields can be marked *not* required
+ form = self.make_form(fields=['foo', 'bar'])
+ form.set_required('bar', False)
+ schema = form.get_schema()
+ self.assertIs(schema['foo'].missing, colander.required)
+ self.assertIs(schema['bar'].missing, colander.null)
+
+ # validator overrides are honored
+ def validate(node, value): pass
+ form = self.make_form(model_class=model.Setting)
+ form.set_validator('name', validate)
+ schema = form.get_schema()
+ self.assertIs(schema['name'].validator, validate)
+
+ # validator can be set for whole form
+ form = self.make_form(model_class=model.Setting)
+ schema = form.get_schema()
+ self.assertIsNone(schema.validator)
+ form = self.make_form(model_class=model.Setting)
+ form.set_validator(None, validate)
+ schema = form.get_schema()
+ self.assertIs(schema.validator, validate)
+
def test_get_deform(self):
+ model = self.app.model
schema = self.make_schema()
# basic
@@ -145,12 +244,52 @@ class TestForm(TestCase):
self.assertIsInstance(dform, deform.Form)
self.assertIs(form.deform_form, dform)
- # with model instance / cstruct
+ # with model instance as dict
myobj = {'foo': 'one', 'bar': 'two'}
form = self.make_form(schema=schema, model_instance=myobj)
dform = form.get_deform()
self.assertEqual(dform.cstruct, myobj)
+ # with sqlalchemy model instance
+ myobj = model.Setting(name='foo', value='bar')
+ form = self.make_form(model_instance=myobj)
+ dform = form.get_deform()
+ self.assertEqual(dform.cstruct, {'name': 'foo', 'value': 'bar'})
+
+ # sqlalchemy instance with null value
+ myobj = model.Setting(name='foo', value=None)
+ form = self.make_form(model_instance=myobj)
+ dform = form.get_deform()
+ self.assertEqual(dform.cstruct, {'name': 'foo', 'value': colander.null})
+
+ def test_get_cancel_url(self):
+
+ # is referrer by default
+ form = self.make_form()
+ self.request.get_referrer = MagicMock(return_value='/cancel-default')
+ self.assertEqual(form.get_cancel_url(), '/cancel-default')
+ del self.request.get_referrer
+
+ # or can be static URL
+ form = self.make_form(cancel_url='/cancel-static')
+ self.assertEqual(form.get_cancel_url(), '/cancel-static')
+
+ # or can be fallback URL (nb. 'NOPE' indicates no referrer)
+ form = self.make_form(cancel_url_fallback='/cancel-fallback')
+ self.request.get_referrer = MagicMock(return_value='NOPE')
+ self.assertEqual(form.get_cancel_url(), '/cancel-fallback')
+ del self.request.get_referrer
+
+ # or can be referrer fallback, i.e. home page
+ form = self.make_form()
+ def get_referrer(default=None):
+ if default == 'NOPE':
+ return 'NOPE'
+ return '/home-page'
+ self.request.get_referrer = get_referrer
+ self.assertEqual(form.get_cancel_url(), '/home-page')
+ del self.request.get_referrer
+
def test_get_label(self):
form = self.make_form(fields=['foo', 'bar'])
self.assertEqual(form.get_label('foo'), "Foo")
@@ -170,6 +309,46 @@ class TestForm(TestCase):
self.assertEqual(form.get_label('foo'), "Woohoo")
self.assertEqual(schema['foo'].title, "Woohoo")
+ def test_readonly_fields(self):
+ form = self.make_form(fields=['foo', 'bar'])
+ self.assertEqual(form.readonly_fields, set())
+ self.assertFalse(form.is_readonly('foo'))
+
+ form.set_readonly('foo')
+ self.assertEqual(form.readonly_fields, {'foo'})
+ self.assertTrue(form.is_readonly('foo'))
+ self.assertFalse(form.is_readonly('bar'))
+
+ form.set_readonly('bar')
+ self.assertEqual(form.readonly_fields, {'foo', 'bar'})
+ self.assertTrue(form.is_readonly('foo'))
+ self.assertTrue(form.is_readonly('bar'))
+
+ form.set_readonly('foo', False)
+ self.assertEqual(form.readonly_fields, {'bar'})
+ self.assertFalse(form.is_readonly('foo'))
+ self.assertTrue(form.is_readonly('bar'))
+
+ def test_required_fields(self):
+ form = self.make_form(fields=['foo', 'bar'])
+ self.assertEqual(form.required_fields, {})
+ self.assertIsNone(form.is_required('foo'))
+
+ form.set_required('foo')
+ self.assertEqual(form.required_fields, {'foo': True})
+ self.assertTrue(form.is_required('foo'))
+ self.assertIsNone(form.is_required('bar'))
+
+ form.set_required('bar')
+ self.assertEqual(form.required_fields, {'foo': True, 'bar': True})
+ self.assertTrue(form.is_required('foo'))
+ self.assertTrue(form.is_required('bar'))
+
+ form.set_required('foo', False)
+ self.assertEqual(form.required_fields, {'foo': False, 'bar': True})
+ self.assertFalse(form.is_required('foo'))
+ self.assertTrue(form.is_required('bar'))
+
def test_render_vue_tag(self):
schema = self.make_schema()
form = self.make_form(schema=schema)
@@ -183,13 +362,13 @@ class TestForm(TestCase):
# form button is disabled on @submit by default
schema = self.make_schema()
- form = self.make_form(schema=schema)
+ form = self.make_form(schema=schema, cancel_url='/')
html = form.render_vue_template()
self.assertIn('