Add colander magic for association proxy fields

hopefully now any association proxy fields which are included, will be given
the appropriate type and widget.  however this still doesn't work for the
readonly rendering of fields...
This commit is contained in:
Lance Edgar 2017-11-21 11:11:18 -06:00
parent 6ea88808b2
commit 9c205d7da5
2 changed files with 102 additions and 25 deletions

View file

@ -38,8 +38,9 @@ from rattail.time import localtime
from rattail.util import prettify, pretty_boolean, pretty_hours
import colander
from colanderalchemy import SQLAlchemySchemaNode
import deform
from colanderalchemy import SQLAlchemySchemaNode
from colanderalchemy.schema import _creation_order
from deform import widget as dfwidget
from pyramid.renderers import render
from webhelpers2.html import tags, HTML
@ -51,8 +52,90 @@ from .widgets import ReadonlyWidget, JQueryDateWidget, JQueryTimeWidget
log = logging.getLogger(__name__)
def get_association_proxy(mapper, field):
"""
Returns the association proxy corresponding to the given field name if one
exists, or ``None``.
"""
try:
desc = getattr(mapper.all_orm_descriptors, field)
except AttributeError:
pass
else:
if desc.extension_type == ASSOCIATION_PROXY:
return desc
class CustomSchemaNode(SQLAlchemySchemaNode):
def association_proxy(self, field):
"""
Returns the association proxy corresponding to the given field name if
one exists, or ``None``.
"""
return get_association_proxy(self.inspector, field)
def add_nodes(self, includes, excludes, overrides):
"""
Add all automatic nodes to the schema.
.. note::
This method was copied from upstream and modified to add automatic
handling of "association proxy" fields.
"""
if set(excludes) & set(includes):
msg = 'excludes and includes are mutually exclusive.'
raise ValueError(msg)
properties = sorted(self.inspector.attrs, key=_creation_order)
# sorted to maintain the order in which the attributes
# are defined
for name in includes or [item.key for item in properties]:
prop = self.inspector.attrs.get(name, name)
if name in excludes or (includes and name not in includes):
log.debug('Attribute %s skipped imperatively', name)
continue
name_overrides_copy = overrides.get(name, {}).copy()
if (isinstance(prop, orm.ColumnProperty)
and isinstance(prop.columns[0], sa.Column)):
node = self.get_schema_from_column(
prop,
name_overrides_copy
)
elif isinstance(prop, orm.RelationshipProperty):
if prop.mapper.class_ in self.parents_ and name not in includes:
continue
node = self.get_schema_from_relationship(
prop,
name_overrides_copy
)
elif isinstance(prop, colander.SchemaNode):
node = prop
else:
# magic for association proxy fields
proxy = self.association_proxy(name)
if proxy:
proxy_prop = self.inspector.get_property(proxy.target_collection)
if isinstance(proxy_prop, orm.RelationshipProperty):
prop = proxy_prop.mapper.get_property(name)
if isinstance(prop, orm.ColumnProperty) and isinstance(prop.columns[0], sa.Column):
node = self.get_schema_from_column(prop, name_overrides_copy)
else:
log.debug(
'Attribute %s skipped due to not being '
'a ColumnProperty or RelationshipProperty',
name
)
continue
if node is not None:
self.add(node)
def get_schema_from_relationship(self, prop, overrides):
""" Build and return a :class:`colander.SchemaNode` for a relationship.
"""
@ -84,15 +167,10 @@ class CustomSchemaNode(SQLAlchemySchemaNode):
name = node.name
if name not in dict_:
try:
desc = getattr(self.inspector.all_orm_descriptors, name)
if desc.extension_type != ASSOCIATION_PROXY:
if not self.association_proxy(name):
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
@ -149,9 +227,9 @@ class CustomSchemaNode(SQLAlchemySchemaNode):
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:
if self.association_proxy(attr):
value = dict_[attr]
if value is colander.null:
# `colander.null` is never an appropriate
@ -245,16 +323,21 @@ class Form(object):
and not f.startswith('_')
and f != 'versions']
# filter list further, to avoid magic for nodes we already have
auto_includes = list(includes)
for field in self.nodes:
if field in auto_includes:
auto_includes.remove(field)
# derive list of "auto included" fields. this is all "included"
# fields which are part of the SQLAlchemy ORM for the object
auto_includes = []
property_keys = [p.key for p in mapper.iterate_properties]
inspector = sa.inspect(self.model_class)
for field in includes:
if field in self.nodes:
continue # these are explicitly set; no magic wanted
if field in property_keys:
auto_includes.append(field)
elif get_association_proxy(inspector, field):
auto_includes.append(field)
# 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 auto_includes])
schema = CustomSchemaNode(self.model_class, includes=auto_includes)
# for now, must manually add any "extra" fields? this includes all
# association proxy fields, not sure how other fields will behave
@ -328,7 +411,7 @@ class Form(object):
self.set_renderer(key, self.render_duration)
elif type_ == 'boolean':
self.set_renderer(key, self.render_boolean)
self.set_widget(key, dfwidget.CheckboxWidget(true_val='True', false_val='False'))
self.set_widget(key, dfwidget.CheckboxWidget())
elif type_ == 'currency':
self.set_renderer(key, self.render_currency)
elif type_ == 'enum':

View file

@ -64,14 +64,8 @@ class DepartmentsView(MasterView):
super(DepartmentsView, self).configure_form(f)
f.remove_field('subdepartments')
f.remove_field('employees')
# TODO: widget should not be necessary, per type
f.set_type('product', 'boolean')
f.set_widget('product', dfwidget.CheckboxWidget())
# TODO: widget should not be necessary, per type
f.set_type('personnel', 'boolean')
f.set_widget('personnel', dfwidget.CheckboxWidget())
def template_kwargs_view(self, **kwargs):
department = kwargs['instance']