diff --git a/tailbone/forms2/__init__.py b/tailbone/forms2/__init__.py
new file mode 100644
index 00000000..5e1ffcae
--- /dev/null
+++ b/tailbone/forms2/__init__.py
@@ -0,0 +1,29 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+# Rattail -- Retail Software Framework
+# Copyright © 2010-2017 Lance Edgar
+#
+# This file is part of Rattail.
+#
+# Rattail 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.
+#
+# Rattail 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
+# Rattail. If not, see .
+#
+################################################################################
+"""
+Forms Library
+"""
+
+from __future__ import unicode_literals, absolute_import
+
+from .core import Form
diff --git a/tailbone/forms2/core.py b/tailbone/forms2/core.py
new file mode 100644
index 00000000..40dbc44d
--- /dev/null
+++ b/tailbone/forms2/core.py
@@ -0,0 +1,381 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+# Rattail -- Retail Software Framework
+# Copyright © 2010-2017 Lance Edgar
+#
+# This file is part of Rattail.
+#
+# Rattail 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.
+#
+# Rattail 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
+# Rattail. If not, see .
+#
+################################################################################
+"""
+Forms Core
+"""
+
+from __future__ import unicode_literals, absolute_import
+
+import logging
+
+import six
+import sqlalchemy as sa
+from sqlalchemy import orm
+from sqlalchemy.ext.associationproxy import AssociationProxy, ASSOCIATION_PROXY
+
+from rattail.util import prettify
+
+import colander
+from colanderalchemy import SQLAlchemySchemaNode
+import deform
+from deform import widget as dfwidget
+from pyramid.renderers import render
+from webhelpers2.html import tags, HTML
+
+
+log = logging.getLogger(__name__)
+
+
+class CustomSchemaNode(SQLAlchemySchemaNode):
+
+ def get_schema_from_relationship(self, prop, overrides):
+ """ Build and return a :class:`colander.SchemaNode` for a relationship.
+ """
+
+ # for some reason ColanderAlchemy wants to crawl our entire ORM by
+ # default, by way of relationships. this 'excludes' hack is used to
+ # prevent that, by forcing skip of 2nd-level relationships
+
+ excludes = []
+ if isinstance(prop, orm.RelationshipProperty):
+ for next_prop in prop.mapper.iterate_properties:
+ if isinstance(next_prop, orm.RelationshipProperty):
+ excludes.append(next_prop.key)
+
+ if excludes:
+ overrides['excludes'] = excludes
+
+ return super(CustomSchemaNode, self).get_schema_from_relationship(prop, overrides)
+
+ def dictify(self, obj):
+ """ Return a dictified version of `obj` using schema information.
+
+ .. note::
+ This method was copied from upstream and modified to add automatic
+ handling of "association proxy" fields.
+ """
+ dict_ = super(CustomSchemaNode, self).dictify(obj)
+ for node in self:
+
+ name = node.name
+ if name not in dict_:
+
+ try:
+ desc = getattr(self.inspector.all_orm_descriptors, name)
+ if desc.extension_type != ASSOCIATION_PROXY:
+ continue
+ value = getattr(obj, name)
+ except AttributeError:
+ continue
+
+ if value is None:
+ if isinstance(node.typ, colander.String):
+ # colander has an issue with `None` on a String type
+ # where it translates it into "None". Let's check
+ # for that specific case and turn it into a
+ # `colander.null`.
+ dict_[name] = colander.null
+ else:
+ # A specific case this helps is with Integer where
+ # `None` is an invalid value. We call serialize()
+ # to test if we have a value that will work later
+ # for serialization and then allow it if it doesn't
+ # raise an exception. Hopefully this also catches
+ # issues with user defined types and future issues.
+ try:
+ node.serialize(value)
+ except:
+ dict_[name] = colander.null
+ else:
+ dict_[name] = value
+ else:
+ dict_[name] = value
+
+ return dict_
+
+ def objectify(self, dict_, context=None):
+ """ Return an object representing ``dict_`` using schema information.
+
+ .. note::
+ This method was copied from upstream and modified to add automatic
+ handling of "association proxy" fields.
+ """
+ mapper = self.inspector
+ context = mapper.class_() if context is None else context
+ for attr in dict_:
+ if mapper.has_property(attr):
+ prop = mapper.get_property(attr)
+ if hasattr(prop, 'mapper'):
+ cls = prop.mapper.class_
+ if prop.uselist:
+ # Sequence of objects
+ value = [self[attr].children[0].objectify(obj)
+ for obj in dict_[attr]]
+ else:
+ # Single object
+ value = self[attr].objectify(dict_[attr])
+ else:
+ value = dict_[attr]
+ if value is colander.null:
+ # `colander.null` is never an appropriate
+ # value to be placed on an SQLAlchemy object
+ # so we translate it into `None`.
+ value = None
+ setattr(context, attr, value)
+
+ else:
+ # try to process association proxy field
+ desc = mapper.all_orm_descriptors.get(attr)
+ if desc and desc.extension_type == ASSOCIATION_PROXY:
+ value = dict_[attr]
+ if value is colander.null:
+ # `colander.null` is never an appropriate
+ # value to be placed on an SQLAlchemy object
+ # so we translate it into `None`.
+ value = None
+ setattr(context, attr, value)
+
+ else:
+ # Ignore attributes if they are not mapped
+ log.debug(
+ 'SQLAlchemySchemaNode.objectify: %s not found on '
+ '%s. This property has been ignored.',
+ attr, self
+ )
+ continue
+
+ return context
+
+
+class Form(object):
+ """
+ Base class for all forms.
+ """
+
+ def __init__(self, fields=None, schema=None, request=None, readonly=False, readonly_fields=[],
+ model_instance=None, model_class=None, labels={}, renderers={}, widgets={},
+ action_url=None, cancel_url=None):
+
+ self.fields = fields
+ self.schema = schema
+ self.request = request
+ self.readonly = readonly
+ self.readonly_fields = set(readonly_fields or [])
+ self.model_instance = model_instance
+ self.model_class = model_class
+ if self.model_instance and not self.model_class:
+ self.model_class = type(self.model_instance)
+ if self.model_class and self.fields is None:
+ self.fields = self.make_fields()
+ self.labels = labels or {}
+ self.renderers = renderers or {}
+ self.widgets = widgets or {}
+ self.action_url = action_url
+ self.cancel_url = cancel_url
+
+ def make_fields(self):
+ """
+ Return a default list of fields, based on :attr:`model_class`.
+ """
+ if not self.model_class:
+ raise ValueError("Must define model_class to use make_fields()")
+
+ mapper = orm.class_mapper(self.model_class)
+
+ # first add primary column fields
+ fields = [prop.key for prop in mapper.iterate_properties
+ if not prop.key.startswith('_')
+ and prop.key != 'versions']
+
+ # then add association proxy fields
+ for key, desc in sa.inspect(self.model_class).all_orm_descriptors.items():
+ if desc.extension_type == ASSOCIATION_PROXY:
+ fields.append(key)
+
+ return fields
+
+ def remove_field(self, key):
+ if key in self.fields:
+ self.fields.remove(key)
+
+ def make_schema(self):
+ if not self.model_class:
+ # TODO
+ raise NotImplementedError
+
+ if not self.schema:
+
+ mapper = orm.class_mapper(self.model_class)
+
+ # first filter our "full" field list so we ignore certain ones. in
+ # particular we don't want readonly fields in the schema, or any
+ # which appear to be "private"
+ includes = [f for f in self.fields
+ if f not in self.readonly_fields
+ and not f.startswith('_')
+ and f != 'versions']
+
+ # make schema - only include *property* fields at this point
+ schema = CustomSchemaNode(self.model_class,
+ includes=[p.key for p in mapper.iterate_properties
+ if p.key in includes])
+
+ # for now, must manually add any "extra" fields? this includes all
+ # association proxy fields, not sure how other fields will behave
+ for field in includes:
+ if field not in schema:
+ schema.add(colander.SchemaNode(colander.String(), name=field))
+
+ # apply any label overrides
+ for key, label in self.labels.items():
+ if key in schema:
+ schema[key].title = label
+
+ # apply any widget overrides
+ for key, widget in self.widgets.items():
+ if key in schema:
+ schema[key].widget = widget
+
+ self.schema = schema
+
+ return self.schema
+
+ def set_label(self, key, label):
+ self.labels[key] = label
+
+ def get_label(self, key):
+ return self.labels.get(key, prettify(key))
+
+ def set_readonly(self, key, readonly=True):
+ if readonly:
+ self.readonly_fields.add(key)
+ else:
+ if key in self.readonly_fields:
+ self.readonly_fields.remove(key)
+
+ def set_type(self, key, type_):
+ if type_ == 'codeblock':
+ self.set_renderer(key, self.render_codeblock)
+ self.set_widget(key, dfwidget.TextAreaWidget(cols=80, rows=8))
+
+ def set_renderer(self, key, renderer):
+ self.renderers[key] = renderer
+
+ def set_widget(self, key, widget):
+ self.widgets[key] = widget
+
+ def set_validator(self, key, validator):
+ schema = self.make_schema()
+ schema[key].validator = validator
+
+ def render(self, template=None, **kwargs):
+ if not template:
+ if self.readonly:
+ template = '/forms2/form_readonly.mako'
+ else:
+ template = '/forms2/form.mako'
+ context = kwargs
+ context['form'] = self
+ return render(template, context)
+
+ def make_deform_form(self):
+ if not hasattr(self, 'deform_form'):
+
+ schema = self.make_schema()
+
+ # get initial form values from model instance
+ kwargs = {}
+ if self.model_instance:
+ kwargs['appstruct'] = schema.dictify(self.model_instance)
+
+ # create form
+ form = deform.Form(schema, **kwargs)
+
+ # set readonly widget where applicable
+ for field in self.readonly_fields:
+ if field in form:
+ form[field].widget = ReadonlyWidget()
+
+ self.deform_form = form
+
+ return self.deform_form
+
+ def render_deform(self, dform=None, template='/forms2/deform.mako', **kwargs):
+ if dform is None:
+ dform = self.make_deform_form()
+
+ # TODO: would perhaps be nice to leverage deform's default rendering
+ # someday..? i.e. using Chameleon *.pt templates
+ # return form.render()
+
+ context = kwargs
+ context['form'] = self
+ context['dform'] = dform
+ context['request'] = self.request
+ context['readonly_fields'] = self.readonly_fields
+ context['render_field_readonly'] = self.render_field_readonly
+ return render('/forms2/deform.mako', context)
+
+ def render_field_readonly(self, field_name, **kwargs):
+ label = HTML.tag('label', self.get_label(field_name), for_=field_name)
+ field = self.render_field_value(field_name) or ''
+ field_div = HTML.tag('div', class_='field', c=field)
+ return HTML.tag('div', class_='field-wrapper {}'.format(field), c=label + field_div)
+
+ def render_field_value(self, field_name):
+ record = self.model_instance
+ if self.renderers and field_name in self.renderers:
+ return self.renderers[field_name](record, field_name)
+ return self.render_generic(record, field_name)
+
+ def render_generic(self, record, field_name):
+ value = self.obtain_value(record, field_name)
+ if value is None:
+ return ""
+ return six.text_type(value)
+
+ def render_codeblock(self, record, field_name):
+ value = self.obtain_value(record, field_name)
+ if value is None:
+ return ""
+ return HTML.tag('pre', value)
+
+ def obtain_value(self, record, field_name):
+ try:
+ return record[field_name]
+ except TypeError:
+ return getattr(record, field_name)
+
+ def validate(self, *args, **kwargs):
+ form = self.make_deform_form()
+ return form.validate(*args, **kwargs)
+
+
+class ReadonlyWidget(dfwidget.HiddenWidget):
+
+ readonly = True
+
+ def serialize(self, field, cstruct, **kw):
+ if cstruct in (colander.null, None):
+ cstruct = ''
+ return HTML.tag('span', cstruct) + tags.hidden(field.name, value=cstruct, id=field.oid)
diff --git a/tailbone/static/css/forms.css b/tailbone/static/css/forms.css
index b3cec279..59e8b8e0 100644
--- a/tailbone/static/css/forms.css
+++ b/tailbone/static/css/forms.css
@@ -54,6 +54,7 @@ div.field-wrapper.error {
}
div.field-wrapper label {
+ color: Black;
display: block;
float: left;
width: 15em;
diff --git a/tailbone/templates/forms2/deform.mako b/tailbone/templates/forms2/deform.mako
new file mode 100644
index 00000000..afc08096
--- /dev/null
+++ b/tailbone/templates/forms2/deform.mako
@@ -0,0 +1,81 @@
+## -*- coding: utf-8; -*-
+
+% if not readonly:
+<% _focus_rendered = False %>
+${h.form(form.action_url, id=dform.formid, method='post', enctype='multipart/form-data')}
+${h.csrf_token(request)}
+% endif
+
+## % for error in fieldset.errors.get(None, []):
+##
${error}
+## % endfor
+
+% for field in form.fields:
+
+ ## % if readonly or field.name in readonly_fields:
+ % if readonly:
+ ${render_field_readonly(field)|n}
+ % elif field not in dform and field in form.readonly_fields:
+ ${render_field_readonly(field)|n}
+ % elif field in dform:
+ <% field = dform[field] %>
+
+ ## % if field.requires_label:
+
+ ## % for error in field.errors:
+ ##
${error}
+ ## % endfor
+ % if field.error:
+
${field.error.msg}
+ % endif
+
${field.title}
+
+ ${field.serialize()|n}
+
+ ## % if 'instructions' in field.metadata:
+ ##
${field.metadata['instructions']}
+ ## % endif
+
+
+ ## % if not _focus_rendered and (fieldset.focus is True or fieldset.focus is field):
+ % if not readonly and not _focus_rendered:
+ ## % if not field.is_readonly() and getattr(field.renderer, 'needs_focus', True):
+ % if not field.widget.readonly:
+
+ <% _focus_rendered = True %>
+ % endif
+ % endif
+
+ ## % else:
+ ## ${field.render()|n}
+ ## % endif
+
+ % endif
+
+% endfor
+
+% if buttons:
+ ${buttons|n}
+% elif not readonly:
+
+ ## ${h.submit('create', form.create_label if form.creating else form.update_label)}
+ ${h.submit('save', "Save")}
+## % if form.creating and form.allow_successive_creates:
+## ${h.submit('create_and_continue', form.successive_create_label)}
+## % endif
+ ${h.link_to("Cancel", form.cancel_url, class_='button')}
+
+% endif
+
+% if not readonly:
+${h.end_form()}
+% endif
diff --git a/tailbone/templates/forms2/form.mako b/tailbone/templates/forms2/form.mako
new file mode 100644
index 00000000..2ee96e3c
--- /dev/null
+++ b/tailbone/templates/forms2/form.mako
@@ -0,0 +1,5 @@
+## -*- coding: utf-8; -*-
+
+
+ ${form.render_deform()|n}
+
diff --git a/tailbone/templates/forms2/form_readonly.mako b/tailbone/templates/forms2/form_readonly.mako
new file mode 100644
index 00000000..ed61a44e
--- /dev/null
+++ b/tailbone/templates/forms2/form_readonly.mako
@@ -0,0 +1,8 @@
+## -*- coding: utf-8; -*-
+
+
+ ${form.render_deform(readonly=True)|n}
+## % if buttons:
+## ${buttons|n}
+## % endif
+
diff --git a/tailbone/templates/master/view.mako b/tailbone/templates/master/view.mako
index 7a398676..32f7884a 100644
--- a/tailbone/templates/master/view.mako
+++ b/tailbone/templates/master/view.mako
@@ -1,10 +1,10 @@
-## -*- coding: utf-8 -*-
+## -*- coding: utf-8; -*-
<%inherit file="/base.mako" />
-<%def name="title()">${model_title}: ${instance_title}%def>
+<%def name="title()">${model_title_plural} » ${instance_title}%def>
-<%def name="head_tags()">
- ${parent.head_tags()}
+<%def name="extra_javascript()">
+ ${parent.extra_javascript()}
% if master.has_rows:
${h.javascript_link(request.static_url('tailbone:static/js/jquery.ui.tailbone.js'))}
+ % endif
+%def>
+
+<%def name="extra_styles()">
+ ${parent.extra_styles()}
+ % if master.has_rows: