Add new v3 master with v2 forms, with colander/deform
goal here is to replace FormAlchemy dependency, slowly but surely.. so far only the Settings and Stores views use v3 master
This commit is contained in:
parent
2b5aaa0753
commit
4dcd89fba7
29
tailbone/forms2/__init__.py
Normal file
29
tailbone/forms2/__init__.py
Normal file
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
################################################################################
|
||||||
|
"""
|
||||||
|
Forms Library
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import unicode_literals, absolute_import
|
||||||
|
|
||||||
|
from .core import Form
|
381
tailbone/forms2/core.py
Normal file
381
tailbone/forms2/core.py
Normal file
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
################################################################################
|
||||||
|
"""
|
||||||
|
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)
|
|
@ -54,6 +54,7 @@ div.field-wrapper.error {
|
||||||
}
|
}
|
||||||
|
|
||||||
div.field-wrapper label {
|
div.field-wrapper label {
|
||||||
|
color: Black;
|
||||||
display: block;
|
display: block;
|
||||||
float: left;
|
float: left;
|
||||||
width: 15em;
|
width: 15em;
|
||||||
|
|
81
tailbone/templates/forms2/deform.mako
Normal file
81
tailbone/templates/forms2/deform.mako
Normal file
|
@ -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, []):
|
||||||
|
## <div class="fieldset-error">${error}</div>
|
||||||
|
## % 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:
|
||||||
|
<div class="field-wrapper ${field.name} ${'error' if field.error else ''}">
|
||||||
|
## % for error in field.errors:
|
||||||
|
## <div class="field-error">${error}</div>
|
||||||
|
## % endfor
|
||||||
|
% if field.error:
|
||||||
|
<div class="field-error">${field.error.msg}</div>
|
||||||
|
% endif
|
||||||
|
<label for="${field.name}">${field.title}</label>
|
||||||
|
<div class="field">
|
||||||
|
${field.serialize()|n}
|
||||||
|
</div>
|
||||||
|
## % if 'instructions' in field.metadata:
|
||||||
|
## <span class="instructions">${field.metadata['instructions']}</span>
|
||||||
|
## % endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## % 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:
|
||||||
|
<script type="text/javascript">
|
||||||
|
$(function() {
|
||||||
|
## % if hasattr(field.renderer, 'focus_name'):
|
||||||
|
## $('#${field.renderer.focus_name}').focus();
|
||||||
|
## % else:
|
||||||
|
## $('#${field.renderer.name}').focus();
|
||||||
|
## % endif
|
||||||
|
$('#${field.oid}').focus();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<% _focus_rendered = True %>
|
||||||
|
% endif
|
||||||
|
% endif
|
||||||
|
|
||||||
|
## % else:
|
||||||
|
## ${field.render()|n}
|
||||||
|
## % endif
|
||||||
|
|
||||||
|
% endif
|
||||||
|
|
||||||
|
% endfor
|
||||||
|
|
||||||
|
% if buttons:
|
||||||
|
${buttons|n}
|
||||||
|
% elif not readonly:
|
||||||
|
<div class="buttons">
|
||||||
|
## ${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')}
|
||||||
|
</div>
|
||||||
|
% endif
|
||||||
|
|
||||||
|
% if not readonly:
|
||||||
|
${h.end_form()}
|
||||||
|
% endif
|
5
tailbone/templates/forms2/form.mako
Normal file
5
tailbone/templates/forms2/form.mako
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
## -*- coding: utf-8; -*-
|
||||||
|
|
||||||
|
<div class="form">
|
||||||
|
${form.render_deform()|n}
|
||||||
|
</div>
|
8
tailbone/templates/forms2/form_readonly.mako
Normal file
8
tailbone/templates/forms2/form_readonly.mako
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
## -*- coding: utf-8; -*-
|
||||||
|
|
||||||
|
<div class="form">
|
||||||
|
${form.render_deform(readonly=True)|n}
|
||||||
|
## % if buttons:
|
||||||
|
## ${buttons|n}
|
||||||
|
## % endif
|
||||||
|
</div><!-- form -->
|
|
@ -1,10 +1,10 @@
|
||||||
## -*- coding: utf-8 -*-
|
## -*- coding: utf-8; -*-
|
||||||
<%inherit file="/base.mako" />
|
<%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()">
|
<%def name="extra_javascript()">
|
||||||
${parent.head_tags()}
|
${parent.extra_javascript()}
|
||||||
% if master.has_rows:
|
% if master.has_rows:
|
||||||
${h.javascript_link(request.static_url('tailbone:static/js/jquery.ui.tailbone.js'))}
|
${h.javascript_link(request.static_url('tailbone:static/js/jquery.ui.tailbone.js'))}
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
|
@ -12,6 +12,12 @@
|
||||||
$('.grid-wrapper').gridwrapper();
|
$('.grid-wrapper').gridwrapper();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
% endif
|
||||||
|
</%def>
|
||||||
|
|
||||||
|
<%def name="extra_styles()">
|
||||||
|
${parent.extra_styles()}
|
||||||
|
% if master.has_rows:
|
||||||
<style type="text/css">
|
<style type="text/css">
|
||||||
.grid-wrapper {
|
.grid-wrapper {
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
|
|
|
@ -7,6 +7,8 @@
|
||||||
## ##############################################################################
|
## ##############################################################################
|
||||||
<%inherit file="/mobile/base.mako" />
|
<%inherit file="/mobile/base.mako" />
|
||||||
|
|
||||||
<%def name="title()">${instance_title}</%def>
|
<%def name="title()">${index_title} » ${instance_title}</%def>
|
||||||
|
|
||||||
|
<%def name="page_title()">${h.link_to(index_title, index_url)} » ${instance_title}</%def>
|
||||||
|
|
||||||
${form.render()|n}
|
${form.render()|n}
|
||||||
|
|
|
@ -1,8 +0,0 @@
|
||||||
## -*- coding: utf-8; -*-
|
|
||||||
<%inherit file="/mobile/base.mako" />
|
|
||||||
|
|
||||||
<%def name="title()">Receiving</%def>
|
|
||||||
|
|
||||||
${h.link_to("New Receiving Batch", url('purchases.batch.mobile_create'), class_='ui-btn')}
|
|
||||||
|
|
||||||
${h.link_to("View Receiving Batches", url('mobile.purchases.batch'), class_='ui-btn')}
|
|
|
@ -1,7 +1,9 @@
|
||||||
## -*- coding: utf-8; -*-
|
## -*- coding: utf-8; -*-
|
||||||
<%inherit file="/mobile/base.mako" />
|
<%inherit file="/mobile/base.mako" />
|
||||||
|
|
||||||
<%def name="title()">${h.link_to("Receiving", url('mobile.receiving'))} » New Batch</%def>
|
<%def name="title()">Receiving » New Batch</%def>
|
||||||
|
|
||||||
|
<%def name="page_title()">${h.link_to("Receiving", url('mobile.receiving'))} » New Batch</%def>
|
||||||
|
|
||||||
${h.form(request.current_route_url(), class_='ui-filterable', name='new-purchasing-batch')}
|
${h.form(request.current_route_url(), class_='ui-filterable', name='new-purchasing-batch')}
|
||||||
${h.csrf_token(request)}
|
${h.csrf_token(request)}
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
## -*- coding: utf-8; -*-
|
## -*- coding: utf-8; -*-
|
||||||
<%inherit file="/mobile/master/view.mako" />
|
<%inherit file="/mobile/master/view.mako" />
|
||||||
|
|
||||||
<%def name="title()">${h.link_to("Receiving", url('mobile.receiving'))} » ${instance.id_str}</%def>
|
<%def name="title()">Receiving » ${instance.id_str}</%def>
|
||||||
|
|
||||||
|
<%def name="page_title()">${h.link_to("Receiving", url('mobile.receiving'))} » ${instance.id_str}</%def>
|
||||||
|
|
||||||
${form.render()|n}
|
${form.render()|n}
|
||||||
<br />
|
<br />
|
||||||
|
|
|
@ -2,8 +2,9 @@
|
||||||
<%inherit file="/mobile/master/view_row.mako" />
|
<%inherit file="/mobile/master/view_row.mako" />
|
||||||
<%namespace file="/mobile/keypad.mako" import="keypad" />
|
<%namespace file="/mobile/keypad.mako" import="keypad" />
|
||||||
|
|
||||||
## TODO: this is broken for actual page (header) title
|
<%def name="title()">Receiving » ${instance.batch.id_str} » ${row.upc.pretty()}</%def>
|
||||||
<%def name="title()">${h.link_to("Receiving", url('mobile.receiving'))} » ${h.link_to(instance.batch.id_str, url('mobile.receiving.view', uuid=instance.batch_uuid))} » ${row.upc.pretty()}</%def>
|
|
||||||
|
<%def name="page_title()">${h.link_to("Receiving", url('mobile.receiving'))} » ${h.link_to(instance.batch.id_str, url('mobile.receiving.view', uuid=instance.batch_uuid))} » ${row.upc.pretty()}</%def>
|
||||||
|
|
||||||
<%
|
<%
|
||||||
unit_uom = 'LB' if row.product and row.product.weighed else 'EA'
|
unit_uom = 'LB' if row.product and row.product.weighed else 'EA'
|
||||||
|
|
|
@ -29,6 +29,7 @@ from __future__ import unicode_literals, absolute_import
|
||||||
from .core import View
|
from .core import View
|
||||||
from .master import MasterView
|
from .master import MasterView
|
||||||
from .master2 import MasterView2
|
from .master2 import MasterView2
|
||||||
|
from .master3 import MasterView3
|
||||||
|
|
||||||
# TODO: deprecate / remove some of this
|
# TODO: deprecate / remove some of this
|
||||||
from .autocomplete import AutocompleteView
|
from .autocomplete import AutocompleteView
|
||||||
|
|
|
@ -225,7 +225,7 @@ class MasterView(View):
|
||||||
self.creating = True
|
self.creating = True
|
||||||
form = self.make_form(self.get_model_class())
|
form = self.make_form(self.get_model_class())
|
||||||
if self.request.method == 'POST':
|
if self.request.method == 'POST':
|
||||||
if form.validate():
|
if self.validate_form(form):
|
||||||
# let save_create_form() return alternate object if necessary
|
# let save_create_form() return alternate object if necessary
|
||||||
obj = self.save_create_form(form) or form.fieldset.model
|
obj = self.save_create_form(form) or form.fieldset.model
|
||||||
self.after_create(obj)
|
self.after_create(obj)
|
||||||
|
@ -615,6 +615,7 @@ class MasterView(View):
|
||||||
"""
|
"""
|
||||||
self.editing = True
|
self.editing = True
|
||||||
instance = self.get_instance()
|
instance = self.get_instance()
|
||||||
|
instance_title = self.get_instance_title(instance)
|
||||||
|
|
||||||
if not self.editable_instance(instance):
|
if not self.editable_instance(instance):
|
||||||
self.request.session.flash("Edit is not permitted for {}: {}".format(
|
self.request.session.flash("Edit is not permitted for {}: {}".format(
|
||||||
|
@ -624,18 +625,22 @@ class MasterView(View):
|
||||||
form = self.make_form(instance)
|
form = self.make_form(instance)
|
||||||
|
|
||||||
if self.request.method == 'POST':
|
if self.request.method == 'POST':
|
||||||
if form.validate():
|
if self.validate_form(form):
|
||||||
self.save_edit_form(form)
|
self.save_edit_form(form)
|
||||||
|
# note we must fetch new instance title, in case it changed
|
||||||
self.request.session.flash("{} has been updated: {}".format(
|
self.request.session.flash("{} has been updated: {}".format(
|
||||||
self.get_model_title(), self.get_instance_title(instance)))
|
self.get_model_title(), self.get_instance_title(instance)))
|
||||||
return self.redirect_after_edit(instance)
|
return self.redirect_after_edit(instance)
|
||||||
|
|
||||||
return self.render_to_response('edit', {
|
return self.render_to_response('edit', {
|
||||||
'instance': instance,
|
'instance': instance,
|
||||||
'instance_title': self.get_instance_title(instance),
|
'instance_title': instance_title,
|
||||||
'instance_deletable': self.deletable_instance(instance),
|
'instance_deletable': self.deletable_instance(instance),
|
||||||
'form': form})
|
'form': form})
|
||||||
|
|
||||||
|
def validate_form(self, form):
|
||||||
|
return form.validate()
|
||||||
|
|
||||||
def save_edit_form(self, form):
|
def save_edit_form(self, form):
|
||||||
self.save_form(form)
|
self.save_form(form)
|
||||||
self.after_edit(form.fieldset.model)
|
self.after_edit(form.fieldset.model)
|
||||||
|
|
|
@ -39,6 +39,7 @@ class MasterView2(MasterView):
|
||||||
sortable = True
|
sortable = True
|
||||||
rows_pageable = True
|
rows_pageable = True
|
||||||
mobile_pageable = True
|
mobile_pageable = True
|
||||||
|
labels = {'uuid': "UUID"}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_grid_factory(cls):
|
def get_grid_factory(cls):
|
||||||
|
@ -391,8 +392,12 @@ class MasterView2(MasterView):
|
||||||
the given row object, or ``None``.
|
the given row object, or ``None``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
def set_labels(self, obj):
|
||||||
|
for key, label in self.labels.items():
|
||||||
|
obj.set_label(key, label)
|
||||||
|
|
||||||
def configure_grid(self, grid):
|
def configure_grid(self, grid):
|
||||||
pass
|
self.set_labels(grid)
|
||||||
|
|
||||||
def configure_row_grid(self, grid):
|
def configure_row_grid(self, grid):
|
||||||
pass
|
pass
|
||||||
|
|
134
tailbone/views/master3.py
Normal file
134
tailbone/views/master3.py
Normal file
|
@ -0,0 +1,134 @@
|
||||||
|
# -*- 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 <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
################################################################################
|
||||||
|
"""
|
||||||
|
Master View
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import unicode_literals, absolute_import
|
||||||
|
|
||||||
|
from sqlalchemy import orm
|
||||||
|
|
||||||
|
import deform
|
||||||
|
|
||||||
|
from tailbone import forms2 as forms
|
||||||
|
from tailbone.views import MasterView2
|
||||||
|
|
||||||
|
|
||||||
|
class MasterView3(MasterView2):
|
||||||
|
"""
|
||||||
|
Base "master" view class. All model master views should derive from this.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_form_factory(cls):
|
||||||
|
"""
|
||||||
|
Returns the grid factory or class which is to be used when creating new
|
||||||
|
grid instances.
|
||||||
|
"""
|
||||||
|
return getattr(cls, 'form_factory', forms.Form)
|
||||||
|
|
||||||
|
def make_form(self, instance=None, factory=None, fields=None, schema=None, **kwargs):
|
||||||
|
"""
|
||||||
|
Creates a new form for the given model class/instance
|
||||||
|
"""
|
||||||
|
if factory is None:
|
||||||
|
factory = self.get_form_factory()
|
||||||
|
if fields is None:
|
||||||
|
fields = self.get_form_fields()
|
||||||
|
if schema is None:
|
||||||
|
schema = self.make_form_schema()
|
||||||
|
|
||||||
|
if not self.creating:
|
||||||
|
kwargs['model_instance'] = instance
|
||||||
|
kwargs = self.make_form_kwargs(**kwargs)
|
||||||
|
form = factory(fields, schema, **kwargs)
|
||||||
|
self.configure_form(form)
|
||||||
|
return form
|
||||||
|
|
||||||
|
def make_form_schema(self):
|
||||||
|
if not self.model_class:
|
||||||
|
# TODO
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def get_form_fields(self):
|
||||||
|
if hasattr(self, 'form_fields'):
|
||||||
|
return self.form_fields
|
||||||
|
# TODO
|
||||||
|
# raise NotImplementedError
|
||||||
|
|
||||||
|
def make_form_kwargs(self, **kwargs):
|
||||||
|
"""
|
||||||
|
Return a dictionary of kwargs to be passed to the factory when creating
|
||||||
|
new form instances.
|
||||||
|
"""
|
||||||
|
defaults = {
|
||||||
|
'request': self.request,
|
||||||
|
'readonly': self.viewing,
|
||||||
|
'model_class': getattr(self, 'model_class', None),
|
||||||
|
'action_url': self.request.current_route_url(_query=None),
|
||||||
|
}
|
||||||
|
if self.creating:
|
||||||
|
kwargs.setdefault('cancel_url', self.get_index_url())
|
||||||
|
else:
|
||||||
|
instance = kwargs['model_instance']
|
||||||
|
kwargs.setdefault('cancel_url', self.get_action_url('view', instance))
|
||||||
|
defaults.update(kwargs)
|
||||||
|
return defaults
|
||||||
|
|
||||||
|
def configure_form(self, form):
|
||||||
|
"""
|
||||||
|
Configure the primary form. By default this just sets any primary key
|
||||||
|
fields to be readonly (if we have a :attr:`model_class`).
|
||||||
|
"""
|
||||||
|
|
||||||
|
if self.editing and self.model_class:
|
||||||
|
mapper = orm.class_mapper(self.model_class)
|
||||||
|
for key in mapper.primary_key:
|
||||||
|
for field in form.fields:
|
||||||
|
if field == key.name:
|
||||||
|
form.set_readonly(field)
|
||||||
|
break
|
||||||
|
|
||||||
|
form.remove_field('uuid')
|
||||||
|
|
||||||
|
self.set_labels(form)
|
||||||
|
|
||||||
|
def validate_form(self, form):
|
||||||
|
controls = self.request.POST.items()
|
||||||
|
try:
|
||||||
|
self.form_deserialized = form.validate(controls)
|
||||||
|
except deform.ValidationFailure:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def save_create_form(self, form):
|
||||||
|
self.before_create(form)
|
||||||
|
obj = form.schema.objectify(self.form_deserialized)
|
||||||
|
self.Session.add(obj)
|
||||||
|
self.Session.flush()
|
||||||
|
return obj
|
||||||
|
|
||||||
|
def save_edit_form(self, form):
|
||||||
|
obj = form.schema.objectify(self.form_deserialized, context=form.model_instance)
|
||||||
|
self.after_edit(obj)
|
||||||
|
self.Session.flush()
|
|
@ -30,16 +30,9 @@ import re
|
||||||
|
|
||||||
from rattail.db import model
|
from rattail.db import model
|
||||||
|
|
||||||
import formalchemy as fa
|
import colander
|
||||||
|
|
||||||
from tailbone.db import Session
|
from tailbone.views import MasterView3 as MasterView
|
||||||
from tailbone.views import MasterView2 as MasterView
|
|
||||||
|
|
||||||
|
|
||||||
def unique_name(value, field):
|
|
||||||
setting = Session.query(model.Setting).get(value)
|
|
||||||
if setting:
|
|
||||||
raise fa.ValidationError("Setting name must be unique")
|
|
||||||
|
|
||||||
|
|
||||||
class SettingsView(MasterView):
|
class SettingsView(MasterView):
|
||||||
|
@ -48,27 +41,28 @@ class SettingsView(MasterView):
|
||||||
"""
|
"""
|
||||||
model_class = model.Setting
|
model_class = model.Setting
|
||||||
feedback = re.compile(r'^rattail\.mail\.user_feedback\..*')
|
feedback = re.compile(r'^rattail\.mail\.user_feedback\..*')
|
||||||
|
|
||||||
grid_columns = [
|
grid_columns = [
|
||||||
'name',
|
'name',
|
||||||
'value',
|
'value',
|
||||||
]
|
]
|
||||||
|
|
||||||
def configure_grid(self, g):
|
def configure_grid(self, g):
|
||||||
|
super(SettingsView, self).configure_grid(g)
|
||||||
g.filters['name'].default_active = True
|
g.filters['name'].default_active = True
|
||||||
g.filters['name'].default_verb = 'contains'
|
g.filters['name'].default_verb = 'contains'
|
||||||
g.default_sortkey = 'name'
|
g.default_sortkey = 'name'
|
||||||
g.set_link('name')
|
g.set_link('name')
|
||||||
|
|
||||||
def _preconfigure_fieldset(self, fs):
|
def configure_form(self, f):
|
||||||
fs.name.set(validate=unique_name)
|
super(SettingsView, self).configure_form(f)
|
||||||
if self.editing:
|
if self.creating:
|
||||||
fs.name.set(readonly=True)
|
f.set_validator('name', self.unique_name)
|
||||||
|
|
||||||
def configure_fieldset(self, fs):
|
def unique_name(self, node, value):
|
||||||
fs.configure(include=[
|
setting = self.Session.query(model.Setting).get(value)
|
||||||
fs.name,
|
if setting:
|
||||||
fs.value,
|
raise colander.Invalid(node, "Setting name must be unique")
|
||||||
])
|
|
||||||
|
|
||||||
def editable_instance(self, setting):
|
def editable_instance(self, setting):
|
||||||
if self.rattail_config.demo():
|
if self.rattail_config.demo():
|
||||||
|
|
|
@ -30,7 +30,9 @@ import sqlalchemy as sa
|
||||||
|
|
||||||
from rattail.db import model
|
from rattail.db import model
|
||||||
|
|
||||||
from tailbone.views import MasterView2 as MasterView
|
import colander
|
||||||
|
|
||||||
|
from tailbone.views import MasterView3 as MasterView
|
||||||
|
|
||||||
|
|
||||||
class StoresView(MasterView):
|
class StoresView(MasterView):
|
||||||
|
@ -39,6 +41,7 @@ class StoresView(MasterView):
|
||||||
"""
|
"""
|
||||||
model_class = model.Store
|
model_class = model.Store
|
||||||
has_versions = True
|
has_versions = True
|
||||||
|
|
||||||
grid_columns = [
|
grid_columns = [
|
||||||
'id',
|
'id',
|
||||||
'name',
|
'name',
|
||||||
|
@ -46,40 +49,47 @@ class StoresView(MasterView):
|
||||||
'email',
|
'email',
|
||||||
]
|
]
|
||||||
|
|
||||||
def configure_grid(self, g):
|
labels = {
|
||||||
|
'id': "ID",
|
||||||
|
'phone': "Phone Number",
|
||||||
|
'email': "Email Address",
|
||||||
|
}
|
||||||
|
|
||||||
g.joiners['email'] = lambda q: q.outerjoin(model.StoreEmailAddress, sa.and_(
|
def configure_grid(self, g):
|
||||||
|
super(StoresView, self).configure_grid(g)
|
||||||
|
|
||||||
|
g.set_joiner('email', lambda q: q.outerjoin(model.StoreEmailAddress, sa.and_(
|
||||||
model.StoreEmailAddress.parent_uuid == model.Store.uuid,
|
model.StoreEmailAddress.parent_uuid == model.Store.uuid,
|
||||||
model.StoreEmailAddress.preference == 1))
|
model.StoreEmailAddress.preference == 1)))
|
||||||
g.joiners['phone'] = lambda q: q.outerjoin(model.StorePhoneNumber, sa.and_(
|
g.set_joiner('phone', lambda q: q.outerjoin(model.StorePhoneNumber, sa.and_(
|
||||||
model.StorePhoneNumber.parent_uuid == model.Store.uuid,
|
model.StorePhoneNumber.parent_uuid == model.Store.uuid,
|
||||||
model.StorePhoneNumber.preference == 1))
|
model.StorePhoneNumber.preference == 1)))
|
||||||
|
|
||||||
g.filters['phone'] = g.make_filter('phone', model.StorePhoneNumber.number)
|
g.filters['phone'] = g.make_filter('phone', model.StorePhoneNumber.number)
|
||||||
g.filters['email'] = g.make_filter('email', model.StoreEmailAddress.address)
|
g.filters['email'] = g.make_filter('email', model.StoreEmailAddress.address)
|
||||||
g.filters['name'].default_active = True
|
g.filters['name'].default_active = True
|
||||||
g.filters['name'].default_verb = 'contains'
|
g.filters['name'].default_verb = 'contains'
|
||||||
|
|
||||||
g.sorters['phone'] = g.make_sorter(model.StorePhoneNumber.number)
|
g.set_sorter('phone', model.StorePhoneNumber.number)
|
||||||
g.sorters['email'] = g.make_sorter(model.StoreEmailAddress.address)
|
g.set_sorter('email', model.StoreEmailAddress.address)
|
||||||
g.default_sortkey = 'id'
|
g.default_sortkey = 'id'
|
||||||
|
|
||||||
g.set_link('id')
|
g.set_link('id')
|
||||||
g.set_link('name')
|
g.set_link('name')
|
||||||
|
|
||||||
g.set_label('id', "ID")
|
def configure_form(self, f):
|
||||||
g.set_label('phone', "Phone Number")
|
super(StoresView, self).configure_form(f)
|
||||||
g.set_label('email', "Email Address")
|
|
||||||
|
|
||||||
def configure_fieldset(self, fs):
|
f.remove_field('employees')
|
||||||
fs.configure(
|
f.remove_field('phones')
|
||||||
include=[
|
f.remove_field('emails')
|
||||||
fs.id.label("ID"),
|
|
||||||
fs.name,
|
if self.creating:
|
||||||
fs.database_key,
|
f.remove_field('phone')
|
||||||
fs.phone.label("Phone Number").readonly(),
|
f.remove_field('email')
|
||||||
fs.email.label("Email Address").readonly(),
|
else:
|
||||||
])
|
f.set_readonly('phone')
|
||||||
|
f.set_readonly('email')
|
||||||
|
|
||||||
def get_version_child_classes(self):
|
def get_version_child_classes(self):
|
||||||
return [
|
return [
|
||||||
|
|
Loading…
Reference in a new issue