From 578e4bde2a64e55e93b07ffdc75e56ce9be65be8 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 19 Jul 2013 15:04:03 -0700 Subject: [PATCH] Stole grids and grid-based views from `edbob`. --- .../{renderers.py => renderers/__init__.py} | 8 +- rattail/pyramid/forms/renderers/common.py | 54 ++++ rattail/pyramid/forms/renderers/products.py | 44 ++++ rattail/pyramid/forms/renderers/users.py | 44 ++++ rattail/pyramid/grids/__init__.py | 5 +- rattail/pyramid/grids/alchemy.py | 125 +++++++++ rattail/pyramid/grids/core.py | 138 ++++++++++ rattail/pyramid/grids/search.py | 248 +++++++++++++++++- rattail/pyramid/grids/util.py | 138 ++++++++++ rattail/pyramid/views/__init__.py | 9 + rattail/pyramid/views/employees.py | 2 +- rattail/pyramid/views/grids/__init__.py | 30 +++ rattail/pyramid/views/grids/alchemy.py | 184 +++++++++++++ rattail/pyramid/views/grids/core.py | 70 +++++ 14 files changed, 1093 insertions(+), 6 deletions(-) rename rattail/pyramid/forms/{renderers.py => renderers/__init__.py} (93%) create mode 100644 rattail/pyramid/forms/renderers/common.py create mode 100644 rattail/pyramid/forms/renderers/products.py create mode 100644 rattail/pyramid/forms/renderers/users.py create mode 100644 rattail/pyramid/grids/alchemy.py create mode 100644 rattail/pyramid/grids/core.py create mode 100644 rattail/pyramid/grids/util.py create mode 100644 rattail/pyramid/views/grids/__init__.py create mode 100644 rattail/pyramid/views/grids/alchemy.py create mode 100644 rattail/pyramid/views/grids/core.py diff --git a/rattail/pyramid/forms/renderers.py b/rattail/pyramid/forms/renderers/__init__.py similarity index 93% rename from rattail/pyramid/forms/renderers.py rename to rattail/pyramid/forms/renderers/__init__.py index c444e0a9..b1cd38dc 100644 --- a/rattail/pyramid/forms/renderers.py +++ b/rattail/pyramid/forms/renderers/__init__.py @@ -33,15 +33,19 @@ import formalchemy from edbob.pyramid.forms import pretty_datetime from edbob.pyramid.forms.formalchemy.renderers import ( - AutocompleteFieldRenderer, EnumFieldRenderer, YesNoFieldRenderer) + AutocompleteFieldRenderer, YesNoFieldRenderer) import rattail from rattail.gpc import GPC +from .common import EnumFieldRenderer +from .products import ProductFieldRenderer +from .users import UserFieldRenderer + __all__ = ['AutocompleteFieldRenderer', 'EnumFieldRenderer', 'YesNoFieldRenderer', 'GPCFieldRenderer', 'PersonFieldRenderer', 'PriceFieldRenderer', - 'PriceWithExpirationFieldRenderer'] + 'PriceWithExpirationFieldRenderer', 'ProductFieldRenderer', 'UserFieldRenderer'] class GPCFieldRenderer(formalchemy.TextFieldRenderer): diff --git a/rattail/pyramid/forms/renderers/common.py b/rattail/pyramid/forms/renderers/common.py new file mode 100644 index 00000000..da945b51 --- /dev/null +++ b/rattail/pyramid/forms/renderers/common.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2012 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 Affero 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 Affero General Public License for +# more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Rattail. If not, see . +# +################################################################################ + +""" +``rattail.pyramid.forms.renderers.common`` -- Common Field Renderers +""" + +from formalchemy.fields import SelectFieldRenderer + + +__all__ = ['EnumFieldRenderer'] + + +def EnumFieldRenderer(enum): + """ + Adds support for enumeration fields. + """ + + class Renderer(SelectFieldRenderer): + + def render_readonly(self, **kwargs): + value = self.raw_value + if value is None: + return '' + if value in enum: + return enum[value] + return str(value) + + def render(self, **kwargs): + opts = [(enum[x], x) for x in sorted(enum)] + return SelectFieldRenderer.render(self, opts, **kwargs) + + return Renderer diff --git a/rattail/pyramid/forms/renderers/products.py b/rattail/pyramid/forms/renderers/products.py new file mode 100644 index 00000000..d17f5177 --- /dev/null +++ b/rattail/pyramid/forms/renderers/products.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2012 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 Affero 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 Affero General Public License for +# more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Rattail. If not, see . +# +################################################################################ + +""" +``rattail.pyramid.forms.renderers.products`` -- Product Field Renderers +""" + +from formalchemy.fields import TextFieldRenderer + + +__all__ = ['ProductFieldRenderer'] + + +class ProductFieldRenderer(TextFieldRenderer): + """ + Renderer for fields which represent ``Product`` instances. + """ + + def render_readonly(self, **kwargs): + product = self.raw_value + if product is None: + return '' + return product.full_description diff --git a/rattail/pyramid/forms/renderers/users.py b/rattail/pyramid/forms/renderers/users.py new file mode 100644 index 00000000..da4603e9 --- /dev/null +++ b/rattail/pyramid/forms/renderers/users.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2012 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 Affero 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 Affero General Public License for +# more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Rattail. If not, see . +# +################################################################################ + +""" +``rattail.pyramid.forms.renderers.users`` -- User Field Renderers +""" + +from formalchemy.fields import TextFieldRenderer + + +__all__ = ['UserFieldRenderer'] + + +class UserFieldRenderer(TextFieldRenderer): + """ + Renderer for fields which represent ``User`` instances. + """ + + def render_readonly(self, **kwargs): + user = self.raw_value + if user is None: + return u'' + return unicode(user.display_name) diff --git a/rattail/pyramid/grids/__init__.py b/rattail/pyramid/grids/__init__.py index 38bdc8e2..9c936f2b 100644 --- a/rattail/pyramid/grids/__init__.py +++ b/rattail/pyramid/grids/__init__.py @@ -26,4 +26,7 @@ ``rattail.pyramid.grids`` -- Grids """ -from rattail.pyramid.grids.search import * +from rattail.pyramid.grids.core import * +from rattail.pyramid.grids.alchemy import * +from rattail.pyramid.grids import util +from rattail.pyramid.grids import search diff --git a/rattail/pyramid/grids/alchemy.py b/rattail/pyramid/grids/alchemy.py new file mode 100644 index 00000000..d6d69bd3 --- /dev/null +++ b/rattail/pyramid/grids/alchemy.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2012 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 Affero 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 Affero General Public License for +# more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Rattail. If not, see . +# +################################################################################ + +""" +``rattail.pyramid.grids.alchemy`` -- FormAlchemy Grid Classes +""" + +from webhelpers.html import tags +from webhelpers.html import HTML + +import formalchemy + +import edbob +from edbob.util import prettify + +from rattail.pyramid.grids.core import Grid +from rattail.pyramid import Session + + +__all__ = ['AlchemyGrid'] + + +class AlchemyGrid(Grid): + + sort_map = {} + + pager = None + pager_format = '$link_first $link_previous ~1~ $link_next $link_last' + + def __init__(self, request, cls, instances, **kwargs): + super(AlchemyGrid, self).__init__(request, **kwargs) + self._formalchemy_grid = formalchemy.Grid( + cls, instances, session=Session(), request=request) + self._formalchemy_grid.prettify = prettify + self.noclick_fields = [] + + def __delattr__(self, attr): + delattr(self._formalchemy_grid, attr) + + def __getattr__(self, attr): + return getattr(self._formalchemy_grid, attr) + + def cell_class(self, field): + classes = [field.name] + if field.name in self.noclick_fields: + classes.append('noclick') + return ' '.join(classes) + + def checkbox(self, row): + return tags.checkbox('check-'+row.uuid) + + def click_route_kwargs(self, row): + return {'uuid': row.uuid} + + def column_header(self, field): + class_ = None + label = field.label() + if field.key in self.sort_map: + class_ = 'sortable' + if field.key == self.config['sort']: + class_ += ' sorted ' + self.config['dir'] + label = tags.link_to(label, '#') + return HTML.tag('th', class_=class_, field=field.key, + title=self.column_titles.get(field.key), c=label) + + def edit_route_kwargs(self, row): + return {'uuid': row.uuid} + + def delete_route_kwargs(self, row): + return {'uuid': row.uuid} + + def iter_fields(self): + return self._formalchemy_grid.render_fields.itervalues() + + def iter_rows(self): + for row in self._formalchemy_grid.rows: + self._formalchemy_grid._set_active(row) + yield row + + def page_count_options(self): + options = edbob.config.get('edbob.pyramid', 'grid.page_count_options') + if options: + options = options.split(',') + options = [int(x.strip()) for x in options] + else: + options = [5, 10, 20, 50, 100] + return options + + def page_links(self): + return self.pager.pager(self.pager_format, + symbol_next='next', + symbol_previous='prev', + onclick="grid_navigate_page(this, '$partial_url'); return false;") + + def render_field(self, field): + if self._formalchemy_grid.readonly: + return field.render_readonly() + return field.render() + + def row_attrs(self, row, i): + attrs = super(AlchemyGrid, self).row_attrs(row, i) + if hasattr(row, 'uuid'): + attrs['uuid'] = row.uuid + return attrs diff --git a/rattail/pyramid/grids/core.py b/rattail/pyramid/grids/core.py new file mode 100644 index 00000000..d1e05a49 --- /dev/null +++ b/rattail/pyramid/grids/core.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2012 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 Affero 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 Affero General Public License for +# more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Rattail. If not, see . +# +################################################################################ + +""" +``rattail.pyramid.grids.core`` -- Core Grid Classes +""" + +try: + from collections import OrderedDict +except ImportError: + from ordereddict import OrderedDict + +from webhelpers.html import HTML +from webhelpers.html.builder import format_attrs + +from pyramid.renderers import render + +from edbob.core import Object + + +__all__ = ['Grid'] + + +class Grid(Object): + + full = False + hoverable = True + clickable = False + checkboxes = False + editable = False + deletable = False + + partial_only = False + + click_route_name = None + click_route_kwargs = None + + edit_route_name = None + edit_route_kwargs = None + + delete_route_name = None + delete_route_kwargs = None + + def __init__(self, request, **kwargs): + kwargs.setdefault('fields', OrderedDict()) + kwargs.setdefault('column_titles', {}) + kwargs.setdefault('extra_columns', []) + super(Grid, self).__init__(**kwargs) + self.request = request + + def add_column(self, name, label, callback): + self.extra_columns.append( + Object(name=name, label=label, callback=callback)) + + def column_header(self, field): + return HTML.tag('th', field=field.name, + title=self.column_titles.get(field.name), + c=field.label) + + def div_attrs(self): + classes = ['grid'] + if self.full: + classes.append('full') + if self.clickable: + classes.append('clickable') + if self.hoverable: + classes.append('hoverable') + return format_attrs( + class_=' '.join(classes), + url=self.request.current_route_url()) + + def get_delete_url(self, row): + kwargs = {} + if self.delete_route_kwargs: + if callable(self.delete_route_kwargs): + kwargs = self.delete_route_kwargs(row) + else: + kwargs = self.delete_route_kwargs + return self.request.route_url(self.delete_route_name, **kwargs) + + def get_edit_url(self, row): + kwargs = {} + if self.edit_route_kwargs: + if callable(self.edit_route_kwargs): + kwargs = self.edit_route_kwargs(row) + else: + kwargs = self.edit_route_kwargs + return self.request.route_url(self.edit_route_name, **kwargs) + + def get_row_attrs(self, row, i): + attrs = self.row_attrs(row, i) + if self.clickable: + kwargs = {} + if self.click_route_kwargs: + if callable(self.click_route_kwargs): + kwargs = self.click_route_kwargs(row) + else: + kwargs = self.click_route_kwargs + attrs['url'] = self.request.route_url(self.click_route_name, **kwargs) + return format_attrs(**attrs) + + def iter_fields(self): + return self.fields.itervalues() + + def iter_rows(self): + raise NotImplementedError + + def render(self, template='/grids/grid.mako', **kwargs): + kwargs.setdefault('grid', self) + return render(template, kwargs) + + def render_field(self, field): + raise NotImplementedError + + def row_attrs(self, row, i): + attrs = {'class_': 'odd' if i % 2 else 'even'} + return attrs diff --git a/rattail/pyramid/grids/search.py b/rattail/pyramid/grids/search.py index bb0513ff..359096f5 100644 --- a/rattail/pyramid/grids/search.py +++ b/rattail/pyramid/grids/search.py @@ -26,9 +26,57 @@ ``rattail.pyramid.grids.search`` -- Grid Search Filters """ -from webhelpers.html import tags +from sqlalchemy import or_ -from edbob.pyramid.grids.search import SearchFilter +from webhelpers.html import tags +from webhelpers.html import literal + +from pyramid.renderers import render +from pyramid_simpleform import Form +from pyramid_simpleform.renderers import FormRenderer + +from edbob.core import Object +from edbob.util import prettify + + +class SearchFilter(Object): + """ + Base class and default implementation for search filters. + """ + + def __init__(self, name, label=None, **kwargs): + Object.__init__(self, **kwargs) + self.name = name + self.label = label or prettify(name) + + def types_select(self): + types = [ + ('is', "is"), + ('nt', "is not"), + ('lk', "contains"), + ('nl', "doesn't contain"), + ] + options = [] + filter_map = self.search.filter_map[self.name] + for value, label in types: + if value in filter_map: + options.append((value, label)) + return tags.select('filter_type_'+self.name, + self.search.config.get('filter_type_'+self.name), + options, class_='filter-type') + + def value_control(self): + return tags.text(self.name, self.search.config.get(self.name)) + + +class BooleanSearchFilter(SearchFilter): + """ + Boolean search filter. + """ + + def value_control(self): + return tags.select(self.name, self.search.config.get(self.name), + ["True", "False"]) def EnumSearchFilter(enum): @@ -39,3 +87,199 @@ def EnumSearchFilter(enum): return tags.select(self.name, self.search.config.get(self.name), options) return EnumSearchFilter + + +class SearchForm(Form): + """ + Generic form class which aggregates :class:`SearchFilter` instances. + """ + + def __init__(self, request, filter_map, config, *args, **kwargs): + super(SearchForm, self).__init__(request, *args, **kwargs) + self.filter_map = filter_map + self.config = config + self.filters = {} + + def add_filter(self, filter_): + filter_.search = self + self.filters[filter_.name] = filter_ + + +class SearchFormRenderer(FormRenderer): + """ + Renderer for :class:`SearchForm` instances. + """ + + def __init__(self, form, *args, **kwargs): + super(SearchFormRenderer, self).__init__(form, *args, **kwargs) + self.request = form.request + self.filters = form.filters + self.config = form.config + + def add_filter(self, visible): + options = ['add a filter'] + for f in self.sorted_filters(): + options.append((f.name, f.label)) + return self.select('add-filter', options, + style='display: none;' if len(visible) == len(self.filters) else None) + + def checkbox(self, name, checked=None, **kwargs): + if name.startswith('include_filter_'): + if checked is None: + checked = self.config[name] + return tags.checkbox(name, checked=checked, **kwargs) + if checked is None: + checked = False + return super(SearchFormRenderer, self).checkbox(name, checked=checked, **kwargs) + + def render(self, **kwargs): + kwargs['search'] = self + return literal(render('/grids/search.mako', kwargs)) + + def sorted_filters(self): + return sorted(self.filters.values(), key=lambda x: x.label) + + def text(self, name, **kwargs): + return tags.text(name, value=self.config.get(name), **kwargs) + + +def filter_exact(field): + """ + Convenience function which returns a filter map entry, with typical logic + built in for "exact match" queries applied to ``field``. + """ + + return { + 'is': + lambda q, v: q.filter(field == v) if v else q, + 'nt': + lambda q, v: q.filter(field != v) if v else q, + } + + +def filter_ilike(field): + """ + Convenience function which returns a filter map entry, with typical logic + built in for "ILIKE" queries applied to ``field``. + """ + + def ilike(query, value): + if value: + query = query.filter(field.ilike('%%%s%%' % value)) + return query + + def not_ilike(query, value): + if value: + query = query.filter(or_( + field == None, + ~field.ilike('%%%s%%' % value), + )) + return query + + return {'lk': ilike, 'nl': not_ilike} + + +def get_filter_config(prefix, request, filter_map, **kwargs): + """ + Returns a configuration dictionary for a search form. + """ + + config = {} + + def update_config(dict_, prefix='', exclude_by_default=False): + """ + Updates the ``config`` dictionary based on the contents of ``dict_``. + """ + + for field in filter_map: + if prefix+'include_filter_'+field in dict_: + include = dict_[prefix+'include_filter_'+field] + include = bool(include) and include != '0' + config['include_filter_'+field] = include + elif exclude_by_default: + config['include_filter_'+field] = False + if prefix+'filter_type_'+field in dict_: + config['filter_type_'+field] = dict_[prefix+'filter_type_'+field] + if prefix+field in dict_: + config[field] = dict_[prefix+field] + + # Update config to exclude all filters by default. + for field in filter_map: + config['include_filter_'+field] = False + + # Update config to honor default settings. + config.update(kwargs) + + # Update config with data cached in session. + update_config(request.session, prefix=prefix+'.') + + # Update config with data from GET/POST request. + if request.params.get('filters') == 'true': + update_config(request.params, exclude_by_default=True) + + # Cache filter data in session. + for key in config: + if (not key.startswith('filter_factory_') + and not key.startswith('filter_label_')): + request.session[prefix+'.'+key] = config[key] + + return config + + +def get_filter_map(cls, exact=[], ilike=[], **kwargs): + """ + Convenience function which returns a "filter map" for ``cls``. + + ``exact``, if provided, should be a list of field names for which "exact" + filtering is to be allowed. + + ``ilike``, if provided, should be a list of field names for which "ILIKE" + filtering is to be allowed. + + Any remaining ``kwargs`` are assumed to be filter map entries themselves, + and are added directly to the map. + """ + + fmap = {} + for name in exact: + fmap[name] = filter_exact(getattr(cls, name)) + for name in ilike: + fmap[name] = filter_ilike(getattr(cls, name)) + fmap.update(kwargs) + return fmap + + +def get_search_form(request, filter_map, config): + """ + Returns a :class:`SearchForm` instance with a :class:`SearchFilter` for + each filter in ``filter_map``, using configuration from ``config``. + """ + + search = SearchForm(request, filter_map, config) + for field in filter_map: + factory = config.get('filter_factory_%s' % field, SearchFilter) + label = config.get('filter_label_%s' % field) + search.add_filter(factory(field, label=label)) + return search + + +def filter_query(query, config, filter_map, join_map): + """ + Filters ``query`` according to ``config`` and ``filter_map``. ``join_map`` + is used, if necessary, to join additional tables to the base query. The + filtered query is returned. + """ + + joins = config.setdefault('joins', []) + for key in config: + if key.startswith('include_filter_') and config[key]: + field = key[15:] + if field in join_map and field not in joins: + query = join_map[field](query) + joins.append(field) + value = config.get(field) + if value: + fmap = filter_map[field] + filt = fmap[config['filter_type_'+field]] + query = filt(query, value) + return query diff --git a/rattail/pyramid/grids/util.py b/rattail/pyramid/grids/util.py new file mode 100644 index 00000000..8970fdc5 --- /dev/null +++ b/rattail/pyramid/grids/util.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2012 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 Affero 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 Affero General Public License for +# more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Rattail. If not, see . +# +################################################################################ + +""" +``rattail.pyramid.grids.util`` -- Grid Utilities +""" + +from sqlalchemy.orm.attributes import InstrumentedAttribute + +from webhelpers.html import literal + +from pyramid.response import Response + +from rattail.pyramid.grids.search import SearchFormRenderer + + +def get_sort_config(name, request, **kwargs): + """ + Returns a configuration dictionary for grid sorting. + """ + + # Initial config uses some default values. + config = { + 'dir': 'asc', + 'per_page': 20, + 'page': 1, + } + + # Override with defaults provided by caller. + config.update(kwargs) + + # Override with values from GET/POST request and/or session. + for key in config: + full_key = name+'_'+key + if request.params.get(key): + value = request.params[key] + config[key] = value + request.session[full_key] = value + elif request.session.get(full_key): + value = request.session[full_key] + config[key] = value + + return config + + +def get_sort_map(cls, names=None, **kwargs): + """ + Convenience function which returns a sort map for ``cls``. + + If ``names`` is not specified, the map will include all "standard" fields + present on the mapped class. Otherwise, the map will be limited to only + the fields which are named. + + All remaining ``kwargs`` are assumed to be sort map entries, and will be + added to the map directly. + """ + + smap = {} + if names is None: + names = [] + for attr in cls.__dict__: + obj = getattr(cls, attr) + if isinstance(obj, InstrumentedAttribute): + if obj.key != 'uuid': + names.append(obj.key) + for name in names: + smap[name] = sorter(getattr(cls, name)) + smap.update(kwargs) + return smap + + +def render_grid(grid, search_form=None, **kwargs): + """ + Convenience function to render ``grid`` (which should be a + :class:`edbob.pyramid.grids.Grid` instance). + + This "usually" will return a dictionary to be used as context for rendering + the final view template. + + However, if a partial grid is requested (or mandated), then the grid body + will be rendered and a :class:`pyramid.response.Response` object will be + returned instead. + """ + + if grid.partial_only or grid.request.params.get('partial'): + return Response(body=grid.render(), content_type='text/html') + kwargs['grid'] = literal(grid.render()) + if search_form: + kwargs['search'] = SearchFormRenderer(search_form) + return kwargs + + +def sort_query(query, config, sort_map, join_map={}): + """ + Sorts ``query`` according to ``config`` and ``sort_map``. ``join_map`` is + used, if necessary, to join additional tables to the base query. The + sorted query is returned. + """ + + field = config.get('sort') + if not field: + return query + joins = config.setdefault('joins', []) + if field in join_map and field not in joins: + query = join_map[field](query) + joins.append(field) + sort = sort_map[field] + return sort(query, config['dir']) + + +def sorter(field): + """ + Returns a function suitable for a sort map callable, with typical logic + built in for sorting applied to ``field``. + """ + + return lambda q, d: q.order_by(getattr(field, d)()) diff --git a/rattail/pyramid/views/__init__.py b/rattail/pyramid/views/__init__.py index e5820a78..2cfdfc5b 100644 --- a/rattail/pyramid/views/__init__.py +++ b/rattail/pyramid/views/__init__.py @@ -30,6 +30,15 @@ from rattail.pyramid.views.crud import * from rattail.pyramid.views.autocomplete import * +class View(object): + """ + Base for all class-based views. + """ + + def __init__(self, request): + self.request = request + + def includeme(config): config.include('rattail.pyramid.views.batches') # config.include('rattail.pyramid.views.categories') diff --git a/rattail/pyramid/views/employees.py b/rattail/pyramid/views/employees.py index 56fad799..a173ac4e 100644 --- a/rattail/pyramid/views/employees.py +++ b/rattail/pyramid/views/employees.py @@ -31,7 +31,7 @@ from sqlalchemy import and_ from edbob.pyramid.views import SearchableAlchemyGridView from rattail.pyramid.views import CrudView -from rattail.pyramid.grids import EnumSearchFilter +from rattail.pyramid.grids.search import EnumSearchFilter from rattail.pyramid.forms import AssociationProxyField, EnumFieldRenderer from rattail.db.model import ( Employee, EmployeePhoneNumber, EmployeeEmailAddress, Person) diff --git a/rattail/pyramid/views/grids/__init__.py b/rattail/pyramid/views/grids/__init__.py new file mode 100644 index 00000000..a6f513fa --- /dev/null +++ b/rattail/pyramid/views/grids/__init__.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2012 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 Affero 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 Affero General Public License for +# more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Rattail. If not, see . +# +################################################################################ + +""" +``rattail.pyramid.views.grids`` -- Grid Views +""" + +from rattail.pyramid.views.grids.core import * +from rattail.pyramid.views.grids.alchemy import * diff --git a/rattail/pyramid/views/grids/alchemy.py b/rattail/pyramid/views/grids/alchemy.py new file mode 100644 index 00000000..726e4d69 --- /dev/null +++ b/rattail/pyramid/views/grids/alchemy.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2012 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 Affero 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 Affero General Public License for +# more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Rattail. If not, see . +# +################################################################################ + +""" +``rattail.pyramid.views.grids.alchemy`` -- FormAlchemy Grid Views +""" + +from webhelpers import paginate + +from rattail.pyramid.views.grids.core import GridView +from rattail.pyramid import grids +from rattail.pyramid import Session + + +__all__ = ['AlchemyGridView', 'SortableAlchemyGridView', + 'PagedAlchemyGridView', 'SearchableAlchemyGridView'] + + +class AlchemyGridView(GridView): + + def make_query(self): + q = Session.query(self.mapped_class) + return q + + def query(self): + return self.make_query() + + def make_grid(self, **kwargs): + self.update_grid_kwargs(kwargs) + return grids.AlchemyGrid( + self.request, self.mapped_class, self._data, **kwargs) + + def grid(self): + return self.make_grid() + + def __call__(self): + self._data = self.query() + grid = self.grid() + return grids.util.render_grid(grid) + + +class SortableAlchemyGridView(AlchemyGridView): + + sort = None + + @property + def config_prefix(self): + raise NotImplementedError + + def join_map(self): + return {} + + def make_sort_map(self, *args, **kwargs): + return grids.util.get_sort_map( + self.mapped_class, names=args or None, **kwargs) + + def sorter(self, field): + return grids.util.sorter(field) + + def sort_map(self): + return self.make_sort_map() + + def make_sort_config(self, **kwargs): + return grids.util.get_sort_config( + self.config_prefix, self.request, **kwargs) + + def sort_config(self): + return self.make_sort_config(sort=self.sort) + + def make_query(self): + query = Session.query(self.mapped_class) + query = grids.util.sort_query( + query, self._sort_config, self.sort_map(), self.join_map()) + return query + + def query(self): + return self.make_query() + + def make_grid(self, **kwargs): + self.update_grid_kwargs(kwargs) + return grids.AlchemyGrid( + self.request, self.mapped_class, self._data, + sort_map=self.sort_map(), config=self._sort_config, **kwargs) + + def grid(self): + return self.make_grid() + + def __call__(self): + self._sort_config = self.sort_config() + self._data = self.query() + grid = self.grid() + return grids.util.render_grid(grid) + + +class PagedAlchemyGridView(SortableAlchemyGridView): + + full = True + + def make_pager(self): + config = self._sort_config + query = self.query() + return paginate.Page( + query, item_count=query.count(), + items_per_page=int(config['per_page']), + page=int(config['page']), + url=paginate.PageURL_WebOb(self.request)) + + def __call__(self): + self._sort_config = self.sort_config() + self._data = self.make_pager() + grid = self.grid() + grid.pager = self._data + return grids.util.render_grid(grid) + + +class SearchableAlchemyGridView(PagedAlchemyGridView): + + def filter_exact(self, field): + return grids.search.filter_exact(field) + + def filter_ilike(self, field): + return grids.search.filter_ilike(field) + + def make_filter_map(self, **kwargs): + return grids.search.get_filter_map(self.mapped_class, **kwargs) + + def filter_map(self): + return self.make_filter_map() + + def make_filter_config(self, **kwargs): + return grids.search.get_filter_config( + self.config_prefix, self.request, self.filter_map(), **kwargs) + + def filter_config(self): + return self.make_filter_config() + + def make_search_form(self): + return grids.search.get_search_form( + self.request, self.filter_map(), self._filter_config) + + def search_form(self): + return self.make_search_form() + + def make_query(self, session=Session): + join_map = self.join_map() + query = session.query(self.mapped_class) + query = grids.search.filter_query( + query, self._filter_config, self.filter_map(), join_map) + if hasattr(self, '_sort_config'): + self._sort_config['joins'] = self._filter_config['joins'] + query = grids.util.sort_query( + query, self._sort_config, self.sort_map(), join_map) + return query + + def __call__(self): + self._filter_config = self.filter_config() + search = self.search_form() + self._sort_config = self.sort_config() + self._data = self.make_pager() + grid = self.grid() + grid.pager = self._data + kwargs = self.render_kwargs() + return grids.util.render_grid(grid, search, **kwargs) diff --git a/rattail/pyramid/views/grids/core.py b/rattail/pyramid/views/grids/core.py new file mode 100644 index 00000000..c119b258 --- /dev/null +++ b/rattail/pyramid/views/grids/core.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2012 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 Affero 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 Affero General Public License for +# more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Rattail. If not, see . +# +################################################################################ + +""" +``rattail.pyramid.views.grids.core`` -- Core Grid View +""" + +from rattail.pyramid.views import View +from rattail.pyramid import grids + + +__all__ = ['GridView'] + + +class GridView(View): + + route_name = None + route_url = None + renderer = None + permission = None + + full = False + checkboxes = False + clickable = False + deletable = False + + partial_only = False + + def update_grid_kwargs(self, kwargs): + kwargs.setdefault('full', self.full) + kwargs.setdefault('checkboxes', self.checkboxes) + kwargs.setdefault('clickable', self.clickable) + kwargs.setdefault('deletable', self.deletable) + kwargs.setdefault('partial_only', self.partial_only) + + def make_grid(self, **kwargs): + self.update_grid_kwargs(kwargs) + return grids.Grid(self.request, **kwargs) + + def grid(self): + return self.make_grid() + + def render_kwargs(self): + return {} + + def __call__(self): + grid = self.grid() + kwargs = self.render_kwargs() + return grids.util.render_grid(grid, **kwargs)