diff --git a/tailbone/forms2/core.py b/tailbone/forms2/core.py index d167f908..0702b73e 100644 --- a/tailbone/forms2/core.py +++ b/tailbone/forms2/core.py @@ -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: - continue - value = getattr(obj, name) - except AttributeError: + if not self.association_proxy(name): continue + value = getattr(obj, name) 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': diff --git a/tailbone/views/departments.py b/tailbone/views/departments.py index db1d633c..200ebc00 100644 --- a/tailbone/views/departments.py +++ b/tailbone/views/departments.py @@ -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']