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:
Lance Edgar 2017-07-18 12:29:05 -05:00
parent 2b5aaa0753
commit 4dcd89fba7
18 changed files with 718 additions and 59 deletions

View 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
View 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)

View file

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

View 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

View file

@ -0,0 +1,5 @@
## -*- coding: utf-8; -*-
<div class="form">
${form.render_deform()|n}
</div>

View file

@ -0,0 +1,8 @@
## -*- coding: utf-8; -*-
<div class="form">
${form.render_deform(readonly=True)|n}
## % if buttons:
## ${buttons|n}
## % endif
</div><!-- form -->

View file

@ -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} &raquo; ${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;

View file

@ -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} &raquo; ${instance_title}</%def>
<%def name="page_title()">${h.link_to(index_title, index_url)} &raquo; ${instance_title}</%def>
${form.render()|n} ${form.render()|n}

View file

@ -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')}

View file

@ -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'))} &raquo; New Batch</%def> <%def name="title()">Receiving &raquo; New Batch</%def>
<%def name="page_title()">${h.link_to("Receiving", url('mobile.receiving'))} &raquo; 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)}

View file

@ -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'))} &raquo; ${instance.id_str}</%def> <%def name="title()">Receiving &raquo; ${instance.id_str}</%def>
<%def name="page_title()">${h.link_to("Receiving", url('mobile.receiving'))} &raquo; ${instance.id_str}</%def>
${form.render()|n} ${form.render()|n}
<br /> <br />

View file

@ -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 &raquo; ${instance.batch.id_str} &raquo; ${row.upc.pretty()}</%def>
<%def name="title()">${h.link_to("Receiving", url('mobile.receiving'))} &raquo; ${h.link_to(instance.batch.id_str, url('mobile.receiving.view', uuid=instance.batch_uuid))} &raquo; ${row.upc.pretty()}</%def>
<%def name="page_title()">${h.link_to("Receiving", url('mobile.receiving'))} &raquo; ${h.link_to(instance.batch.id_str, url('mobile.receiving.view', uuid=instance.batch_uuid))} &raquo; ${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'

View file

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

View file

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

View file

@ -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
View 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()

View file

@ -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():

View file

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