From 589c3e6ca61b044c9370367b2d68a457dfb62fde Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 27 Nov 2012 21:53:37 -0800 Subject: [PATCH 0001/3860] bump version --- rattail/pyramid/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rattail/pyramid/_version.py b/rattail/pyramid/_version.py index 29c584bf..74dbb3e4 100644 --- a/rattail/pyramid/_version.py +++ b/rattail/pyramid/_version.py @@ -1 +1 @@ -__version__ = '0.3a14' +__version__ = '0.3a15' From a53f4477e459d2a95395776b84377aad629d280b Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 28 Nov 2012 09:32:22 -0800 Subject: [PATCH 0002/3860] use StrippingFieldRenderer for LabelProfile.printer_spec and .formatter_spec --- rattail/pyramid/views/labels.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/rattail/pyramid/views/labels.py b/rattail/pyramid/views/labels.py index 931cb14e..be2be88a 100644 --- a/rattail/pyramid/views/labels.py +++ b/rattail/pyramid/views/labels.py @@ -33,8 +33,9 @@ import formalchemy from webhelpers.html import HTML from edbob.pyramid import Session -from edbob.pyramid.grids.search import BooleanSearchFilter from edbob.pyramid.views import SearchableAlchemyGridView, CrudView +from edbob.pyramid.grids.search import BooleanSearchFilter +from edbob.pyramid.forms import StrippingFieldRenderer import rattail @@ -101,6 +102,8 @@ class ProfileCrud(CrudView): return super(FormatFieldRenderer, self).render(**kwargs) fs = self.make_fieldset(model) + fs.printer_spec.set(renderer=StrippingFieldRenderer) + fs.formatter_spec.set(renderer=StrippingFieldRenderer) fs.format.set(renderer=FormatFieldRenderer) fs.configure( include=[ From 4cd598f33e1d4643a6ce5a7f4997f3e0d48729b9 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 28 Nov 2012 15:47:00 -0800 Subject: [PATCH 0003/3860] add inventory worksheet report --- .../pyramid/reports/inventory_worksheet.mako | 96 +++++++++++++++++++ .../pyramid/templates/reports/inventory.mako | 29 ++++++ rattail/pyramid/views/reports.py | 95 ++++++++++++++---- 3 files changed, 202 insertions(+), 18 deletions(-) create mode 100644 rattail/pyramid/reports/inventory_worksheet.mako create mode 100644 rattail/pyramid/templates/reports/inventory.mako diff --git a/rattail/pyramid/reports/inventory_worksheet.mako b/rattail/pyramid/reports/inventory_worksheet.mako new file mode 100644 index 00000000..b39ff57f --- /dev/null +++ b/rattail/pyramid/reports/inventory_worksheet.mako @@ -0,0 +1,96 @@ + + + + + Inventory Worksheet : ${department.name} + + + + +

Inventory Worksheet

+

Department:  ${department.name} (${department.number})

+

generated on ${date} at ${time}

+
+ + + % for subdepartment in department.subdepartments: + <% products = get_products(subdepartment) %> + % if products: + + + + + + + + + + % for product in products: + + + + + + + % endfor + + + % endif + % endfor +
Subdepartment:  ${subdepartment.name} (${subdepartment.number})
UPCBrandDescriptionCount
${get_upc(product)}${product.brand or ''}${product.description} 
+
+ + diff --git a/rattail/pyramid/templates/reports/inventory.mako b/rattail/pyramid/templates/reports/inventory.mako new file mode 100644 index 00000000..8fa5ac1b --- /dev/null +++ b/rattail/pyramid/templates/reports/inventory.mako @@ -0,0 +1,29 @@ +<%inherit file="/reports/base.mako" /> + +<%def name="title()">Report : Inventory Worksheet + +

Please provide the following criteria to generate your report:

+
+ +${h.form(request.current_route_url())} + +
+ +
+ +
+
+ +
+ ${h.checkbox('weighted-only', label=h.literal("Include items sold by weight only."))} +
+ +
+ ${h.submit('submit', "Generate Report")} +
+ +${h.end_form()} diff --git a/rattail/pyramid/views/reports.py b/rattail/pyramid/views/reports.py index 8556e874..11729922 100644 --- a/rattail/pyramid/views/reports.py +++ b/rattail/pyramid/views/reports.py @@ -14,10 +14,75 @@ from pyramid.response import Response import edbob from edbob.pyramid import Session +from edbob.files import resource_path import rattail +plu_upc_pattern = re.compile(r'^000000000(\d{5})$') +weighted_upc_pattern = re.compile(r'^002(\d{5})00000\d$') + +def get_upc(product): + upc = '%014u' % product.upc + m = plu_upc_pattern.match(upc) + if m: + return str(int(m.group(1))) + m = weighted_upc_pattern.match(upc) + if m: + return str(int(m.group(1))) + return upc + + +def inventory_report(request): + """ + This is the "Inventory Worksheet" report. + """ + + departments = Session.query(rattail.Department) + + if request.params.get('department'): + department = departments.get(request.params['department']) + if department: + body = write_inventory_worksheet(request, department) + response = Response(content_type='text/html') + response.headers['Content-Length'] = len(body) + response.headers['Content-Disposition'] = 'attachment; filename=inventory.html' + response.text = body + return response + + departments = departments.order_by(rattail.Department.name) + departments = departments.all() + return{'departments': departments} + + +def write_inventory_worksheet(request, department): + """ + Generates the Inventory Worksheet report. + """ + + def get_products(subdepartment): + q = Session.query(rattail.Product) + q = q.outerjoin(rattail.Brand) + q = q.filter(rattail.Product.subdepartment == subdepartment) + if request.params.get('weighted-only'): + q = q.filter(rattail.Product.unit_of_measure == rattail.UNIT_OF_MEASURE_POUND) + q = q.order_by(rattail.Brand.name, rattail.Product.description) + return q.all() + + now = edbob.local_time() + data = dict( + date=now.strftime('%a %d %b %Y'), + time=now.strftime('%I:%M %p'), + department=department, + get_products=get_products, + get_upc=get_upc, + ) + + report = resource_path('rattail.pyramid:reports/inventory_worksheet.mako') + template = Template(filename=report) + return template.render(**data) + + def ordering_report(request): """ This is the "Ordering Worksheet" report. @@ -37,7 +102,7 @@ def ordering_report(request): response = Response(content_type='text/html') response.headers['Content-Length'] = len(body) response.headers['Content-Disposition'] = 'attachment; filename=ordering.html' - response.body = body + response.text = body return response return {} @@ -60,19 +125,6 @@ def write_ordering_worksheet(vendor, departments): costs[dept].setdefault(subdept, []) costs[dept][subdept].append(cost) - plu_upc_pattern = re.compile(r'^0000000(\d{5})$') - weighted_upc_pattern = re.compile(r'^02(\d{5})00000$') - - def get_upc(prod): - upc = '%012u' % prod.upc - m = plu_upc_pattern.match(upc) - if m: - return str(int(m.group(1))) - m = weighted_upc_pattern.match(upc) - if m: - return str(int(m.group(1))) - return upc - now = edbob.local_time() data = dict( vendor=vendor, @@ -83,12 +135,19 @@ def write_ordering_worksheet(vendor, departments): rattail=rattail, ) - report = os.path.join(os.path.dirname(__file__), os.pardir, 'reports', 'ordering_worksheet.mako') - report = os.path.abspath(report) - template = Template(filename=report, disable_unicode=True) + report = resource_path('rattail.pyramid:reports/ordering_worksheet.mako') + template = Template(filename=report) return template.render(**data) def includeme(config): + + config.add_route('reports.inventory', '/reports/inventory') + config.add_view(inventory_report, + route_name='reports.inventory', + renderer='/reports/inventory.mako') + config.add_route('reports.ordering', '/reports/ordering') - config.add_view(ordering_report, route_name='reports.ordering', renderer='/reports/ordering.mako') + config.add_view(ordering_report, + route_name='reports.ordering', + renderer='/reports/ordering.mako') From 86e96273a5d56439de405ba4a9a63beb5fb5f865 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 10 Feb 2013 17:15:25 -0800 Subject: [PATCH 0004/3860] Fixed bug where requesting deletion of non-existent batch row was redirecting to a non-existent route. --- rattail/pyramid/views/batches/rows.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rattail/pyramid/views/batches/rows.py b/rattail/pyramid/views/batches/rows.py index 9c389767..87840e0f 100644 --- a/rattail/pyramid/views/batches/rows.py +++ b/rattail/pyramid/views/batches/rows.py @@ -146,7 +146,7 @@ def batch_row_crud(request, attr): row_uuid = request.matchdict['uuid'] row = Session.query(batch.rowclass).get(row_uuid) if not row: - return HTTPFound(location=request.route_url('batch', uuid=batch.uuid)) + return HTTPFound(location=request.route_url('batch.read', uuid=batch.uuid)) class BatchRowCrud(CrudView): From d14b388b4b0feeb7cab1b476a7bce25781b93f59 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 10 Feb 2013 17:20:05 -0800 Subject: [PATCH 0005/3860] update changelog --- CHANGES.txt | 6 ++++++ rattail/pyramid/_version.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGES.txt b/CHANGES.txt index 828f3ba1..49666032 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,4 +1,10 @@ +0.3a16 +------ + +- [bug] Fixed bug where requesting deletion of non-existent batch row was + redirecting to a non-existent route. + 0.3a15 ------ diff --git a/rattail/pyramid/_version.py b/rattail/pyramid/_version.py index 74dbb3e4..841bba42 100644 --- a/rattail/pyramid/_version.py +++ b/rattail/pyramid/_version.py @@ -1 +1 @@ -__version__ = '0.3a15' +__version__ = '0.3a16' From 80eb55878366e30aacbc8512635be25c965d96da Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 11 Feb 2013 18:55:21 -0800 Subject: [PATCH 0006/3860] Added Brand and Size fields to the Ordering Worksheet. Also tweaked the template styles slightly, and added the ability to override the template via config. --- .../pyramid/reports/ordering_worksheet.mako | 23 +++++++++++++++---- rattail/pyramid/views/reports.py | 5 +++- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/rattail/pyramid/reports/ordering_worksheet.mako b/rattail/pyramid/reports/ordering_worksheet.mako index d54cf820..c7d5a872 100644 --- a/rattail/pyramid/reports/ordering_worksheet.mako +++ b/rattail/pyramid/reports/ordering_worksheet.mako @@ -5,6 +5,10 @@ Ordering Worksheet : ${vendor.name} + + + +
+ +
+ +

${initial_msg or "Working"} (please wait) ...

+ + + + + + + +
+ + + + + +
+
+ +
+ +
+ +
+ + From 6b5ca78a8300e6bde0c50f057bc467048c8a931c Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 10 Sep 2013 09:55:11 -0700 Subject: [PATCH 0067/3860] update changelog --- CHANGES.rst | 8 ++++++++ tailbone/_version.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 9cac2e3d..0c103c94 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,4 +1,12 @@ +0.3.5 +----- + +* Added ``forms.alchemy`` module and changed CRUD view to use it. + +* Added progress template. + + 0.3.4 ----- diff --git a/tailbone/_version.py b/tailbone/_version.py index bfeb9e74..40ed83d9 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1 +1 @@ -__version__ = '0.3.4' +__version__ = '0.3.5' From 857a4b88e5ccd009f61fced6525c324369d36681 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 10 Sep 2013 20:37:19 -0700 Subject: [PATCH 0068/3860] Fixed change password template/form. --- tailbone/templates/change_password.mako | 15 +++++++++++++++ tailbone/views/auth.py | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 tailbone/templates/change_password.mako diff --git a/tailbone/templates/change_password.mako b/tailbone/templates/change_password.mako new file mode 100644 index 00000000..e00d2a5d --- /dev/null +++ b/tailbone/templates/change_password.mako @@ -0,0 +1,15 @@ +<%inherit file="/base.mako" /> + +<%def name="title()">Change Password + +
+ ${h.form(url('change_password'))} + ${form.referrer_field()} + ${form.field_div('current_password', form.password('current_password'))} + ${form.field_div('new_password', form.password('new_password'))} + ${form.field_div('confirm_password', form.password('confirm_password'))} +
+ ${h.submit('submit', "Change Password")} +
+ ${h.end_form()} +
diff --git a/tailbone/views/auth.py b/tailbone/views/auth.py index 3e5acd1c..54f3264a 100644 --- a/tailbone/views/auth.py +++ b/tailbone/views/auth.py @@ -97,7 +97,7 @@ class CurrentPasswordCorrect(formencode.validators.FancyValidator): def _to_python(self, value, state): user = state - if not authenticate_user(user.username, value, session=Session()): + if not authenticate_user(Session, user.username, value): raise formencode.Invalid("The password is incorrect.", value, state) return value From fab49e6b20a378402eb86fa82e140d8d85846c1d Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 10 Sep 2013 20:38:31 -0700 Subject: [PATCH 0069/3860] update changelog --- CHANGES.rst | 6 ++++++ tailbone/_version.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 0c103c94..3b84c121 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,4 +1,10 @@ +0.3.6 +----- + +* Fixed change password template/form. + + 0.3.5 ----- diff --git a/tailbone/_version.py b/tailbone/_version.py index 40ed83d9..4596d037 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1 +1 @@ -__version__ = '0.3.5' +__version__ = '0.3.6' From ba5dc6ab0269c06613cd9e8ee6387b50f2793f20 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 12 Sep 2013 06:51:00 -0700 Subject: [PATCH 0070/3860] Added some autocomplete Javascript magic. Not sure how this got missed the first time around. --- tailbone/static/js/tailbone.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tailbone/static/js/tailbone.js b/tailbone/static/js/tailbone.js index 5ac3c049..eac2ebd1 100644 --- a/tailbone/static/js/tailbone.js +++ b/tailbone/static/js/tailbone.js @@ -248,6 +248,24 @@ $(function() { }); + /* + * Whenever the "change" button is clicked within the context of an + * autocomplete field, hide the static display and show the autocomplete + * textbox. + */ + $('div.autocomplete-container button.autocomplete-change').click(function() { + var container = $(this).parents('div.autocomplete-container'); + var textbox = container.find('input.autocomplete-textbox'); + + container.find('input[type="hidden"]').val(''); + container.find('div.autocomplete-display').hide(); + + textbox.val(''); + textbox.show(); + textbox.select(); + textbox.focus(); + }); + /* * Add "check all" functionality to tables with checkboxes. */ From 685b391dd29e885987dddaae2275ca229fef1870 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 20 Sep 2013 14:54:27 -0700 Subject: [PATCH 0071/3860] Added `products.search` route/view. This is for simple AJAX uses. --- tailbone/views/products.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index d4ac3b30..c9fb6663 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -47,6 +47,7 @@ from rattail.db.model import ( Product, ProductPrice, ProductCost, ProductCode, Brand, Vendor, Department, Subdepartment, LabelProfile) from rattail.gpc import GPC +from rattail.db.api import get_product_by_upc from ..db import Session from ..forms import AutocompleteFieldRenderer, GPCFieldRenderer, PriceFieldRenderer @@ -250,6 +251,30 @@ class ProductCrud(CrudView): return fs +def products_search(request): + """ + Locate a product(s) by UPC. + + Eventually this should be more generic, or at least offer more fields for + search. For now it operates only on the ``Product.upc`` field. + """ + + product = None + upc = request.GET.get('upc') + if upc: + product = get_product_by_upc(Session, upc) + if not product: + # Try again, assuming caller did not include check digit. + upc = GPC(upc, calc_check_digit='upc') + product = get_product_by_upc(Session, upc) + if product: + product = { + 'uuid': product.uuid, + 'full_description': product.full_description, + } + return {'product': product} + + def print_labels(request): profile = request.params.get('profile') profile = Session.query(LabelProfile).get(profile) if profile else None @@ -346,6 +371,7 @@ class CreateProductsBatch(ProductsGrid): def add_routes(config): config.add_route('products', '/products') + config.add_route('products.search', '/products/search') config.add_route('products.print_labels', '/products/labels') config.add_route('products.create_batch', '/products/batch') config.add_route('product.create', '/products/new') @@ -376,3 +402,5 @@ def includeme(config): permission='products.update') config.add_view(ProductCrud, attr='delete', route_name='product.delete', permission='products.delete') + config.add_view(products_search, route_name='products.search', + renderer='json', permission='products.list') From 23e08d0bb05bdd900209c9ee59fd5f94f087a959 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 20 Sep 2013 15:49:48 -0700 Subject: [PATCH 0072/3860] Fixed grid join map bug. --- tailbone/grids/search.py | 6 +++--- tailbone/views/products.py | 7 +------ 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/tailbone/grids/search.py b/tailbone/grids/search.py index a2dda061..797c58bb 100644 --- a/tailbone/grids/search.py +++ b/tailbone/grids/search.py @@ -274,11 +274,11 @@ def filter_query(query, config, filter_map, join_map): 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: + if field in join_map and field not in joins: + query = join_map[field](query) + joins.append(field) fmap = filter_map[field] filt = fmap[config['filter_type_'+field]] query = filt(query, value) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index c9fb6663..a1ef98bb 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -73,11 +73,6 @@ class ProductsGrid(SearchableAlchemyGridView): # q = q.outerjoin(Vendor) # return q - def join_vendor_any(q): - return q.outerjoin(ProductCost, - ProductCost.product_uuid == Product.uuid)\ - .outerjoin(Vendor) - return { 'brand': lambda q: q.outerjoin(Brand), @@ -96,7 +91,7 @@ class ProductsGrid(SearchableAlchemyGridView): # 'vendor': # join_vendor, 'vendor_any': - join_vendor_any, + lambda q: q.outerjoin(ProductCost, ProductCost.product_uuid == Product.uuid).outerjoin(Vendor), 'code': lambda q: q.outerjoin(ProductCode), } From 919279f3bcd5727abf00d43f86fa425c2e33bc80 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 20 Sep 2013 15:51:37 -0700 Subject: [PATCH 0073/3860] update changelog --- CHANGES.rst | 14 ++++++++++++++ tailbone/_version.py | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 3b84c121..86110ef5 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,4 +1,18 @@ +0.3.7 +----- + +* Added some autocomplete Javascript magic. + + Not sure how this got missed the first time around. + +* Added ``products.search`` route/view. + + This is for simple AJAX uses. + +* Fixed grid join map bug. + + 0.3.6 ----- diff --git a/tailbone/_version.py b/tailbone/_version.py index 4596d037..d93912ee 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1 +1 @@ -__version__ = '0.3.6' +__version__ = '0.3.7' From 644c297f638dd0b6ed0922a11f8bc22acf228e06 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 20 Sep 2013 16:01:25 -0700 Subject: [PATCH 0074/3860] Fixed manifest (whoops). --- MANIFEST.in | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/MANIFEST.in b/MANIFEST.in index a4a5832b..984d2491 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -3,8 +3,12 @@ include *.txt include *.rst include *.py +recursive-include tailbone/static *.js recursive-include tailbone/static *.css recursive-include tailbone/static *.png +recursive-include tailbone/static *.jpg +recursive-include tailbone/static *.gif +recursive-include tailbone/static *.ico recursive-include tailbone/templates *.mako recursive-include tailbone/reports *.mako From 2bdac13a14a6b263a0f9f913157cca5674ea71ab Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 20 Sep 2013 16:02:17 -0700 Subject: [PATCH 0075/3860] update changelog --- CHANGES.rst | 6 ++++++ tailbone/_version.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 86110ef5..98727892 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,4 +1,10 @@ +0.3.8 +----- + +* Fixed manifest (whoops). + + 0.3.7 ----- diff --git a/tailbone/_version.py b/tailbone/_version.py index d93912ee..17a33147 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1 +1 @@ -__version__ = '0.3.7' +__version__ = '0.3.8' From 62a0b6750232cbb48783c82df587919da18938cf Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 20 Sep 2013 20:17:59 -0700 Subject: [PATCH 0076/3860] Added forbidden view. --- tailbone/views/__init__.py | 2 -- tailbone/views/auth.py | 27 ++++++++++++++++++++++++++- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/tailbone/views/__init__.py b/tailbone/views/__init__.py index d43578a6..975028e8 100644 --- a/tailbone/views/__init__.py +++ b/tailbone/views/__init__.py @@ -47,8 +47,6 @@ def add_routes(config): def includeme(config): add_routes(config) - config.add_forbidden_view('edbob.pyramid.views.forbidden') - config.add_view(home, route_name='home', renderer='/home.mako') diff --git a/tailbone/views/auth.py b/tailbone/views/auth.py index 54f3264a..b35d7ca8 100644 --- a/tailbone/views/auth.py +++ b/tailbone/views/auth.py @@ -27,7 +27,10 @@ Auth Views """ from pyramid.httpexceptions import HTTPFound -from pyramid.security import remember, forget +from pyramid.security import remember, forget, authenticated_userid + +from webhelpers.html import literal +from webhelpers.html import tags import formencode from pyramid_simpleform import Form @@ -38,6 +41,26 @@ from ..db import Session from rattail.db.auth import authenticate_user, set_user_password +def forbidden(request): + """ + Access forbidden view. + + This is triggered whenever access is not allowed for an otherwise + appropriate view. + """ + + msg = literal("You do not have permission to do that.") + if not authenticated_userid(request): + msg += literal("  (Perhaps you should %s?)" % + tags.link_to("log in", request.route_url('login'))) + request.session.flash(msg, allow_duplicate=False) + + url = request.referer + if not url or url == request.current_route_url(): + url = request.route_url('home') + return HTTPFound(location=url) + + class UserLogin(formencode.Schema): allow_extra_fields = True filter_extra_fields = True @@ -143,6 +166,8 @@ def add_routes(config): def includeme(config): add_routes(config) + config.add_forbidden_view(forbidden) + config.add_view(login, route_name='login', renderer='/login.mako') From e58f8594c47f690c2b353ae46e33938c716cc563 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 20 Sep 2013 20:21:59 -0700 Subject: [PATCH 0077/3860] Fixed bug with `request.has_any_perm()`. --- tailbone/subscribers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index b9f6557f..eea4f403 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.py @@ -73,7 +73,7 @@ def context_found(event): return has_permission(Session(), request.user, perm) request.has_perm = has_perm - def has_any_perm(perms): + def has_any_perm(*perms): for perm in perms: if has_permission(Session(), request.user, perm): return True From c1d726d48ccf153af95a7ac8b7cac4300c8cd581 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 20 Sep 2013 21:51:29 -0700 Subject: [PATCH 0078/3860] Made `SortableAlchemyGridView` default to full (100%) width. --- tailbone/views/grids/alchemy.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tailbone/views/grids/alchemy.py b/tailbone/views/grids/alchemy.py index 92d05be7..9f0ada45 100644 --- a/tailbone/views/grids/alchemy.py +++ b/tailbone/views/grids/alchemy.py @@ -66,6 +66,7 @@ class AlchemyGridView(GridView): class SortableAlchemyGridView(AlchemyGridView): sort = None + full = True @property def config_prefix(self): @@ -113,8 +114,6 @@ class SortableAlchemyGridView(AlchemyGridView): class PagedAlchemyGridView(SortableAlchemyGridView): - full = True - def make_pager(self): config = self._sort_config query = self.query() From 9f8a3d3a5ced9c1cdb8890d3253e670da3464f78 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 21 Sep 2013 15:02:55 -0700 Subject: [PATCH 0079/3860] Refactored `AutocompleteFieldRenderer`. Also improved some organization of renderers. --- tailbone/forms/renderers/__init__.py | 74 +---------------------- tailbone/forms/renderers/common.py | 64 ++++++++++++++------ tailbone/forms/renderers/people.py | 88 ++++++++++++++++++++++++++++ tailbone/forms/renderers/products.py | 76 +++++++++++++++++++++--- tailbone/forms/renderers/users.py | 44 -------------- tailbone/views/products.py | 7 +-- tailbone/views/users.py | 6 +- tailbone/views/vendors.py | 4 +- 8 files changed, 211 insertions(+), 152 deletions(-) create mode 100644 tailbone/forms/renderers/people.py delete mode 100644 tailbone/forms/renderers/users.py diff --git a/tailbone/forms/renderers/__init__.py b/tailbone/forms/renderers/__init__.py index eabfec98..3b1d0c58 100644 --- a/tailbone/forms/renderers/__init__.py +++ b/tailbone/forms/renderers/__init__.py @@ -26,74 +26,6 @@ FormAlchemy Field Renderers """ -from webhelpers.html import literal -from webhelpers.html import tags - -import formalchemy - -from edbob.pyramid.forms import pretty_datetime -from edbob.pyramid.forms.formalchemy.renderers import YesNoFieldRenderer - -from .common import AutocompleteFieldRenderer, EnumFieldRenderer -from .products import GPCFieldRenderer, ProductFieldRenderer -from .users import UserFieldRenderer - - -__all__ = ['AutocompleteFieldRenderer', 'EnumFieldRenderer', 'YesNoFieldRenderer', - 'GPCFieldRenderer', 'PersonFieldRenderer', 'PriceFieldRenderer', - 'PriceWithExpirationFieldRenderer', 'ProductFieldRenderer', 'UserFieldRenderer'] - - -def PersonFieldRenderer(url): - - BaseRenderer = AutocompleteFieldRenderer(url) - - class PersonFieldRenderer(BaseRenderer): - - def render_readonly(self, **kwargs): - person = self.raw_value - if not person: - return '' - return tags.link_to( - str(person), - self.request.route_url('person.read', uuid=person.uuid)) - - return PersonFieldRenderer - - -class PriceFieldRenderer(formalchemy.TextFieldRenderer): - """ - Renderer for fields which reference a :class:`ProductPrice` instance. - """ - - def render_readonly(self, **kwargs): - price = self.field.raw_value - if price: - if price.price is not None and price.pack_price is not None: - if price.multiple > 1: - return literal('$ %0.2f / %u  ($ %0.2f / %u)' % ( - price.price, price.multiple, - price.pack_price, price.pack_multiple)) - return literal('$ %0.2f  ($ %0.2f / %u)' % ( - price.price, price.pack_price, price.pack_multiple)) - if price.price is not None: - if price.multiple > 1: - return '$ %0.2f / %u' % (price.price, price.multiple) - return '$ %0.2f' % price.price - if price.pack_price is not None: - return '$ %0.2f / %u' % (price.pack_price, price.pack_multiple) - return '' - - -class PriceWithExpirationFieldRenderer(PriceFieldRenderer): - """ - Price field renderer which also displays the expiration date, if present. - """ - - def render_readonly(self, **kwargs): - res = super(PriceWithExpirationFieldRenderer, self).render_readonly(**kwargs) - if res: - price = self.field.raw_value - if price.ends: - res += '  (%s)' % pretty_datetime(price.ends, from_='utc') - return res +from .common import * +from .people import * +from .products import * diff --git a/tailbone/forms/renderers/common.py b/tailbone/forms/renderers/common.py index 30bc491d..ca908701 100644 --- a/tailbone/forms/renderers/common.py +++ b/tailbone/forms/renderers/common.py @@ -26,37 +26,54 @@ Common Field Renderers """ -from formalchemy.fields import FieldRenderer, SelectFieldRenderer +from formalchemy.fields import FieldRenderer, SelectFieldRenderer, CheckBoxFieldRenderer from pyramid.renderers import render -__all__ = ['AutocompleteFieldRenderer', 'EnumFieldRenderer'] +__all__ = ['AutocompleteFieldRenderer', 'EnumFieldRenderer', 'YesNoFieldRenderer'] -def AutocompleteFieldRenderer(service_url, field_value=None, field_display=None, width='300px'): +class AutocompleteFieldRenderer(FieldRenderer): """ - Returns a custom renderer class for an autocomplete field. + Custom renderer for an autocomplete field. """ - class AutocompleteFieldRenderer(FieldRenderer): + service_route = None + width = '300px' - @property - def focus_name(self): - return self.name + '-textbox' + @property + def focus_name(self): + return self.name + '-textbox' - @property - def needs_focus(self): - return not bool(self.value or field_value) + @property + def needs_focus(self): + return not bool(self.value or self.field_value) - def render(self, **kwargs): - kwargs.setdefault('field_name', self.name) - kwargs.setdefault('field_value', self.value or field_value) - kwargs.setdefault('field_display', self.raw_value or field_display) - kwargs.setdefault('service_url', service_url) - kwargs.setdefault('width', width) - return render('/forms/field_autocomplete.mako', kwargs) + @property + def field_display(self): + return self.raw_value - return AutocompleteFieldRenderer + @property + def field_value(self): + return self.value + + @property + def service_url(self): + return self.request.route_url(self.service_route) + + def render(self, **kwargs): + kwargs.setdefault('field_name', self.name) + kwargs.setdefault('field_value', self.field_value) + kwargs.setdefault('field_display', self.field_display) + kwargs.setdefault('service_url', self.service_url) + kwargs.setdefault('width', self.width) + return render('/forms/field_autocomplete.mako', kwargs) + + def render_readonly(self, **kwargs): + value = self.field_display + if value is None: + return u'' + return unicode(value) def EnumFieldRenderer(enum): @@ -79,3 +96,12 @@ def EnumFieldRenderer(enum): return SelectFieldRenderer.render(self, opts, **kwargs) return Renderer + + +class YesNoFieldRenderer(CheckBoxFieldRenderer): + + def render_readonly(self, **kwargs): + value = self.raw_value + if value is None: + return u'' + return u'Yes' if value else u'No' diff --git a/tailbone/forms/renderers/people.py b/tailbone/forms/renderers/people.py new file mode 100644 index 00000000..dfaa397d --- /dev/null +++ b/tailbone/forms/renderers/people.py @@ -0,0 +1,88 @@ +#!/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 . +# +################################################################################ + +""" +People Field Renderers +""" + +from formalchemy.fields import TextFieldRenderer +from .common import AutocompleteFieldRenderer +from webhelpers.html import tags + + +__all__ = ['PersonFieldRenderer', 'PersonFieldLinkRenderer', + 'CustomerFieldRenderer', 'CustomerFieldLinkRenderer', + 'UserFieldRenderer'] + + +class PersonFieldRenderer(AutocompleteFieldRenderer): + """ + Renderer for :class:`rattail.db.model.Person` instance fields. + """ + + service_route = 'people.autocomplete' + + +class PersonFieldLinkRenderer(PersonFieldRenderer): + """ + Renderer for :class:`rattail.db.model.Person` instance fields (with hyperlink). + """ + + def render_readonly(self, **kwargs): + person = self.raw_value + if person: + return tags.link_to( + unicode(person), + self.request.route_url('person.read', uuid=person.uuid)) + return u'' + + +class CustomerFieldRenderer(AutocompleteFieldRenderer): + """ + Renderer for :class:`rattail.db.model.Customer` instance fields. + """ + + service_route = 'customers.autocomplete' + + +class CustomerFieldLinkRenderer(CustomerFieldRenderer): + """ + Renderer for :class:`rattail.db.model.Customer` instance fields (with hyperlink). + """ + + def render_readonly(self, **kwargs): + customer = self.raw_value + if customer: + return tags.link_to( + unicode(customer), + self.request.route_url('customer.read', uuid=customer.uuid)) + return u'' + + +class UserFieldRenderer(TextFieldRenderer): + """ + Renderer for :class:`rattail.db.model.User` instance fields. + """ + + pass diff --git a/tailbone/forms/renderers/products.py b/tailbone/forms/renderers/products.py index beb5ac44..52916543 100644 --- a/tailbone/forms/renderers/products.py +++ b/tailbone/forms/renderers/products.py @@ -28,9 +28,29 @@ Product Field Renderers from formalchemy import TextFieldRenderer from rattail.gpc import GPC +from .common import AutocompleteFieldRenderer +from webhelpers.html import literal +from edbob.pyramid.forms import pretty_datetime -__all__ = ['GPCFieldRenderer', 'ProductFieldRenderer'] +__all__ = ['ProductFieldRenderer', 'GPCFieldRenderer', + 'BrandFieldRenderer', 'VendorFieldRenderer', + 'PriceFieldRenderer', 'PriceWithExpirationFieldRenderer'] + + +class ProductFieldRenderer(AutocompleteFieldRenderer): + """ + Renderer for :class:`rattail.db.model.Product` instance fields. + """ + + service_route = 'products.autocomplete' + + @property + def field_display(self): + product = self.raw_value + if product: + return product.full_description + return '' class GPCFieldRenderer(TextFieldRenderer): @@ -44,13 +64,55 @@ class GPCFieldRenderer(TextFieldRenderer): return len(str(GPC(0))) -class ProductFieldRenderer(TextFieldRenderer): +class BrandFieldRenderer(AutocompleteFieldRenderer): """ - Renderer for fields which represent :class:`rattail.db.Product` instances. + Renderer for :class:`rattail.db.model.Brand` instance fields. + """ + + service_route = 'brands.autocomplete' + + +class VendorFieldRenderer(AutocompleteFieldRenderer): + """ + Renderer for :class:`rattail.db.model.Vendor` instance fields. + """ + + service_route = 'vendors.autocomplete' + + +class PriceFieldRenderer(TextFieldRenderer): + """ + Renderer for fields which reference a :class:`ProductPrice` instance. """ def render_readonly(self, **kwargs): - product = self.raw_value - if product is None: - return '' - return product.full_description + price = self.field.raw_value + if price: + if price.price is not None and price.pack_price is not None: + if price.multiple > 1: + return literal('$ %0.2f / %u  ($ %0.2f / %u)' % ( + price.price, price.multiple, + price.pack_price, price.pack_multiple)) + return literal('$ %0.2f  ($ %0.2f / %u)' % ( + price.price, price.pack_price, price.pack_multiple)) + if price.price is not None: + if price.multiple > 1: + return '$ %0.2f / %u' % (price.price, price.multiple) + return '$ %0.2f' % price.price + if price.pack_price is not None: + return '$ %0.2f / %u' % (price.pack_price, price.pack_multiple) + return '' + + +class PriceWithExpirationFieldRenderer(PriceFieldRenderer): + """ + Price field renderer which also displays the expiration date, if present. + """ + + def render_readonly(self, **kwargs): + result = super(PriceWithExpirationFieldRenderer, self).render_readonly(**kwargs) + if result: + price = self.field.raw_value + if price.ends: + result += '  (%s)' % pretty_datetime(price.ends, from_='utc') + return result diff --git a/tailbone/forms/renderers/users.py b/tailbone/forms/renderers/users.py deleted file mode 100644 index 253cb576..00000000 --- a/tailbone/forms/renderers/users.py +++ /dev/null @@ -1,44 +0,0 @@ -#!/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 . -# -################################################################################ - -""" -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/tailbone/views/products.py b/tailbone/views/products.py index a1ef98bb..e1a11bbf 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -50,7 +50,7 @@ from rattail.gpc import GPC from rattail.db.api import get_product_by_upc from ..db import Session -from ..forms import AutocompleteFieldRenderer, GPCFieldRenderer, PriceFieldRenderer +from ..forms import GPCFieldRenderer, BrandFieldRenderer, PriceFieldRenderer from . import CrudView @@ -225,14 +225,13 @@ class ProductCrud(CrudView): def fieldset(self, model): fs = self.make_fieldset(model) fs.upc.set(renderer=GPCFieldRenderer) - fs.brand.set(renderer=AutocompleteFieldRenderer( - self.request.route_url('brands.autocomplete'))) + fs.brand.set(options=[]) fs.regular_price.set(renderer=PriceFieldRenderer) fs.current_price.set(renderer=PriceFieldRenderer) fs.configure( include=[ fs.upc.label("UPC"), - fs.brand, + fs.brand.with_renderer(BrandFieldRenderer), fs.description, fs.size, fs.department, diff --git a/tailbone/views/users.py b/tailbone/views/users.py index e23dbb01..c5b58adb 100644 --- a/tailbone/views/users.py +++ b/tailbone/views/users.py @@ -32,7 +32,7 @@ from formalchemy.fields import SelectFieldRenderer from edbob.pyramid.views import users from . import SearchableAlchemyGridView, CrudView -from ..forms import PersonFieldRenderer +from ..forms import PersonFieldLinkRenderer from ..db import Session from rattail.db.model import User, Person, Role from rattail.db.auth import guest_role @@ -143,8 +143,6 @@ class UserCrud(CrudView): # Must set Person options to empty set to avoid unwanted magic. fs.person.set(options=[]) - fs.person.set(renderer=PersonFieldRenderer( - self.request.route_url('people.autocomplete'))) fs.append(users.PasswordField('password')) fs.append(Field('confirm_password', @@ -155,7 +153,7 @@ class UserCrud(CrudView): fs.configure( include=[ fs.username, - fs.person, + fs.person.with_renderer(PersonFieldLinkRenderer), fs.password.label("Set Password"), fs.confirm_password, fs.roles, diff --git a/tailbone/views/vendors.py b/tailbone/views/vendors.py index 22cd2658..ad4e0e30 100644 --- a/tailbone/views/vendors.py +++ b/tailbone/views/vendors.py @@ -81,8 +81,6 @@ class VendorCrud(CrudView): def fieldset(self, model): fs = self.make_fieldset(model) fs.append(AssociationProxyField('contact')) - fs.contact.set(renderer=PersonFieldRenderer( - self.request.route_url('people.autocomplete'))) fs.configure( include=[ fs.id.label("ID"), @@ -90,7 +88,7 @@ class VendorCrud(CrudView): fs.special_discount, fs.phone.label("Phone Number").readonly(), fs.email.label("Email Address").readonly(), - fs.contact.readonly(), + fs.contact.with_renderer(PersonFieldRenderer).readonly(), ]) return fs From a11b8d9ff26f6188336d89215727ca1618486385 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 21 Sep 2013 17:09:10 -0700 Subject: [PATCH 0080/3860] Allow overriding form class/factory for CRUD views. --- tailbone/forms/alchemy.py | 2 +- tailbone/views/crud.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tailbone/forms/alchemy.py b/tailbone/forms/alchemy.py index 9b67879c..0a3d6d12 100644 --- a/tailbone/forms/alchemy.py +++ b/tailbone/forms/alchemy.py @@ -26,7 +26,7 @@ FormAlchemy Forms """ -from edbob import Object +from rattail.core import Object from pyramid.renderers import render from ..db import Session diff --git a/tailbone/views/crud.py b/tailbone/views/crud.py index fb010602..7c00da3f 100644 --- a/tailbone/views/crud.py +++ b/tailbone/views/crud.py @@ -81,7 +81,7 @@ class CrudView(View): def fieldset(self, model): return self.make_fieldset(model) - def make_form(self, model, **kwargs): + def make_form(self, model, form_factory=AlchemyForm, **kwargs): if self.readonly: self.creating = False self.updating = False @@ -99,7 +99,7 @@ class CrudView(View): kwargs.setdefault('cancel_url', self.cancel_url) kwargs.setdefault('creating', self.creating) kwargs.setdefault('updating', self.updating) - form = AlchemyForm(self.request, fieldset, **kwargs) + form = form_factory(self.request, fieldset, **kwargs) if form.creating: if hasattr(self, 'create_label'): From 3070c280ccc0f9e4b7d1c78969e54be6c0b05f35 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 27 Sep 2013 08:16:22 -0700 Subject: [PATCH 0081/3860] Made `EnumFieldRenderer` a proper class. --- tailbone/forms/renderers/common.py | 36 +++++++++++++++++------------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/tailbone/forms/renderers/common.py b/tailbone/forms/renderers/common.py index ca908701..f2e7743d 100644 --- a/tailbone/forms/renderers/common.py +++ b/tailbone/forms/renderers/common.py @@ -76,26 +76,32 @@ class AutocompleteFieldRenderer(FieldRenderer): return unicode(value) -def EnumFieldRenderer(enum): +class EnumFieldRenderer(SelectFieldRenderer): """ - Adds support for enumeration fields. + Renderer for simple 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) + enumeration = {} - def render(self, **kwargs): - opts = [(enum[x], x) for x in sorted(enum)] - return SelectFieldRenderer.render(self, opts, **kwargs) + def __init__(self, arg): + if isinstance(arg, dict): + self.enumeration = arg + else: + self(arg) - return Renderer + def __call__(self, field): + super(EnumFieldRenderer, self).__init__(field) + return self + + def render_readonly(self, **kwargs): + value = self.raw_value + if value is None: + return u'' + return self.enumeration.get(value, unicode(value)) + + def render(self, **kwargs): + opts = [(self.enumeration[x], x) for x in sorted(self.enumeration)] + return SelectFieldRenderer.render(self, opts, **kwargs) class YesNoFieldRenderer(CheckBoxFieldRenderer): From 0ac0ef4079a4192c72b6b7f9b883149e88dbb752 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 4 Oct 2013 11:37:47 -0700 Subject: [PATCH 0082/3860] Don't sort values in `EnumFieldRenderer`. The dictionaries used to supply enumeration values should be `OrderedDict` instances if sorting is needed. --- tailbone/forms/renderers/common.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/forms/renderers/common.py b/tailbone/forms/renderers/common.py index f2e7743d..b6f315b3 100644 --- a/tailbone/forms/renderers/common.py +++ b/tailbone/forms/renderers/common.py @@ -100,7 +100,7 @@ class EnumFieldRenderer(SelectFieldRenderer): return self.enumeration.get(value, unicode(value)) def render(self, **kwargs): - opts = [(self.enumeration[x], x) for x in sorted(self.enumeration)] + opts = [(self.enumeration[x], x) for x in self.enumeration] return SelectFieldRenderer.render(self, opts, **kwargs) From 650e6389e574456a7394f5c007f6f677034fa1a4 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 11 Oct 2013 14:15:43 -0700 Subject: [PATCH 0083/3860] Added `Product.family` to CRUD view. --- tailbone/views/products.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index e1a11bbf..14ea3b3f 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -235,6 +235,7 @@ class ProductCrud(CrudView): fs.description, fs.size, fs.department, + fs.family, fs.subdepartment, fs.regular_price, fs.current_price, From 8aaff93ff764d3de1db00c7fa4070665f77000d9 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 11 Oct 2013 14:26:45 -0700 Subject: [PATCH 0084/3860] update changelog --- CHANGES.rst | 25 +++++++++++++++++++++++++ tailbone/_version.py | 2 +- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 98727892..061b34d4 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,4 +1,29 @@ +0.3.9 +----- + +* Added forbidden view. + +* Fixed bug with ``request.has_any_perm()``. + +* Made ``SortableAlchemyGridView`` default to full (100%) width. + +* Refactored ``AutocompleteFieldRenderer``. + + Also improved some organization of renderers. + +* Allow overriding form class/factory for CRUD views. + +* Made ``EnumFieldRenderer`` a proper class. + +* Don't sort values in ``EnumFieldRenderer``. + + The dictionaries used to supply enumeration values should be ``OrderedDict`` + instances if sorting is needed. + +* Added ``Product.family`` to CRUD view. + + 0.3.8 ----- diff --git a/tailbone/_version.py b/tailbone/_version.py index 17a33147..6a49b246 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1 +1 @@ -__version__ = '0.3.8' +__version__ = '0.3.9' From d838203ec7c2f3dec5e8006959258e228eea657d Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 22 Nov 2013 14:39:47 -0800 Subject: [PATCH 0085/3860] Changed `UserFieldRenderer` to leverage `User.display_name`. --- tailbone/forms/renderers/people.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tailbone/forms/renderers/people.py b/tailbone/forms/renderers/people.py index dfaa397d..c6c6e5fc 100644 --- a/tailbone/forms/renderers/people.py +++ b/tailbone/forms/renderers/people.py @@ -85,4 +85,8 @@ class UserFieldRenderer(TextFieldRenderer): Renderer for :class:`rattail.db.model.User` instance fields. """ - pass + def render_readonly(self, **kwargs): + user = self.raw_value + if user: + return user.display_name + return u'' From 1a557f39479495a656d1770d2959d8c34548331c Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 17 Dec 2013 05:57:55 -0800 Subject: [PATCH 0086/3860] Refactored model imports, etc. This is in preparation for using database models only from `rattail` (i.e. no `edbob`). Mostly the model and enum imports were affected. --- tailbone/grids/core.py | 2 +- tailbone/grids/search.py | 2 +- tailbone/reports/ordering_worksheet.mako | 2 +- tailbone/views/batches/core.py | 4 +-- tailbone/views/customers.py | 38 +++++++++++------------- tailbone/views/reports.py | 37 ++++++++++++----------- 6 files changed, 42 insertions(+), 43 deletions(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index f5541525..00667e22 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -36,7 +36,7 @@ from webhelpers.html.builder import format_attrs from pyramid.renderers import render -from edbob.core import Object +from rattail.core import Object __all__ = ['Grid'] diff --git a/tailbone/grids/search.py b/tailbone/grids/search.py index 797c58bb..00e166a4 100644 --- a/tailbone/grids/search.py +++ b/tailbone/grids/search.py @@ -35,7 +35,7 @@ from pyramid.renderers import render from pyramid_simpleform import Form from pyramid_simpleform.renderers import FormRenderer -from edbob.core import Object +from rattail.core import Object from edbob.util import prettify diff --git a/tailbone/reports/ordering_worksheet.mako b/tailbone/reports/ordering_worksheet.mako index 567e5418..477d723f 100644 --- a/tailbone/reports/ordering_worksheet.mako +++ b/tailbone/reports/ordering_worksheet.mako @@ -110,7 +110,7 @@ ${cost.product.brand or ''} ${cost.product.description} ${cost.product.size or ''} - ${cost.case_size} ${rattail.UNIT_OF_MEASURE.get(cost.product.unit_of_measure, '')} + ${cost.case_size} ${rattail.enum.UNIT_OF_MEASURE.get(cost.product.unit_of_measure, '')} ${cost.code or ''} ${'X' if cost.preference == 1 else ''} % for i in range(14): diff --git a/tailbone/views/batches/core.py b/tailbone/views/batches/core.py index ca4fe27d..8751beca 100644 --- a/tailbone/views/batches/core.py +++ b/tailbone/views/batches/core.py @@ -37,7 +37,7 @@ from ...grids.search import BooleanSearchFilter from edbob.pyramid.progress import SessionProgress from .. import SearchableAlchemyGridView, CrudView, View -import rattail +from rattail import enum from rattail import batches from ...db import Session from rattail.db.model import Batch @@ -119,7 +119,7 @@ class BatchCrud(CrudView): def fieldset(self, model): fs = self.make_fieldset(model) - fs.action_type.set(renderer=EnumFieldRenderer(rattail.BATCH_ACTION)) + fs.action_type.set(renderer=EnumFieldRenderer(enum.BATCH_ACTION)) fs.executed.set(renderer=PrettyDateTimeFieldRenderer(from_='utc')) fs.configure( include=[ diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py index 533ec6e5..4f4f68af 100644 --- a/tailbone/views/customers.py +++ b/tailbone/views/customers.py @@ -30,41 +30,37 @@ from sqlalchemy import and_ from edbob.enum import EMAIL_PREFERENCE -from . import SearchableAlchemyGridView +from . import SearchableAlchemyGridView, CrudView from ..forms import EnumFieldRenderer -import rattail from ..db import Session -from rattail.db.model import ( - Customer, CustomerPerson, CustomerGroupAssignment, - CustomerEmailAddress, CustomerPhoneNumber) -from . import CrudView +from rattail.db import model class CustomersGrid(SearchableAlchemyGridView): - mapped_class = Customer + mapped_class = model.Customer config_prefix = 'customers' sort = 'name' def join_map(self): return { 'email': - lambda q: q.outerjoin(CustomerEmailAddress, and_( - CustomerEmailAddress.parent_uuid == Customer.uuid, - CustomerEmailAddress.preference == 1)), + lambda q: q.outerjoin(model.CustomerEmailAddress, and_( + model.CustomerEmailAddress.parent_uuid == model.Customer.uuid, + model.CustomerEmailAddress.preference == 1)), 'phone': - lambda q: q.outerjoin(CustomerPhoneNumber, and_( - CustomerPhoneNumber.parent_uuid == Customer.uuid, - CustomerPhoneNumber.preference == 1)), + lambda q: q.outerjoin(model.CustomerPhoneNumber, and_( + model.CustomerPhoneNumber.parent_uuid == model.Customer.uuid, + model.CustomerPhoneNumber.preference == 1)), } def filter_map(self): return self.make_filter_map( exact=['id'], ilike=['name'], - email=self.filter_ilike(CustomerEmailAddress.address), - phone=self.filter_ilike(CustomerPhoneNumber.number)) + email=self.filter_ilike(model.CustomerEmailAddress.address), + phone=self.filter_ilike(model.CustomerPhoneNumber.number)) def filter_config(self): return self.make_filter_config( @@ -77,8 +73,8 @@ class CustomersGrid(SearchableAlchemyGridView): def sort_map(self): return self.make_sort_map( 'id', 'name', - email=self.sorter(CustomerEmailAddress.address), - phone=self.sorter(CustomerPhoneNumber.number)) + email=self.sorter(model.CustomerEmailAddress.address), + phone=self.sorter(model.CustomerPhoneNumber.number)) def grid(self): g = self.make_grid() @@ -106,20 +102,20 @@ class CustomersGrid(SearchableAlchemyGridView): class CustomerCrud(CrudView): - mapped_class = Customer + mapped_class = model.Customer home_route = 'customers' def get_model(self, key): model = super(CustomerCrud, self).get_model(key) if model: return model - model = Session.query(Customer).filter_by(id=key).first() + model = Session.query(model.Customer).filter_by(id=key).first() if model: return model - model = Session.query(CustomerPerson).get(key) + model = Session.query(model.CustomerPerson).get(key) if model: return model.customer - model = Session.query(CustomerGroupAssignment).get(key) + model = Session.query(model.CustomerGroupAssignment).get(key) if model: return model.customer return None diff --git a/tailbone/views/reports.py b/tailbone/views/reports.py index a4077be6..ccad2e39 100644 --- a/tailbone/views/reports.py +++ b/tailbone/views/reports.py @@ -26,16 +26,19 @@ Report Views """ +import re + from .core import View from mako.template import Template from pyramid.response import Response from ..db import Session -from rattail.db.model import Vendor, Department, Product, ProductCost -import re -import rattail from edbob.time import local_time + +import rattail +from rattail import enum +from rattail.db import model from rattail.files import resource_path @@ -64,13 +67,13 @@ class OrderingWorksheet(View): def __call__(self): if self.request.params.get('vendor'): - vendor = Session.query(Vendor).get(self.request.params['vendor']) + vendor = Session.query(model.Vendor).get(self.request.params['vendor']) if vendor: departments = [] uuids = self.request.params.get('departments') if uuids: for uuid in uuids.split(','): - dept = Session.query(Department).get(uuid) + dept = Session.query(model.Department).get(uuid) if dept: departments.append(dept) preferred_only = self.request.params.get('preferred_only') == '1' @@ -87,12 +90,12 @@ class OrderingWorksheet(View): Rendering engine for the ordering worksheet report. """ - q = Session.query(ProductCost) - q = q.join(Product) - q = q.filter(ProductCost.vendor == vendor) - q = q.filter(Product.department_uuid.in_([x.uuid for x in departments])) + q = Session.query(model.ProductCost) + q = q.join(model.Product) + q = q.filter(model.ProductCost.vendor == vendor) + q = q.filter(model.Product.department_uuid.in_([x.uuid for x in departments])) if preferred_only: - q = q.filter(ProductCost.preference == 1) + q = q.filter(model.ProductCost.preference == 1) costs = {} for cost in q: @@ -138,7 +141,7 @@ class InventoryWorksheet(View): This is the "Inventory Worksheet" report. """ - departments = Session.query(Department) + departments = Session.query(model.Department) if self.request.params.get('department'): department = departments.get(self.request.params['department']) @@ -150,7 +153,7 @@ class InventoryWorksheet(View): response.text = body return response - departments = departments.order_by(rattail.Department.name) + departments = departments.order_by(model.Department.name) departments = departments.all() return{'departments': departments} @@ -160,12 +163,12 @@ class InventoryWorksheet(View): """ def get_products(subdepartment): - q = Session.query(rattail.Product) - q = q.outerjoin(rattail.Brand) - q = q.filter(rattail.Product.subdepartment == subdepartment) + q = Session.query(model.Product) + q = q.outerjoin(model.Brand) + q = q.filter(model.Product.subdepartment == subdepartment) if self.request.params.get('weighted-only'): - q = q.filter(rattail.Product.unit_of_measure == rattail.UNIT_OF_MEASURE_POUND) - q = q.order_by(rattail.Brand.name, rattail.Product.description) + q = q.filter(model.Product.unit_of_measure == enum.UNIT_OF_MEASURE_POUND) + q = q.order_by(model.Brand.name, model.Product.description) return q.all() now = local_time() From 874ad44e6bace3172b6acf4a167e5bbc13a648a9 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 17 Dec 2013 06:09:28 -0800 Subject: [PATCH 0087/3860] Removed references to `edbob.enum`. --- tailbone/views/customers.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py index 4f4f68af..8d0461eb 100644 --- a/tailbone/views/customers.py +++ b/tailbone/views/customers.py @@ -28,12 +28,12 @@ Customer Views from sqlalchemy import and_ -from edbob.enum import EMAIL_PREFERENCE - from . import SearchableAlchemyGridView, CrudView from ..forms import EnumFieldRenderer from ..db import Session + +from rattail import enum from rattail.db import model @@ -122,7 +122,7 @@ class CustomerCrud(CrudView): def fieldset(self, model): fs = self.make_fieldset(model) - fs.email_preference.set(renderer=EnumFieldRenderer(EMAIL_PREFERENCE)) + fs.email_preference.set(renderer=EnumFieldRenderer(enum.EMAIL_PREFERENCE)) fs.configure( include=[ fs.id.label("ID"), From 18453c01131247d7ed9d5c5b5f864bf86ce0decc Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 18 Dec 2013 18:40:16 -0800 Subject: [PATCH 0088/3860] update changelog --- CHANGES.rst | 13 +++++++++++++ setup.py | 2 +- tailbone/_version.py | 2 +- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 061b34d4..aa2a36ba 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,4 +1,17 @@ +0.3.10 +------ + +* Changed ``UserFieldRenderer`` to leverage ``User.display_name``. + +* Refactored model imports, etc. + + This is in preparation for using database models only from ``rattail`` + (i.e. no ``edbob``). Mostly the model and enum imports were affected. + +* Removed references to ``edbob.enum``. + + 0.3.9 ----- diff --git a/setup.py b/setup.py index dca0efa3..f3f80457 100644 --- a/setup.py +++ b/setup.py @@ -74,7 +74,7 @@ requires = [ 'pyramid_exclog', # 0.6 'pyramid_simpleform', # 0.6.1 'pyramid_tm', # 0.3 - 'rattail[db]>=0.3.4', # 0.3.4 + 'rattail[db]>=0.3.9', # 0.3.9 'transaction', # 1.2.0 'waitress', # 0.8.1 'WebHelpers', # 1.3 diff --git a/tailbone/_version.py b/tailbone/_version.py index 6a49b246..7414642e 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1 +1 @@ -__version__ = '0.3.9' +__version__ = '0.3.10' From 7638020aa04f5361f13eb36a41629897ddf2d6f3 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 18 Dec 2013 21:13:03 -0800 Subject: [PATCH 0089/3860] Removed reliance on global `rattail.db.Session` class. --- tailbone/views/batches/core.py | 4 +++- tailbone/views/products.py | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/tailbone/views/batches/core.py b/tailbone/views/batches/core.py index 8751beca..b54256b1 100644 --- a/tailbone/views/batches/core.py +++ b/tailbone/views/batches/core.py @@ -31,6 +31,7 @@ from pyramid.renderers import render_to_response from webhelpers.html import tags +import edbob from edbob.pyramid.forms import PrettyDateTimeFieldRenderer from ...forms import EnumFieldRenderer from ...grids.search import BooleanSearchFilter @@ -140,7 +141,8 @@ class BatchCrud(CrudView): class ExecuteBatch(View): def execute_batch(self, batch, progress): - from rattail.db import Session + from rattail.db import get_session_class + Session = get_session_class(edbob.config) session = Session() batch = session.merge(batch) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 14ea3b3f..de3f6e40 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -300,7 +300,8 @@ def print_labels(request): class CreateProductsBatch(ProductsGrid): def make_batch(self, provider, progress): - from rattail.db import Session + from rattail.db import get_session_class + Session = get_session_class(edbob.config) session = Session() self._filter_config = self.filter_config() From c4257809e525d72d807ce0b41d0b9e5591accf21 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 26 Jan 2014 00:02:15 -0800 Subject: [PATCH 0090/3860] update changelog --- CHANGES.rst | 6 ++++++ tailbone/_version.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index aa2a36ba..75fc8e87 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,4 +1,10 @@ +0.3.11 +------ + +* Removed reliance on global ``rattail.db.Session`` class. + + 0.3.10 ------ diff --git a/tailbone/_version.py b/tailbone/_version.py index 7414642e..b23a3900 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1 +1 @@ -__version__ = '0.3.10' +__version__ = '0.3.11' From a6226700f1d7a5a46767166d17c94bcba1509957 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 26 Jan 2014 13:17:15 -0800 Subject: [PATCH 0091/3860] Fix customer lookup bug in customer detail view. --- tailbone/views/customers.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py index 8d0461eb..f6ada2c3 100644 --- a/tailbone/views/customers.py +++ b/tailbone/views/customers.py @@ -106,18 +106,18 @@ class CustomerCrud(CrudView): home_route = 'customers' def get_model(self, key): - model = super(CustomerCrud, self).get_model(key) - if model: - return model - model = Session.query(model.Customer).filter_by(id=key).first() - if model: - return model - model = Session.query(model.CustomerPerson).get(key) - if model: - return model.customer - model = Session.query(model.CustomerGroupAssignment).get(key) - if model: - return model.customer + customer = super(CustomerCrud, self).get_model(key) + if customer: + return customer + customer = Session.query(model.Customer).filter_by(id=key).first() + if customer: + return customer + person = Session.query(model.CustomerPerson).get(key) + if person: + return person.customer + group = Session.query(model.CustomerGroupAssignment).get(key) + if group: + return group.customer return None def fieldset(self, model): From c6d01e02053b2069a00e1f7d075590b6c0a83261 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 28 Jan 2014 22:14:11 -0800 Subject: [PATCH 0092/3860] Add `SessionProgress` class. --- tailbone/progress.py | 78 ++++++++++++++++++++++++++++++++++ tailbone/views/batches/core.py | 2 +- tailbone/views/products.py | 2 +- 3 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 tailbone/progress.py diff --git a/tailbone/progress.py b/tailbone/progress.py new file mode 100644 index 00000000..04eda280 --- /dev/null +++ b/tailbone/progress.py @@ -0,0 +1,78 @@ +#!/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 . +# +################################################################################ + +""" +Progress Indicator +""" + +from beaker.session import Session + + +def get_progress_session(session, key): + request = session.request + id = '%s.progress.%s' % (session.id, key) + session = Session(request, id) + return session + + +class SessionProgress(object): + """ + Provides a session-based progress bar mechanism. + + This class is only responsible for keeping the progress *data* current. It + is the responsibility of some client-side AJAX (etc.) to consume the data + for display to the user. + """ + + def __init__(self, session, key): + self.session = get_progress_session(session, key) + self.canceled = False + self.clear() + + def __call__(self, message, maximum): + self.clear() + self.session['message'] = message + self.session['maximum'] = maximum + self.session['value'] = 0 + self.session.save() + return self + + def clear(self): + self.session.clear() + self.session['complete'] = False + self.session['error'] = False + self.session['canceled'] = False + self.session.save() + + def update(self, value): + self.session.load() + if self.session.get('canceled'): + self.canceled = True + else: + self.session['value'] = value + self.session.save() + return not self.canceled + + def destroy(self): + pass diff --git a/tailbone/views/batches/core.py b/tailbone/views/batches/core.py index b54256b1..67716106 100644 --- a/tailbone/views/batches/core.py +++ b/tailbone/views/batches/core.py @@ -35,8 +35,8 @@ import edbob from edbob.pyramid.forms import PrettyDateTimeFieldRenderer from ...forms import EnumFieldRenderer from ...grids.search import BooleanSearchFilter -from edbob.pyramid.progress import SessionProgress from .. import SearchableAlchemyGridView, CrudView, View +from ...progress import SessionProgress from rattail import enum from rattail import batches diff --git a/tailbone/views/products.py b/tailbone/views/products.py index de3f6e40..5f5804ac 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -35,7 +35,6 @@ from pyramid.httpexceptions import HTTPFound from pyramid.renderers import render_to_response import edbob -from edbob.pyramid.progress import SessionProgress from . import SearchableAlchemyGridView import rattail.labels @@ -52,6 +51,7 @@ from rattail.db.api import get_product_by_upc from ..db import Session from ..forms import GPCFieldRenderer, BrandFieldRenderer, PriceFieldRenderer from . import CrudView +from ..progress import SessionProgress class ProductsGrid(SearchableAlchemyGridView): From 6fcb5a5ddf5d8e4b4198dc5bc7d6ab9519b5c9c7 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 28 Jan 2014 22:35:33 -0800 Subject: [PATCH 0093/3860] Add `progress` views. --- tailbone/views/__init__.py | 1 + tailbone/views/progress.py | 58 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 tailbone/views/progress.py diff --git a/tailbone/views/__init__.py b/tailbone/views/__init__.py index 975028e8..c1b27547 100644 --- a/tailbone/views/__init__.py +++ b/tailbone/views/__init__.py @@ -61,6 +61,7 @@ def includeme(config): config.include('tailbone.views.labels') config.include('tailbone.views.people') config.include('tailbone.views.products') + config.include('tailbone.views.progress') config.include('tailbone.views.roles') config.include('tailbone.views.stores') config.include('tailbone.views.subdepartments') diff --git a/tailbone/views/progress.py b/tailbone/views/progress.py new file mode 100644 index 00000000..83a6297a --- /dev/null +++ b/tailbone/views/progress.py @@ -0,0 +1,58 @@ +#!/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 . +# +################################################################################ + +""" +Progress Views +""" + +from ..progress import get_progress_session + + +def progress(request): + key = request.matchdict['key'] + session = get_progress_session(request.session, key) + if session.get('complete'): + request.session.flash(session.get('success_msg', "The process has completed successfully.")) + elif session.get('error'): + request.session.flash(session.get('error_msg', "An unspecified error occurred."), 'error') + return session + + +def cancel(request): + key = request.matchdict['key'] + session = get_progress_session(request.session, key) + session.clear() + session['canceled'] = True + session.save() + msg = request.params.get('cancel_msg', "The operation was canceled.") + request.session.flash(msg) + return {} + + +def includeme(config): + config.add_route('progress', '/progress/{key}') + config.add_view(progress, route_name='progress', renderer='json') + + config.add_route('progress.cancel', '/progress/{key}/cancel') + config.add_view(cancel, route_name='progress.cancel', renderer='json') From f3947dc6de2833ab7a7c41eac913e50024fb0a00 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 30 Jan 2014 09:28:19 -0800 Subject: [PATCH 0094/3860] update changelog --- CHANGES.rst | 8 ++++++++ tailbone/_version.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 75fc8e87..453118c8 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,4 +1,12 @@ +0.3.12 +------ + +* Fix customer lookup bug in customer detail view. + +* Add ``SessionProgress`` class, and ``progress`` views. + + 0.3.11 ------ diff --git a/tailbone/_version.py b/tailbone/_version.py index b23a3900..abda4949 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1 +1 @@ -__version__ = '0.3.11' +__version__ = '0.3.12' From d6f2b1afb111077783f674c852734bf30455feff Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 15 Feb 2014 19:27:22 -0800 Subject: [PATCH 0095/3860] Use global `Session` from rattail (again). --- tailbone/views/batches/core.py | 3 +-- tailbone/views/products.py | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/tailbone/views/batches/core.py b/tailbone/views/batches/core.py index 67716106..7b2aab4c 100644 --- a/tailbone/views/batches/core.py +++ b/tailbone/views/batches/core.py @@ -141,8 +141,7 @@ class BatchCrud(CrudView): class ExecuteBatch(View): def execute_batch(self, batch, progress): - from rattail.db import get_session_class - Session = get_session_class(edbob.config) + from rattail.db import Session session = Session() batch = session.merge(batch) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 5f5804ac..8b2104a3 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -300,8 +300,7 @@ def print_labels(request): class CreateProductsBatch(ProductsGrid): def make_batch(self, provider, progress): - from rattail.db import get_session_class - Session = get_session_class(edbob.config) + from rattail.db import Session session = Session() self._filter_config = self.filter_config() From 67f8960655bbf4f99d9b46228ec6244c269b1f5d Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 15 Feb 2014 19:27:50 -0800 Subject: [PATCH 0096/3860] Apply zope transaction to global Tailbone Session class. --- tailbone/db.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/tailbone/db.py b/tailbone/db.py index d5bf1025..de50902b 100644 --- a/tailbone/db.py +++ b/tailbone/db.py @@ -29,7 +29,14 @@ Database Stuff from sqlalchemy.orm import sessionmaker, scoped_session -__all__ = ['Session'] - - Session = scoped_session(sessionmaker()) + + +try: + # Requires zope.sqlalchemy >= 0.7.4 + from zope.sqlalchemy import register +except ImportError: + from zope.sqlalchemy import ZopeTransactionExtension + Session.configure(extension=ZopeTransactionExtension()) +else: + register(Session) From bc156b46f4582ea224e104e9031522ec4d101b7c Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 15 Feb 2014 22:39:27 -0800 Subject: [PATCH 0097/3860] Fix tests for Python 2.6. --- tests/views/test_autocomplete.py | 6 +++--- tests/views/test_departments.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/views/test_autocomplete.py b/tests/views/test_autocomplete.py index bfb7ad22..dc630af4 100644 --- a/tests/views/test_autocomplete.py +++ b/tests/views/test_autocomplete.py @@ -21,7 +21,7 @@ class BareAutocompleteViewTests(TestCase): view = self.view() query = Mock() filtered = view.filter_query(query) - self.assertIs(filtered, query) + self.assertTrue(filtered is query) def test_make_query(self): view = self.view() @@ -33,7 +33,7 @@ class BareAutocompleteViewTests(TestCase): query = Mock() view.make_query = Mock(return_value=query) filtered = view.query('test') - self.assertIs(filtered, query) + self.assertTrue(filtered is query) def test_display(self): view = self.view() @@ -78,7 +78,7 @@ class SampleAutocompleteViewTests(TestCase): def test_make_query(self): view = self.view() view.mapped_class.thing.ilike.return_value = 'whatever' - self.assertIs(view.make_query('test'), self.query) + self.assertTrue(view.make_query('test') is self.query) view.mapped_class.thing.ilike.assert_called_with('%test%') self.query.filter.assert_called_with('whatever') self.query.order_by.assert_called_with(view.mapped_class.thing) diff --git a/tests/views/test_departments.py b/tests/views/test_departments.py index 877fecf0..3041359d 100644 --- a/tests/views/test_departments.py +++ b/tests/views/test_departments.py @@ -71,7 +71,7 @@ class DepartmentCrudTests(TestCase): fieldset = Mock() view.make_fieldset = Mock(return_value=fieldset) fs = view.fieldset(Mock()) - self.assertIs(fs, fieldset) + self.assertTrue(fs is fieldset) class DepartmentsByVendorGridTests(TestCase): @@ -84,10 +84,10 @@ class DepartmentsByVendorGridTests(TestCase): query = mock_query() view = self.view(params={'uuid': '1'}) view.make_query = Mock(return_value=query) - self.assertIs(view.query(), query) + self.assertTrue(view.query() is query) def test_grid(self): view = self.view() grid = Mock() view.make_grid = Mock(return_value=grid) - self.assertIs(view.grid(), grid) + self.assertTrue(view.grid() is grid) From 247c8d7060aae85cc1ed85b22ca0242e98aecad2 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 15 Feb 2014 22:45:11 -0800 Subject: [PATCH 0098/3860] Remove change log from project description. --- setup.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/setup.py b/setup.py index f3f80457..e78b4480 100644 --- a/setup.py +++ b/setup.py @@ -30,7 +30,6 @@ from setuptools import setup, find_packages here = os.path.abspath(os.path.dirname(__file__)) execfile(os.path.join(here, 'tailbone', '_version.py')) README = open(os.path.join(here, 'README.txt')).read() -CHANGES = open(os.path.join(here, 'CHANGES.rst')).read() requires = [ @@ -104,7 +103,7 @@ setup( url = "http://rattail.edbob.org/", license = "GNU Affero GPL v3", description = "Backoffice Web Application for Rattail", - long_description = README + '\n\n' + CHANGES, + long_description = READMES, classifiers = [ 'Development Status :: 3 - Alpha', From 580e91c54419da789f71cc461c8b7157390483f1 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 15 Feb 2014 22:53:53 -0800 Subject: [PATCH 0099/3860] Rename README to .rst. --- README.txt => README.rst | 0 setup.py | 4 ++-- 2 files changed, 2 insertions(+), 2 deletions(-) rename README.txt => README.rst (100%) diff --git a/README.txt b/README.rst similarity index 100% rename from README.txt rename to README.rst diff --git a/setup.py b/setup.py index e78b4480..3b6b11ea 100644 --- a/setup.py +++ b/setup.py @@ -29,7 +29,7 @@ from setuptools import setup, find_packages here = os.path.abspath(os.path.dirname(__file__)) execfile(os.path.join(here, 'tailbone', '_version.py')) -README = open(os.path.join(here, 'README.txt')).read() +README = open(os.path.join(here, 'README.rst')).read() requires = [ @@ -103,7 +103,7 @@ setup( url = "http://rattail.edbob.org/", license = "GNU Affero GPL v3", description = "Backoffice Web Application for Rattail", - long_description = READMES, + long_description = README, classifiers = [ 'Development Status :: 3 - Alpha', From 4032efbc618ce0da203731839e714de524486e35 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 15 Feb 2014 23:17:34 -0800 Subject: [PATCH 0100/3860] Add extra 'docs' requirements. --- setup.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/setup.py b/setup.py index 3b6b11ea..603aa34a 100644 --- a/setup.py +++ b/setup.py @@ -83,6 +83,13 @@ requires = [ extras = { + 'docs': [ + # + # package # low high + + 'Sphinx', # 1.2 + ], + 'tests': [ # # package # low high From 72c7f698733ee64d3c90351d652b263fcd1ed599 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 15 Feb 2014 23:18:30 -0800 Subject: [PATCH 0101/3860] Initial docs (as generated by sphinx-quickstart). --- docs/Makefile | 177 +++++++++++++++++++++++++++++++++ docs/conf.py | 261 +++++++++++++++++++++++++++++++++++++++++++++++++ docs/index.rst | 22 +++++ docs/make.bat | 242 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 702 insertions(+) create mode 100644 docs/Makefile create mode 100644 docs/conf.py create mode 100644 docs/index.rst create mode 100644 docs/make.bat diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 00000000..ea41334a --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,177 @@ +# Makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +PAPER = +BUILDDIR = _build + +# User-friendly check for sphinx-build +ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) +$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) +endif + +# Internal variables. +PAPEROPT_a4 = -D latex_paper_size=a4 +PAPEROPT_letter = -D latex_paper_size=letter +ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . +# the i18n builder cannot share the environment and doctrees with the others +I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . + +.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext + +help: + @echo "Please use \`make ' where is one of" + @echo " html to make standalone HTML files" + @echo " dirhtml to make HTML files named index.html in directories" + @echo " singlehtml to make a single large HTML file" + @echo " pickle to make pickle files" + @echo " json to make JSON files" + @echo " htmlhelp to make HTML files and a HTML help project" + @echo " qthelp to make HTML files and a qthelp project" + @echo " devhelp to make HTML files and a Devhelp project" + @echo " epub to make an epub" + @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" + @echo " latexpdf to make LaTeX files and run them through pdflatex" + @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" + @echo " text to make text files" + @echo " man to make manual pages" + @echo " texinfo to make Texinfo files" + @echo " info to make Texinfo files and run them through makeinfo" + @echo " gettext to make PO message catalogs" + @echo " changes to make an overview of all changed/added/deprecated items" + @echo " xml to make Docutils-native XML files" + @echo " pseudoxml to make pseudoxml-XML files for display purposes" + @echo " linkcheck to check all external links for integrity" + @echo " doctest to run all doctests embedded in the documentation (if enabled)" + +clean: + rm -rf $(BUILDDIR)/* + +html: + $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." + +dirhtml: + $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." + +singlehtml: + $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml + @echo + @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." + +pickle: + $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle + @echo + @echo "Build finished; now you can process the pickle files." + +json: + $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json + @echo + @echo "Build finished; now you can process the JSON files." + +htmlhelp: + $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp + @echo + @echo "Build finished; now you can run HTML Help Workshop with the" \ + ".hhp project file in $(BUILDDIR)/htmlhelp." + +qthelp: + $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp + @echo + @echo "Build finished; now you can run "qcollectiongenerator" with the" \ + ".qhcp project file in $(BUILDDIR)/qthelp, like this:" + @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Tailbone.qhcp" + @echo "To view the help file:" + @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Tailbone.qhc" + +devhelp: + $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp + @echo + @echo "Build finished." + @echo "To view the help file:" + @echo "# mkdir -p $$HOME/.local/share/devhelp/Tailbone" + @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Tailbone" + @echo "# devhelp" + +epub: + $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub + @echo + @echo "Build finished. The epub file is in $(BUILDDIR)/epub." + +latex: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo + @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." + @echo "Run \`make' in that directory to run these through (pdf)latex" \ + "(use \`make latexpdf' here to do that automatically)." + +latexpdf: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through pdflatex..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +latexpdfja: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through platex and dvipdfmx..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +text: + $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text + @echo + @echo "Build finished. The text files are in $(BUILDDIR)/text." + +man: + $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man + @echo + @echo "Build finished. The manual pages are in $(BUILDDIR)/man." + +texinfo: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo + @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." + @echo "Run \`make' in that directory to run these through makeinfo" \ + "(use \`make info' here to do that automatically)." + +info: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo "Running Texinfo files through makeinfo..." + make -C $(BUILDDIR)/texinfo info + @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." + +gettext: + $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale + @echo + @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." + +changes: + $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes + @echo + @echo "The overview file is in $(BUILDDIR)/changes." + +linkcheck: + $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck + @echo + @echo "Link check complete; look for any errors in the above output " \ + "or in $(BUILDDIR)/linkcheck/output.txt." + +doctest: + $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest + @echo "Testing of doctests in the sources finished, look at the " \ + "results in $(BUILDDIR)/doctest/output.txt." + +xml: + $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml + @echo + @echo "Build finished. The XML files are in $(BUILDDIR)/xml." + +pseudoxml: + $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml + @echo + @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 00000000..168f068e --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,261 @@ +# -*- coding: utf-8 -*- +# +# Tailbone documentation build configuration file, created by +# sphinx-quickstart on Sat Feb 15 23:15:27 2014. +# +# This file is execfile()d with the current directory set to its +# containing dir. +# +# Note that not all possible configuration values are present in this +# autogenerated file. +# +# All configuration values have a default; values that are commented out +# serve to show the default. + +import sys +import os + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +#sys.path.insert(0, os.path.abspath('.')) + +# -- General configuration ------------------------------------------------ + +# If your documentation needs a minimal Sphinx version, state it here. +#needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.todo', +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix of source filenames. +source_suffix = '.rst' + +# The encoding of source files. +#source_encoding = 'utf-8-sig' + +# The master toctree document. +master_doc = 'index' + +# General information about the project. +project = u'Tailbone' +copyright = u'2014, Lance Edgar' + +# The version info for the project you're documenting, acts as replacement for +# |version| and |release|, also used in various other places throughout the +# built documents. +# +# The short X.Y version. +version = '0.3' +# The full version, including alpha/beta/rc tags. +release = '0.3.12' + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +#language = None + +# There are two options for replacing |today|: either, you set today to some +# non-false value, then it is used: +#today = '' +# Else, today_fmt is used as the format for a strftime call. +#today_fmt = '%B %d, %Y' + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +exclude_patterns = ['_build'] + +# The reST default role (used for this markup: `text`) to use for all +# documents. +#default_role = None + +# If true, '()' will be appended to :func: etc. cross-reference text. +#add_function_parentheses = True + +# If true, the current module name will be prepended to all description +# unit titles (such as .. function::). +#add_module_names = True + +# If true, sectionauthor and moduleauthor directives will be shown in the +# output. They are ignored by default. +#show_authors = False + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'sphinx' + +# A list of ignored prefixes for module index sorting. +#modindex_common_prefix = [] + +# If true, keep warnings as "system message" paragraphs in the built documents. +#keep_warnings = False + + +# -- Options for HTML output ---------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +html_theme = 'default' + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +#html_theme_options = {} + +# Add any paths that contain custom themes here, relative to this directory. +#html_theme_path = [] + +# The name for this set of Sphinx documents. If None, it defaults to +# " v documentation". +#html_title = None + +# A shorter title for the navigation bar. Default is the same as html_title. +#html_short_title = None + +# The name of an image file (relative to this directory) to place at the top +# of the sidebar. +#html_logo = None + +# The name of an image file (within the static path) to use as favicon of the +# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 +# pixels large. +#html_favicon = None + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + +# Add any extra paths that contain custom files (such as robots.txt or +# .htaccess) here, relative to this directory. These files are copied +# directly to the root of the documentation. +#html_extra_path = [] + +# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, +# using the given strftime format. +#html_last_updated_fmt = '%b %d, %Y' + +# If true, SmartyPants will be used to convert quotes and dashes to +# typographically correct entities. +#html_use_smartypants = True + +# Custom sidebar templates, maps document names to template names. +#html_sidebars = {} + +# Additional templates that should be rendered to pages, maps page names to +# template names. +#html_additional_pages = {} + +# If false, no module index is generated. +#html_domain_indices = True + +# If false, no index is generated. +#html_use_index = True + +# If true, the index is split into individual pages for each letter. +#html_split_index = False + +# If true, links to the reST sources are added to the pages. +#html_show_sourcelink = True + +# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. +#html_show_sphinx = True + +# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. +#html_show_copyright = True + +# If true, an OpenSearch description file will be output, and all pages will +# contain a tag referring to it. The value of this option must be the +# base URL from which the finished HTML is served. +#html_use_opensearch = '' + +# This is the file name suffix for HTML files (e.g. ".xhtml"). +#html_file_suffix = None + +# Output file base name for HTML help builder. +htmlhelp_basename = 'Tailbonedoc' + + +# -- Options for LaTeX output --------------------------------------------- + +latex_elements = { +# The paper size ('letterpaper' or 'a4paper'). +#'papersize': 'letterpaper', + +# The font size ('10pt', '11pt' or '12pt'). +#'pointsize': '10pt', + +# Additional stuff for the LaTeX preamble. +#'preamble': '', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, +# author, documentclass [howto, manual, or own class]). +latex_documents = [ + ('index', 'Tailbone.tex', u'Tailbone Documentation', + u'Lance Edgar', 'manual'), +] + +# The name of an image file (relative to this directory) to place at the top of +# the title page. +#latex_logo = None + +# For "manual" documents, if this is true, then toplevel headings are parts, +# not chapters. +#latex_use_parts = False + +# If true, show page references after internal links. +#latex_show_pagerefs = False + +# If true, show URL addresses after external links. +#latex_show_urls = False + +# Documents to append as an appendix to all manuals. +#latex_appendices = [] + +# If false, no module index is generated. +#latex_domain_indices = True + + +# -- Options for manual page output --------------------------------------- + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + ('index', 'tailbone', u'Tailbone Documentation', + [u'Lance Edgar'], 1) +] + +# If true, show URL addresses after external links. +#man_show_urls = False + + +# -- Options for Texinfo output ------------------------------------------- + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + ('index', 'Tailbone', u'Tailbone Documentation', + u'Lance Edgar', 'Tailbone', 'One line description of project.', + 'Miscellaneous'), +] + +# Documents to append as an appendix to all manuals. +#texinfo_appendices = [] + +# If false, no module index is generated. +#texinfo_domain_indices = True + +# How to display URL addresses: 'footnote', 'no', or 'inline'. +#texinfo_show_urls = 'footnote' + +# If true, do not generate a @detailmenu in the "Top" node's menu. +#texinfo_no_detailmenu = False diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 00000000..cbfb9772 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,22 @@ +.. Tailbone documentation master file, created by + sphinx-quickstart on Sat Feb 15 23:15:27 2014. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +Welcome to Tailbone's documentation! +==================================== + +Contents: + +.. toctree:: + :maxdepth: 2 + + + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` + diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 00000000..c2898264 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,242 @@ +@ECHO OFF + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set BUILDDIR=_build +set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . +set I18NSPHINXOPTS=%SPHINXOPTS% . +if NOT "%PAPER%" == "" ( + set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% + set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% +) + +if "%1" == "" goto help + +if "%1" == "help" ( + :help + echo.Please use `make ^` where ^ is one of + echo. html to make standalone HTML files + echo. dirhtml to make HTML files named index.html in directories + echo. singlehtml to make a single large HTML file + echo. pickle to make pickle files + echo. json to make JSON files + echo. htmlhelp to make HTML files and a HTML help project + echo. qthelp to make HTML files and a qthelp project + echo. devhelp to make HTML files and a Devhelp project + echo. epub to make an epub + echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter + echo. text to make text files + echo. man to make manual pages + echo. texinfo to make Texinfo files + echo. gettext to make PO message catalogs + echo. changes to make an overview over all changed/added/deprecated items + echo. xml to make Docutils-native XML files + echo. pseudoxml to make pseudoxml-XML files for display purposes + echo. linkcheck to check all external links for integrity + echo. doctest to run all doctests embedded in the documentation if enabled + goto end +) + +if "%1" == "clean" ( + for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i + del /q /s %BUILDDIR%\* + goto end +) + + +%SPHINXBUILD% 2> nul +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "html" ( + %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/html. + goto end +) + +if "%1" == "dirhtml" ( + %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. + goto end +) + +if "%1" == "singlehtml" ( + %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. + goto end +) + +if "%1" == "pickle" ( + %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can process the pickle files. + goto end +) + +if "%1" == "json" ( + %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can process the JSON files. + goto end +) + +if "%1" == "htmlhelp" ( + %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can run HTML Help Workshop with the ^ +.hhp project file in %BUILDDIR%/htmlhelp. + goto end +) + +if "%1" == "qthelp" ( + %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can run "qcollectiongenerator" with the ^ +.qhcp project file in %BUILDDIR%/qthelp, like this: + echo.^> qcollectiongenerator %BUILDDIR%\qthelp\Tailbone.qhcp + echo.To view the help file: + echo.^> assistant -collectionFile %BUILDDIR%\qthelp\Tailbone.ghc + goto end +) + +if "%1" == "devhelp" ( + %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. + goto end +) + +if "%1" == "epub" ( + %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The epub file is in %BUILDDIR%/epub. + goto end +) + +if "%1" == "latex" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "latexpdf" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + cd %BUILDDIR%/latex + make all-pdf + cd %BUILDDIR%/.. + echo. + echo.Build finished; the PDF files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "latexpdfja" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + cd %BUILDDIR%/latex + make all-pdf-ja + cd %BUILDDIR%/.. + echo. + echo.Build finished; the PDF files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "text" ( + %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The text files are in %BUILDDIR%/text. + goto end +) + +if "%1" == "man" ( + %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The manual pages are in %BUILDDIR%/man. + goto end +) + +if "%1" == "texinfo" ( + %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. + goto end +) + +if "%1" == "gettext" ( + %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The message catalogs are in %BUILDDIR%/locale. + goto end +) + +if "%1" == "changes" ( + %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes + if errorlevel 1 exit /b 1 + echo. + echo.The overview file is in %BUILDDIR%/changes. + goto end +) + +if "%1" == "linkcheck" ( + %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck + if errorlevel 1 exit /b 1 + echo. + echo.Link check complete; look for any errors in the above output ^ +or in %BUILDDIR%/linkcheck/output.txt. + goto end +) + +if "%1" == "doctest" ( + %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest + if errorlevel 1 exit /b 1 + echo. + echo.Testing of doctests in the sources finished, look at the ^ +results in %BUILDDIR%/doctest/output.txt. + goto end +) + +if "%1" == "xml" ( + %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The XML files are in %BUILDDIR%/xml. + goto end +) + +if "%1" == "pseudoxml" ( + %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. + goto end +) + +:end From 9c9706cee29abdbb5e99d97d21f41483810e0129 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 15 Feb 2014 23:28:50 -0800 Subject: [PATCH 0102/3860] Docs tweak; testing buildbot. --- docs/index.rst | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index cbfb9772..d9d6e5a7 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,15 +1,16 @@ -.. Tailbone documentation master file, created by - sphinx-quickstart on Sat Feb 15 23:15:27 2014. - You can adapt this file completely to your liking, but it should at least - contain the root `toctree` directive. -Welcome to Tailbone's documentation! -==================================== +Tailbone +======== -Contents: +Welcome to Tailbone, part of the Rattail project. -.. toctree:: - :maxdepth: 2 +The documentation you are currently reading is for the Tailbone web application +package. More information is (sort of) available at http://rattail.edbob.org/. + +.. Contents: + +.. .. toctree:: +.. :maxdepth: 2 From 6a29000db741f239b798c587ecef58a06f58f74a Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 15 Feb 2014 23:54:17 -0800 Subject: [PATCH 0103/3860] Add global release version to docs. --- docs/conf.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index 168f068e..09bbd359 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -15,6 +15,9 @@ import sys import os +execfile(os.path.join(os.pardir, 'tailbone', '_version.py')) + + # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. @@ -56,7 +59,7 @@ copyright = u'2014, Lance Edgar' # The short X.Y version. version = '0.3' # The full version, including alpha/beta/rc tags. -release = '0.3.12' +release = __version__ # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. From 354635e9730e5312d1c748270ed0acedbacdad4a Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 15 Feb 2014 23:56:45 -0800 Subject: [PATCH 0104/3860] Add hidden file to force presence of `docs/_static`. --- docs/_static/.dummy | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/_static/.dummy diff --git a/docs/_static/.dummy b/docs/_static/.dummy new file mode 100644 index 00000000..e69de29b From 54dff4a81244f352b2de5e959087a09a23b03fbb Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 16 Feb 2014 00:04:17 -0800 Subject: [PATCH 0105/3860] Docs tweak (test buildbot). --- docs/index.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index d9d6e5a7..f5b66144 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -7,10 +7,10 @@ Welcome to Tailbone, part of the Rattail project. The documentation you are currently reading is for the Tailbone web application package. More information is (sort of) available at http://rattail.edbob.org/. -.. Contents: +Contents: -.. .. toctree:: -.. :maxdepth: 2 +.. toctree:: + :maxdepth: 2 From e47209527115f09666c3a6e421fcde2289cff96e Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 16 Feb 2014 00:21:03 -0800 Subject: [PATCH 0106/3860] Bump rattail dependency. --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 603aa34a..4b25b91d 100644 --- a/setup.py +++ b/setup.py @@ -73,7 +73,7 @@ requires = [ 'pyramid_exclog', # 0.6 'pyramid_simpleform', # 0.6.1 'pyramid_tm', # 0.3 - 'rattail[db]>=0.3.9', # 0.3.9 + 'rattail[db]>=0.3.18', # 0.3.18 'transaction', # 1.2.0 'waitress', # 0.8.1 'WebHelpers', # 1.3 From 23ffcc5a784d5034c948c4f2803ee8b3a908aca0 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 16 Feb 2014 19:19:30 -0800 Subject: [PATCH 0107/3860] update changelog --- CHANGES.rst | 8 ++++++++ tailbone/_version.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 453118c8..9be0ebb3 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,4 +1,12 @@ +0.3.13 +------ + +* Use global ``Session`` from rattail (again). + +* Apply zope transaction to global Tailbone Session class. + + 0.3.12 ------ diff --git a/tailbone/_version.py b/tailbone/_version.py index abda4949..8300a52e 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1 +1 @@ -__version__ = '0.3.12' +__version__ = '0.3.13' From a958a7b285f8b763324fd3df3000a7f781cfdc91 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 21 Feb 2014 10:10:10 -0800 Subject: [PATCH 0108/3860] Add event hook for attaching Rattail `config` to new requests. --- docs/api/subscribers.rst | 7 +++++++ docs/index.rst | 8 ++++++-- tailbone/subscribers.py | 30 ++++++++++++++++++++++++++++++ tests/test_subscribers.py | 28 +++++++++++++++++++++++++++- 4 files changed, 70 insertions(+), 3 deletions(-) create mode 100644 docs/api/subscribers.rst diff --git a/docs/api/subscribers.rst b/docs/api/subscribers.rst new file mode 100644 index 00000000..abafe0c9 --- /dev/null +++ b/docs/api/subscribers.rst @@ -0,0 +1,7 @@ + +``tailbone.subscribers`` +======================== + +.. automodule:: tailbone.subscribers + +.. autofunction:: add_rattail_config_attribute_to_request diff --git a/docs/index.rst b/docs/index.rst index f5b66144..19c21af6 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -5,13 +5,17 @@ Tailbone Welcome to Tailbone, part of the Rattail project. The documentation you are currently reading is for the Tailbone web application -package. More information is (sort of) available at http://rattail.edbob.org/. +package. More information is (sort of) available at http://rattailproject.org/. -Contents: +Clearly not everything is documented yet. Below you can see what has received +some attention thus far. + +API: .. toctree:: :maxdepth: 2 + api/subscribers Indices and tables diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index eea4f403..890d6809 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.py @@ -36,6 +36,35 @@ from rattail.db.model import User from rattail.db.auth import has_permission +def add_rattail_config_attribute_to_request(event): + """ + Add a ``rattail_config`` attribute to a request object. + + This function is really just a matter of convenience, but it should help to + make other code more terse (example below). It is designed to act as a + subscriber to the Pyramid ``NewRequest`` event. + + A global Rattail ``config`` should already be present within the Pyramid + application registry's settings, which would normally be accessed via:: + + request.registry.settings['rattail_config'] + + This function merely "promotes" this config object so that it is more + directly accessible, a la:: + + request.rattail_config + + .. note:: + All this of course assumes that a Rattail ``config`` object *has* in + fact already been placed in the application registry settings. If this + is not the case, this function will do nothing. + """ + request = event.request + rattail_config = request.registry.settings.get('rattail_config') + if rattail_config: + request.rattail_config = rattail_config + + def before_render(event): """ Adds goodies to the global template renderer context. @@ -96,5 +125,6 @@ def context_found(event): def includeme(config): + config.add_subscriber(add_rattail_config_attribute_to_request, 'pyramid.events.NewRequest') config.add_subscriber(before_render, 'pyramid.events.BeforeRender') config.add_subscriber(context_found, 'pyramid.events.ContextFound') diff --git a/tests/test_subscribers.py b/tests/test_subscribers.py index 7e63b7c7..4317a262 100644 --- a/tests/test_subscribers.py +++ b/tests/test_subscribers.py @@ -1,7 +1,9 @@ +from unittest import TestCase + from mock import Mock +from pyramid import testing -from . import TestCase from tailbone import subscribers @@ -11,3 +13,27 @@ class SubscribersTests(TestCase): event = Mock() event.__setitem__ = Mock() subscribers.before_render(event) + + +class TestAddRattailConfigAttributeToRequest(TestCase): + + def test_nothing_is_done_if_no_config_in_registry_settings(self): + request = testing.DummyRequest() + config = testing.setUp(request=request) + self.assertFalse('rattail_config' in request.registry.settings) + self.assertFalse(hasattr(request, 'rattail_config')) + event = Mock(request=request) + subscribers.add_rattail_config_attribute_to_request(event) + self.assertFalse(hasattr(request, 'rattail_config')) + testing.tearDown() + + def test_attribute_added_if_config_present_in_registry_settings(self): + rattail_config = Mock() + request = testing.DummyRequest() + config = testing.setUp(request=request, settings={'rattail_config': rattail_config}) + self.assertTrue('rattail_config' in request.registry.settings) + self.assertFalse(hasattr(request, 'rattail_config')) + event = Mock(request=request) + subscribers.add_rattail_config_attribute_to_request(event) + self.assertTrue(request.rattail_config is rattail_config) + testing.tearDown() From 0d6569195217780cf61505e924e118842ef63a21 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 21 Feb 2014 10:10:49 -0800 Subject: [PATCH 0109/3860] Update URL references to Rattail home page. --- README.rst | 5 +++-- setup.py | 2 +- tailbone/templates/base.mako | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/README.rst b/README.rst index 3f47d5c7..0cffc62d 100644 --- a/README.rst +++ b/README.rst @@ -5,5 +5,6 @@ Tailbone Tailbone is an extensible web application based on Rattail. It provides a "back-office network environment" (BONE) for use in managing retail data. -Please see Rattail's `home page `_ for more -information. +Please see Rattail's `home page`_ for more information. + +.. _home page: http://rattailproject.org/ diff --git a/setup.py b/setup.py index 4b25b91d..ce3af4e7 100644 --- a/setup.py +++ b/setup.py @@ -107,7 +107,7 @@ setup( version = __version__, author = "Lance Edgar", author_email = "lance@edbob.org", - url = "http://rattail.edbob.org/", + url = "http://rattailproject.org/", license = "GNU Affero GPL v3", description = "Backoffice Web Application for Rattail", long_description = README, diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako index 0dcd4348..31c5df56 100644 --- a/tailbone/templates/base.mako +++ b/tailbone/templates/base.mako @@ -130,7 +130,7 @@ From e4ef46d4fc5cc10ff5bfdb309c96f782ca165abe Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 12 Apr 2014 19:14:14 -0700 Subject: [PATCH 0110/3860] Fix vendor filter/sort issues in products grid. --- tailbone/views/products.py | 47 +++++++++++++++++++++++++------------- 1 file changed, 31 insertions(+), 16 deletions(-) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 8b2104a3..079bdc5d 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -27,7 +27,7 @@ Product Views """ from sqlalchemy import and_ -from sqlalchemy.orm import joinedload +from sqlalchemy.orm import joinedload, aliased from webhelpers.html.tags import link_to @@ -60,18 +60,32 @@ class ProductsGrid(SearchableAlchemyGridView): config_prefix = 'products' sort = 'description' + # These aliases enable the grid queries to filter products which may be + # purchased from *any* vendor, and yet sort by only the "preferred" vendor + # (since that's what shows up in the grid column). + ProductCostAny = aliased(ProductCost) + VendorAny = aliased(Vendor) + def join_map(self): - # def join_vendor(q): - # q = q.outerjoin( - # ProductCost, - # ProductCost.product_uuid == Product.uuid, - # and_( - # ProductCost.product_uuid == Product.uuid, - # ProductCost.preference == 1, - # )) - # q = q.outerjoin(Vendor) - # return q + def join_vendor(q): + q = q.outerjoin( + ProductCost, + and_( + ProductCost.product_uuid == Product.uuid, + ProductCost.preference == 1, + )) + q = q.outerjoin(Vendor) + return q + + def join_vendor_any(q): + q = q.outerjoin( + self.ProductCostAny, + self.ProductCostAny.product_uuid == Product.uuid) + q = q.outerjoin( + self.VendorAny, + self.VendorAny.uuid == self.ProductCostAny.vendor_uuid) + return q return { 'brand': @@ -88,10 +102,10 @@ class ProductsGrid(SearchableAlchemyGridView): 'current_price': lambda q: q.outerjoin(ProductPrice, ProductPrice.uuid == Product.current_price_uuid), - # 'vendor': - # join_vendor, + 'vendor': + join_vendor, 'vendor_any': - lambda q: q.outerjoin(ProductCost, ProductCost.product_uuid == Product.uuid).outerjoin(Vendor), + join_vendor_any, 'code': lambda q: q.outerjoin(ProductCode), } @@ -126,8 +140,8 @@ class ProductsGrid(SearchableAlchemyGridView): brand=self.filter_ilike(Brand.name), department=self.filter_ilike(Department.name), subdepartment=self.filter_ilike(Subdepartment.name), - # vendor=self.filter_ilike(Vendor.name), - vendor_any=self.filter_ilike(Vendor.name), + vendor=self.filter_ilike(Vendor.name), + vendor_any=self.filter_ilike(self.VendorAny.name), code=self.filter_ilike(ProductCode.code)) def filter_config(self): @@ -141,6 +155,7 @@ class ProductsGrid(SearchableAlchemyGridView): filter_type_description='lk', include_filter_department=True, filter_type_department='lk', + filter_label_vendor="Vendor (preferred)", include_filter_vendor_any=True, filter_label_vendor_any="Vendor (any)", filter_type_vendor_any='lk') From 087342b09c05567c2a37cc910b1d995f0eddbfa4 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 13 Apr 2014 00:04:30 -0700 Subject: [PATCH 0111/3860] Add `Family` and `Product.family` to the general grid/crud UI. --- tailbone/templates/families/crud.mako | 12 +++ tailbone/templates/families/index.mako | 11 +++ tailbone/views/__init__.py | 1 + tailbone/views/families.py | 113 +++++++++++++++++++++++++ tailbone/views/products.py | 4 + 5 files changed, 141 insertions(+) create mode 100644 tailbone/templates/families/crud.mako create mode 100644 tailbone/templates/families/index.mako create mode 100644 tailbone/views/families.py diff --git a/tailbone/templates/families/crud.mako b/tailbone/templates/families/crud.mako new file mode 100644 index 00000000..00fda7b5 --- /dev/null +++ b/tailbone/templates/families/crud.mako @@ -0,0 +1,12 @@ +<%inherit file="/crud.mako" /> + +<%def name="context_menu_items()"> +
  • ${h.link_to("Back to Families", url('families'))}
  • + % if form.readonly: +
  • ${h.link_to("Edit this Family", url('family.update', uuid=form.fieldset.model.uuid))}
  • + % elif form.updating: +
  • ${h.link_to("View this Family", url('family.read', uuid=form.fieldset.model.uuid))}
  • + % endif + + +${parent.body()} diff --git a/tailbone/templates/families/index.mako b/tailbone/templates/families/index.mako new file mode 100644 index 00000000..9b6b8b62 --- /dev/null +++ b/tailbone/templates/families/index.mako @@ -0,0 +1,11 @@ +<%inherit file="/grid.mako" /> + +<%def name="title()">Families + +<%def name="context_menu_items()"> + % if request.has_perm('families.create'): +
  • ${h.link_to("Create a new Family", url('family.create'))}
  • + % endif + + +${parent.body()} diff --git a/tailbone/views/__init__.py b/tailbone/views/__init__.py index c1b27547..2da18b69 100644 --- a/tailbone/views/__init__.py +++ b/tailbone/views/__init__.py @@ -58,6 +58,7 @@ def includeme(config): config.include('tailbone.views.customers') config.include('tailbone.views.departments') config.include('tailbone.views.employees') + config.include('tailbone.views.families') config.include('tailbone.views.labels') config.include('tailbone.views.people') config.include('tailbone.views.products') diff --git a/tailbone/views/families.py b/tailbone/views/families.py new file mode 100644 index 00000000..b852be48 --- /dev/null +++ b/tailbone/views/families.py @@ -0,0 +1,113 @@ +#!/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 . +# +################################################################################ + +""" +Family Views +""" + +from . import SearchableAlchemyGridView, CrudView + +from rattail.db import model + + +class FamiliesGrid(SearchableAlchemyGridView): + + mapped_class = model.Family + config_prefix = 'families' + sort = 'name' + + def filter_map(self): + return self.make_filter_map( + exact=['code'], + ilike=['name']) + + def filter_config(self): + return self.make_filter_config( + include_filter_name=True, + filter_type_name='lk') + + def sort_map(self): + return self.make_sort_map('code', 'name') + + def grid(self): + g = self.make_grid() + g.configure( + include=[ + g.code, + g.name, + ], + readonly=True) + if self.request.has_perm('families.read'): + g.viewable = True + g.view_route_name = 'family.read' + if self.request.has_perm('families.update'): + g.editable = True + g.edit_route_name = 'family.update' + if self.request.has_perm('families.delete'): + g.deletable = True + g.delete_route_name = 'family.delete' + return g + + +class FamilyCrud(CrudView): + + mapped_class = model.Family + home_route = 'families' + + def fieldset(self, model): + fs = self.make_fieldset(model) + fs.configure( + include=[ + fs.code, + fs.name, + ]) + return fs + + +def add_routes(config): + config.add_route('families', '/families') + config.add_route('family.create', '/families/new') + config.add_route('family.read', '/families/{uuid}') + config.add_route('family.update', '/families/{uuid}/edit') + config.add_route('family.delete', '/families/{uuid}/delete') + + +def includeme(config): + add_routes(config) + + config.add_view(FamiliesGrid, route_name='families', + renderer='/families/index.mako', + permission='families.list') + + config.add_view(FamilyCrud, attr='create', route_name='family.create', + renderer='/families/crud.mako', + permission='families.create') + config.add_view(FamilyCrud, attr='read', route_name='family.read', + renderer='/families/crud.mako', + permission='families.read') + config.add_view(FamilyCrud, attr='update', route_name='family.update', + renderer='/families/crud.mako', + permission='families.update') + config.add_view(FamilyCrud, attr='delete', route_name='family.delete', + permission='families.delete') diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 079bdc5d..a6f1715f 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -42,6 +42,7 @@ from rattail import sil from rattail import batches from rattail.threads import Thread from rattail.exceptions import LabelPrintingError +from rattail.db import model from rattail.db.model import ( Product, ProductPrice, ProductCost, ProductCode, Brand, Vendor, Department, Subdepartment, LabelProfile) @@ -90,6 +91,8 @@ class ProductsGrid(SearchableAlchemyGridView): return { 'brand': lambda q: q.outerjoin(Brand), + 'family': + lambda q: q.outerjoin(model.Family), 'department': lambda q: q.outerjoin(Department, Department.uuid == Product.department_uuid), @@ -138,6 +141,7 @@ class ProductsGrid(SearchableAlchemyGridView): ilike=['description', 'size'], upc=filter_upc(), brand=self.filter_ilike(Brand.name), + family=self.filter_ilike(model.Family.name), department=self.filter_ilike(Department.name), subdepartment=self.filter_ilike(Subdepartment.name), vendor=self.filter_ilike(Vendor.name), From 389bb5dcc6e46eeef764817e5a894a42e08cbd8d Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 13 Apr 2014 17:54:22 -0700 Subject: [PATCH 0112/3860] Add POD image support to product view page. --- setup.py | 3 +++ tailbone/templates/products/read.mako | 23 ++++++++++++++++++++++- tailbone/views/products.py | 15 +++++++++++++++ 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index ce3af4e7..09e28431 100644 --- a/setup.py +++ b/setup.py @@ -78,6 +78,9 @@ requires = [ 'waitress', # 0.8.1 'WebHelpers', # 1.3 'zope.sqlalchemy', # 0.7 + + # This is used to obtain POD image dimensions. + 'PIL', # 1.1.7 ] diff --git a/tailbone/templates/products/read.mako b/tailbone/templates/products/read.mako index 5bf6d296..690f9300 100644 --- a/tailbone/templates/products/read.mako +++ b/tailbone/templates/products/read.mako @@ -1,6 +1,27 @@ <%inherit file="/products/crud.mako" /> -${parent.body()} +<%def name="head_tags()"> + ${parent.head_tags()} + + + +
    +
      + ${self.context_menu_items()} +
    + + ${form.render()|n} + + % if image: + ${h.image(image_url, u"Product Image", id='product-image', path=image_path, use_pil=True)} + % endif +
    <% product = form.fieldset.model %> diff --git a/tailbone/views/products.py b/tailbone/views/products.py index a6f1715f..8f59d879 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -26,6 +26,8 @@ Product Views """ +import os + from sqlalchemy import and_ from sqlalchemy.orm import joinedload, aliased @@ -48,6 +50,7 @@ from rattail.db.model import ( Brand, Vendor, Department, Subdepartment, LabelProfile) from rattail.gpc import GPC from rattail.db.api import get_product_by_upc +from rattail.pod import get_image_url, get_image_path from ..db import Session from ..forms import GPCFieldRenderer, BrandFieldRenderer, PriceFieldRenderer @@ -264,6 +267,18 @@ class ProductCrud(CrudView): del fs.current_price return fs + def template_kwargs(self, form): + kwargs = {'image': False} + product = form.fieldset.model + if product.upc: + kwargs['image_url'] = get_image_url( + self.request.rattail_config, product.upc) + kwargs['image_path'] = get_image_path( + self.request.rattail_config, product.upc) + if os.path.exists(kwargs['image_path']): + kwargs['image'] = True + return kwargs + def products_search(request): """ From ec65a9ee0752eaed25c5b5e71493c93076944481 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 13 Apr 2014 18:51:04 -0700 Subject: [PATCH 0113/3860] Stop using `find_packages()`; it was including tests. --- setup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 09e28431..8ac89bdd 100644 --- a/setup.py +++ b/setup.py @@ -24,7 +24,7 @@ import os.path -from setuptools import setup, find_packages +from setuptools import setup here = os.path.abspath(os.path.dirname(__file__)) @@ -136,7 +136,7 @@ setup( tests_require = ['Tailbone[tests]'], test_suite = 'nose.collector', - packages = find_packages(), + packages = ['tailbone'], include_package_data = True, zip_safe = False, From 0eb1bf4558d413bfedb29bad64a0190874dba92a Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 28 Apr 2014 18:32:35 -0700 Subject: [PATCH 0114/3860] Fix the `find_packages()` usage. --- setup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 8ac89bdd..7fbd44dd 100644 --- a/setup.py +++ b/setup.py @@ -24,7 +24,7 @@ import os.path -from setuptools import setup +from setuptools import setup, find_packages here = os.path.abspath(os.path.dirname(__file__)) @@ -136,7 +136,7 @@ setup( tests_require = ['Tailbone[tests]'], test_suite = 'nose.collector', - packages = ['tailbone'], + packages = find_packages(exclude=['tests.*', 'tests']), include_package_data = True, zip_safe = False, From d7a135f77fc8f033d7e7b2ec43f9308f86d74be8 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 28 Apr 2014 18:39:39 -0700 Subject: [PATCH 0115/3860] update changelog --- CHANGES.rst | 12 ++++++++++++ tailbone/_version.py | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 9be0ebb3..69e098e2 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,4 +1,16 @@ +0.3.14 +------ + +* Add event hook for attaching Rattail ``config`` to new requests. + +* Fix vendor filter/sort issues in products grid. + +* Add ``Family`` and ``Product.family`` to the general grid/crud UI. + +* Add POD image support to product view page. + + 0.3.13 ------ diff --git a/tailbone/_version.py b/tailbone/_version.py index 8300a52e..1ec0501b 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1 +1 @@ -__version__ = '0.3.13' +__version__ = '0.3.14' From 79bfeced64b60ad19889f9745d6eeca58f1dd4b8 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 17 May 2014 20:22:21 -0700 Subject: [PATCH 0116/3860] Add tox support. --- .gitignore | 3 ++- setup.cfg | 1 - setup.py | 15 ++++++++++++--- tox.ini | 22 ++++++++++++++++++++++ 4 files changed, 36 insertions(+), 5 deletions(-) create mode 100644 tox.ini diff --git a/.gitignore b/.gitignore index e63adb45..04e21472 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -Tailbone.egg-info +.tox/ +Tailbone.egg-info/ diff --git a/setup.cfg b/setup.cfg index 5ec1ac59..7712ec72 100644 --- a/setup.cfg +++ b/setup.cfg @@ -2,6 +2,5 @@ nocapture = 1 cover-package = tailbone cover-erase = 1 -cover-inclusive = 1 cover-html = 1 cover-html-dir = htmlcov diff --git a/setup.py b/setup.py index 7fbd44dd..e4cbec16 100644 --- a/setup.py +++ b/setup.py @@ -61,12 +61,21 @@ requires = [ # # package # low high + # For now, let's restrict FormEncode to 1.2 since the 1.3 release + # introduces some deprecation warnings. Once we're running 1.2 everywhere + # in production, we can start looking at adding 1.3 support. + # TODO: Remove this restriction. + 'FormEncode<=1.2.99', # 1.2.4 1.2.6 + # Pyramid 1.3 introduced 'pcreate' command (and friends) to replace - # deprecated 'paster create' (and friends). - 'pyramid>=1.3a1', # 1.3b2 + # deprecated 'paster create' (and friends). Also for now, let's restrict + # Pyramid to 1.4 since the 1.5 release introduces some + # backwards-incompatible changes. Once we're running 1.4 everywhere in + # production, we can start looking at adding 1.5 support. + # TODO: Remove the latter restriction. + 'pyramid>=1.3a1,<=1.4.99', # 1.3b2 1.4.5 'FormAlchemy', # 1.4.2 - 'FormEncode', # 1.2.4 'Mako', # 0.6.2 'pyramid_beaker>=0.6', # 0.6.1 'pyramid_debugtoolbar', # 1.0 diff --git a/tox.ini b/tox.ini new file mode 100644 index 00000000..817c9a61 --- /dev/null +++ b/tox.ini @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- + +[tox] +envlist = py26, py27 + +[testenv] +deps = + coverage + fixture + mock + nose +commands = nosetests {posargs} + +[testenv:coverage] +basepython = python +commands = nosetests {posargs:--with-coverage --cover-html-dir={envtmpdir}/coverage} + +[testenv:docs] +basepython = python +deps = Sphinx +changedir = docs +commands = sphinx-build -b html -d {envtmpdir}/doctrees -W -T . {envtmpdir}/docs From 8f1e34c73c324c52b6c5f94e7591e5876fcd4474 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 12 Jun 2014 20:40:07 -0700 Subject: [PATCH 0117/3860] Freeze FormAlchemy version below 1.5. --- setup.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/setup.py b/setup.py index e4cbec16..2d71de97 100644 --- a/setup.py +++ b/setup.py @@ -1,9 +1,8 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- +# -*- coding: utf-8 -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2012 Lance Edgar +# Copyright © 2010-2014 Lance Edgar # # This file is part of Rattail. # @@ -67,6 +66,10 @@ requires = [ # TODO: Remove this restriction. 'FormEncode<=1.2.99', # 1.2.4 1.2.6 + # FormAlchemy 1.5 supports Python 3 but is being a little aggressive about + # it, for our needs...We'll have to stick with 1.4 for now. + u'FormAlchemy<=1.4.99', # 1.4.3 + # Pyramid 1.3 introduced 'pcreate' command (and friends) to replace # deprecated 'paster create' (and friends). Also for now, let's restrict # Pyramid to 1.4 since the 1.5 release introduces some @@ -75,7 +78,6 @@ requires = [ # TODO: Remove the latter restriction. 'pyramid>=1.3a1,<=1.4.99', # 1.3b2 1.4.5 - 'FormAlchemy', # 1.4.2 'Mako', # 0.6.2 'pyramid_beaker>=0.6', # 0.6.1 'pyramid_debugtoolbar', # 1.0 From 54bb9e2869336ca72e445b56cf7d854064b10690 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 12 Jun 2014 20:41:25 -0700 Subject: [PATCH 0118/3860] Add experimental soundex filter support to the Customers grid. --- tailbone/grids/search.py | 37 +++++++++++++++++++++++++++++---- tailbone/views/customers.py | 7 +++---- tailbone/views/grids/alchemy.py | 11 +++++++--- 3 files changed, 44 insertions(+), 11 deletions(-) diff --git a/tailbone/grids/search.py b/tailbone/grids/search.py index 00e166a4..95341207 100644 --- a/tailbone/grids/search.py +++ b/tailbone/grids/search.py @@ -1,9 +1,8 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- +# -*- coding: utf-8 -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2012 Lance Edgar +# Copyright © 2010-2014 Lance Edgar # # This file is part of Rattail. # @@ -26,7 +25,7 @@ Grid Search Filters """ -from sqlalchemy import or_ +from sqlalchemy import func, or_ from webhelpers.html import tags from webhelpers.html import literal @@ -55,6 +54,8 @@ class SearchFilter(Object): ('nt', "is not"), ('lk', "contains"), ('nl', "doesn't contain"), + (u'sx', u"sounds like"), + (u'nx', u"doesn't sound like"), ] options = [] filter_map = self.search.filter_map[self.name] @@ -179,6 +180,34 @@ def filter_ilike(field): return {'lk': ilike, 'nl': not_ilike} +def filter_soundex(field): + """ + Returns a filter map entry which leverages the `soundex()` SQL function. + """ + + def soundex(query, value): + if value: + query = query.filter(func.soundex(field) == func.soundex(value)) + return query + + def not_soundex(query, value): + if value: + query = query.filter(func.soundex(field) != func.soundex(value)) + return query + + return {u'sx': soundex, u'nx': not_soundex} + + +def filter_ilike_and_soundex(field): + """ + Returns a filter map which provides both the `ilike` and `soundex` + features. + """ + filters = filter_ilike(field) + filters.update(filter_soundex(field)) + return filters + + def get_filter_config(prefix, request, filter_map, **kwargs): """ Returns a configuration dictionary for a search form. diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py index f6ada2c3..0a41ade6 100644 --- a/tailbone/views/customers.py +++ b/tailbone/views/customers.py @@ -1,9 +1,8 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- +# -*- coding: utf-8 -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2012 Lance Edgar +# Copyright © 2010-2014 Lance Edgar # # This file is part of Rattail. # @@ -58,7 +57,7 @@ class CustomersGrid(SearchableAlchemyGridView): def filter_map(self): return self.make_filter_map( exact=['id'], - ilike=['name'], + name=self.filter_ilike_and_soundex(model.Customer.name), email=self.filter_ilike(model.CustomerEmailAddress.address), phone=self.filter_ilike(model.CustomerPhoneNumber.number)) diff --git a/tailbone/views/grids/alchemy.py b/tailbone/views/grids/alchemy.py index 9f0ada45..3cfc8a89 100644 --- a/tailbone/views/grids/alchemy.py +++ b/tailbone/views/grids/alchemy.py @@ -1,9 +1,8 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- +# -*- coding: utf-8 -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2012 Lance Edgar +# Copyright © 2010-2014 Lance Edgar # # This file is part of Rattail. # @@ -139,6 +138,12 @@ class SearchableAlchemyGridView(PagedAlchemyGridView): def filter_ilike(self, field): return grids.search.filter_ilike(field) + def filter_soundex(self, field): + return grids.search.filter_soundex(field) + + def filter_ilike_and_soundex(self, field): + return grids.search.filter_ilike_and_soundex(field) + def make_filter_map(self, **kwargs): return grids.search.get_filter_map(self.mapped_class, **kwargs) From 59cefd7182020967a3cbd17ee881352cc3fe98b7 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 12 Jun 2014 20:47:47 -0700 Subject: [PATCH 0119/3860] update changelog --- CHANGES.rst | 7 +++++++ tailbone/_version.py | 4 +++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 69e098e2..7ba9b857 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,3 +1,10 @@ +.. -*- coding: utf-8 -*- + +0.3.15 +------ + +* Add experimental soundex filter support to the Customers grid. + 0.3.14 ------ diff --git a/tailbone/_version.py b/tailbone/_version.py index 1ec0501b..94395b2d 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1 +1,3 @@ -__version__ = '0.3.14' +# -*- coding: utf-8 -*- + +__version__ = u'0.3.15' From 8cf5605e8e8a0c0048f50e5c6f6d8f478c8dbfcd Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 12 Jun 2014 22:07:34 -0700 Subject: [PATCH 0120/3860] Remove some `edbob` references. --- tailbone/views/auth.py | 4 ++-- tailbone/views/batches/core.py | 1 - tailbone/views/products.py | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/tailbone/views/auth.py b/tailbone/views/auth.py index b35d7ca8..de369231 100644 --- a/tailbone/views/auth.py +++ b/tailbone/views/auth.py @@ -92,9 +92,9 @@ def login(request): return HTTPFound(location=referrer, headers=headers) request.session.flash("Invalid username or password") - url = edbob.config.get('edbob.pyramid', 'login.logo_url', + url = request.rattail_config.get('edbob.pyramid', 'login.logo_url', default=request.static_url('edbob.pyramid:static/img/logo.jpg')) - kwargs = eval(edbob.config.get('edbob.pyramid', 'login.logo_kwargs', + kwargs = eval(request.rattail_config.get('edbob.pyramid', 'login.logo_kwargs', default="dict(width=500)")) return {'form': FormRenderer(form), 'referrer': referrer, diff --git a/tailbone/views/batches/core.py b/tailbone/views/batches/core.py index 7b2aab4c..845a76b4 100644 --- a/tailbone/views/batches/core.py +++ b/tailbone/views/batches/core.py @@ -31,7 +31,6 @@ from pyramid.renderers import render_to_response from webhelpers.html import tags -import edbob from edbob.pyramid.forms import PrettyDateTimeFieldRenderer from ...forms import EnumFieldRenderer from ...grids.search import BooleanSearchFilter diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 8f59d879..16878034 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -386,7 +386,7 @@ class CreateProductsBatch(ProductsGrid): } return render_to_response('/progress.mako', kwargs, request=self.request) - enabled = edbob.config.get('rattail.pyramid', 'batches.providers') + enabled = self.request.rattail_config.get('rattail.pyramid', 'batches.providers') if enabled: enabled = enabled.split() From bdf835a4ddab72f8cef0cf64e26b50a5064c4658 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 12 Jul 2014 16:01:29 -0700 Subject: [PATCH 0121/3860] Ignore coverage stuff. --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 04e21472..35ebd7fd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ +.coverage .tox/ +htmlcov/ Tailbone.egg-info/ From 6943298ee0555e5b8e1f41387613e7b793c86ef6 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 12 Jul 2014 16:02:54 -0700 Subject: [PATCH 0122/3860] Add product report codes to the UI. --- setup.py | 2 +- tailbone/templates/reportcodes/crud.mako | 13 +++ tailbone/templates/reportcodes/index.mako | 12 +++ tailbone/views/__init__.py | 6 +- tailbone/views/products.py | 11 ++- tailbone/views/reportcodes.py | 112 ++++++++++++++++++++++ 6 files changed, 148 insertions(+), 8 deletions(-) create mode 100644 tailbone/templates/reportcodes/crud.mako create mode 100644 tailbone/templates/reportcodes/index.mako create mode 100644 tailbone/views/reportcodes.py diff --git a/setup.py b/setup.py index 2d71de97..30787d8c 100644 --- a/setup.py +++ b/setup.py @@ -84,7 +84,7 @@ requires = [ 'pyramid_exclog', # 0.6 'pyramid_simpleform', # 0.6.1 'pyramid_tm', # 0.3 - 'rattail[db]>=0.3.18', # 0.3.18 + u'rattail[db]>=0.3.32', # 0.3.32 'transaction', # 1.2.0 'waitress', # 0.8.1 'WebHelpers', # 1.3 diff --git a/tailbone/templates/reportcodes/crud.mako b/tailbone/templates/reportcodes/crud.mako new file mode 100644 index 00000000..84e5db43 --- /dev/null +++ b/tailbone/templates/reportcodes/crud.mako @@ -0,0 +1,13 @@ +## -*- coding: utf-8 -*- +<%inherit file="/crud.mako" /> + +<%def name="context_menu_items()"> +
  • ${h.link_to(u"Back to Report Codes", url(u'reportcodes'))}
  • + % if form.readonly: +
  • ${h.link_to(u"Edit this Report Code", url(u'reportcode.update', uuid=form.fieldset.model.uuid))}
  • + % elif form.updating: +
  • ${h.link_to(u"View this Report Code", url(u'reportcode.read', uuid=form.fieldset.model.uuid))}
  • + % endif + + +${parent.body()} diff --git a/tailbone/templates/reportcodes/index.mako b/tailbone/templates/reportcodes/index.mako new file mode 100644 index 00000000..c2d1391e --- /dev/null +++ b/tailbone/templates/reportcodes/index.mako @@ -0,0 +1,12 @@ +## -*- coding: utf-8 -*- +<%inherit file="/grid.mako" /> + +<%def name="title()">Report Codes + +<%def name="context_menu_items()"> + % if request.has_perm(u'reportcodes.create'): +
  • ${h.link_to(u"Create a new Report Code", url(u'reportcode.create'))}
  • + % endif + + +${parent.body()} diff --git a/tailbone/views/__init__.py b/tailbone/views/__init__.py index 2da18b69..adc82ac6 100644 --- a/tailbone/views/__init__.py +++ b/tailbone/views/__init__.py @@ -1,9 +1,8 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- +# -*- coding: utf-8 -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2012 Lance Edgar +# Copyright © 2010-2014 Lance Edgar # # This file is part of Rattail. # @@ -63,6 +62,7 @@ def includeme(config): config.include('tailbone.views.people') config.include('tailbone.views.products') config.include('tailbone.views.progress') + config.include(u'tailbone.views.reportcodes') config.include('tailbone.views.roles') config.include('tailbone.views.stores') config.include('tailbone.views.subdepartments') diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 16878034..efa208db 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -1,9 +1,8 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- +# -*- coding: utf-8 -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2012 Lance Edgar +# Copyright © 2010-2014 Lance Edgar # # This file is part of Rattail. # @@ -102,6 +101,8 @@ class ProductsGrid(SearchableAlchemyGridView): 'subdepartment': lambda q: q.outerjoin(Subdepartment, Subdepartment.uuid == Product.subdepartment_uuid), + u'report_code': + lambda q: q.outerjoin(model.ReportCode), 'regular_price': lambda q: q.outerjoin(ProductPrice, ProductPrice.uuid == Product.regular_price_uuid), @@ -146,6 +147,7 @@ class ProductsGrid(SearchableAlchemyGridView): brand=self.filter_ilike(Brand.name), family=self.filter_ilike(model.Family.name), department=self.filter_ilike(Department.name), + report_code=self.filter_ilike(model.ReportCode.name), subdepartment=self.filter_ilike(Subdepartment.name), vendor=self.filter_ilike(Vendor.name), vendor_any=self.filter_ilike(self.VendorAny.name), @@ -257,8 +259,9 @@ class ProductCrud(CrudView): fs.description, fs.size, fs.department, - fs.family, fs.subdepartment, + fs.family, + fs.report_code, fs.regular_price, fs.current_price, ]) diff --git a/tailbone/views/reportcodes.py b/tailbone/views/reportcodes.py new file mode 100644 index 00000000..74347af0 --- /dev/null +++ b/tailbone/views/reportcodes.py @@ -0,0 +1,112 @@ +# -*- coding: utf-8 -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2014 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 . +# +################################################################################ + +""" +Report Code Views +""" + +from tailbone.views import SearchableAlchemyGridView, CrudView + +from rattail.db import model + + +class ReportCodesGrid(SearchableAlchemyGridView): + + mapped_class = model.ReportCode + config_prefix = u'reportcodes' + sort = u'name' + + def filter_map(self): + return self.make_filter_map( + exact=[u'code'], + ilike=[u'name']) + + def filter_config(self): + return self.make_filter_config( + include_filter_name=True, + filter_type_name=u'lk') + + def sort_map(self): + return self.make_sort_map(u'code', u'name') + + def grid(self): + g = self.make_grid() + g.configure( + include=[ + g.code, + g.name, + ], + readonly=True) + if self.request.has_perm(u'reportcodes.read'): + g.viewable = True + g.view_route_name = u'reportcode.read' + if self.request.has_perm(u'reportcodes.update'): + g.editable = True + g.edit_route_name = u'reportcode.update' + if self.request.has_perm(u'reportcodes.delete'): + g.deletable = True + g.delete_route_name = u'reportcode.delete' + return g + + +class ReportCodeCrud(CrudView): + + mapped_class = model.ReportCode + home_route = u'reportcodes' + + def fieldset(self, model): + fs = self.make_fieldset(model) + fs.configure( + include=[ + fs.code, + fs.name, + ]) + return fs + + +def add_routes(config): + config.add_route(u'reportcodes', u'/reportcodes') + config.add_route(u'reportcode.create', u'/reportcodes/new') + config.add_route(u'reportcode.read', u'/reportcodes/{uuid}') + config.add_route(u'reportcode.update', u'/reportcodes/{uuid}/edit') + config.add_route(u'reportcode.delete', u'/reportcodes/{uuid}/delete') + + +def includeme(config): + add_routes(config) + + config.add_view(ReportCodesGrid, route_name=u'reportcodes', + renderer=u'/reportcodes/index.mako', + permission=u'reportcodes.list') + + config.add_view(ReportCodeCrud, attr=u'create', route_name=u'reportcode.create', + renderer=u'/reportcodes/crud.mako', + permission=u'reportcodes.create') + config.add_view(ReportCodeCrud, attr=u'read', route_name=u'reportcode.read', + renderer=u'/reportcodes/crud.mako', + permission=u'reportcodes.read') + config.add_view(ReportCodeCrud, attr=u'update', route_name=u'reportcode.update', + renderer=u'/reportcodes/crud.mako', + permission=u'reportcodes.update') + config.add_view(ReportCodeCrud, attr=u'delete', route_name=u'reportcode.delete', + permission=u'reportcodes.delete') From bfd1b034ee81cebd094ee9e5392dadaa1b1c2d26 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 12 Jul 2014 16:04:36 -0700 Subject: [PATCH 0123/3860] Update changelog. --- CHANGES.rst | 6 ++++++ tailbone/_version.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 7ba9b857..01bbd61d 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,5 +1,11 @@ .. -*- coding: utf-8 -*- +0.3.16 +------ + +* Add product report codes to the UI. + + 0.3.15 ------ diff --git a/tailbone/_version.py b/tailbone/_version.py index 94395b2d..1b8a8d35 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- -__version__ = u'0.3.15' +__version__ = u'0.3.16' From f9d22f59f2c906ed2f39a0b4648df685737826aa Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 13 Jul 2014 12:43:58 -0700 Subject: [PATCH 0124/3860] Add customer phone autocomplete and customer "info" AJAX view. This autocomplete view is a little different than the typical ones used prior, and required some refactoring of the base autocomplete view as well as the autocomplete template. --- tailbone/templates/autocomplete.mako | 31 ++++++---- tailbone/views/__init__.py | 2 +- tailbone/views/autocomplete.py | 37 ++++++++---- tailbone/views/customers.py | 87 +++++++++++++++++++++++++--- 4 files changed, 125 insertions(+), 32 deletions(-) diff --git a/tailbone/templates/autocomplete.mako b/tailbone/templates/autocomplete.mako index e9548794..cf76ee7c 100644 --- a/tailbone/templates/autocomplete.mako +++ b/tailbone/templates/autocomplete.mako @@ -1,4 +1,6 @@ -<%def name="autocomplete(field_name, service_url, field_value=None, field_display=None, width='300px', selected=None, cleared=None)"> +## -*- coding: utf-8 -*- +## TODO: This function signature is getting out of hand... +<%def name="autocomplete(field_name, service_url, field_value=None, field_display=None, width=u'300px', select=None, selected=None, cleared=None, options={})">
    ${h.hidden(field_name, id=field_name, value=field_value)} ${h.text(field_name+'-textbox', id=field_name+'-textbox', value=field_display, @@ -13,19 +15,26 @@ $('#${field_name}-textbox').autocomplete({ source: '${service_url}', autoFocus: true, + % for key, value in options.items(): + ${key}: ${value}, + % endfor focus: function(event, ui) { return false; }, - select: function(event, ui) { - $('#${field_name}').val(ui.item.value); - $('#${field_name}-display span:first').text(ui.item.label); - $('#${field_name}-textbox').hide(); - $('#${field_name}-display').show(); - % if selected: - ${selected}(ui.item.value, ui.item.label); - % endif - return false; - } + % if select: + select: ${select} + % else: + select: function(event, ui) { + $('#${field_name}').val(ui.item.value); + $('#${field_name}-display span:first').text(ui.item.label); + $('#${field_name}-textbox').hide(); + $('#${field_name}-display').show(); + % if selected: + ${selected}(ui.item.value, ui.item.label); + % endif + return false; + } + % endif }); $('#${field_name}-change').click(function() { $('#${field_name}').val(''); diff --git a/tailbone/views/__init__.py b/tailbone/views/__init__.py index adc82ac6..6a8ae08b 100644 --- a/tailbone/views/__init__.py +++ b/tailbone/views/__init__.py @@ -28,7 +28,7 @@ Pyramid Views from .core import * from .grids import * from .crud import * -from .autocomplete import * +from tailbone.views.autocomplete import AutocompleteView def home(request): diff --git a/tailbone/views/autocomplete.py b/tailbone/views/autocomplete.py index 2865c5ca..26fb47db 100644 --- a/tailbone/views/autocomplete.py +++ b/tailbone/views/autocomplete.py @@ -1,9 +1,8 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- +# -*- coding: utf-8 -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2012 Lance Edgar +# Copyright © 2010-2014 Lance Edgar # # This file is part of Rattail. # @@ -26,14 +25,20 @@ Autocomplete View """ -from .core import View -from ..db import Session - - -__all__ = ['AutocompleteView'] +from tailbone.views.core import View +from tailbone.db import Session class AutocompleteView(View): + """ + Base class for generic autocomplete views. + """ + + def prepare_term(self, term): + """ + If necessary, massage the incoming search term for use with the query. + """ + return term def filter_query(self, q): return q @@ -51,11 +56,21 @@ class AutocompleteView(View): def display(self, instance): return getattr(instance, self.fieldname) + def value(self, instance): + """ + Determine the data value for a query result instance. + """ + return instance.uuid + def __call__(self): - term = self.request.params.get('term') + """ + View implementation. + """ + term = self.request.params.get(u'term') or u'' + term = term.strip() if term: - term = term.strip() + term = self.prepare_term(term) if not term: return [] results = self.query(term).all() - return [{'label': self.display(x), 'value': x.uuid} for x in results] + return [{u'label': self.display(x), u'value': self.value(x)} for x in results] diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py index 0a41ade6..bdedcad9 100644 --- a/tailbone/views/customers.py +++ b/tailbone/views/customers.py @@ -25,12 +25,14 @@ Customer Views """ -from sqlalchemy import and_ +import re -from . import SearchableAlchemyGridView, CrudView -from ..forms import EnumFieldRenderer +from sqlalchemy import func, and_ +from sqlalchemy.orm import joinedload -from ..db import Session +from tailbone.views import SearchableAlchemyGridView, CrudView, AutocompleteView +from tailbone.forms import EnumFieldRenderer +from tailbone.db import Session from rattail import enum from rattail.db import model @@ -133,12 +135,67 @@ class CustomerCrud(CrudView): return fs +class CustomerNameAutocomplete(AutocompleteView): + """ + Autocomplete view which operates on customer name. + """ + mapped_class = model.Customer + fieldname = u'name' + + +class CustomerPhoneAutocomplete(AutocompleteView): + """ + Autocomplete view which operates on customer phone number. + + .. note:: + As currently implemented, this view will only work with a PostgreSQL + database. It normalizes the user's search term and the database values + to numeric digits only (i.e. removes special characters from each) in + order to be able to perform smarter matching. However normalizing the + database value currently uses the PG SQL ``regexp_replace()`` function. + """ + invalid_pattern = re.compile(ur'\D') + + def prepare_term(self, term): + return self.invalid_pattern.sub(u'', term) + + def query(self, term): + return Session.query(model.CustomerPhoneNumber)\ + .filter(func.regexp_replace(model.CustomerPhoneNumber.number, ur'\D', u'', u'g').like(u'%{0}%'.format(term)))\ + .order_by(model.CustomerPhoneNumber.number)\ + .options(joinedload(model.CustomerPhoneNumber.customer)) + + def display(self, phone): + return u"{0} {1}".format(phone.number, phone.customer) + + def value(self, phone): + return phone.customer.uuid + + +def customer_info(request): + """ + View which returns simple dictionary of info for a particular customer. + """ + uuid = request.params.get(u'uuid') + customer = Session.query(model.Customer).get(uuid) if uuid else None + if not customer: + return {} + return { + u'uuid': customer.uuid, + u'name': customer.name, + u'phone_number': customer.phone.number if customer.phone else u'', + } + + def add_routes(config): - config.add_route('customers', '/customers') - config.add_route('customer.create', '/customers/new') - config.add_route('customer.read', '/customers/{uuid}') - config.add_route('customer.update', '/customers/{uuid}/edit') - config.add_route('customer.delete', '/customers/{uuid}/delete') + config.add_route(u'customers', u'/customers') + config.add_route(u'customer.create', u'/customers/new') + config.add_route(u'customer.info', u'/customers/info') + config.add_route(u'customers.autocomplete', u'/customers/autocomplete') + config.add_route(u'customers.autocomplete.phone', u'/customers/autocomplete/phone') + config.add_route(u'customer.read', u'/customers/{uuid}') + config.add_route(u'customer.update', u'/customers/{uuid}/edit') + config.add_route(u'customer.delete', u'/customers/{uuid}/delete') def includeme(config): @@ -147,6 +204,7 @@ def includeme(config): config.add_view(CustomersGrid, route_name='customers', renderer='/customers/index.mako', permission='customers.list') + config.add_view(CustomerCrud, attr='create', route_name='customer.create', renderer='/customers/crud.mako', permission='customers.create') @@ -158,3 +216,14 @@ def includeme(config): permission='customers.update') config.add_view(CustomerCrud, attr='delete', route_name='customer.delete', permission='customers.delete') + + config.add_view(CustomerNameAutocomplete, route_name=u'customers.autocomplete', + renderer=u'json', + permission=u'customers.list') + config.add_view(CustomerPhoneAutocomplete, route_name=u'customers.autocomplete.phone', + renderer=u'json', + permission=u'customers.list') + + config.add_view(customer_info, route_name=u'customer.info', + renderer=u'json', + permission=u'customers.read') From 9c294f2e4dc45a59b6fc776fb361dddad203e265 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 13 Jul 2014 20:39:36 -0700 Subject: [PATCH 0125/3860] Allow editing `User.active` field. --- tailbone/views/users.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tailbone/views/users.py b/tailbone/views/users.py index c5b58adb..a2f8d847 100644 --- a/tailbone/views/users.py +++ b/tailbone/views/users.py @@ -157,6 +157,7 @@ class UserCrud(CrudView): fs.password.label("Set Password"), fs.confirm_password, fs.roles, + fs.active, ]) if self.readonly: From 2626ff4fdfa7bfc3d2d96cfda71325c943aba071 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 19 Jul 2014 18:49:00 -0700 Subject: [PATCH 0126/3860] Add Person autocomplete view which restricts to employees only. --- tailbone/views/people.py | 21 +++++++++++++++++++++ tailbone/views/users.py | 21 ++++++++++++--------- 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 5f9246e2..32a32e87 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -31,6 +31,8 @@ from sqlalchemy import and_ from . import SearchableAlchemyGridView, CrudView, AutocompleteView from ..db import Session + +from rattail.db import model from rattail.db.model import (Person, PersonEmailAddress, PersonPhoneNumber, VendorContact) @@ -132,9 +134,20 @@ class PeopleAutocomplete(AutocompleteView): fieldname = 'display_name' +class PeopleEmployeesAutocomplete(PeopleAutocomplete): + """ + Autocomplete view for the Person model, but restricted to return only + results for people who are employees. + """ + + def filter_query(self, q): + return q.join(model.Employee) + + def add_routes(config): config.add_route('people', '/people') config.add_route('people.autocomplete', '/people/autocomplete') + config.add_route(u'people.autocomplete.employees', u'/people/autocomplete/employees') config.add_route('person.read', '/people/{uuid}') config.add_route('person.update', '/people/{uuid}/edit') @@ -142,15 +155,23 @@ def add_routes(config): def includeme(config): add_routes(config) + # List config.add_view(PeopleGrid, route_name='people', renderer='/people/index.mako', permission='people.list') + + # CRUD config.add_view(PersonCrud, attr='read', route_name='person.read', renderer='/people/crud.mako', permission='people.read') config.add_view(PersonCrud, attr='update', route_name='person.update', renderer='/people/crud.mako', permission='people.update') + + # Autocomplete config.add_view(PeopleAutocomplete, route_name='people.autocomplete', renderer='json', permission='people.list') + config.add_view(PeopleEmployeesAutocomplete, route_name=u'people.autocomplete.employees', + renderer=u'json', + permission=u'people.list') diff --git a/tailbone/views/users.py b/tailbone/views/users.py index a2f8d847..2f1cbf4f 100644 --- a/tailbone/views/users.py +++ b/tailbone/views/users.py @@ -167,28 +167,31 @@ class UserCrud(CrudView): return fs -def includeme(config): +def add_routes(config): + config.add_route(u'users', u'/users') + config.add_route(u'user.create', u'/users/new') + config.add_route(u'user.read', u'/users/{uuid}') + config.add_route(u'user.update', u'/users/{uuid}/edit') + config.add_route(u'user.delete', u'/users/{uuid}/delete') - config.add_route('users', '/users') + +def includeme(config): + add_routes(config) + + # List config.add_view(UsersGrid, route_name='users', renderer='/users/index.mako', permission='users.list') - config.add_route('user.create', '/users/new') + # CRUD config.add_view(UserCrud, attr='create', route_name='user.create', renderer='/users/crud.mako', permission='users.create') - - config.add_route('user.read', '/users/{uuid}') config.add_view(UserCrud, attr='read', route_name='user.read', renderer='/users/crud.mako', permission='users.read') - - config.add_route('user.update', '/users/{uuid}/edit') config.add_view(UserCrud, attr='update', route_name='user.update', renderer='/users/crud.mako', permission='users.update') - - config.add_route('user.delete', '/users/{uuid}/delete') config.add_view(UserCrud, attr='delete', route_name='user.delete', permission='users.delete') From 124e28c0c2ca496ffb785d0e70ef9d7066d2eb27 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 19 Jul 2014 18:56:00 -0700 Subject: [PATCH 0127/3860] Update changelog. --- CHANGES.rst | 10 ++++++++++ setup.py | 2 +- tailbone/_version.py | 2 +- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 01bbd61d..51704b1b 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,5 +1,15 @@ .. -*- coding: utf-8 -*- +0.3.17 +------ + +* Add customer phone autocomplete and customer "info" AJAX view. + +* Allow editing ``User.active`` field. + +* Add Person autocomplete view which restricts to employees only. + + 0.3.16 ------ diff --git a/setup.py b/setup.py index 30787d8c..a7d330c4 100644 --- a/setup.py +++ b/setup.py @@ -84,7 +84,7 @@ requires = [ 'pyramid_exclog', # 0.6 'pyramid_simpleform', # 0.6.1 'pyramid_tm', # 0.3 - u'rattail[db]>=0.3.32', # 0.3.32 + u'rattail[db]>=0.3.33', # 0.3.33 'transaction', # 1.2.0 'waitress', # 0.8.1 'WebHelpers', # 1.3 diff --git a/tailbone/_version.py b/tailbone/_version.py index 1b8a8d35..f5bc0b78 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- -__version__ = u'0.3.16' +__version__ = u'0.3.17' From dcc1699f69a143cb2523ba7857276887ac033739 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 23 Jul 2014 21:35:33 -0700 Subject: [PATCH 0128/3860] Add explicit file encoding to all Mako templates. Also remove some u"" style strings within templates, since they appear to be unnecessary. --- tailbone/reports/inventory_worksheet.mako | 1 + tailbone/reports/ordering_worksheet.mako | 1 + tailbone/templates/autocomplete.mako | 2 +- tailbone/templates/base.mako | 1 + tailbone/templates/batches/crud.mako | 1 + tailbone/templates/batches/index.mako | 1 + tailbone/templates/batches/params.mako | 1 + tailbone/templates/batches/params/print_labels.mako | 1 + tailbone/templates/batches/read.mako | 1 + tailbone/templates/batches/rows/crud.mako | 1 + tailbone/templates/batches/rows/index.mako | 1 + tailbone/templates/brands/crud.mako | 1 + tailbone/templates/brands/index.mako | 1 + tailbone/templates/categories/crud.mako | 1 + tailbone/templates/categories/index.mako | 1 + tailbone/templates/change_password.mako | 1 + tailbone/templates/crud.mako | 1 + tailbone/templates/customergroups/crud.mako | 1 + tailbone/templates/customergroups/index.mako | 1 + tailbone/templates/customers/crud.mako | 1 + tailbone/templates/customers/index.mako | 1 + tailbone/templates/customers/read.mako | 1 + tailbone/templates/departments/crud.mako | 1 + tailbone/templates/departments/index.mako | 1 + tailbone/templates/employees/crud.mako | 1 + tailbone/templates/employees/index.mako | 1 + tailbone/templates/families/crud.mako | 1 + tailbone/templates/families/index.mako | 1 + tailbone/templates/form.mako | 1 + tailbone/templates/forms/field_autocomplete.mako | 1 + tailbone/templates/forms/fieldset.mako | 1 + tailbone/templates/forms/fieldset_readonly.mako | 1 + tailbone/templates/forms/form.mako | 1 + tailbone/templates/forms/form_readonly.mako | 1 + tailbone/templates/grid.mako | 1 + tailbone/templates/grids/grid.mako | 1 + tailbone/templates/grids/search.mako | 1 + tailbone/templates/home.mako | 1 + tailbone/templates/labels/profiles/crud.mako | 1 + tailbone/templates/labels/profiles/index.mako | 1 + tailbone/templates/labels/profiles/printer.mako | 1 + tailbone/templates/labels/profiles/read.mako | 1 + tailbone/templates/login.mako | 1 + tailbone/templates/people/crud.mako | 1 + tailbone/templates/people/index.mako | 1 + tailbone/templates/products/batch.mako | 1 + tailbone/templates/products/crud.mako | 1 + tailbone/templates/products/index.mako | 1 + tailbone/templates/products/read.mako | 3 ++- tailbone/templates/progress.mako | 1 + tailbone/templates/reportcodes/crud.mako | 6 +++--- tailbone/templates/reportcodes/index.mako | 4 ++-- tailbone/templates/reports/base.mako | 1 + tailbone/templates/reports/inventory.mako | 1 + tailbone/templates/reports/ordering.mako | 1 + tailbone/templates/roles/crud.mako | 1 + tailbone/templates/roles/index.mako | 1 + tailbone/templates/stores/crud.mako | 1 + tailbone/templates/stores/index.mako | 1 + tailbone/templates/subdepartments/crud.mako | 1 + tailbone/templates/subdepartments/index.mako | 1 + tailbone/templates/users/crud.mako | 1 + tailbone/templates/users/index.mako | 1 + tailbone/templates/vendors/crud.mako | 1 + tailbone/templates/vendors/index.mako | 1 + 65 files changed, 69 insertions(+), 7 deletions(-) diff --git a/tailbone/reports/inventory_worksheet.mako b/tailbone/reports/inventory_worksheet.mako index b39ff57f..6277a94d 100644 --- a/tailbone/reports/inventory_worksheet.mako +++ b/tailbone/reports/inventory_worksheet.mako @@ -1,3 +1,4 @@ +## -*- coding: utf-8 -*- diff --git a/tailbone/reports/ordering_worksheet.mako b/tailbone/reports/ordering_worksheet.mako index 477d723f..7870fa3f 100644 --- a/tailbone/reports/ordering_worksheet.mako +++ b/tailbone/reports/ordering_worksheet.mako @@ -1,3 +1,4 @@ +## -*- coding: utf-8 -*- diff --git a/tailbone/templates/autocomplete.mako b/tailbone/templates/autocomplete.mako index cf76ee7c..341ad8bb 100644 --- a/tailbone/templates/autocomplete.mako +++ b/tailbone/templates/autocomplete.mako @@ -1,6 +1,6 @@ ## -*- coding: utf-8 -*- ## TODO: This function signature is getting out of hand... -<%def name="autocomplete(field_name, service_url, field_value=None, field_display=None, width=u'300px', select=None, selected=None, cleared=None, options={})"> +<%def name="autocomplete(field_name, service_url, field_value=None, field_display=None, width='300px', select=None, selected=None, cleared=None, options={})">
    ${h.hidden(field_name, id=field_name, value=field_value)} ${h.text(field_name+'-textbox', id=field_name+'-textbox', value=field_display, diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako index 31c5df56..7d90a5a6 100644 --- a/tailbone/templates/base.mako +++ b/tailbone/templates/base.mako @@ -1,3 +1,4 @@ +## -*- coding: utf-8 -*- diff --git a/tailbone/templates/batches/crud.mako b/tailbone/templates/batches/crud.mako index f2875e56..56e40bde 100644 --- a/tailbone/templates/batches/crud.mako +++ b/tailbone/templates/batches/crud.mako @@ -1,3 +1,4 @@ +## -*- coding: utf-8 -*- <%inherit file="/crud.mako" /> <%def name="context_menu_items()"> diff --git a/tailbone/templates/batches/index.mako b/tailbone/templates/batches/index.mako index 8b426e03..1ce1b925 100644 --- a/tailbone/templates/batches/index.mako +++ b/tailbone/templates/batches/index.mako @@ -1,3 +1,4 @@ +## -*- coding: utf-8 -*- <%inherit file="/grid.mako" /> <%def name="title()">Batches diff --git a/tailbone/templates/batches/params.mako b/tailbone/templates/batches/params.mako index 48a494fa..34b75022 100644 --- a/tailbone/templates/batches/params.mako +++ b/tailbone/templates/batches/params.mako @@ -1,3 +1,4 @@ +## -*- coding: utf-8 -*- <%inherit file="/base.mako" /> <%def name="title()">Batch Parameters diff --git a/tailbone/templates/batches/params/print_labels.mako b/tailbone/templates/batches/params/print_labels.mako index c559c767..3ad7b8d0 100644 --- a/tailbone/templates/batches/params/print_labels.mako +++ b/tailbone/templates/batches/params/print_labels.mako @@ -1,3 +1,4 @@ +## -*- coding: utf-8 -*- <%inherit file="/batches/params.mako" /> <%def name="batch_params()"> diff --git a/tailbone/templates/batches/read.mako b/tailbone/templates/batches/read.mako index 3ce97fea..5d1ce152 100644 --- a/tailbone/templates/batches/read.mako +++ b/tailbone/templates/batches/read.mako @@ -1,3 +1,4 @@ +## -*- coding: utf-8 -*- <%inherit file="/batches/crud.mako" /> <%def name="context_menu_items()"> diff --git a/tailbone/templates/batches/rows/crud.mako b/tailbone/templates/batches/rows/crud.mako index f257a2a4..27ffd848 100644 --- a/tailbone/templates/batches/rows/crud.mako +++ b/tailbone/templates/batches/rows/crud.mako @@ -1,3 +1,4 @@ +## -*- coding: utf-8 -*- <%inherit file="/crud.mako" /> <%def name="context_menu_items()"> diff --git a/tailbone/templates/batches/rows/index.mako b/tailbone/templates/batches/rows/index.mako index d362918d..dbe2ee0f 100644 --- a/tailbone/templates/batches/rows/index.mako +++ b/tailbone/templates/batches/rows/index.mako @@ -1,3 +1,4 @@ +## -*- coding: utf-8 -*- <%inherit file="/grid.mako" /> <%def name="title()">Batch Rows : ${batch.description} diff --git a/tailbone/templates/brands/crud.mako b/tailbone/templates/brands/crud.mako index 4da3d733..3a35a993 100644 --- a/tailbone/templates/brands/crud.mako +++ b/tailbone/templates/brands/crud.mako @@ -1,3 +1,4 @@ +## -*- coding: utf-8 -*- <%inherit file="/crud.mako" /> <%def name="context_menu_items()"> diff --git a/tailbone/templates/brands/index.mako b/tailbone/templates/brands/index.mako index eff4ce0a..d069b2e5 100644 --- a/tailbone/templates/brands/index.mako +++ b/tailbone/templates/brands/index.mako @@ -1,3 +1,4 @@ +## -*- coding: utf-8 -*- <%inherit file="/grid.mako" /> <%def name="title()">Brands diff --git a/tailbone/templates/categories/crud.mako b/tailbone/templates/categories/crud.mako index 8a36d063..62ee9b43 100644 --- a/tailbone/templates/categories/crud.mako +++ b/tailbone/templates/categories/crud.mako @@ -1,3 +1,4 @@ +## -*- coding: utf-8 -*- <%inherit file="/crud.mako" /> <%def name="context_menu_items()"> diff --git a/tailbone/templates/categories/index.mako b/tailbone/templates/categories/index.mako index 03591137..979339f6 100644 --- a/tailbone/templates/categories/index.mako +++ b/tailbone/templates/categories/index.mako @@ -1,3 +1,4 @@ +## -*- coding: utf-8 -*- <%inherit file="/grid.mako" /> <%def name="title()">Categories diff --git a/tailbone/templates/change_password.mako b/tailbone/templates/change_password.mako index e00d2a5d..6b1ab948 100644 --- a/tailbone/templates/change_password.mako +++ b/tailbone/templates/change_password.mako @@ -1,3 +1,4 @@ +## -*- coding: utf-8 -*- <%inherit file="/base.mako" /> <%def name="title()">Change Password diff --git a/tailbone/templates/crud.mako b/tailbone/templates/crud.mako index b2ca1aea..9d3a6297 100644 --- a/tailbone/templates/crud.mako +++ b/tailbone/templates/crud.mako @@ -1,3 +1,4 @@ +## -*- coding: utf-8 -*- <%inherit file="/form.mako" /> <%def name="title()">${"New "+form.pretty_name if form.creating else form.pretty_name+' : '+capture(self.model_title)} diff --git a/tailbone/templates/customergroups/crud.mako b/tailbone/templates/customergroups/crud.mako index 5e534665..d109ad46 100644 --- a/tailbone/templates/customergroups/crud.mako +++ b/tailbone/templates/customergroups/crud.mako @@ -1,3 +1,4 @@ +## -*- coding: utf-8 -*- <%inherit file="/crud.mako" /> <%def name="context_menu_items()"> diff --git a/tailbone/templates/customergroups/index.mako b/tailbone/templates/customergroups/index.mako index dc777762..55aa168f 100644 --- a/tailbone/templates/customergroups/index.mako +++ b/tailbone/templates/customergroups/index.mako @@ -1,3 +1,4 @@ +## -*- coding: utf-8 -*- <%inherit file="/grid.mako" /> <%def name="title()">Customer Groups diff --git a/tailbone/templates/customers/crud.mako b/tailbone/templates/customers/crud.mako index c385719c..4d0d6b1a 100644 --- a/tailbone/templates/customers/crud.mako +++ b/tailbone/templates/customers/crud.mako @@ -1,3 +1,4 @@ +## -*- coding: utf-8 -*- <%inherit file="/crud.mako" /> <%def name="context_menu_items()"> diff --git a/tailbone/templates/customers/index.mako b/tailbone/templates/customers/index.mako index 29394f2f..0ec7016f 100644 --- a/tailbone/templates/customers/index.mako +++ b/tailbone/templates/customers/index.mako @@ -1,3 +1,4 @@ +## -*- coding: utf-8 -*- <%inherit file="/grid.mako" /> <%def name="title()">Customers diff --git a/tailbone/templates/customers/read.mako b/tailbone/templates/customers/read.mako index c5cfa315..107bfccd 100644 --- a/tailbone/templates/customers/read.mako +++ b/tailbone/templates/customers/read.mako @@ -1,3 +1,4 @@ +## -*- coding: utf-8 -*- <%inherit file="/customers/crud.mako" /> ${parent.body()} diff --git a/tailbone/templates/departments/crud.mako b/tailbone/templates/departments/crud.mako index ba8d9a06..e726ec77 100644 --- a/tailbone/templates/departments/crud.mako +++ b/tailbone/templates/departments/crud.mako @@ -1,3 +1,4 @@ +## -*- coding: utf-8 -*- <%inherit file="/crud.mako" /> <%def name="context_menu_items()"> diff --git a/tailbone/templates/departments/index.mako b/tailbone/templates/departments/index.mako index 1684d399..6774a050 100644 --- a/tailbone/templates/departments/index.mako +++ b/tailbone/templates/departments/index.mako @@ -1,3 +1,4 @@ +## -*- coding: utf-8 -*- <%inherit file="/grid.mako" /> <%def name="title()">Departments diff --git a/tailbone/templates/employees/crud.mako b/tailbone/templates/employees/crud.mako index 9a6bbe0a..4a71e30e 100644 --- a/tailbone/templates/employees/crud.mako +++ b/tailbone/templates/employees/crud.mako @@ -1,3 +1,4 @@ +## -*- coding: utf-8 -*- <%inherit file="/crud.mako" /> <%def name="context_menu_items()"> diff --git a/tailbone/templates/employees/index.mako b/tailbone/templates/employees/index.mako index b51b80b8..f521541c 100644 --- a/tailbone/templates/employees/index.mako +++ b/tailbone/templates/employees/index.mako @@ -1,3 +1,4 @@ +## -*- coding: utf-8 -*- <%inherit file="/grid.mako" /> <%def name="title()">Employees diff --git a/tailbone/templates/families/crud.mako b/tailbone/templates/families/crud.mako index 00fda7b5..01cfc292 100644 --- a/tailbone/templates/families/crud.mako +++ b/tailbone/templates/families/crud.mako @@ -1,3 +1,4 @@ +## -*- coding: utf-8 -*- <%inherit file="/crud.mako" /> <%def name="context_menu_items()"> diff --git a/tailbone/templates/families/index.mako b/tailbone/templates/families/index.mako index 9b6b8b62..8bc83e68 100644 --- a/tailbone/templates/families/index.mako +++ b/tailbone/templates/families/index.mako @@ -1,3 +1,4 @@ +## -*- coding: utf-8 -*- <%inherit file="/grid.mako" /> <%def name="title()">Families diff --git a/tailbone/templates/form.mako b/tailbone/templates/form.mako index 095d6384..0f230dba 100644 --- a/tailbone/templates/form.mako +++ b/tailbone/templates/form.mako @@ -1,3 +1,4 @@ +## -*- coding: utf-8 -*- <%inherit file="/base.mako" /> <%def name="context_menu_items()"> diff --git a/tailbone/templates/forms/field_autocomplete.mako b/tailbone/templates/forms/field_autocomplete.mako index 0db4783a..17720c68 100644 --- a/tailbone/templates/forms/field_autocomplete.mako +++ b/tailbone/templates/forms/field_autocomplete.mako @@ -1,3 +1,4 @@ +## -*- coding: utf-8 -*- <%namespace file="/autocomplete.mako" import="autocomplete" /> ${autocomplete(field_name, service_url, field_value, field_display, width=width, selected=selected, cleared=cleared)} diff --git a/tailbone/templates/forms/fieldset.mako b/tailbone/templates/forms/fieldset.mako index 0e5a6d4a..7f0306e9 100644 --- a/tailbone/templates/forms/fieldset.mako +++ b/tailbone/templates/forms/fieldset.mako @@ -1,3 +1,4 @@ +## -*- coding: utf-8 -*- <% _focus_rendered = False %> % for error in fieldset.errors.get(None, []): diff --git a/tailbone/templates/forms/fieldset_readonly.mako b/tailbone/templates/forms/fieldset_readonly.mako index 350a3151..b3068b3a 100644 --- a/tailbone/templates/forms/fieldset_readonly.mako +++ b/tailbone/templates/forms/fieldset_readonly.mako @@ -1,3 +1,4 @@ +## -*- coding: utf-8 -*-
    % for field in fieldset.render_fields.itervalues(): % if field.requires_label: diff --git a/tailbone/templates/forms/form.mako b/tailbone/templates/forms/form.mako index ea25d01b..9f14a482 100644 --- a/tailbone/templates/forms/form.mako +++ b/tailbone/templates/forms/form.mako @@ -1,3 +1,4 @@ +## -*- coding: utf-8 -*-
    ${h.form(form.action_url, enctype='multipart/form-data')} diff --git a/tailbone/templates/forms/form_readonly.mako b/tailbone/templates/forms/form_readonly.mako index 96920421..f8715630 100644 --- a/tailbone/templates/forms/form_readonly.mako +++ b/tailbone/templates/forms/form_readonly.mako @@ -1,3 +1,4 @@ +## -*- coding: utf-8 -*-
    ${form.fieldset.render()|n}
    diff --git a/tailbone/templates/grid.mako b/tailbone/templates/grid.mako index 9c78db07..c077a1ab 100644 --- a/tailbone/templates/grid.mako +++ b/tailbone/templates/grid.mako @@ -1,3 +1,4 @@ +## -*- coding: utf-8 -*- <%inherit file="/base.mako" /> <%def name="context_menu_items()"> diff --git a/tailbone/templates/grids/grid.mako b/tailbone/templates/grids/grid.mako index 0daf2a18..d2fe16d5 100644 --- a/tailbone/templates/grids/grid.mako +++ b/tailbone/templates/grids/grid.mako @@ -1,3 +1,4 @@ +## -*- coding: utf-8 -*-
    diff --git a/tailbone/templates/grids/search.mako b/tailbone/templates/grids/search.mako index 17fbabd2..fbb030f9 100644 --- a/tailbone/templates/grids/search.mako +++ b/tailbone/templates/grids/search.mako @@ -1,3 +1,4 @@ +## -*- coding: utf-8 -*-
    ${search.begin()} ${search.hidden('filters', 'true')} diff --git a/tailbone/templates/home.mako b/tailbone/templates/home.mako index 21ea320b..7d60bf2f 100644 --- a/tailbone/templates/home.mako +++ b/tailbone/templates/home.mako @@ -1,3 +1,4 @@ +## -*- coding: utf-8 -*- <%inherit file="/base.mako" /> <%def name="title()">Home diff --git a/tailbone/templates/labels/profiles/crud.mako b/tailbone/templates/labels/profiles/crud.mako index 76a39dac..16a253e7 100644 --- a/tailbone/templates/labels/profiles/crud.mako +++ b/tailbone/templates/labels/profiles/crud.mako @@ -1,3 +1,4 @@ +## -*- coding: utf-8 -*- <%inherit file="/crud.mako" /> <%def name="head_tags()"> diff --git a/tailbone/templates/labels/profiles/index.mako b/tailbone/templates/labels/profiles/index.mako index a5383fbe..509aa09c 100644 --- a/tailbone/templates/labels/profiles/index.mako +++ b/tailbone/templates/labels/profiles/index.mako @@ -1,3 +1,4 @@ +## -*- coding: utf-8 -*- <%inherit file="/grid.mako" /> <%def name="title()">Label Profiles diff --git a/tailbone/templates/labels/profiles/printer.mako b/tailbone/templates/labels/profiles/printer.mako index 9b1c5545..d2f1f7d1 100644 --- a/tailbone/templates/labels/profiles/printer.mako +++ b/tailbone/templates/labels/profiles/printer.mako @@ -1,3 +1,4 @@ +## -*- coding: utf-8 -*- <%inherit file="/base.mako" /> <%def name="title()">Printer Settings diff --git a/tailbone/templates/labels/profiles/read.mako b/tailbone/templates/labels/profiles/read.mako index 75ded2d6..3cc36a20 100644 --- a/tailbone/templates/labels/profiles/read.mako +++ b/tailbone/templates/labels/profiles/read.mako @@ -1,3 +1,4 @@ +## -*- coding: utf-8 -*- <%inherit file="/labels/profiles/crud.mako" /> <%def name="context_menu_items()"> diff --git a/tailbone/templates/login.mako b/tailbone/templates/login.mako index d297212a..852175a1 100644 --- a/tailbone/templates/login.mako +++ b/tailbone/templates/login.mako @@ -1,3 +1,4 @@ +## -*- coding: utf-8 -*- <%inherit file="/base.mako" /> <%def name="title()">Login diff --git a/tailbone/templates/people/crud.mako b/tailbone/templates/people/crud.mako index 9f7a5b0d..6f1f77fa 100644 --- a/tailbone/templates/people/crud.mako +++ b/tailbone/templates/people/crud.mako @@ -1,3 +1,4 @@ +## -*- coding: utf-8 -*- <%inherit file="/crud.mako" /> <%def name="context_menu_items()"> diff --git a/tailbone/templates/people/index.mako b/tailbone/templates/people/index.mako index 77b7badf..9e1ca615 100644 --- a/tailbone/templates/people/index.mako +++ b/tailbone/templates/people/index.mako @@ -1,3 +1,4 @@ +## -*- coding: utf-8 -*- <%inherit file="/grid.mako" /> <%def name="title()">People diff --git a/tailbone/templates/products/batch.mako b/tailbone/templates/products/batch.mako index 335c8574..a0aaeaa4 100644 --- a/tailbone/templates/products/batch.mako +++ b/tailbone/templates/products/batch.mako @@ -1,3 +1,4 @@ +## -*- coding: utf-8 -*- <%inherit file="/base.mako" /> <%def name="title()">Create Products Batch diff --git a/tailbone/templates/products/crud.mako b/tailbone/templates/products/crud.mako index 46d1e9c0..4df96af2 100644 --- a/tailbone/templates/products/crud.mako +++ b/tailbone/templates/products/crud.mako @@ -1,3 +1,4 @@ +## -*- coding: utf-8 -*- <%inherit file="/crud.mako" /> <%def name="context_menu_items()"> diff --git a/tailbone/templates/products/index.mako b/tailbone/templates/products/index.mako index 3d97fda1..fec57c50 100644 --- a/tailbone/templates/products/index.mako +++ b/tailbone/templates/products/index.mako @@ -1,3 +1,4 @@ +## -*- coding: utf-8 -*- <%inherit file="/grid.mako" /> <%def name="title()">Products diff --git a/tailbone/templates/products/read.mako b/tailbone/templates/products/read.mako index 690f9300..f6353a61 100644 --- a/tailbone/templates/products/read.mako +++ b/tailbone/templates/products/read.mako @@ -1,3 +1,4 @@ +## -*- coding: utf-8 -*- <%inherit file="/products/crud.mako" /> <%def name="head_tags()"> @@ -19,7 +20,7 @@ ${form.render()|n} % if image: - ${h.image(image_url, u"Product Image", id='product-image', path=image_path, use_pil=True)} + ${h.image(image_url, "Product Image", id='product-image', path=image_path, use_pil=True)} % endif
    diff --git a/tailbone/templates/progress.mako b/tailbone/templates/progress.mako index ea83598e..8216dfc7 100644 --- a/tailbone/templates/progress.mako +++ b/tailbone/templates/progress.mako @@ -1,3 +1,4 @@ +## -*- coding: utf-8 -*- diff --git a/tailbone/templates/reportcodes/crud.mako b/tailbone/templates/reportcodes/crud.mako index 84e5db43..4a5d6e00 100644 --- a/tailbone/templates/reportcodes/crud.mako +++ b/tailbone/templates/reportcodes/crud.mako @@ -2,11 +2,11 @@ <%inherit file="/crud.mako" /> <%def name="context_menu_items()"> -
  • ${h.link_to(u"Back to Report Codes", url(u'reportcodes'))}
  • +
  • ${h.link_to("Back to Report Codes", url('reportcodes'))}
  • % if form.readonly: -
  • ${h.link_to(u"Edit this Report Code", url(u'reportcode.update', uuid=form.fieldset.model.uuid))}
  • +
  • ${h.link_to("Edit this Report Code", url('reportcode.update', uuid=form.fieldset.model.uuid))}
  • % elif form.updating: -
  • ${h.link_to(u"View this Report Code", url(u'reportcode.read', uuid=form.fieldset.model.uuid))}
  • +
  • ${h.link_to("View this Report Code", url('reportcode.read', uuid=form.fieldset.model.uuid))}
  • % endif diff --git a/tailbone/templates/reportcodes/index.mako b/tailbone/templates/reportcodes/index.mako index c2d1391e..af10e4a4 100644 --- a/tailbone/templates/reportcodes/index.mako +++ b/tailbone/templates/reportcodes/index.mako @@ -4,8 +4,8 @@ <%def name="title()">Report Codes <%def name="context_menu_items()"> - % if request.has_perm(u'reportcodes.create'): -
  • ${h.link_to(u"Create a new Report Code", url(u'reportcode.create'))}
  • + % if request.has_perm('reportcodes.create'): +
  • ${h.link_to("Create a new Report Code", url('reportcode.create'))}
  • % endif diff --git a/tailbone/templates/reports/base.mako b/tailbone/templates/reports/base.mako index 27f7dd90..5833b0ec 100644 --- a/tailbone/templates/reports/base.mako +++ b/tailbone/templates/reports/base.mako @@ -1,2 +1,3 @@ +## -*- coding: utf-8 -*- <%inherit file="/base.mako" /> ${parent.body()} diff --git a/tailbone/templates/reports/inventory.mako b/tailbone/templates/reports/inventory.mako index 8fa5ac1b..3d3b5bd3 100644 --- a/tailbone/templates/reports/inventory.mako +++ b/tailbone/templates/reports/inventory.mako @@ -1,3 +1,4 @@ +## -*- coding: utf-8 -*- <%inherit file="/reports/base.mako" /> <%def name="title()">Report : Inventory Worksheet diff --git a/tailbone/templates/reports/ordering.mako b/tailbone/templates/reports/ordering.mako index 4cd014fc..1b8d555c 100644 --- a/tailbone/templates/reports/ordering.mako +++ b/tailbone/templates/reports/ordering.mako @@ -1,3 +1,4 @@ +## -*- coding: utf-8 -*- <%inherit file="/reports/base.mako" /> <%def name="title()">Report : Ordering Worksheet diff --git a/tailbone/templates/roles/crud.mako b/tailbone/templates/roles/crud.mako index 863a773b..3477b33f 100644 --- a/tailbone/templates/roles/crud.mako +++ b/tailbone/templates/roles/crud.mako @@ -1,3 +1,4 @@ +## -*- coding: utf-8 -*- <%inherit file="edbob.pyramid:templates/crud.mako" /> <%def name="head_tags()"> diff --git a/tailbone/templates/roles/index.mako b/tailbone/templates/roles/index.mako index 49deacbd..a39a87f7 100644 --- a/tailbone/templates/roles/index.mako +++ b/tailbone/templates/roles/index.mako @@ -1,3 +1,4 @@ +## -*- coding: utf-8 -*- <%inherit file="/grid.mako" /> <%def name="title()">Roles diff --git a/tailbone/templates/stores/crud.mako b/tailbone/templates/stores/crud.mako index dd2ae4cd..23fefc09 100644 --- a/tailbone/templates/stores/crud.mako +++ b/tailbone/templates/stores/crud.mako @@ -1,3 +1,4 @@ +## -*- coding: utf-8 -*- <%inherit file="/crud.mako" /> <%def name="context_menu_items()"> diff --git a/tailbone/templates/stores/index.mako b/tailbone/templates/stores/index.mako index d2e76951..1d8d1fb8 100644 --- a/tailbone/templates/stores/index.mako +++ b/tailbone/templates/stores/index.mako @@ -1,3 +1,4 @@ +## -*- coding: utf-8 -*- <%inherit file="/grid.mako" /> <%def name="title()">Stores diff --git a/tailbone/templates/subdepartments/crud.mako b/tailbone/templates/subdepartments/crud.mako index 446121db..b71cc553 100644 --- a/tailbone/templates/subdepartments/crud.mako +++ b/tailbone/templates/subdepartments/crud.mako @@ -1,3 +1,4 @@ +## -*- coding: utf-8 -*- <%inherit file="/crud.mako" /> <%def name="context_menu_items()"> diff --git a/tailbone/templates/subdepartments/index.mako b/tailbone/templates/subdepartments/index.mako index 154df855..bf426c18 100644 --- a/tailbone/templates/subdepartments/index.mako +++ b/tailbone/templates/subdepartments/index.mako @@ -1,3 +1,4 @@ +## -*- coding: utf-8 -*- <%inherit file="/grid.mako" /> <%def name="title()">Subdepartments diff --git a/tailbone/templates/users/crud.mako b/tailbone/templates/users/crud.mako index 2e5a70ef..e8006cc2 100644 --- a/tailbone/templates/users/crud.mako +++ b/tailbone/templates/users/crud.mako @@ -1,3 +1,4 @@ +## -*- coding: utf-8 -*- <%inherit file="/crud.mako" /> <%def name="context_menu_items()"> diff --git a/tailbone/templates/users/index.mako b/tailbone/templates/users/index.mako index c9f9918d..a7ec4532 100644 --- a/tailbone/templates/users/index.mako +++ b/tailbone/templates/users/index.mako @@ -1,3 +1,4 @@ +## -*- coding: utf-8 -*- <%inherit file="/grid.mako" /> <%def name="title()">Users diff --git a/tailbone/templates/vendors/crud.mako b/tailbone/templates/vendors/crud.mako index a60036d9..db718ae9 100644 --- a/tailbone/templates/vendors/crud.mako +++ b/tailbone/templates/vendors/crud.mako @@ -1,3 +1,4 @@ +## -*- coding: utf-8 -*- <%inherit file="/crud.mako" /> <%def name="context_menu_items()"> diff --git a/tailbone/templates/vendors/index.mako b/tailbone/templates/vendors/index.mako index 62d69078..27ea89f4 100644 --- a/tailbone/templates/vendors/index.mako +++ b/tailbone/templates/vendors/index.mako @@ -1,3 +1,4 @@ +## -*- coding: utf-8 -*- <%inherit file="/grid.mako" /> <%def name="title()">Vendors From 16bba17e83f6db89ebd4f23a6c9cd440dda31f3a Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 5 Aug 2014 21:23:55 -0700 Subject: [PATCH 0129/3860] Add "active" filter to users view; enable it by default. --- tailbone/views/users.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/tailbone/views/users.py b/tailbone/views/users.py index 2f1cbf4f..52683c99 100644 --- a/tailbone/views/users.py +++ b/tailbone/views/users.py @@ -1,9 +1,8 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- +# -*- coding: utf-8 -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2012 Lance Edgar +# Copyright © 2010-2014 Lance Edgar # # This file is part of Rattail. # @@ -26,6 +25,8 @@ User Views """ +from __future__ import unicode_literals + from formalchemy import Field from formalchemy.fields import SelectFieldRenderer @@ -40,6 +41,8 @@ from rattail.db.auth import guest_role from webhelpers.html import tags from webhelpers.html import HTML +from tailbone.grids.search import BooleanSearchFilter + class UsersGrid(SearchableAlchemyGridView): @@ -56,6 +59,7 @@ class UsersGrid(SearchableAlchemyGridView): def filter_map(self): return self.make_filter_map( ilike=['username'], + exact=['active'], person=self.filter_ilike(Person.display_name)) def filter_config(self): @@ -63,7 +67,11 @@ class UsersGrid(SearchableAlchemyGridView): include_filter_username=True, filter_type_username='lk', include_filter_person=True, - filter_type_person='lk') + filter_type_person='lk', + filter_factory_active=BooleanSearchFilter, + include_filter_active=True, + filter_type_active='is', + active='True') def sort_map(self): return self.make_sort_map( From 98f6a7377b8911e91e1fd4c273b05efa50615a96 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 5 Aug 2014 21:25:22 -0700 Subject: [PATCH 0130/3860] Update changelog. --- CHANGES.rst | 8 ++++++++ tailbone/_version.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 51704b1b..3254cae8 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,5 +1,13 @@ .. -*- coding: utf-8 -*- +0.3.18 +------ + +* Add explicit file encoding to all Mako templates. + +* Add "active" filter to users view; enable it by default. + + 0.3.17 ------ diff --git a/tailbone/_version.py b/tailbone/_version.py index f5bc0b78..0c06624b 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- -__version__ = u'0.3.17' +__version__ = u'0.3.18' From dfb5e83c7e9b4a9ae7aea5572507f739df490e37 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 10 Sep 2014 19:38:49 -0700 Subject: [PATCH 0131/3860] Add support for `Product.not_for_sale` flag. This involved a couple of ancillary changes: * The price field renderer will not display a price for products marked not for sale. * The "grid" class now allows specifying a custom callable to provide additional CSS class for table rows. * The products grid uses this to add a "not-for-sale" class to table rows for products which are marked thusly. --- tailbone/forms/renderers/products.py | 34 +++++++++++++++------------- tailbone/grids/core.py | 21 +++++++++++++---- tailbone/views/products.py | 5 +++- 3 files changed, 39 insertions(+), 21 deletions(-) diff --git a/tailbone/forms/renderers/products.py b/tailbone/forms/renderers/products.py index 52916543..dd9deb1b 100644 --- a/tailbone/forms/renderers/products.py +++ b/tailbone/forms/renderers/products.py @@ -1,9 +1,8 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- +# -*- coding: utf-8 -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2012 Lance Edgar +# Copyright © 2010-2014 Lance Edgar # # This file is part of Rattail. # @@ -26,6 +25,8 @@ Product Field Renderers """ +from __future__ import unicode_literals + from formalchemy import TextFieldRenderer from rattail.gpc import GPC from .common import AutocompleteFieldRenderer @@ -88,19 +89,20 @@ class PriceFieldRenderer(TextFieldRenderer): def render_readonly(self, **kwargs): price = self.field.raw_value if price: - if price.price is not None and price.pack_price is not None: - if price.multiple > 1: - return literal('$ %0.2f / %u  ($ %0.2f / %u)' % ( - price.price, price.multiple, - price.pack_price, price.pack_multiple)) - return literal('$ %0.2f  ($ %0.2f / %u)' % ( - price.price, price.pack_price, price.pack_multiple)) - if price.price is not None: - if price.multiple > 1: - return '$ %0.2f / %u' % (price.price, price.multiple) - return '$ %0.2f' % price.price - if price.pack_price is not None: - return '$ %0.2f / %u' % (price.pack_price, price.pack_multiple) + if not price.product.not_for_sale: + if price.price is not None and price.pack_price is not None: + if price.multiple > 1: + return literal('$ %0.2f / %u  ($ %0.2f / %u)' % ( + price.price, price.multiple, + price.pack_price, price.pack_multiple)) + return literal('$ %0.2f  ($ %0.2f / %u)' % ( + price.price, price.pack_price, price.pack_multiple)) + if price.price is not None: + if price.multiple > 1: + return '$ %0.2f / %u' % (price.price, price.multiple) + return '$ %0.2f' % price.price + if price.pack_price is not None: + return '$ %0.2f / %u' % (price.pack_price, price.pack_multiple) return '' diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index 00667e22..1fad4b14 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -61,6 +61,9 @@ class Grid(Object): delete_route_name = None delete_route_kwargs = None + # Set this to a callable to allow ad-hoc row class additions. + extra_row_class = None + def __init__(self, request, **kwargs): kwargs.setdefault('fields', OrderedDict()) kwargs.setdefault('column_titles', {}) @@ -118,6 +121,20 @@ class Grid(Object): attrs = self.row_attrs(row, i) return format_attrs(**attrs) + def row_attrs(self, row, i): + return {'class_': self.get_row_class(row, i)} + + def get_row_class(self, row, i): + class_ = self.default_row_class(row, i) + if callable(self.extra_row_class): + extra = self.extra_row_class(row, i) + if extra: + class_ = '{0} {1}'.format(class_, extra) + return class_ + + def default_row_class(self, row, i): + return 'odd' if i % 2 else 'even' + def iter_fields(self): return self.fields.itervalues() @@ -130,7 +147,3 @@ class Grid(Object): 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/tailbone/views/products.py b/tailbone/views/products.py index efa208db..ba317172 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -190,7 +190,9 @@ class ProductsGrid(SearchableAlchemyGridView): return q def grid(self): - g = self.make_grid() + def extra_row_class(row, i): + return 'not-for-sale' if row.not_for_sale else None + g = self.make_grid(extra_row_class=extra_row_class) g.upc.set(renderer=GPCFieldRenderer) g.regular_price.set(renderer=PriceFieldRenderer) g.current_price.set(renderer=PriceFieldRenderer) @@ -264,6 +266,7 @@ class ProductCrud(CrudView): fs.report_code, fs.regular_price, fs.current_price, + fs.not_for_sale, ]) if not self.readonly: del fs.regular_price From a3cfbd1e09a0f322b3212e6735255c95e662c53e Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 11 Sep 2014 20:59:43 -0700 Subject: [PATCH 0132/3860] Add "exclude not for sale" option to Inventory Worksheet. --- tailbone/templates/reports/inventory.mako | 8 ++++++-- tailbone/views/reports.py | 15 +++++++++------ 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/tailbone/templates/reports/inventory.mako b/tailbone/templates/reports/inventory.mako index 3d3b5bd3..b8148bb1 100644 --- a/tailbone/templates/reports/inventory.mako +++ b/tailbone/templates/reports/inventory.mako @@ -19,8 +19,12 @@ ${h.form(request.current_route_url())} -
    - ${h.checkbox('weighted-only', label=h.literal("Include items sold by weight only."))} +
    + ${h.checkbox('weighted-only', label="Only include items which are sold by weight.")} +
    + +
    + ${h.checkbox('exclude-not-for-sale', label="Exclude items marked \"not for sale\".", checked=True)}
    diff --git a/tailbone/views/reports.py b/tailbone/views/reports.py index ccad2e39..a65a8753 100644 --- a/tailbone/views/reports.py +++ b/tailbone/views/reports.py @@ -1,9 +1,8 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- +# -*- coding: utf-8 -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2012 Lance Edgar +# Copyright © 2010-2014 Lance Edgar # # This file is part of Rattail. # @@ -26,6 +25,8 @@ Report Views """ +from __future__ import unicode_literals + import re from .core import View @@ -147,9 +148,9 @@ class InventoryWorksheet(View): department = departments.get(self.request.params['department']) if department: body = self.write_report(department) - response = Response(content_type='text/html') - response.headers['Content-Length'] = len(body) - response.headers['Content-Disposition'] = 'attachment; filename=inventory.html' + response = Response(content_type=b'text/html') + response.headers[b'Content-Length'] = len(body) + response.headers[b'Content-Disposition'] = b'attachment; filename=inventory.html' response.text = body return response @@ -168,6 +169,8 @@ class InventoryWorksheet(View): q = q.filter(model.Product.subdepartment == subdepartment) if self.request.params.get('weighted-only'): q = q.filter(model.Product.unit_of_measure == enum.UNIT_OF_MEASURE_POUND) + if self.request.params.get('exclude-not-for-sale'): + q = q.filter(model.Product.not_for_sale == False) q = q.order_by(model.Brand.name, model.Product.description) return q.all() From 03c72d850df1bdfc34b5b5215ed5bb953e548143 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 13 Sep 2014 12:08:03 -0700 Subject: [PATCH 0133/3860] Update changelog. --- CHANGES.rst | 6 ++++++ setup.py | 3 ++- tailbone/_version.py | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 3254cae8..63065c1e 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,5 +1,11 @@ .. -*- coding: utf-8 -*- +0.3.19 +------ + +* Add support for ``Product.not_for_sale`` flag. + + 0.3.18 ------ diff --git a/setup.py b/setup.py index a7d330c4..53ba24ea 100644 --- a/setup.py +++ b/setup.py @@ -21,6 +21,7 @@ # ################################################################################ +from __future__ import unicode_literals import os.path from setuptools import setup, find_packages @@ -84,7 +85,7 @@ requires = [ 'pyramid_exclog', # 0.6 'pyramid_simpleform', # 0.6.1 'pyramid_tm', # 0.3 - u'rattail[db]>=0.3.33', # 0.3.33 + 'rattail[db]>=0.3.37', # 0.3.37 'transaction', # 1.2.0 'waitress', # 0.8.1 'WebHelpers', # 1.3 diff --git a/tailbone/_version.py b/tailbone/_version.py index 0c06624b..36896fad 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- -__version__ = u'0.3.18' +__version__ = u'0.3.19' From 9d2a35c8b1808299d0bded26938115a79d4999ed Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 13 Sep 2014 19:15:40 -0700 Subject: [PATCH 0134/3860] Refactor some label printing stuff, per rattail changes. This had to do with some edbob removal. --- tailbone/views/batches/params/labels.py | 17 +++++++++-------- tailbone/views/products.py | 4 +++- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/tailbone/views/batches/params/labels.py b/tailbone/views/batches/params/labels.py index d05a33d5..49ef3bb1 100644 --- a/tailbone/views/batches/params/labels.py +++ b/tailbone/views/batches/params/labels.py @@ -1,9 +1,8 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- +# -*- coding: utf-8 -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2012 Lance Edgar +# Copyright © 2010-2014 Lance Edgar # # This file is part of Rattail. # @@ -26,10 +25,12 @@ Print Labels Batch """ -from ....db import Session +from __future__ import unicode_literals -import rattail -from . import BatchParamsView +from rattail.db import model + +from tailbone.db import Session +from tailbone.views.batches.params import BatchParamsView class PrintLabels(BatchParamsView): @@ -37,8 +38,8 @@ class PrintLabels(BatchParamsView): provider_name = 'print_labels' def render_kwargs(self): - q = Session.query(rattail.LabelProfile) - q = q.order_by(rattail.LabelProfile.ordinal) + q = Session.query(model.LabelProfile) + q = q.order_by(model.LabelProfile.ordinal) profiles = [(x.code, x.description) for x in q] return {'label_profiles': profiles} diff --git a/tailbone/views/products.py b/tailbone/views/products.py index ba317172..9a33b501 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -25,6 +25,8 @@ Product Views """ +from __future__ import unicode_literals + import os from sqlalchemy import and_ @@ -326,7 +328,7 @@ def print_labels(request): return {'error': "Quantity must be numeric"} quantity = int(quantity) - printer = profile.get_printer() + printer = profile.get_printer(request.rattail_config) if not printer: return {'error': "Couldn't get printer from label profile"} From 350c944e91b17b6bb8ecb417a42d212be818a077 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 26 Sep 2014 15:31:34 -0700 Subject: [PATCH 0135/3860] Fix some bugs with printer profile stuff, per recent rattail changes. --- tailbone/templates/labels/profiles/crud.mako | 2 +- tailbone/templates/labels/profiles/read.mako | 4 ++-- tailbone/views/labels.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tailbone/templates/labels/profiles/crud.mako b/tailbone/templates/labels/profiles/crud.mako index 16a253e7..85bff2cb 100644 --- a/tailbone/templates/labels/profiles/crud.mako +++ b/tailbone/templates/labels/profiles/crud.mako @@ -16,7 +16,7 @@
  • ${h.link_to("Back to Label Profiles", url('label_profiles'))}
  • % if form.updating: <% profile = form.fieldset.model %> - <% printer = profile.get_printer() %> + <% printer = profile.get_printer(request.rattail_config) %> % if printer and printer.required_settings:
  • ${h.link_to("Edit Printer Settings", url('label_profile.printer_settings', uuid=profile.uuid))}
  • % endif diff --git a/tailbone/templates/labels/profiles/read.mako b/tailbone/templates/labels/profiles/read.mako index 3cc36a20..074a287a 100644 --- a/tailbone/templates/labels/profiles/read.mako +++ b/tailbone/templates/labels/profiles/read.mako @@ -5,7 +5,7 @@
  • ${h.link_to("Back to Label Profiles", url('label_profiles'))}
  • % if form.readonly and request.has_perm('label_profiles.update'): <% profile = form.fieldset.model %> - <% printer = profile.get_printer() %> + <% printer = profile.get_printer(request.rattail_config) %>
  • ${h.link_to("Edit this Label Profile", url('label_profile.update', uuid=form.fieldset.model.uuid))}
  • % if printer and printer.required_settings:
  • ${h.link_to("Edit Printer Settings", url('label_profile.printer_settings', uuid=profile.uuid))}
  • @@ -16,7 +16,7 @@ ${parent.body()} <% profile = form.fieldset.model %> -<% printer = profile.get_printer() %> +<% printer = profile.get_printer(request.rattail_config) %> % if printer and printer.required_settings:

    Printer Settings

    diff --git a/tailbone/views/labels.py b/tailbone/views/labels.py index 5207e2fd..82b49dec 100644 --- a/tailbone/views/labels.py +++ b/tailbone/views/labels.py @@ -141,7 +141,7 @@ def printer_settings(request): read_profile = HTTPFound(location=request.route_url( 'label_profile.read', uuid=profile.uuid)) - printer = profile.get_printer() + printer = profile.get_printer(request.rattail_config) if not printer: request.session.flash("Label profile \"%s\" does not have a functional " "printer spec." % profile) From f7c3955d8c76a2c4207d9dce5f9d7a472f7a555b Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 26 Sep 2014 15:41:54 -0700 Subject: [PATCH 0136/3860] Update changelog. --- CHANGES.rst | 6 ++++++ setup.py | 2 +- tailbone/_version.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 63065c1e..e3c63100 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,5 +1,11 @@ .. -*- coding: utf-8 -*- +0.3.20 +------ + +* Refactor some label printing stuff, per rattail changes. + + 0.3.19 ------ diff --git a/setup.py b/setup.py index 53ba24ea..aa4bc862 100644 --- a/setup.py +++ b/setup.py @@ -85,7 +85,7 @@ requires = [ 'pyramid_exclog', # 0.6 'pyramid_simpleform', # 0.6.1 'pyramid_tm', # 0.3 - 'rattail[db]>=0.3.37', # 0.3.37 + 'rattail[db]>=0.3.38', # 0.3.38 'transaction', # 1.2.0 'waitress', # 0.8.1 'WebHelpers', # 1.3 diff --git a/tailbone/_version.py b/tailbone/_version.py index 36896fad..3d18f319 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- -__version__ = u'0.3.19' +__version__ = u'0.3.20' From b2439dee70ea0af57f7f7c3a495ffe3609888ffe Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 29 Oct 2014 16:29:16 -0500 Subject: [PATCH 0137/3860] Add monospace font for label printer format command. --- tailbone/templates/labels/profiles/crud.mako | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tailbone/templates/labels/profiles/crud.mako b/tailbone/templates/labels/profiles/crud.mako index 85bff2cb..ace05e9f 100644 --- a/tailbone/templates/labels/profiles/crud.mako +++ b/tailbone/templates/labels/profiles/crud.mako @@ -6,6 +6,8 @@ + + +
    + +
      +
    • ${h.link_to("Back to {0}".format(batch_display_plural), url(route_prefix))}
    • + % if not batch.executed: + % if request.has_perm('{0}.edit'.format(permission_prefix)): + ##
    • ${h.link_to("Edit this {0}".format(batch_display), url('{0}.edit'.format(route_prefix), uuid=batch.uuid))}
    • + % if batch.refreshable: +
    • ${h.link_to("Refresh Data for this {0}".format(batch_display), url('{0}.refresh'.format(route_prefix), uuid=batch.uuid))}
    • + % endif + % endif + % if request.has_perm('{0}.execute'.format(permission_prefix)): +
    • ${h.link_to("Execute this {0}".format(batch_display), url('{0}.execute'.format(route_prefix), uuid=batch.uuid))}
    • + % endif + % endif + % if request.has_perm('{0}.delete'.format(permission_prefix)): +
    • ${h.link_to("Delete this {0}".format(batch_display), url('{0}.delete'.format(route_prefix), uuid=batch.uuid))}
    • + % endif +
    + + ${form.render()|n} + +
    + +
    diff --git a/tailbone/templates/vendors/catalogs/create.mako b/tailbone/templates/vendors/catalogs/create.mako new file mode 100644 index 00000000..2e46901c --- /dev/null +++ b/tailbone/templates/vendors/catalogs/create.mako @@ -0,0 +1,3 @@ +## -*- coding: utf-8 -*- +<%inherit file="/batch/create.mako" /> +${parent.body()} diff --git a/tailbone/templates/vendors/catalogs/index.mako b/tailbone/templates/vendors/catalogs/index.mako new file mode 100644 index 00000000..acddd2fb --- /dev/null +++ b/tailbone/templates/vendors/catalogs/index.mako @@ -0,0 +1,3 @@ +## -*- coding: utf-8 -*- +<%inherit file="/batch/index.mako" /> +${parent.body()} diff --git a/tailbone/templates/vendors/catalogs/view.mako b/tailbone/templates/vendors/catalogs/view.mako new file mode 100644 index 00000000..9b89af91 --- /dev/null +++ b/tailbone/templates/vendors/catalogs/view.mako @@ -0,0 +1,3 @@ +## -*- coding: utf-8 -*- +<%inherit file="/batch/view.mako" /> +${parent.body()} diff --git a/tailbone/views/batch.py b/tailbone/views/batch.py new file mode 100644 index 00000000..3c945d93 --- /dev/null +++ b/tailbone/views/batch.py @@ -0,0 +1,849 @@ +# -*- coding: utf-8 -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2015 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 . +# +################################################################################ +""" +Base views for maintaining new-style batches. + +.. note:: + This is all still very experimental. +""" + +from __future__ import unicode_literals + +import os +import datetime +import logging + +import formalchemy +from pyramid.renderers import render_to_response +from pyramid.httpexceptions import HTTPFound, HTTPNotFound + +from rattail.db import model +from rattail.db import Session as RatSession +from rattail.threads import Thread + +from tailbone.db import Session +from tailbone.views import SearchableAlchemyGridView, CrudView +from tailbone.forms import DateTimeFieldRenderer, UserFieldRenderer, EnumFieldRenderer +from tailbone.grids.search import BooleanSearchFilter, EnumSearchFilter +from tailbone.progress import SessionProgress + + +log = logging.getLogger(__name__) + + +class BaseGrid(SearchableAlchemyGridView): + """ + Base view for batch and batch row grid views. You should not derive from + this class, but :class:`BatchGrid` or :class:`BatchRowGrid` instead. + """ + + @property + def config_prefix(self): + """ + Config prefix for the grid view. This is used to keep track of current + filtering and sorting, within the user's session. Derived classes may + override this. + """ + return self.mapped_class.__name__.lower() + + @property + def permission_prefix(self): + """ + Permission prefix for the grid view. This is used to automatically + protect certain views common to all batches. Derived classes can + override this. + """ + return self.route_prefix + + def join_map_extras(self): + """ + Derived classes can override this. The value returned will be used to + supplement the default join map. + """ + return {} + + def filter_map_extras(self): + """ + Derived classes can override this. The value returned will be used to + supplement the default filter map. + """ + return {} + + def make_filter_map(self, **kwargs): + """ + Make a filter map by combining kwargs from the base class, with extras + supplied by a derived class. + """ + extras = self.filter_map_extras() + exact = extras.pop('exact', None) + if exact: + kwargs.setdefault('exact', []).extend(exact) + ilike = extras.pop('ilike', None) + if ilike: + kwargs.setdefault('ilike', []).extend(ilike) + kwargs.update(extras) + return super(BaseGrid, self).make_filter_map(**kwargs) + + def filter_config_extras(self): + """ + Derived classes can override this. The value returned will be used to + supplement the default filter config. + """ + return {} + + def sort_map_extras(self): + """ + Derived classes can override this. The value returned will be used to + supplement the default sort map. + """ + return {} + + def _configure_grid(self, grid): + """ + Internal method for configuring the grid. This is meant only for base + classes; derived classes should not need to override it. + """ + + def configure_grid(self, grid): + """ + Derived classes can override this. Customizes a grid which has already + been created with defaults by the base class. + """ + + +class BatchGrid(BaseGrid): + """ + Base grid view for batches, which can be filtered and sorted. + """ + + @property + def batch_class(self): + raise NotImplementedError + + @property + def mapped_class(self): + return self.batch_class + + @property + def batch_display_plural(self): + """ + Plural display text for the batch type. + """ + return "{0}s".format(self.batch_display) + + def join_map(self): + """ + Provides the default join map for batch grid views. Derived classes + should *not* override this, but :meth:`join_map_extras()` instead. + """ + map_ = { + 'created_by': + lambda q: q.join(model.User, model.User.uuid == self.batch_class.created_by_uuid), + } + map_.update(self.join_map_extras()) + return map_ + + def filter_map(self): + """ + Provides the default filter map for batch grid views. Derived classes + should *not* override this, but :meth:`filter_map_extras()` instead. + """ + + def executed_is(q, v): + if v == 'True': + return q.filter(self.batch_class.executed != None) + else: + return q.filter(self.batch_class.executed == None) + + def executed_nt(q, v): + if v == 'True': + return q.filter(self.batch_class.executed == None) + else: + return q.filter(self.batch_class.executed != None) + + return self.make_filter_map( + executed={'is': executed_is, 'nt': executed_nt}) + + def filter_config(self): + """ + Provides the default filter config for batch grid views. Derived + classes should *not* override this, but :meth:`filter_config_extras()` + instead. + """ + config = self.make_filter_config( + filter_factory_executed=BooleanSearchFilter, + filter_type_executed='is', + executed=False, + include_filter_executed=True) + config.update(self.filter_config_extras()) + return config + + def sort_map(self): + """ + Provides the default sort map for batch grid views. Derived classes + should *not* override this, but :meth:`sort_map_extras()` instead. + """ + map_ = self.make_sort_map( + created_by=self.sorter(model.User.username)) + map_.update(self.sort_map_extras()) + return map_ + + def sort_config(self): + """ + Provides the default sort config for batch grid views. Derived classes + may override this. + """ + return self.make_sort_config(sort='created', dir='desc') + + def grid(self): + """ + Creates the grid for the view. Derived classes should *not* override + this, but :meth:`configure_grid()` instead. + """ + g = self.make_grid() + g.created.set(renderer=DateTimeFieldRenderer(self.request.rattail_config)) + g.created_by.set(renderer=UserFieldRenderer) + g.cognized.set(renderer=DateTimeFieldRenderer(self.request.rattail_config)) + g.cognized_by.set(renderer=UserFieldRenderer) + g.executed.set(renderer=DateTimeFieldRenderer(self.request.rattail_config)) + g.executed_by.set(renderer=UserFieldRenderer) + self._configure_grid(g) + self.configure_grid(g) + if self.request.has_perm('{0}.view'.format(self.permission_prefix)): + g.viewable = True + g.view_route_name = '{0}.view'.format(self.route_prefix) + # if self.request.has_perm('{0}.edit'.format(self.permission_prefix)): + # g.editable = True + # g.edit_route_name = '{0}.edit'.format(self.route_prefix) + if self.request.has_perm('{0}.delete'.format(self.permission_prefix)): + g.deletable = True + g.delete_route_name = '{0}.delete'.format(self.route_prefix) + return g + + def _configure_grid(self, grid): + grid.created_by.set(label="Created by") + grid.executed_by.set(label="Executed by") + + def configure_grid(self, grid): + """ + Derived classes can override this. Customizes a grid which has already + been created with defaults by the base class. + """ + g = grid + g.configure( + include=[ + g.created, + g.created_by, + g.executed, + g.executed_by, + ], + readonly=True) + + def render_kwargs(self): + """ + Add some things to the template context: batch type display name, route + and permission prefixes. + """ + return { + 'batch_display': self.batch_display, + 'batch_display_plural': self.batch_display_plural, + 'route_prefix': self.route_prefix, + 'permission_prefix': self.permission_prefix, + } + + +class FileBatchGrid(BatchGrid): + """ + Base grid view for batches, which involve primarily a file upload. + """ + + def _configure_grid(self, g): + super(FileBatchGrid, self)._configure_grid(g) + g.created.set(label="Uploaded") + g.created_by.set(label="Uploaded by") + + def configure_grid(self, grid): + """ + Derived classes can override this. Customizes a grid which has already + been created with defaults by the base class. + """ + g = grid + g.configure( + include=[ + g.created, + g.created_by, + g.filename, + g.executed, + g.executed_by, + ], + readonly=True) + + +class BaseCrud(CrudView): + """ + Base CRUD view for batches and batch rows. + """ + flash = {} + + @property + def permission_prefix(self): + """ + Permission prefix used to generically protect certain views common to + all batches. Derived classes can override this. + """ + return self.route_prefix + + def flash_create(self, model): + if 'create' in self.flash: + self.request.session.flash(self.flash['create']) + else: + super(BaseCrud, self).flash_create(model) + + def flash_delete(self, model): + if 'delete' in self.flash: + self.request.session.flash(self.flash['delete']) + else: + super(BaseCrud, self).flash_delete(model) + + +class BatchCrud(BaseCrud): + """ + Base CRUD view for batches. + """ + refreshable = False + flash = {} + + @property + def batch_class(self): + raise NotImplementedError + + @property + def mapped_class(self): + return self.batch_class + + @property + def permission_prefix(self): + """ + Permission prefix for the grid view. This is used to automatically + protect certain views common to all batches. Derived classes can - and + typically should - override this. + """ + return self.route_prefix + + @property + def home_route(self): + """ + The "home" route for the batch type, i.e. its grid view. + """ + return self.route_prefix + + @property + def batch_display_plural(self): + """ + Plural display text for the batch type. + """ + return "{0}s".format(self.batch_display) + + def __init__(self, request): + self.request = request + self.handler = self.batch_handler_class(config=self.request.rattail_config) + + def fieldset(self, model): + """ + Creates the fieldset for the view. Derived classes should *not* + override this, but :meth:`configure_fieldset()` instead. + """ + fs = self.make_fieldset(model) + fs.created.set(renderer=DateTimeFieldRenderer(self.request.rattail_config)) + fs.created_by.set(label="Created by", renderer=UserFieldRenderer) + fs.cognized.set(renderer=DateTimeFieldRenderer(self.request.rattail_config)) + fs.cognized_by.set(label="Cognized by", renderer=UserFieldRenderer) + fs.executed.set(renderer=DateTimeFieldRenderer(self.request.rattail_config)) + fs.executed_by.set(label="Executed by", renderer=UserFieldRenderer) + self.configure_fieldset(fs) + if self.creating: + del fs.created + del fs.created_by + del fs.cognized + del fs.cognized_by + return fs + + def configure_fieldset(self, fieldset): + """ + Derived classes can override this. Customizes a fieldset which has + already been created with defaults by the base class. + """ + fs = fieldset + fs.configure( + include=[ + fs.created, + fs.created_by, + # fs.cognized, + # fs.cognized_by, + fs.executed, + fs.executed_by, + ]) + + def template_kwargs(self, form): + """ + Add some things to the template context: current batch model, batch + type display name, route and permission prefixes, batch row grid. + """ + batch = form.fieldset.model + batch.refreshable = self.refreshable + return { + 'batch': batch, + 'batch_display': self.batch_display, + 'batch_display_plural': self.batch_display_plural, + 'route_prefix': self.route_prefix, + 'permission_prefix': self.permission_prefix, + } + + def flash_create(self, batch): + if 'create' in self.flash: + self.request.session.flash(self.flash['create']) + else: + super(BatchCrud, self).flash_create(batch) + + def flash_delete(self, batch): + if 'delete' in self.flash: + self.request.session.flash(self.flash['delete']) + else: + super(BatchCrud, self).flash_delete(batch) + + def current_batch(self): + """ + Return the current batch, based on the UUID within the URL. + """ + return Session.query(self.mapped_class).get(self.request.matchdict['uuid']) + + def refresh(self): + """ + View which will attempt to refresh all data for the batch. What + exactly this means will depend on the type of batch etc. + """ + batch = self.current_batch() + + # If handler doesn't declare the need for progress indicator, things + # are nice and simple. + if not self.handler.show_progress: + self.refresh_data(Session, batch) + self.request.session.flash("Batch data has been refreshed.") + return HTTPFound(location=self.view_url(batch.uuid)) + + # Showing progress requires a separate thread; start that first. + key = '{0}.refresh'.format(self.batch_class.__tablename__) + progress = SessionProgress(self.request, key) + thread = Thread(target=self.refresh_thread, args=(batch.uuid, progress)) + thread.start() + + # Send user to progress page. + kwargs = { + 'key': key, + 'cancel_url': self.view_url(batch.uuid), + 'cancel_msg': "Batch refresh was canceled.", + } + return render_to_response('/progress.mako', kwargs, request=self.request) + + def refresh_data(self, session, batch, progress_factory=None): + """ + Instruct the batch handler to refresh all data for the batch. + """ + self.handler.refresh_data(session, batch, progress_factory=progress_factory) + batch.cognized = datetime.datetime.utcnow() + batch.cognized_by = self.request.user + + def refresh_thread(self, batch_uuid, progress): + """ + Thread target for refreshing batch data with progress indicator. + """ + # Refresh data for the batch, with progress. Note that we must use the + # rattail session here; can't use tailbone because it has web request + # transaction binding etc. + session = RatSession() + batch = session.query(self.batch_class).get(batch_uuid) + self.refresh_data(session, batch, progress_factory=progress) + session.commit() + session.refresh(batch) + session.close() + + # Finalize progress indicator. + progress.session.load() + progress.session['complete'] = True + progress.session['success_url'] = self.view_url(batch.uuid) + progress.session.save() + + def view_url(self, uuid=None): + """ + Returns the URL for viewing a batch; defaults to current batch. + """ + if uuid is None: + uuid = self.request.matchdict['uuid'] + return self.request.route_url('{0}.view'.format(self.route_prefix), uuid=uuid) + + def execute(self): + batch = self.current_batch() + if self.handler.execute(batch): + batch.executed = datetime.datetime.utcnow() + batch.executed_by = self.request.user + return HTTPFound(location=self.view_url(batch.uuid)) + + +class FileBatchCrud(BatchCrud): + """ + Base CRUD view for batches which involve a file upload as the first step. + """ + refreshable = True + + def pre_crud(self, batch): + """ + Force refresh if batch has yet to be cognized. + """ + if not self.creating and not batch.cognized: + return HTTPFound(location=self.request.route_url( + '{0}.refresh'.format(self.route_prefix), uuid=batch.uuid)) + + def fieldset(self, model): + """ + Creates the fieldset for the view. Derived classes should *not* + override this, but :meth:`configure_fieldset()` instead. + """ + fs = self.make_fieldset(model) + fs.created.set(label="Uploaded", renderer=DateTimeFieldRenderer(self.request.rattail_config)) + fs.created_by.set(label="Uploaded by", renderer=UserFieldRenderer) + fs.cognized.set(renderer=DateTimeFieldRenderer(self.request.rattail_config)) + fs.cognized_by.set(label="Cognized by", renderer=UserFieldRenderer) + fs.executed.set(renderer=DateTimeFieldRenderer(self.request.rattail_config)) + fs.executed_by.set(label="Executed by", renderer=UserFieldRenderer) + fs.append(formalchemy.Field('data_file')) + fs.data_file.set(renderer=formalchemy.fields.FileFieldRenderer) + self.configure_fieldset(fs) + if self.creating: + del fs.created + del fs.created_by + del fs.filename + if 'cognized' in fs.render_fields: + del fs.cognized + if 'cognized_by' in fs.render_fields: + del fs.cognized_by + if 'executed' in fs.render_fields: + del fs.executed + if 'executed_by' in fs.render_fields: + del fs.executed_by + if 'data_rows' in fs.render_fields: + del fs.data_rows + else: + if 'data_file' in fs.render_fields: + del fs.data_file + batch = fs.model + if not batch.executed: + if 'executed' in fs.render_fields: + del fs.executed + if 'executed_by' in fs.render_fields: + del fs.executed_by + return fs + + def configure_fieldset(self, fieldset): + """ + Derived classes can override this. Customizes a fieldset which has + already been created with defaults by the base class. + """ + fs = fieldset + fs.configure( + include=[ + fs.created, + fs.created_by, + fs.data_file, + fs.filename, + # fs.cognized, + # fs.cognized_by, + fs.executed, + fs.executed_by, + ]) + + def save_form(self, form): + """ + Save the uploaded data file if necessary, etc. + """ + # Transfer form data to batch instance. + form.fieldset.sync() + batch = form.fieldset.model + + # For new batches, assign current user as creator, save file etc. + if self.creating: + batch.created_by = self.request.user + batch.filename = form.fieldset.data_file.renderer._filename + # Expunge batch from session to prevent it from being flushed. + Session.expunge(batch) + self.init_batch(batch) + Session.add(batch) + batch.write_file(self.request.rattail_config, form.fieldset.data_file.value) + + def init_batch(self, batch): + """ + Initialize a new batch. Derived classes can override this to + effectively provide default values for a batch, etc. This method is + invoked after a batch has been fully prepared for insertion to the + database, but before the push to the database occurs. + """ + + def post_save_url(self, form): + """ + Redirect to "view batch" after creating or updating a batch. + """ + batch = form.fieldset.model + return self.view_url(batch.uuid) + + def pre_delete(self, batch): + """ + Delete all data (files etc.) for the batch. + """ + batch.delete_data(self.request.rattail_config) + del batch.data_rows[:] + + +class BatchRowGrid(BaseGrid): + """ + Base grid view for batch rows, which can be filtered and sorted. + """ + + @property + def row_class(self): + raise NotImplementedError + + @property + def mapped_class(self): + return self.row_class + + @property + def config_prefix(self): + """ + Config prefix for the grid view. This is used to keep track of current + filtering and sorting, within the user's session. Derived classes may + override this. + """ + return '{0}.{1}'.format(self.mapped_class.__name__.lower(), + self.request.matchdict['uuid']) + + def current_batch(self): + """ + Return the current batch, based on the UUID within the URL. + """ + batch_class = self.row_class.__batch_class__ + return Session.query(batch_class).get(self.request.matchdict['uuid']) + + def modify_query(self, q): + q = super(BatchRowGrid, self).modify_query(q) + q = q.filter_by(batch=self.current_batch()) + q = q.filter_by(removed=False) + return q + + def join_map(self): + """ + Provides the default join map for batch row grid views. Derived + classes should *not* override this, but :meth:`join_map_extras()` + instead. + """ + return self.join_map_extras() + + def filter_map(self): + """ + Provides the default filter map for batch row grid views. Derived + classes should *not* override this, but :meth:`filter_map_extras()` + instead. + """ + return self.make_filter_map(exact=['status_code']) + + def filter_config(self): + """ + Provides the default filter config for batch grid views. Derived + classes should *not* override this, but :meth:`filter_config_extras()` + instead. + """ + kwargs = {'filter_label_status_code': "Status", + 'filter_factory_status_code': EnumSearchFilter(self.row_class.STATUS)} + kwargs.update(self.filter_config_extras()) + return self.make_filter_config(**kwargs) + + def sort_map(self): + """ + Provides the default sort map for batch grid views. Derived classes + should *not* override this, but :meth:`sort_map_extras()` instead. + """ + map_ = self.make_sort_map() + map_.update(self.sort_map_extras()) + return map_ + + def sort_config(self): + """ + Provides the default sort config for batch grid views. Derived classes + may override this. + """ + return self.make_sort_config(sort='sequence', dir='asc') + + def grid(self): + """ + Creates the grid for the view. Derived classes should *not* override + this, but :meth:`configure_grid()` instead. + """ + g = self.make_grid() + g.extra_row_class = self.tr_class + g.sequence.set(label="Seq.") + g.status_code.set(label="Status", renderer=EnumFieldRenderer(self.row_class.STATUS)) + self._configure_grid(g) + self.configure_grid(g) + + batch = self.current_batch() + # g.viewable = True + # g.view_route_name = '{0}.rows.view'.format(self.route_prefix) + if not batch.executed and self.request.has_perm('{0}.edit'.format(self.permission_prefix)): + # g.editable = True + # g.edit_route_name = '{0}.rows.edit'.format(self.route_prefix) + g.deletable = True + g.delete_route_name = '{0}.rows.delete'.format(self.route_prefix) + return g + + def tr_class(self, row, i): + pass + + +class ProductBatchRowGrid(BatchRowGrid): + """ + Base grid view for batch rows which deal directly with products. + """ + + def filter_map(self): + """ + Provides the default filter map for batch row grid views. Derived + classes should *not* override this, but :meth:`filter_map_extras()` + instead. + """ + return self.make_filter_map(exact=['upc', 'status_code'], + ilike=['brand_name', 'description', 'size']) + + def filter_config(self): + """ + Provides the default filter config for batch grid views. Derived + classes should *not* override this, but :meth:`filter_config_extras()` + instead. + """ + kwargs = {'filter_label_status_code': "Status", + 'filter_factory_status_code': EnumSearchFilter(self.row_class.STATUS), + 'filter_label_upc': "UPC", + 'filter_label_brand_name': "Brand"} + kwargs.update(self.filter_config_extras()) + return self.make_filter_config(**kwargs) + + +class BatchRowCrud(BaseCrud): + """ + Base CRUD view for batch rows. + """ + + @property + def row_class(self): + raise NotImplementedError + + @property + def mapped_class(self): + return self.row_class + + def delete(self): + """ + "Delete" a row from the batch. This sets the ``removed`` flag on the + row but does not truly delete it. + """ + row = self.get_model_from_request() + if not row: + return HTTPNotFound() + row.removed = True + return HTTPFound(location=self.request.route_url( + '{0}.view'.format(self.route_prefix), uuid=row.batch_uuid)) + + +def defaults(config, batch_grid, batch_crud, row_grid, row_crud, url_prefix, + route_prefix=None, permission_prefix=None, template_prefix=None): + """ + Apply default configuration to the Pyramid configurator object, for the + given batch grid and CRUD views. + """ + assert batch_grid + assert batch_crud + assert url_prefix + if route_prefix is None: + route_prefix = batch_grid.route_prefix + if permission_prefix is None: + permission_prefix = route_prefix + if template_prefix is None: + template_prefix = url_prefix + template_prefix.rstrip('/') + + # Batches grid + config.add_route(route_prefix, url_prefix) + config.add_view(batch_grid, route_name=route_prefix, + renderer='{0}/index.mako'.format(template_prefix), + permission='{0}.view'.format(permission_prefix)) + + # Create batch + config.add_route('{0}.create'.format(route_prefix), '{0}new'.format(url_prefix)) + config.add_view(batch_crud, attr='create', route_name='{0}.create'.format(route_prefix), + renderer='{0}/create.mako'.format(template_prefix), + permission='{0}.create'.format(permission_prefix)) + + # View batch + config.add_route('{0}.view'.format(route_prefix), '{0}{{uuid}}'.format(url_prefix)) + config.add_view(batch_crud, attr='read', route_name='{0}.view'.format(route_prefix), + renderer='{0}/view.mako'.format(template_prefix), + permission='{0}.view'.format(permission_prefix)) + + # Edit batch + config.add_route('{0}.edit'.format(route_prefix), '{0}{{uuid}}/edit'.format(url_prefix)) + config.add_view(batch_crud, attr='update', route_name='{0}.edit'.format(route_prefix), + renderer='{0}/edit.mako'.format(template_prefix), + permission='{0}.edit'.format(permission_prefix)) + + # Refresh batch row data + config.add_route('{0}.refresh'.format(route_prefix), '{0}{{uuid}}/refresh'.format(url_prefix)) + config.add_view(batch_crud, attr='refresh', route_name='{0}.refresh'.format(route_prefix), + permission='{0}.edit'.format(permission_prefix)) + + # Execute batch + config.add_route('{0}.execute'.format(route_prefix), '{0}{{uuid}}/execute'.format(url_prefix)) + config.add_view(batch_crud, attr='execute', route_name='{0}.execute'.format(route_prefix), + permission='{0}.execute'.format(permission_prefix)) + + # Delete batch + config.add_route('{0}.delete'.format(route_prefix), '{0}{{uuid}}/delete'.format(url_prefix)) + config.add_view(batch_crud, attr='delete', route_name='{0}.delete'.format(route_prefix), + permission='{0}.delete'.format(permission_prefix)) + + # Batch rows grid + config.add_route('{0}.rows'.format(route_prefix), '{0}{{uuid}}/rows/'.format(url_prefix)) + config.add_view(row_grid, route_name='{0}.rows'.format(route_prefix), + renderer='/batch/rows.mako', + permission='{0}.view'.format(permission_prefix)) + + # Delete batch row + config.add_route('{0}.rows.delete'.format(route_prefix), '{0}delete-row/{{uuid}}'.format(url_prefix)) + config.add_view(row_crud, attr='delete', route_name='{0}.rows.delete'.format(route_prefix), + permission='{0}.edit'.format(permission_prefix)) diff --git a/tailbone/views/crud.py b/tailbone/views/crud.py index 38f044e5..1a2d3203 100644 --- a/tailbone/views/crud.py +++ b/tailbone/views/crud.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2014 Lance Edgar +# Copyright © 2010-2015 Lance Edgar # # This file is part of Rattail. # @@ -87,13 +87,6 @@ class CrudView(View): return self.make_fieldset(model) def make_form(self, model, form_factory=AlchemyForm, **kwargs): - if self.readonly: - self.creating = False - self.updating = False - else: - self.creating = model is self.mapped_class - self.updating = not self.creating - fieldset = self.fieldset(model) kwargs.setdefault('pretty_name', self.pretty_name) kwargs.setdefault('action_url', self.request.current_route_url()) @@ -119,21 +112,32 @@ class CrudView(View): def form(self, model): return self.make_form(model) + def save_form(self, form): + form.save() + def crud(self, model, readonly=False): - if readonly: - self.readonly = True + self.readonly = readonly + if self.readonly: + self.creating = False + self.updating = False + else: + self.creating = model is self.mapped_class + self.updating = not self.creating + + result = self.pre_crud(model) + if result is not None: + return result form = self.form(model) - if readonly: - form.readonly = True + form.readonly = self.readonly + if not self.readonly and self.request.method == 'POST': - if not form.readonly and self.request.POST: if form.validate(): - form.save() + self.save_form(form) result = self.post_save(form) - if result: + if result is not None: return result if form.creating: @@ -153,6 +157,9 @@ class CrudView(View): kwargs['form'] = form return kwargs + def pre_crud(self, model): + pass + def template_kwargs(self, form): return {} @@ -206,14 +213,25 @@ class CrudView(View): return self.crud(model) def delete(self): + """ + View for deleting a record. Derived classes shouldn't override this, + but see also :meth:`pre_delete()` and :meth:`post_delete()`. + """ model = self.get_model_from_request() if not model: return HTTPNotFound() + + # Let derived classes prep for (or cancel) deletion. result = self.pre_delete(model) - if result: + if result is not None: return result + + # Flush the deletion immediately so that we know it will succeed prior + # to setting a flash message etc. Session.delete(model) - Session.flush() # Don't set flash message if delete fails. + Session.flush() + + # Derived classes can do extra things here; set flash and go home. self.post_delete(model) self.flash_delete(model) return HTTPFound(location=self.home_url) diff --git a/tailbone/views/progress.py b/tailbone/views/progress.py index 878c1eb5..de823e00 100644 --- a/tailbone/views/progress.py +++ b/tailbone/views/progress.py @@ -33,7 +33,9 @@ def progress(request): key = request.matchdict['key'] session = get_progress_session(request, key) if session.get('complete'): - request.session.flash(session.get('success_msg', "The process has completed successfully.")) + msg = session.get('success_msg') + if msg: + request.session.flash(msg) elif session.get('error'): request.session.flash(session.get('error_msg', "An unspecified error occurred."), 'error') return session diff --git a/tailbone/views/vendors/__init__.py b/tailbone/views/vendors/__init__.py new file mode 100644 index 00000000..54f2c7f1 --- /dev/null +++ b/tailbone/views/vendors/__init__.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2015 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 . +# +################################################################################ +""" +Views pertaining to vendors +""" + +from __future__ import unicode_literals + +from .core import VendorsGrid, VendorCrud, VendorsAutocomplete, add_routes + + +def includeme(config): + config.include('tailbone.views.vendors.core') + config.include('tailbone.views.vendors.catalogs') diff --git a/tailbone/views/vendors/catalogs.py b/tailbone/views/vendors/catalogs.py new file mode 100644 index 00000000..d7f3cec1 --- /dev/null +++ b/tailbone/views/vendors/catalogs.py @@ -0,0 +1,158 @@ +# -*- coding: utf-8 -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2015 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 . +# +################################################################################ +""" +Views for maintaining vendor catalogs +""" + +from __future__ import unicode_literals + +from rattail.db import model +from rattail.db.api import get_vendor +from rattail.db.batch.vendorcatalog import VendorCatalogHandler +from rattail.vendors.catalogs import iter_catalog_parsers, require_catalog_parser + +import formalchemy + +from tailbone.db import Session +from tailbone.views.batch import FileBatchGrid, FileBatchCrud, BatchRowGrid, BatchRowCrud, defaults + + +class VendorCatalogGrid(FileBatchGrid): + """ + Grid view for vendor catalogs. + """ + batch_class = model.VendorCatalog + batch_display = "Vendor Catalog" + route_prefix = 'vendors.catalogs' + + def join_map_extras(self): + return {'vendor': lambda q: q.join(model.Vendor)} + + def filter_map_extras(self): + return {'vendor': self.filter_ilike(model.Vendor.name)} + + def filter_config_extras(self): + return {'filter_type_vendor': 'lk', + 'include_filter_vendor': True} + + def sort_map_extras(self): + return {'vendor': self.sorter(model.Vendor.name)} + + def configure_grid(self, g): + g.configure( + include=[ + g.created, + g.created_by, + g.vendor, + g.effective, + g.filename, + g.executed, + ], + readonly=True) + + +class VendorCatalogCrud(FileBatchCrud): + """ + CRUD view for vendor catalogs. + """ + batch_class = model.VendorCatalog + batch_handler_class = VendorCatalogHandler + route_prefix = 'vendors.catalogs' + + batch_display = "Vendor Catalog" + flash = {'create': "New vendor catalog has been uploaded.", + 'delete': "Vendor catalog has been deleted."} + + def configure_fieldset(self, fs): + parsers = sorted(iter_catalog_parsers(), key=lambda p: p.display) + parser_options = [(p.display, p.key) for p in parsers] + parser_options.insert(0, ("(please choose)", '')) + fs.parser_key.set(renderer=formalchemy.fields.SelectFieldRenderer, + options=parser_options) + fs.configure( + include=[ + fs.created, + fs.created_by, + fs.vendor, + fs.data_file.label("Catalog File"), + fs.filename, + fs.parser_key.label("File Type"), + fs.effective, + fs.executed, + fs.executed_by, + ]) + if self.creating: + del fs.vendor + del fs.effective + else: + del fs.parser_key + + def init_batch(self, batch): + parser = require_catalog_parser(batch.parser_key) + batch.vendor = get_vendor(Session, parser.vendor_key) + + +class VendorCatalogRowGrid(BatchRowGrid): + """ + Grid view for vendor catalog rows. + """ + row_class = model.VendorCatalogRow + route_prefix = 'vendors.catalogs' + + def filter_map_extras(self): + return {'ilike': ['upc', 'brand_name', 'description', 'size', 'vendor_code']} + + def filter_config_extras(self): + return {'filter_label_upc': "UPC", + 'filter_label_brand_name': "Brand"} + + def configure_grid(self, g): + g.configure( + include=[ + g.sequence, + g.upc.label("UPC"), + g.brand_name.label("Brand"), + g.description, + g.size, + g.vendor_code, + g.old_unit_cost.label("Old Cost"), + g.unit_cost.label("New Cost"), + g.unit_cost_diff.label("Diff."), + g.status_code, + ], + readonly=True) + + def tr_class(self, row, i): + if row.status_code in (row.STATUS_NEW_COST, row.STATUS_UPDATE_COST): + return 'notice' + if row.status_code == row.STATUS_PRODUCT_NOT_FOUND: + return 'warning' + + +class VendorCatalogRowCrud(BatchRowCrud): + row_class = model.VendorCatalogRow + route_prefix = 'vendors.catalogs' + + +def includeme(config): + defaults(config, VendorCatalogGrid, VendorCatalogCrud, VendorCatalogRowGrid, VendorCatalogRowCrud, '/vendors/catalogs/') diff --git a/tailbone/views/vendors.py b/tailbone/views/vendors/core.py similarity index 96% rename from tailbone/views/vendors.py rename to tailbone/views/vendors/core.py index ad4e0e30..edf973aa 100644 --- a/tailbone/views/vendors.py +++ b/tailbone/views/vendors/core.py @@ -26,8 +26,8 @@ Vendor Views """ -from . import SearchableAlchemyGridView, CrudView, AutocompleteView -from ..forms import AssociationProxyField, PersonFieldRenderer +from tailbone.views import SearchableAlchemyGridView, CrudView, AutocompleteView +from tailbone.forms import AssociationProxyField, PersonFieldRenderer from rattail.db.model import Vendor From 7c761bee99ce9100788b851bb7c41a2ac845923a Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 20 Jan 2015 00:51:51 -0600 Subject: [PATCH 0171/3860] Fix some imports etc. regarding new batch system. --- tailbone/__init__.py | 7 ------- tailbone/views/vendors/catalogs.py | 11 ++++++----- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/tailbone/__init__.py b/tailbone/__init__.py index e9a45dd9..23169426 100644 --- a/tailbone/__init__.py +++ b/tailbone/__init__.py @@ -29,13 +29,6 @@ Backoffice Web Application for Rattail from ._version import __version__ -# TODO: Ugh, hack to get batch models loaded before views can complain... -from rattail.db import model -from rattail.db.batch.vendorcatalog.model import VendorCatalog, VendorCatalogRow -model.VendorCatalog = VendorCatalog -model.VendorCatalogRow = VendorCatalogRow - - def includeme(config): config.include('tailbone.static') config.include('tailbone.subscribers') diff --git a/tailbone/views/vendors/catalogs.py b/tailbone/views/vendors/catalogs.py index d7f3cec1..63e3e71f 100644 --- a/tailbone/views/vendors/catalogs.py +++ b/tailbone/views/vendors/catalogs.py @@ -28,7 +28,8 @@ from __future__ import unicode_literals from rattail.db import model from rattail.db.api import get_vendor -from rattail.db.batch.vendorcatalog import VendorCatalogHandler +from rattail.db.batch.vendorcatalog import VendorCatalog, VendorCatalogRow +from rattail.db.batch.vendorcatalog.handler import VendorCatalogHandler from rattail.vendors.catalogs import iter_catalog_parsers, require_catalog_parser import formalchemy @@ -41,7 +42,7 @@ class VendorCatalogGrid(FileBatchGrid): """ Grid view for vendor catalogs. """ - batch_class = model.VendorCatalog + batch_class = VendorCatalog batch_display = "Vendor Catalog" route_prefix = 'vendors.catalogs' @@ -75,7 +76,7 @@ class VendorCatalogCrud(FileBatchCrud): """ CRUD view for vendor catalogs. """ - batch_class = model.VendorCatalog + batch_class = VendorCatalog batch_handler_class = VendorCatalogHandler route_prefix = 'vendors.catalogs' @@ -116,7 +117,7 @@ class VendorCatalogRowGrid(BatchRowGrid): """ Grid view for vendor catalog rows. """ - row_class = model.VendorCatalogRow + row_class = VendorCatalogRow route_prefix = 'vendors.catalogs' def filter_map_extras(self): @@ -150,7 +151,7 @@ class VendorCatalogRowGrid(BatchRowGrid): class VendorCatalogRowCrud(BatchRowCrud): - row_class = model.VendorCatalogRow + row_class = VendorCatalogRow route_prefix = 'vendors.catalogs' From c328c9620381f23150ad80c8e295fa951a97707c Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 20 Jan 2015 13:22:20 -0600 Subject: [PATCH 0172/3860] Let settings determine which batch handler to use for vendor catalog views. --- tailbone/views/batch.py | 10 +++++++++- tailbone/views/vendors/catalogs.py | 21 ++++++++++++++++++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/tailbone/views/batch.py b/tailbone/views/batch.py index 3c945d93..57bcc404 100644 --- a/tailbone/views/batch.py +++ b/tailbone/views/batch.py @@ -366,7 +366,15 @@ class BatchCrud(BaseCrud): def __init__(self, request): self.request = request - self.handler = self.batch_handler_class(config=self.request.rattail_config) + self.handler = self.get_handler() + + def get_handler(self): + """ + Returns a `BatchHandler` instance for the view. Derived classes may + override this as needed. The default is to create an instance of + :attr:`batch_handler_class`. + """ + return self.batch_handler_class(self.request.rattail_config) def fieldset(self, model): """ diff --git a/tailbone/views/vendors/catalogs.py b/tailbone/views/vendors/catalogs.py index 63e3e71f..e790ee68 100644 --- a/tailbone/views/vendors/catalogs.py +++ b/tailbone/views/vendors/catalogs.py @@ -27,10 +27,11 @@ Views for maintaining vendor catalogs from __future__ import unicode_literals from rattail.db import model -from rattail.db.api import get_vendor +from rattail.db.api import get_setting, get_vendor from rattail.db.batch.vendorcatalog import VendorCatalog, VendorCatalogRow from rattail.db.batch.vendorcatalog.handler import VendorCatalogHandler from rattail.vendors.catalogs import iter_catalog_parsers, require_catalog_parser +from rattail.util import load_object import formalchemy @@ -84,6 +85,24 @@ class VendorCatalogCrud(FileBatchCrud): flash = {'create': "New vendor catalog has been uploaded.", 'delete': "Vendor catalog has been deleted."} + def get_handler(self): + """ + Returns a `BatchHandler` instance for the view. + + Derived classes may override this, but if you only need to replace the + handler (i.e. and not the view logic) then you can instead subclass + :class:`rattail.db.batch.vendorcatalog.handler.VendorCatalogHandler` + and create a setting named "rattail.batch.vendorcatalog.handler" in the + database, the value of which should be a spec string pointed at your + custom handler. + """ + handler = get_setting(Session, 'rattail.batch.vendorcatalog.handler') + if handler: + handler = load_object(handler)(self.request.rattail_config) + if not handler: + handler = super(VendorCatalogCrud, self).get_handler() + return handler + def configure_fieldset(self, fs): parsers = sorted(iter_catalog_parsers(), key=lambda p: p.display) parser_options = [(p.display, p.key) for p in parsers] From ccb7b47912df6da342a18911d11a11d72ff0e65a Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 20 Jan 2015 17:58:31 -0600 Subject: [PATCH 0173/3860] Update changelog. --- CHANGES.rst | 10 ++++++++++ setup.py | 2 +- tailbone/_version.py | 2 +- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index d5dabc3d..92ca549b 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,5 +1,15 @@ .. -*- coding: utf-8 -*- +0.3.28 +------ + +* Add unique username check when creating users. + +* Improve UPC search for rows within batches. + +* New batch system... + + 0.3.27 ------ diff --git a/setup.py b/setup.py index afbdf0e7..6f6b2840 100644 --- a/setup.py +++ b/setup.py @@ -82,7 +82,7 @@ requires = [ 'pyramid_exclog', # 0.6 'pyramid_simpleform', # 0.6.1 'pyramid_tm', # 0.3 - 'rattail[auth]>=0.3.45', # 0.3.45 + 'rattail[auth]>=0.3.46', # 0.3.46 'transaction', # 1.2.0 'waitress', # 0.8.1 'WebHelpers', # 1.3 diff --git a/tailbone/_version.py b/tailbone/_version.py index be698acb..c4625223 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- -__version__ = u'0.3.27' +__version__ = u'0.3.28' From 7fbabc87929ca9d3897101a470b2a5e5b8079175 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 31 Jan 2015 18:18:54 -0600 Subject: [PATCH 0174/3860] Add department to field lists for category views. --- tailbone/views/categories.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tailbone/views/categories.py b/tailbone/views/categories.py index ed6b9a60..36895a4d 100644 --- a/tailbone/views/categories.py +++ b/tailbone/views/categories.py @@ -54,6 +54,7 @@ class CategoriesGrid(SearchableAlchemyGridView): include=[ g.number, g.name, + g.department, ], readonly=True) if self.request.has_perm('categories.read'): @@ -79,6 +80,7 @@ class CategoryCrud(CrudView): include=[ fs.number, fs.name, + fs.department, ]) return fs From 3257010a7e810b2c6b42fcee623ab5227f0e5827 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 2 Feb 2015 20:44:32 -0600 Subject: [PATCH 0175/3860] Change default sort for People grid view. --- tailbone/views/people.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 32a32e87..0889c87c 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -41,7 +41,7 @@ class PeopleGrid(SearchableAlchemyGridView): mapped_class = Person config_prefix = 'people' - sort = 'first_name' + sort = 'display_name' def join_map(self): return { From eedbc5fb9a775068a8368a4ebd70fb0bcb53871b Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 9 Feb 2015 13:20:34 -0600 Subject: [PATCH 0176/3860] Various grid and form tweaks. --- tailbone/forms/fields.py | 8 +++----- tailbone/static/css/forms.css | 1 + tailbone/views/grids/alchemy.py | 9 ++++----- tailbone/views/grids/core.py | 8 ++++++-- 4 files changed, 14 insertions(+), 12 deletions(-) diff --git a/tailbone/forms/fields.py b/tailbone/forms/fields.py index 80fe001f..a5ad17f5 100644 --- a/tailbone/forms/fields.py +++ b/tailbone/forms/fields.py @@ -34,7 +34,8 @@ __all__ = ['AssociationProxyField'] def AssociationProxyField(name, **kwargs): """ - Returns a ``Field`` class which is aware of SQLAlchemy association proxies. + Returns a FormAlchemy ``Field`` class which is aware of association + proxies. """ class ProxyField(Field): @@ -45,10 +46,7 @@ def AssociationProxyField(name, **kwargs): self.renderer.deserialize()) def value(model): - try: - return getattr(model, name) - except AttributeError: - return None + return getattr(model, name, None) kwargs.setdefault('value', value) return ProxyField(name, **kwargs) diff --git a/tailbone/static/css/forms.css b/tailbone/static/css/forms.css index 760232b1..5b26f63a 100644 --- a/tailbone/static/css/forms.css +++ b/tailbone/static/css/forms.css @@ -31,6 +31,7 @@ div.form-wrapper ul.context-menu li { div.form, div.fieldset-form, div.fieldset { + clear: left; float: left; margin-top: 10px; } diff --git a/tailbone/views/grids/alchemy.py b/tailbone/views/grids/alchemy.py index 922683a5..ffb4ed48 100644 --- a/tailbone/views/grids/alchemy.py +++ b/tailbone/views/grids/alchemy.py @@ -59,7 +59,7 @@ class AlchemyGridView(GridView): def __call__(self): self._data = self.query() grid = self.grid() - return grids.util.render_grid(grid) + return self.render_grid(grid) class SortableAlchemyGridView(AlchemyGridView): @@ -108,7 +108,7 @@ class SortableAlchemyGridView(AlchemyGridView): self._sort_config = self.sort_config() self._data = self.query() grid = self.grid() - return grids.util.render_grid(grid) + return self.render_grid(grid) class PagedAlchemyGridView(SortableAlchemyGridView): @@ -127,7 +127,7 @@ class PagedAlchemyGridView(SortableAlchemyGridView): self._data = self.make_pager() grid = self.grid() grid.pager = self._data - return grids.util.render_grid(grid) + return self.render_grid(grid) class SearchableAlchemyGridView(PagedAlchemyGridView): @@ -184,5 +184,4 @@ class SearchableAlchemyGridView(PagedAlchemyGridView): self._data = self.make_pager() grid = self.grid() grid.pager = self._data - kwargs = self.render_kwargs() - return grids.util.render_grid(grid, search, **kwargs) + return self.render_grid(grid, search) diff --git a/tailbone/views/grids/core.py b/tailbone/views/grids/core.py index 157c2817..a2cfd48d 100644 --- a/tailbone/views/grids/core.py +++ b/tailbone/views/grids/core.py @@ -62,7 +62,11 @@ class GridView(View): def render_kwargs(self): return {} + def render_grid(self, grid, search=None, **kwargs): + kwargs = self.render_kwargs() + kwargs['search_form'] = search + return grids.util.render_grid(grid, **kwargs) + def __call__(self): grid = self.grid() - kwargs = self.render_kwargs() - return grids.util.render_grid(grid, **kwargs) + return self.render_grid(grid) From 0455e472f5bf97da04059ea3360770483d032050 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 9 Feb 2015 13:30:29 -0600 Subject: [PATCH 0177/3860] Cleanup some view modules per conventions etc. Mainly this makes extending them easier.. --- tailbone/views/categories.py | 49 ++++++++++---------- tailbone/views/departments.py | 76 ++++++++++++++------------------ tailbone/views/subdepartments.py | 53 ++++++++++------------ 3 files changed, 82 insertions(+), 96 deletions(-) diff --git a/tailbone/views/categories.py b/tailbone/views/categories.py index 36895a4d..18e92baa 100644 --- a/tailbone/views/categories.py +++ b/tailbone/views/categories.py @@ -1,9 +1,8 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- +# -*- coding: utf-8 -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2012 Lance Edgar +# Copyright © 2010-2015 Lance Edgar # # This file is part of Rattail. # @@ -21,15 +20,16 @@ # along with Rattail. If not, see . # ################################################################################ - """ Category Views """ -from . import SearchableAlchemyGridView, CrudView +from __future__ import unicode_literals from rattail.db.model import Category +from . import SearchableAlchemyGridView, CrudView + class CategoriesGrid(SearchableAlchemyGridView): @@ -85,28 +85,27 @@ class CategoryCrud(CrudView): return fs -def includeme(config): - - config.add_route('categories', '/categories') - config.add_view(CategoriesGrid, route_name='categories', - renderer='/categories/index.mako', - permission='categories.list') - +def add_routes(config): + config.add_route('categories', '/categories') config.add_route('category.create', '/categories/new') - config.add_view(CategoryCrud, attr='create', route_name='category.create', - renderer='/categories/crud.mako', - permission='categories.create') - - config.add_route('category.read', '/categories/{uuid}') - config.add_view(CategoryCrud, attr='read', route_name='category.read', - renderer='/categories/crud.mako', - permission='categories.read') - + config.add_route('category.read', '/categories/{uuid}') config.add_route('category.update', '/categories/{uuid}/edit') - config.add_view(CategoryCrud, attr='update', route_name='category.update', - renderer='/categories/crud.mako', - permission='categories.update') - config.add_route('category.delete', '/categories/{uuid}/delete') + + +def includeme(config): + add_routes(config) + + # list + config.add_view(CategoriesGrid, route_name='categories', + renderer='/categories/index.mako', permission='categories.list') + + # crud + config.add_view(CategoryCrud, attr='create', route_name='category.create', + renderer='/categories/crud.mako', permission='categories.create') + config.add_view(CategoryCrud, attr='read', route_name='category.read', + renderer='/categories/crud.mako', permission='categories.read') + config.add_view(CategoryCrud, attr='update', route_name='category.update', + renderer='/categories/crud.mako', permission='categories.update') config.add_view(CategoryCrud, attr='delete', route_name='category.delete', permission='categories.delete') diff --git a/tailbone/views/departments.py b/tailbone/views/departments.py index 9dd346a5..367577b2 100644 --- a/tailbone/views/departments.py +++ b/tailbone/views/departments.py @@ -1,9 +1,8 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- +# -*- coding: utf-8 -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2012 Lance Edgar +# Copyright © 2010-2015 Lance Edgar # # This file is part of Rattail. # @@ -21,16 +20,16 @@ # along with Rattail. If not, see . # ################################################################################ - """ Department Views """ - -from . import SearchableAlchemyGridView, CrudView, AlchemyGridView, AutocompleteView +from __future__ import unicode_literals from rattail.db.model import Department, Product, ProductCost, Vendor +from . import SearchableAlchemyGridView, CrudView, AlchemyGridView, AutocompleteView + class DepartmentsGrid(SearchableAlchemyGridView): @@ -117,44 +116,37 @@ class DepartmentsAutocomplete(AutocompleteView): fieldname = 'name' +def add_routes(config): + config.add_route('departments', '/departments') + config.add_route('departments.autocomplete', '/departments/autocomplete') + config.add_route('departments.by_vendor', '/departments/by-vendor') + config.add_route('department.create', '/departments/new') + config.add_route('department.read', '/departments/{uuid}') + config.add_route('department.update', '/departments/{uuid}/edit') + config.add_route('department.delete', '/departments/{uuid}/delete') + + def includeme(config): + add_routes(config) - config.add_route('departments', '/departments') - config.add_view(DepartmentsGrid, - route_name='departments', - renderer='/departments/index.mako', + # list + config.add_view(DepartmentsGrid, route_name='departments', + renderer='/departments/index.mako', permission='departments.list') + + # autocomplete + config.add_view(DepartmentsAutocomplete, route_name='departments.autocomplete', + renderer='json', permission='departments.list') + + # departments by vendor list + config.add_view(DepartmentsByVendorGrid,route_name='departments.by_vendor', permission='departments.list') - config.add_route('departments.autocomplete', '/departments/autocomplete') - config.add_view(DepartmentsAutocomplete, - route_name='departments.autocomplete', - renderer='json', - permission='departments.list') - - config.add_route('departments.by_vendor', '/departments/by-vendor') - config.add_view(DepartmentsByVendorGrid, - route_name='departments.by_vendor', - permission='departments.list') - - config.add_route('department.create', '/departments/new') - config.add_view(DepartmentCrud, attr='create', - route_name='department.create', - renderer='/departments/crud.mako', - permission='departments.create') - - config.add_route('department.read', '/departments/{uuid}') - config.add_view(DepartmentCrud, attr='read', - route_name='department.read', - renderer='/departments/crud.mako', - permission='departments.read') - - config.add_route('department.update', '/departments/{uuid}/edit') - config.add_view(DepartmentCrud, attr='update', - route_name='department.update', - renderer='/departments/crud.mako', - permission='departments.update') - - config.add_route('department.delete', '/departments/{uuid}/delete') - config.add_view(DepartmentCrud, attr='delete', - route_name='department.delete', + # crud + config.add_view(DepartmentCrud, attr='create', route_name='department.create', + renderer='/departments/crud.mako', permission='departments.create') + config.add_view(DepartmentCrud, attr='read', route_name='department.read', + renderer='/departments/crud.mako', permission='departments.read') + config.add_view(DepartmentCrud, attr='update', route_name='department.update', + renderer='/departments/crud.mako', permission='departments.update') + config.add_view(DepartmentCrud, attr='delete', route_name='department.delete', permission='departments.delete') diff --git a/tailbone/views/subdepartments.py b/tailbone/views/subdepartments.py index cc868e55..f9a89a8d 100644 --- a/tailbone/views/subdepartments.py +++ b/tailbone/views/subdepartments.py @@ -1,9 +1,8 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- +# -*- coding: utf-8 -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2012 Lance Edgar +# Copyright © 2010-2015 Lance Edgar # # This file is part of Rattail. # @@ -21,15 +20,16 @@ # along with Rattail. If not, see . # ################################################################################ - """ Subdepartment Views """ -from . import SearchableAlchemyGridView, CrudView +from __future__ import unicode_literals from rattail.db.model import Subdepartment +from . import SearchableAlchemyGridView, CrudView + class SubdepartmentsGrid(SearchableAlchemyGridView): @@ -85,32 +85,27 @@ class SubdepartmentCrud(CrudView): return fs +def add_routes(config): + config.add_route('subdepartments', '/subdepartments') + config.add_route('subdepartment.create', '/subdepartments/new') + config.add_route('subdepartment.read', '/subdepartments/{uuid}') + config.add_route('subdepartment.update', '/subdepartments/{uuid}/edit') + config.add_route('subdepartment.delete', '/subdepartments/{uuid}/delete') + + def includeme(config): + add_routes(config) - config.add_route('subdepartments', '/subdepartments') + # list config.add_view(SubdepartmentsGrid, route_name='subdepartments', - renderer='/subdepartments/index.mako', - permission='subdepartments.list') + renderer='/subdepartments/index.mako', permission='subdepartments.list') - config.add_route('subdepartment.create', '/subdepartments/new') - config.add_view(SubdepartmentCrud, attr='create', - route_name='subdepartment.create', - renderer='/subdepartments/crud.mako', - permission='subdepartments.create') - - config.add_route('subdepartment.read', '/subdepartments/{uuid}') - config.add_view(SubdepartmentCrud, attr='read', - route_name='subdepartment.read', - renderer='/subdepartments/crud.mako', - permission='subdepartments.read') - - config.add_route('subdepartment.update', '/subdepartments/{uuid}/edit') - config.add_view(SubdepartmentCrud, attr='update', - route_name='subdepartment.update', - renderer='/subdepartments/crud.mako', - permission='subdepartments.update') - - config.add_route('subdepartment.delete', '/subdepartments/{uuid}/delete') - config.add_view(SubdepartmentCrud, attr='delete', - route_name='subdepartment.delete', + # crud + config.add_view(SubdepartmentCrud, attr='create', route_name='subdepartment.create', + renderer='/subdepartments/crud.mako', permission='subdepartments.create') + config.add_view(SubdepartmentCrud, attr='read', route_name='subdepartment.read', + renderer='/subdepartments/crud.mako', permission='subdepartments.read') + config.add_view(SubdepartmentCrud, attr='update', route_name='subdepartment.update', + renderer='/subdepartments/crud.mako', permission='subdepartments.update') + config.add_view(SubdepartmentCrud, attr='delete', route_name='subdepartment.delete', permission='subdepartments.delete') From 41dd2ef17be6eee8d5cfc848c6f44e33e45dcfed Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 9 Feb 2015 13:31:41 -0600 Subject: [PATCH 0178/3860] Add category to product CRUD view. --- tailbone/views/products.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 7e54656e..54f88c6f 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -264,6 +264,7 @@ class ProductCrud(CrudView): fs.size, fs.department, fs.subdepartment, + fs.category, fs.family, fs.report_code, fs.regular_price, From def466935b7bd3cd7e1f61bd974275bd13035957 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 9 Feb 2015 15:33:16 -0600 Subject: [PATCH 0179/3860] Add initial versioning support with SQLAlchemy-Continuum. --- tailbone/db.py | 125 +++++++++- tailbone/subscribers.py | 2 + tailbone/templates/brands/crud.mako | 3 + tailbone/templates/brands/versions/index.mako | 3 + tailbone/templates/brands/versions/view.mako | 3 + tailbone/templates/categories/crud.mako | 3 + .../templates/categories/versions/index.mako | 3 + .../templates/categories/versions/view.mako | 3 + tailbone/templates/departments/crud.mako | 3 + .../templates/departments/versions/index.mako | 3 + .../templates/departments/versions/view.mako | 3 + tailbone/templates/labels/profiles/read.mako | 3 + .../labels/profiles/versions/index.mako | 3 + .../labels/profiles/versions/view.mako | 3 + tailbone/templates/products/crud.mako | 3 + .../templates/products/versions/index.mako | 3 + .../templates/products/versions/view.mako | 3 + tailbone/templates/roles/crud.mako | 3 + tailbone/templates/roles/versions/index.mako | 3 + tailbone/templates/roles/versions/view.mako | 3 + tailbone/templates/subdepartments/crud.mako | 3 + .../subdepartments/versions/index.mako | 3 + .../subdepartments/versions/view.mako | 3 + tailbone/templates/users/crud.mako | 3 + tailbone/templates/users/versions/index.mako | 3 + tailbone/templates/users/versions/view.mako | 3 + tailbone/templates/vendors/crud.mako | 3 + .../templates/vendors/versions/index.mako | 3 + tailbone/templates/vendors/versions/view.mako | 3 + tailbone/templates/versions/index.mako | 15 ++ tailbone/templates/versions/view.mako | 103 ++++++++ tailbone/views/brands.py | 22 +- tailbone/views/categories.py | 14 ++ tailbone/views/continuum.py | 231 ++++++++++++++++++ tailbone/views/crud.py | 26 +- tailbone/views/departments.py | 12 + tailbone/views/labels.py | 17 +- tailbone/views/products.py | 28 ++- tailbone/views/roles.py | 14 ++ tailbone/views/subdepartments.py | 12 + tailbone/views/users.py | 13 + tailbone/views/vendors/__init__.py | 3 +- tailbone/views/vendors/core.py | 25 +- 43 files changed, 717 insertions(+), 26 deletions(-) create mode 100644 tailbone/templates/brands/versions/index.mako create mode 100644 tailbone/templates/brands/versions/view.mako create mode 100644 tailbone/templates/categories/versions/index.mako create mode 100644 tailbone/templates/categories/versions/view.mako create mode 100644 tailbone/templates/departments/versions/index.mako create mode 100644 tailbone/templates/departments/versions/view.mako create mode 100644 tailbone/templates/labels/profiles/versions/index.mako create mode 100644 tailbone/templates/labels/profiles/versions/view.mako create mode 100644 tailbone/templates/products/versions/index.mako create mode 100644 tailbone/templates/products/versions/view.mako create mode 100644 tailbone/templates/roles/versions/index.mako create mode 100644 tailbone/templates/roles/versions/view.mako create mode 100644 tailbone/templates/subdepartments/versions/index.mako create mode 100644 tailbone/templates/subdepartments/versions/view.mako create mode 100644 tailbone/templates/users/versions/index.mako create mode 100644 tailbone/templates/users/versions/view.mako create mode 100644 tailbone/templates/vendors/versions/index.mako create mode 100644 tailbone/templates/vendors/versions/view.mako create mode 100644 tailbone/templates/versions/index.mako create mode 100644 tailbone/templates/versions/view.mako create mode 100644 tailbone/views/continuum.py diff --git a/tailbone/db.py b/tailbone/db.py index de50902b..7e12f5b2 100644 --- a/tailbone/db.py +++ b/tailbone/db.py @@ -26,17 +26,124 @@ Database Stuff """ +import sqlalchemy as sa +from zope.sqlalchemy import datamanager +import sqlalchemy_continuum as continuum from sqlalchemy.orm import sessionmaker, scoped_session - -Session = scoped_session(sessionmaker()) +from rattail.db import SessionBase +from rattail.db import model -try: - # Requires zope.sqlalchemy >= 0.7.4 - from zope.sqlalchemy import register -except ImportError: - from zope.sqlalchemy import ZopeTransactionExtension - Session.configure(extension=ZopeTransactionExtension()) -else: +Session = scoped_session(sessionmaker(class_=SessionBase)) + + +class TailboneSessionDataManager(datamanager.SessionDataManager): + """Integrate a top level sqlalchemy session transaction into a zope transaction + + One phase variant. + + .. note:: + This class appears to be necessary in order for the Continuum + integration to work alongside the Zope transaction integration. + """ + + def tpc_vote(self, trans): + # for a one phase data manager commit last in tpc_vote + if self.tx is not None: # there may have been no work to do + + # Force creation of Continuum versions for current session. + mgr = continuum.get_versioning_manager(model.Product) # any ol' model will do + uow = mgr.unit_of_work(self.session) + uow.make_versions(self.session) + + self.tx.commit() + self._finish('committed') + + +def join_transaction(session, initial_state=datamanager.STATUS_ACTIVE, transaction_manager=datamanager.zope_transaction.manager, keep_session=False): + """Join a session to a transaction using the appropriate datamanager. + + It is safe to call this multiple times, if the session is already joined + then it just returns. + + `initial_state` is either STATUS_ACTIVE, STATUS_INVALIDATED or STATUS_READONLY + + If using the default initial status of STATUS_ACTIVE, you must ensure that + mark_changed(session) is called when data is written to the database. + + The ZopeTransactionExtesion SessionExtension can be used to ensure that this is + called automatically after session write operations. + + .. note:: + This function is copied from upstream, and tweaked so that our custom + :class:`TailboneSessionDataManager` will be used. + """ + if datamanager._SESSION_STATE.get(id(session), None) is None: + if session.twophase: + DataManager = datamanager.TwoPhaseSessionDataManager + else: + DataManager = TailboneSessionDataManager + DataManager(session, initial_state, transaction_manager, keep_session=keep_session) + + +class ZopeTransactionExtension(datamanager.ZopeTransactionExtension): + """Record that a flush has occurred on a session's connection. This allows + the DataManager to rollback rather than commit on read only transactions. + + .. note:: + This class is copied from upstream, and tweaked so that our custom + :func:`join_transaction()` will be used. + """ + + def after_begin(self, session, transaction, connection): + join_transaction(session, self.initial_state, self.transaction_manager, self.keep_session) + + def after_attach(self, session, instance): + join_transaction(session, self.initial_state, self.transaction_manager, self.keep_session) + + +def register(session, initial_state=datamanager.STATUS_ACTIVE, + transaction_manager=datamanager.zope_transaction.manager, keep_session=False): + """Register ZopeTransaction listener events on the + given Session or Session factory/class. + + This function requires at least SQLAlchemy 0.7 and makes use + of the newer sqlalchemy.event package in order to register event listeners + on the given Session. + + The session argument here may be a Session class or subclass, a + sessionmaker or scoped_session instance, or a specific Session instance. + Event listening will be specific to the scope of the type of argument + passed, including specificity to its subclass as well as its identity. + + .. note:: + This function is copied from upstream, and tweaked so that our custom + :class:`ZopeTransactionExtension` will be used. + """ + + from sqlalchemy import __version__ + assert tuple(int(x) for x in __version__.split(".")) >= (0, 7), \ + "SQLAlchemy version 0.7 or greater required to use register()" + + from sqlalchemy import event + + ext = ZopeTransactionExtension( + initial_state=initial_state, + transaction_manager=transaction_manager, + keep_session=keep_session, + ) + + event.listen(session, "after_begin", ext.after_begin) + event.listen(session, "after_attach", ext.after_attach) + event.listen(session, "after_flush", ext.after_flush) + event.listen(session, "after_bulk_update", ext.after_bulk_update) + event.listen(session, "after_bulk_delete", ext.after_bulk_delete) + event.listen(session, "before_commit", ext.before_commit) + + +# TODO: We can probably assume a new SA version since we use Continuum now. +if tuple(int(x) for x in sa.__version__.split('.')) >= (0, 7): register(Session) +else: + Session.configure(extension=ZopeTransactionExtension()) diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index 890d6809..fb7c5d36 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.py @@ -97,6 +97,8 @@ def context_found(event): uuid = authenticated_userid(request) if uuid: request.user = Session.query(User).get(uuid) + if request.user: + Session().set_continuum_user(request.user) def has_perm(perm): return has_permission(Session(), request.user, perm) diff --git a/tailbone/templates/brands/crud.mako b/tailbone/templates/brands/crud.mako index 3a35a993..dea13003 100644 --- a/tailbone/templates/brands/crud.mako +++ b/tailbone/templates/brands/crud.mako @@ -8,6 +8,9 @@ % elif form.updating:
  • ${h.link_to("View this Brand", url('brand.read', uuid=form.fieldset.model.uuid))}
  • % endif + % if not form.creating and request.has_perm('brand.versions.view'): +
  • ${h.link_to("View Change History ({0})".format(version_count), url('brand.versions', uuid=form.fieldset.model.uuid))}
  • + % endif ${parent.body()} diff --git a/tailbone/templates/brands/versions/index.mako b/tailbone/templates/brands/versions/index.mako new file mode 100644 index 00000000..d26bebd4 --- /dev/null +++ b/tailbone/templates/brands/versions/index.mako @@ -0,0 +1,3 @@ +## -*- coding: utf-8 -*- +<%inherit file="/versions/index.mako" /> +${parent.body()} diff --git a/tailbone/templates/brands/versions/view.mako b/tailbone/templates/brands/versions/view.mako new file mode 100644 index 00000000..94567acb --- /dev/null +++ b/tailbone/templates/brands/versions/view.mako @@ -0,0 +1,3 @@ +## -*- coding: utf-8 -*- +<%inherit file="/versions/view.mako" /> +${parent.body()} diff --git a/tailbone/templates/categories/crud.mako b/tailbone/templates/categories/crud.mako index 62ee9b43..b1ce53e7 100644 --- a/tailbone/templates/categories/crud.mako +++ b/tailbone/templates/categories/crud.mako @@ -8,6 +8,9 @@ % elif form.updating:
  • ${h.link_to("View this Category", url('category.read', uuid=form.fieldset.model.uuid))}
  • % endif + % if not form.creating and request.has_perm('category.versions.view'): +
  • ${h.link_to("View Change History ({0})".format(version_count), url('category.versions', uuid=form.fieldset.model.uuid))}
  • + % endif ${parent.body()} diff --git a/tailbone/templates/categories/versions/index.mako b/tailbone/templates/categories/versions/index.mako new file mode 100644 index 00000000..d26bebd4 --- /dev/null +++ b/tailbone/templates/categories/versions/index.mako @@ -0,0 +1,3 @@ +## -*- coding: utf-8 -*- +<%inherit file="/versions/index.mako" /> +${parent.body()} diff --git a/tailbone/templates/categories/versions/view.mako b/tailbone/templates/categories/versions/view.mako new file mode 100644 index 00000000..94567acb --- /dev/null +++ b/tailbone/templates/categories/versions/view.mako @@ -0,0 +1,3 @@ +## -*- coding: utf-8 -*- +<%inherit file="/versions/view.mako" /> +${parent.body()} diff --git a/tailbone/templates/departments/crud.mako b/tailbone/templates/departments/crud.mako index e726ec77..8d819021 100644 --- a/tailbone/templates/departments/crud.mako +++ b/tailbone/templates/departments/crud.mako @@ -8,6 +8,9 @@ % elif form.updating:
  • ${h.link_to("View this Department", url('department.read', uuid=form.fieldset.model.uuid))}
  • % endif + % if not form.creating and request.has_perm('department.versions.view'): +
  • ${h.link_to("View Change History ({0})".format(version_count), url('department.versions', uuid=form.fieldset.model.uuid))}
  • + % endif ${parent.body()} diff --git a/tailbone/templates/departments/versions/index.mako b/tailbone/templates/departments/versions/index.mako new file mode 100644 index 00000000..d26bebd4 --- /dev/null +++ b/tailbone/templates/departments/versions/index.mako @@ -0,0 +1,3 @@ +## -*- coding: utf-8 -*- +<%inherit file="/versions/index.mako" /> +${parent.body()} diff --git a/tailbone/templates/departments/versions/view.mako b/tailbone/templates/departments/versions/view.mako new file mode 100644 index 00000000..94567acb --- /dev/null +++ b/tailbone/templates/departments/versions/view.mako @@ -0,0 +1,3 @@ +## -*- coding: utf-8 -*- +<%inherit file="/versions/view.mako" /> +${parent.body()} diff --git a/tailbone/templates/labels/profiles/read.mako b/tailbone/templates/labels/profiles/read.mako index 074a287a..8af13c45 100644 --- a/tailbone/templates/labels/profiles/read.mako +++ b/tailbone/templates/labels/profiles/read.mako @@ -11,6 +11,9 @@
  • ${h.link_to("Edit Printer Settings", url('label_profile.printer_settings', uuid=profile.uuid))}
  • % endif % endif + % if not form.creating and request.has_perm('labelprofile.versions.view'): +
  • ${h.link_to("View Change History ({0})".format(version_count), url('labelprofile.versions', uuid=form.fieldset.model.uuid))}
  • + % endif ${parent.body()} diff --git a/tailbone/templates/labels/profiles/versions/index.mako b/tailbone/templates/labels/profiles/versions/index.mako new file mode 100644 index 00000000..d26bebd4 --- /dev/null +++ b/tailbone/templates/labels/profiles/versions/index.mako @@ -0,0 +1,3 @@ +## -*- coding: utf-8 -*- +<%inherit file="/versions/index.mako" /> +${parent.body()} diff --git a/tailbone/templates/labels/profiles/versions/view.mako b/tailbone/templates/labels/profiles/versions/view.mako new file mode 100644 index 00000000..94567acb --- /dev/null +++ b/tailbone/templates/labels/profiles/versions/view.mako @@ -0,0 +1,3 @@ +## -*- coding: utf-8 -*- +<%inherit file="/versions/view.mako" /> +${parent.body()} diff --git a/tailbone/templates/products/crud.mako b/tailbone/templates/products/crud.mako index 4df96af2..68370002 100644 --- a/tailbone/templates/products/crud.mako +++ b/tailbone/templates/products/crud.mako @@ -8,6 +8,9 @@ % elif form.updating:
  • ${h.link_to("View this Product", url('product.read', uuid=form.fieldset.model.uuid))}
  • % endif + % if not form.creating and request.has_perm('product.versions.view'): +
  • ${h.link_to("View Change History ({0})".format(version_count), url('product.versions', uuid=form.fieldset.model.uuid))}
  • + % endif ${parent.body()} diff --git a/tailbone/templates/products/versions/index.mako b/tailbone/templates/products/versions/index.mako new file mode 100644 index 00000000..d26bebd4 --- /dev/null +++ b/tailbone/templates/products/versions/index.mako @@ -0,0 +1,3 @@ +## -*- coding: utf-8 -*- +<%inherit file="/versions/index.mako" /> +${parent.body()} diff --git a/tailbone/templates/products/versions/view.mako b/tailbone/templates/products/versions/view.mako new file mode 100644 index 00000000..94567acb --- /dev/null +++ b/tailbone/templates/products/versions/view.mako @@ -0,0 +1,3 @@ +## -*- coding: utf-8 -*- +<%inherit file="/versions/view.mako" /> +${parent.body()} diff --git a/tailbone/templates/roles/crud.mako b/tailbone/templates/roles/crud.mako index 1a7acafd..0965e937 100644 --- a/tailbone/templates/roles/crud.mako +++ b/tailbone/templates/roles/crud.mako @@ -14,6 +14,9 @@
  • ${h.link_to("View this Role", url('role.read', uuid=form.fieldset.model.uuid))}
  • % endif
  • ${h.link_to("Delete this Role", url('role.delete', uuid=form.fieldset.model.uuid), class_='delete')}
  • + % if not form.creating and request.has_perm('role.versions.view'): +
  • ${h.link_to("View Change History ({0})".format(version_count), url('role.versions', uuid=form.fieldset.model.uuid))}
  • + % endif ${parent.body()} diff --git a/tailbone/templates/roles/versions/index.mako b/tailbone/templates/roles/versions/index.mako new file mode 100644 index 00000000..d26bebd4 --- /dev/null +++ b/tailbone/templates/roles/versions/index.mako @@ -0,0 +1,3 @@ +## -*- coding: utf-8 -*- +<%inherit file="/versions/index.mako" /> +${parent.body()} diff --git a/tailbone/templates/roles/versions/view.mako b/tailbone/templates/roles/versions/view.mako new file mode 100644 index 00000000..94567acb --- /dev/null +++ b/tailbone/templates/roles/versions/view.mako @@ -0,0 +1,3 @@ +## -*- coding: utf-8 -*- +<%inherit file="/versions/view.mako" /> +${parent.body()} diff --git a/tailbone/templates/subdepartments/crud.mako b/tailbone/templates/subdepartments/crud.mako index b71cc553..e64a1b7f 100644 --- a/tailbone/templates/subdepartments/crud.mako +++ b/tailbone/templates/subdepartments/crud.mako @@ -8,6 +8,9 @@ % elif form.updating:
  • ${h.link_to("View this Subdepartment", url('subdepartment.read', uuid=form.fieldset.model.uuid))}
  • % endif + % if not form.creating and request.has_perm('subdepartment.versions.view'): +
  • ${h.link_to("View Change History ({0})".format(version_count), url('subdepartment.versions', uuid=form.fieldset.model.uuid))}
  • + % endif ${parent.body()} diff --git a/tailbone/templates/subdepartments/versions/index.mako b/tailbone/templates/subdepartments/versions/index.mako new file mode 100644 index 00000000..d26bebd4 --- /dev/null +++ b/tailbone/templates/subdepartments/versions/index.mako @@ -0,0 +1,3 @@ +## -*- coding: utf-8 -*- +<%inherit file="/versions/index.mako" /> +${parent.body()} diff --git a/tailbone/templates/subdepartments/versions/view.mako b/tailbone/templates/subdepartments/versions/view.mako new file mode 100644 index 00000000..94567acb --- /dev/null +++ b/tailbone/templates/subdepartments/versions/view.mako @@ -0,0 +1,3 @@ +## -*- coding: utf-8 -*- +<%inherit file="/versions/view.mako" /> +${parent.body()} diff --git a/tailbone/templates/users/crud.mako b/tailbone/templates/users/crud.mako index e8006cc2..e18c1b27 100644 --- a/tailbone/templates/users/crud.mako +++ b/tailbone/templates/users/crud.mako @@ -8,6 +8,9 @@ % elif form.updating:
  • ${h.link_to("View this User", url('user.read', uuid=form.fieldset.model.uuid))}
  • % endif + % if not form.creating and request.has_perm('user.versions.view'): +
  • ${h.link_to("View Change History ({0})".format(version_count), url('user.versions', uuid=form.fieldset.model.uuid))}
  • + % endif ${parent.body()} diff --git a/tailbone/templates/users/versions/index.mako b/tailbone/templates/users/versions/index.mako new file mode 100644 index 00000000..d26bebd4 --- /dev/null +++ b/tailbone/templates/users/versions/index.mako @@ -0,0 +1,3 @@ +## -*- coding: utf-8 -*- +<%inherit file="/versions/index.mako" /> +${parent.body()} diff --git a/tailbone/templates/users/versions/view.mako b/tailbone/templates/users/versions/view.mako new file mode 100644 index 00000000..94567acb --- /dev/null +++ b/tailbone/templates/users/versions/view.mako @@ -0,0 +1,3 @@ +## -*- coding: utf-8 -*- +<%inherit file="/versions/view.mako" /> +${parent.body()} diff --git a/tailbone/templates/vendors/crud.mako b/tailbone/templates/vendors/crud.mako index db718ae9..b3b2fd2a 100644 --- a/tailbone/templates/vendors/crud.mako +++ b/tailbone/templates/vendors/crud.mako @@ -8,6 +8,9 @@ % elif form.updating:
  • ${h.link_to("View this Vendor", url('vendor.read', uuid=form.fieldset.model.uuid))}
  • % endif + % if not form.creating and request.has_perm('vendor.versions.view'): +
  • ${h.link_to("View Change History ({0})".format(version_count), url('vendor.versions', uuid=form.fieldset.model.uuid))}
  • + % endif ${parent.body()} diff --git a/tailbone/templates/vendors/versions/index.mako b/tailbone/templates/vendors/versions/index.mako new file mode 100644 index 00000000..d26bebd4 --- /dev/null +++ b/tailbone/templates/vendors/versions/index.mako @@ -0,0 +1,3 @@ +## -*- coding: utf-8 -*- +<%inherit file="/versions/index.mako" /> +${parent.body()} diff --git a/tailbone/templates/vendors/versions/view.mako b/tailbone/templates/vendors/versions/view.mako new file mode 100644 index 00000000..94567acb --- /dev/null +++ b/tailbone/templates/vendors/versions/view.mako @@ -0,0 +1,3 @@ +## -*- coding: utf-8 -*- +<%inherit file="/versions/view.mako" /> +${parent.body()} diff --git a/tailbone/templates/versions/index.mako b/tailbone/templates/versions/index.mako new file mode 100644 index 00000000..be7d956e --- /dev/null +++ b/tailbone/templates/versions/index.mako @@ -0,0 +1,15 @@ +## -*- coding: utf-8 -*- +<%inherit file="/grid.mako" /> + +<%def name="title()">${model_title} Change History + +<%def name="context_menu_items()"> +
  • ${h.link_to("Back to all {0}".format(model_title_plural), url(route_model_list))}
  • +
  • ${h.link_to("Back to current {0}".format(model_title), url(route_model_view, uuid=model_instance.uuid))}
  • + + +<%def name="form()"> +

    Changes for ${model_title}:  ${model_instance}

    + + +${parent.body()} diff --git a/tailbone/templates/versions/view.mako b/tailbone/templates/versions/view.mako new file mode 100644 index 00000000..49d7c4e5 --- /dev/null +++ b/tailbone/templates/versions/view.mako @@ -0,0 +1,103 @@ +## -*- coding: utf-8 -*- +<%inherit file="/base.mako" /> + +<%def name="title()">${model_title} Version Details + +<%def name="head_tags()"> + + + +<%def name="context_menu_items()"> +
  • ${h.link_to("Back to all {0}".format(model_title_plural), url(route_model_list))}
  • +
  • ${h.link_to("Back to current {0}".format(model_title), url(route_model_view, uuid=model_instance.uuid))}
  • +
  • ${h.link_to("Back to Version History", url('{0}.versions'.format(route_prefix), uuid=model_instance.uuid))}
  • + + +
    + +
      + ${self.context_menu_items()} +
    + +
    + +
    + % if previous_transaction or next_transaction: + % if previous_transaction: + ${h.link_to("<< older version", url('{0}.version'.format(route_prefix), uuid=model_instance.uuid, transaction_id=previous_transaction.id))} + % else: + (oldest version) + % endif +   |   + % if next_transaction: + ${h.link_to("newer version >>", url('{0}.version'.format(route_prefix), uuid=model_instance.uuid, transaction_id=next_transaction.id))} + % else: + (newest version) + % endif + % else: + (only version) + % endif +
    + +
    + +
    + +
    ${h.pretty_datetime(request.rattail_config, transaction.issued_at)}
    +
    +
    + +
    ${transaction.user or "(unknown / system)"}
    +
    +
    + +
    ${transaction.remote_addr}
    +
    + + % for ver in versions: + +
    + +
    ${ver.version_parent.__class__.__name__}:  ${ver.version_parent}
    +
    + +
    + +
    +
    +
    + + + + + + + + + % for key in sorted(ver.changeset): + + + + + + % endfor + +
    FieldOld ValueNew Value
    ${key}${ver.changeset[key][0]}${ver.changeset[key][1]}
    +
    +
    +
    + + % endfor + +
    + +
    + + diff --git a/tailbone/views/brands.py b/tailbone/views/brands.py index ceaf6d3d..6099c809 100644 --- a/tailbone/views/brands.py +++ b/tailbone/views/brands.py @@ -1,9 +1,8 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- +# -*- coding: utf-8 -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2012 Lance Edgar +# Copyright © 2010-2015 Lance Edgar # # This file is part of Rattail. # @@ -21,15 +20,18 @@ # along with Rattail. If not, see . # ################################################################################ - """ Brand Views """ -from . import SearchableAlchemyGridView, CrudView, AutocompleteView +from __future__ import unicode_literals +from rattail.db import model from rattail.db.model import Brand +from . import SearchableAlchemyGridView, CrudView, AutocompleteView +from .continuum import VersionView, version_defaults + class BrandsGrid(SearchableAlchemyGridView): @@ -81,6 +83,14 @@ class BrandCrud(CrudView): return fs +class BrandVersionView(VersionView): + """ + View which shows version history for a brand. + """ + parent_class = model.Brand + route_model_view = 'brand.read' + + class BrandsAutocomplete(AutocompleteView): mapped_class = Brand @@ -122,3 +132,5 @@ def includeme(config): config.add_view(BrandCrud, attr='delete', route_name='brand.delete', permission='brands.delete') + + version_defaults(config, BrandVersionView, 'brand') diff --git a/tailbone/views/categories.py b/tailbone/views/categories.py index 18e92baa..8947a78f 100644 --- a/tailbone/views/categories.py +++ b/tailbone/views/categories.py @@ -26,9 +26,11 @@ Category Views from __future__ import unicode_literals +from rattail.db import model from rattail.db.model import Category from . import SearchableAlchemyGridView, CrudView +from .continuum import VersionView, version_defaults class CategoriesGrid(SearchableAlchemyGridView): @@ -85,6 +87,16 @@ class CategoryCrud(CrudView): return fs +class CategoryVersionView(VersionView): + """ + View which shows version history for a category. + """ + parent_class = model.Category + model_title_plural = "Categories" + route_model_list = 'categories' + route_model_view = 'category.read' + + def add_routes(config): config.add_route('categories', '/categories') config.add_route('category.create', '/categories/new') @@ -109,3 +121,5 @@ def includeme(config): renderer='/categories/crud.mako', permission='categories.update') config.add_view(CategoryCrud, attr='delete', route_name='category.delete', permission='categories.delete') + + version_defaults(config, CategoryVersionView, 'category', template_prefix='/categories') diff --git a/tailbone/views/continuum.py b/tailbone/views/continuum.py new file mode 100644 index 00000000..d7f0bdb2 --- /dev/null +++ b/tailbone/views/continuum.py @@ -0,0 +1,231 @@ +# -*- coding: utf-8 -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2015 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 . +# +################################################################################ +""" +Continuum Version Views +""" + +from __future__ import unicode_literals + +import sqlalchemy as sa +import sqlalchemy_continuum as continuum + +from rattail.db import model +from rattail.db.model.continuum import model_transaction_query + +import formalchemy +from pyramid.httpexceptions import HTTPNotFound + +from tailbone.db import Session +from tailbone.views import PagedAlchemyGridView, View +from tailbone.forms import DateTimeFieldRenderer + + +class VersionView(PagedAlchemyGridView): + """ + View which shows version history for a model instance. + """ + + @property + def parent_class(self): + """ + Model class which is "parent" to the version class. + """ + raise NotImplementedError("Please set `parent_class` on your `VersionView` subclass.") + + @property + def child_classes(self): + """ + Model class(es) which are "children" to the version's parent class. + """ + return [] + + @property + def model_title(self): + """ + Human-friendly title for the parent model class. + """ + return self.parent_class.__name__ + + @property + def model_title_plural(self): + """ + Plural version of the human-friendly title for the parent model class. + """ + return '{0}s'.format(self.model_title) + + @property + def prefix(self): + return self.parent_class.__name__.lower() + + @property + def config_prefix(self): + return self.prefix + + @property + def transaction_class(self): + return continuum.transaction_class(self.parent_class) + + @property + def mapped_class(self): + return self.transaction_class + + @property + def version_class(self): + return continuum.version_class(self.parent_class) + + @property + def route_model_list(self): + return '{0}s'.format(self.prefix) + + @property + def route_model_view(self): + return self.prefix + + def join_map(self): + return { + 'user': + lambda q: q.outerjoin(model.User, self.transaction_class.user_uuid == model.User.uuid), + } + + def sort_config(self): + return self.make_sort_config(sort='issued_at', dir='desc') + + def sort_map(self): + return self.make_sort_map('issued_at', 'remote_addr', + user=self.sorter(model.User.username)) + + def transaction_query(self, session=Session): + uuid = self.request.matchdict['uuid'] + return model_transaction_query(session, uuid, self.parent_class, + child_classes=self.child_classes) + + def make_query(self, session=Session): + query = self.transaction_query(session) + return self.modify_query(query) + + def grid(self): + g = self.make_grid() + g.issued_at.set(renderer=DateTimeFieldRenderer(self.request.rattail_config)) + g.configure( + include=[ + g.issued_at.label("When"), + g.user.label("Who"), + g.remote_addr.label("Client IP"), + ], + readonly=True) + g.viewable = True + g.view_route_name = '{0}.version'.format(self.prefix) + g.view_route_kwargs = self.view_route_kwargs + return g + + def render_kwargs(self): + instance = Session.query(self.parent_class).get(self.request.matchdict['uuid']) + return {'model_title': self.model_title, + 'model_title_plural': self.model_title_plural, + 'model_instance': instance, + 'route_model_list': self.route_model_list, + 'route_model_view': self.route_model_view} + + def view_route_kwargs(self, transaction): + return {'uuid': self.request.matchdict['uuid'], + 'transaction_id': transaction.id} + + def list(self): + """ + View which shows the version history list for a model instance. + """ + return self() + + def details(self): + """ + View which shows the change details of a model version. + """ + kwargs = self.render_kwargs() + uuid = self.request.matchdict['uuid'] + transaction_id = self.request.matchdict['transaction_id'] + transaction = Session.query(self.transaction_class).get(transaction_id) + if not transaction: + raise HTTPNotFound + + version = Session.query(self.version_class).get((uuid, transaction_id)) + + def normalize_child_classes(): + classes = [] + for cls in self.child_classes: + if not isinstance(cls, tuple): + cls = (cls, 'uuid') + classes.append(cls) + return classes + + versions = [] + if version: + versions.append(version) + for model_class, attr in normalize_child_classes(): + if isinstance(model_class, type) and issubclass(model_class, model.Base): + cls = continuum.version_class(model_class) + ver = Session.query(cls).filter_by(transaction_id=transaction_id, **{attr: uuid}).first() + if ver: + versions.append(ver) + + previous_transaction = self.transaction_query()\ + .order_by(self.transaction_class.id.desc())\ + .filter(self.transaction_class.id < transaction.id)\ + .first() + + next_transaction = self.transaction_query()\ + .order_by(self.transaction_class.id.asc())\ + .filter(self.transaction_class.id > transaction.id)\ + .first() + + kwargs.update({ + 'route_prefix': self.prefix, + 'version': version, + 'transaction': transaction, + 'versions': versions, + 'parent_class': continuum.parent_class, + 'previous_transaction': previous_transaction, + 'next_transaction': next_transaction, + }) + + return kwargs + + +def version_defaults(config, VersionView, prefix, template_prefix=None): + """ + Apply default route/view configuration for the given ``VersionView``. + """ + if template_prefix is None: + template_prefix = '/{0}s'.format(prefix) + template_prefix = template_prefix.rstrip('/') + + # list changesets + config.add_route('{0}.versions'.format(prefix), '/{0}/{{uuid}}/changesets/'.format(prefix)) + config.add_view(VersionView, attr='list', route_name='{0}.versions'.format(prefix), + renderer='{0}/versions/index.mako'.format(template_prefix), + permission='{0}.versions.view'.format(prefix)) + + # view changeset + config.add_route('{0}.version'.format(prefix), '/{0}/{{uuid}}/changeset/{{transaction_id}}'.format(prefix)) + config.add_view(VersionView, attr='details', route_name='{0}.version'.format(prefix), + renderer='{0}/versions/view.mako'.format(template_prefix), + permission='{0}.versions.view'.format(prefix)) diff --git a/tailbone/views/crud.py b/tailbone/views/crud.py index 1a2d3203..ab74aa60 100644 --- a/tailbone/views/crud.py +++ b/tailbone/views/crud.py @@ -32,16 +32,23 @@ except ImportError: inspect = None from sqlalchemy.orm import class_mapper +import sqlalchemy as sa +from sqlalchemy_continuum import transaction_class, version_class + +from rattail.db import model +from rattail.db.model.continuum import count_versions, model_transaction_query + from pyramid.httpexceptions import HTTPFound, HTTPNotFound from .core import View from ..forms import AlchemyForm from formalchemy import FieldSet -from ..db import Session from edbob.util import prettify +from tailbone.db import Session + __all__ = ['CrudView'] @@ -51,6 +58,7 @@ class CrudView(View): readonly = False allow_successive_creates = False update_cancel_route = None + child_version_classes = [] @property def mapped_class(self): @@ -161,7 +169,21 @@ class CrudView(View): pass def template_kwargs(self, form): - return {} + if form.creating: + return {} + return {'version_count': self.count_versions()} + + def count_versions(self): + query = self.transaction_query() + return query.count() + + def transaction_query(self, parent_class=None, child_classes=None): + uuid = self.request.matchdict['uuid'] + if parent_class is None: + parent_class = self.mapped_class + if child_classes is None: + child_classes = self.child_version_classes + return model_transaction_query(Session, uuid, parent_class, child_classes=child_classes) def post_save(self, form): pass diff --git a/tailbone/views/departments.py b/tailbone/views/departments.py index 367577b2..b477f065 100644 --- a/tailbone/views/departments.py +++ b/tailbone/views/departments.py @@ -26,9 +26,11 @@ Department Views from __future__ import unicode_literals +from rattail.db import model from rattail.db.model import Department, Product, ProductCost, Vendor from . import SearchableAlchemyGridView, CrudView, AlchemyGridView, AutocompleteView +from .continuum import VersionView, version_defaults class DepartmentsGrid(SearchableAlchemyGridView): @@ -83,6 +85,14 @@ class DepartmentCrud(CrudView): return fs +class DepartmentVersionView(VersionView): + """ + View which shows version history for a department. + """ + parent_class = model.Department + route_model_view = 'department.read' + + class DepartmentsByVendorGrid(AlchemyGridView): mapped_class = Department @@ -150,3 +160,5 @@ def includeme(config): renderer='/departments/crud.mako', permission='departments.update') config.add_view(DepartmentCrud, attr='delete', route_name='department.delete', permission='departments.delete') + + version_defaults(config, DepartmentVersionView, 'department') diff --git a/tailbone/views/labels.py b/tailbone/views/labels.py index bd292fcb..fdf8699b 100644 --- a/tailbone/views/labels.py +++ b/tailbone/views/labels.py @@ -26,6 +26,9 @@ Label Views from __future__ import unicode_literals +from rattail.db import model +from rattail.db.model import LabelProfile + from pyramid.httpexceptions import HTTPFound import formalchemy @@ -36,7 +39,7 @@ from ..db import Session from . import SearchableAlchemyGridView, CrudView from ..grids.search import BooleanSearchFilter -from rattail.db.model import LabelProfile +from .continuum import VersionView, version_defaults class ProfilesGrid(SearchableAlchemyGridView): @@ -129,6 +132,16 @@ class ProfileCrud(CrudView): uuid=form.fieldset.model.uuid) +class LabelProfileVersionView(VersionView): + """ + View which shows version history for a label profile. + """ + parent_class = model.LabelProfile + model_title = "Label Profile" + route_model_list = 'label_profiles' + route_model_view = 'label_profile.read' + + def printer_settings(request): uuid = request.matchdict['uuid'] profile = Session.query(LabelProfile).get(uuid) if uuid else None @@ -187,3 +200,5 @@ def includeme(config): config.add_view(printer_settings, route_name='label_profile.printer_settings', renderer='/labels/profiles/printer.mako', permission='label_profiles.update') + + version_defaults(config, LabelProfileVersionView, 'labelprofile', template_prefix='/labels/profiles') diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 54f88c6f..d30aa2cf 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -56,6 +56,7 @@ from rattail.pod import get_image_url, get_image_path from ..db import Session from ..forms import GPCFieldRenderer, BrandFieldRenderer, PriceFieldRenderer from . import CrudView +from .continuum import VersionView, version_defaults from ..progress import SessionProgress @@ -237,9 +238,16 @@ class ProductsGrid(SearchableAlchemyGridView): class ProductCrud(CrudView): - + """ + Product CRUD view class. + """ mapped_class = Product home_route = 'products' + child_version_classes = [ + (model.ProductCode, 'product_uuid'), + (model.ProductCost, 'product_uuid'), + (model.ProductPrice, 'product_uuid'), + ] def get_model(self, key): model = super(ProductCrud, self).get_model(key) @@ -277,7 +285,8 @@ class ProductCrud(CrudView): return fs def template_kwargs(self, form): - kwargs = {'image': False} + kwargs = super(ProductCrud, self).template_kwargs(form) + kwargs['image'] = False product = form.fieldset.model if product.upc: kwargs['image_url'] = get_image_url( @@ -289,6 +298,19 @@ class ProductCrud(CrudView): return kwargs +class ProductVersionView(VersionView): + """ + View which shows version history for a product. + """ + parent_class = model.Product + route_model_view = 'product.read' + child_classes = [ + (model.ProductCode, 'product_uuid'), + (model.ProductCost, 'product_uuid'), + (model.ProductPrice, 'product_uuid'), + ] + + def products_search(request): """ Locate a product(s) by UPC. @@ -443,3 +465,5 @@ def includeme(config): permission='products.delete') config.add_view(products_search, route_name='products.search', renderer='json', permission='products.list') + + version_defaults(config, ProductVersionView, 'product') diff --git a/tailbone/views/roles.py b/tailbone/views/roles.py index ce6520e2..926a1658 100644 --- a/tailbone/views/roles.py +++ b/tailbone/views/roles.py @@ -26,6 +26,8 @@ Role Views """ +from rattail.db import model + from . import SearchableAlchemyGridView, CrudView from pyramid.httpexceptions import HTTPFound @@ -37,6 +39,8 @@ import formalchemy from webhelpers.html import tags from webhelpers.html import HTML +from .continuum import VersionView, version_defaults + default_permissions = [ ("Batches", [ @@ -264,6 +268,14 @@ class RoleCrud(CrudView): return HTTPFound(location=self.request.get_referrer()) +class RoleVersionView(VersionView): + """ + View which shows version history for a role. + """ + parent_class = model.Role + route_model_view = 'role.read' + + def includeme(config): config.add_route('roles', '/roles') @@ -294,3 +306,5 @@ def includeme(config): config.add_route('role.delete', '/roles/{uuid}/delete') config.add_view(RoleCrud, attr='delete', route_name='role.delete', permission='roles.delete') + + version_defaults(config, RoleVersionView, 'role') diff --git a/tailbone/views/subdepartments.py b/tailbone/views/subdepartments.py index f9a89a8d..ee3f3aa0 100644 --- a/tailbone/views/subdepartments.py +++ b/tailbone/views/subdepartments.py @@ -26,9 +26,11 @@ Subdepartment Views from __future__ import unicode_literals +from rattail.db import model from rattail.db.model import Subdepartment from . import SearchableAlchemyGridView, CrudView +from .continuum import VersionView, version_defaults class SubdepartmentsGrid(SearchableAlchemyGridView): @@ -85,6 +87,14 @@ class SubdepartmentCrud(CrudView): return fs +class SubdepartmentVersionView(VersionView): + """ + View which shows version history for a subdepartment. + """ + parent_class = model.Subdepartment + route_model_view = 'subdepartment.read' + + def add_routes(config): config.add_route('subdepartments', '/subdepartments') config.add_route('subdepartment.create', '/subdepartments/new') @@ -109,3 +119,5 @@ def includeme(config): renderer='/subdepartments/crud.mako', permission='subdepartments.update') config.add_view(SubdepartmentCrud, attr='delete', route_name='subdepartment.delete', permission='subdepartments.delete') + + version_defaults(config, SubdepartmentVersionView, 'subdepartment') diff --git a/tailbone/views/users.py b/tailbone/views/users.py index 983fe3ad..f18090d5 100644 --- a/tailbone/views/users.py +++ b/tailbone/views/users.py @@ -26,6 +26,7 @@ User Views from __future__ import unicode_literals +from rattail.db import model from rattail.db.model import User, Person, Role from rattail.db.auth import guest_role, set_user_password @@ -40,6 +41,8 @@ from ..forms import PersonFieldLinkRenderer from ..db import Session from tailbone.grids.search import BooleanSearchFilter +from .continuum import VersionView, version_defaults + class UsersGrid(SearchableAlchemyGridView): @@ -205,6 +208,14 @@ class UserCrud(CrudView): return fs +class UserVersionView(VersionView): + """ + View which shows version history for a user. + """ + parent_class = model.User + route_model_view = 'user.read' + + def add_routes(config): config.add_route(u'users', u'/users') config.add_route(u'user.create', u'/users/new') @@ -233,3 +244,5 @@ def includeme(config): permission='users.update') config.add_view(UserCrud, attr='delete', route_name='user.delete', permission='users.delete') + + version_defaults(config, UserVersionView, 'user') diff --git a/tailbone/views/vendors/__init__.py b/tailbone/views/vendors/__init__.py index 54f2c7f1..f41ba844 100644 --- a/tailbone/views/vendors/__init__.py +++ b/tailbone/views/vendors/__init__.py @@ -26,7 +26,8 @@ Views pertaining to vendors from __future__ import unicode_literals -from .core import VendorsGrid, VendorCrud, VendorsAutocomplete, add_routes +from .core import (VendorsGrid, VendorCrud, VendorVersionView, + VendorsAutocomplete, add_routes) def includeme(config): diff --git a/tailbone/views/vendors/core.py b/tailbone/views/vendors/core.py index edf973aa..96146b1b 100644 --- a/tailbone/views/vendors/core.py +++ b/tailbone/views/vendors/core.py @@ -1,9 +1,8 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- +# -*- coding: utf-8 -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2012 Lance Edgar +# Copyright © 2010-2015 Lance Edgar # # This file is part of Rattail. # @@ -21,15 +20,19 @@ # along with Rattail. If not, see . # ################################################################################ - """ Vendor Views """ -from tailbone.views import SearchableAlchemyGridView, CrudView, AutocompleteView -from tailbone.forms import AssociationProxyField, PersonFieldRenderer +from __future__ import unicode_literals + +from rattail.db import model from rattail.db.model import Vendor +from tailbone.views import SearchableAlchemyGridView, CrudView, AutocompleteView +from tailbone.views.continuum import VersionView, version_defaults +from tailbone.forms import AssociationProxyField, PersonFieldRenderer + class VendorsGrid(SearchableAlchemyGridView): @@ -93,6 +96,14 @@ class VendorCrud(CrudView): return fs +class VendorVersionView(VersionView): + """ + View which shows version history for a vendor. + """ + parent_class = model.Vendor + route_model_view = 'vendor.read' + + class VendorsAutocomplete(AutocompleteView): mapped_class = Vendor @@ -127,3 +138,5 @@ def includeme(config): permission='vendors.update') config.add_view(VendorCrud, attr='delete', route_name='vendor.delete', permission='vendors.delete') + + version_defaults(config, VendorVersionView, 'vendor') From 75729be79fa12914470728898ede34aa64c843b1 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 9 Feb 2015 23:56:25 -0600 Subject: [PATCH 0180/3860] Update changelog. --- CHANGES.rst | 16 ++++++++++++++++ setup.py | 2 +- tailbone/_version.py | 2 +- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 92ca549b..ec5a817e 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,5 +1,21 @@ .. -*- coding: utf-8 -*- +0.4.0 +----- + +This version primarily got the bump it did because of the addition of support +for SQLAlchemy-Continuum versioning. There were several other minor changes as +well. + +* Add department to field lists for category views. + +* Change default sort for People grid view. + +* Add category to product CRUD view. + +* Add initial versioning support with SQLAlchemy-Continuum. + + 0.3.28 ------ diff --git a/setup.py b/setup.py index 6f6b2840..bfba6388 100644 --- a/setup.py +++ b/setup.py @@ -82,7 +82,7 @@ requires = [ 'pyramid_exclog', # 0.6 'pyramid_simpleform', # 0.6.1 'pyramid_tm', # 0.3 - 'rattail[auth]>=0.3.46', # 0.3.46 + 'rattail[auth]>=0.4.0', # 0.4.0 'transaction', # 1.2.0 'waitress', # 0.8.1 'WebHelpers', # 1.3 diff --git a/tailbone/_version.py b/tailbone/_version.py index c4625223..d04f5ae8 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- -__version__ = u'0.3.28' +__version__ = u'0.4.0' From 5ad5cb569d1f1e5ff0ebbd41395cdc2644b41172 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 10 Feb 2015 11:23:35 -0600 Subject: [PATCH 0181/3860] Only attempt to count versions for versioned models. --- tailbone/views/crud.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tailbone/views/crud.py b/tailbone/views/crud.py index ab74aa60..924c91de 100644 --- a/tailbone/views/crud.py +++ b/tailbone/views/crud.py @@ -34,6 +34,7 @@ except ImportError: import sqlalchemy as sa from sqlalchemy_continuum import transaction_class, version_class +from sqlalchemy_continuum.utils import is_versioned from rattail.db import model from rattail.db.model.continuum import count_versions, model_transaction_query @@ -169,9 +170,9 @@ class CrudView(View): pass def template_kwargs(self, form): - if form.creating: - return {} - return {'version_count': self.count_versions()} + if not form.creating and is_versioned(self.mapped_class): + return {'version_count': self.count_versions()} + return {} def count_versions(self): query = self.transaction_query() From bd44d886c41b0821315b8e922f2ef00470346a82 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 10 Feb 2015 11:24:42 -0600 Subject: [PATCH 0182/3860] Update changelog. --- CHANGES.rst | 6 ++++++ tailbone/_version.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index ec5a817e..1ca83677 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,5 +1,11 @@ .. -*- coding: utf-8 -*- +0.4.1 +----- + +* Only attempt to count versions for versioned models (CRUD views). + + 0.4.0 ----- diff --git a/tailbone/_version.py b/tailbone/_version.py index d04f5ae8..cae59458 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- -__version__ = u'0.4.0' +__version__ = u'0.4.1' From aa70ffc9f0352edf46168cb24a1396af4b012b79 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 10 Feb 2015 19:07:46 -0600 Subject: [PATCH 0183/3860] Rework versioning support to allow it to be one or off. Turns out versioning isn't quite ready for prime time, so let's have a fallback plan shall we? --- tailbone/auth.py | 5 +++-- tailbone/db.py | 5 ++--- tailbone/templates/brands/crud.mako | 2 +- tailbone/templates/departments/crud.mako | 2 +- tailbone/templates/products/crud.mako | 2 +- tailbone/templates/subdepartments/crud.mako | 2 +- tailbone/templates/vendors/crud.mako | 2 +- tailbone/views/continuum.py | 2 +- tailbone/views/crud.py | 2 +- 9 files changed, 12 insertions(+), 12 deletions(-) diff --git a/tailbone/auth.py b/tailbone/auth.py index 05c5515c..1bfc4fb1 100644 --- a/tailbone/auth.py +++ b/tailbone/auth.py @@ -31,14 +31,15 @@ from pyramid.interfaces import IAuthorizationPolicy from pyramid.security import Everyone, Authenticated from .db import Session -from rattail.db.model import User -from rattail.db.auth import has_permission @implementer(IAuthorizationPolicy) class TailboneAuthorizationPolicy(object): def permits(self, context, principals, permission): + from rattail.db.model import User + from rattail.db.auth import has_permission + for userid in principals: if userid not in (Everyone, Authenticated): user = Session.query(User).get(userid) diff --git a/tailbone/db.py b/tailbone/db.py index 7e12f5b2..18afce0b 100644 --- a/tailbone/db.py +++ b/tailbone/db.py @@ -32,7 +32,7 @@ import sqlalchemy_continuum as continuum from sqlalchemy.orm import sessionmaker, scoped_session from rattail.db import SessionBase -from rattail.db import model +from rattail.db.continuum import versioning_manager Session = scoped_session(sessionmaker(class_=SessionBase)) @@ -53,8 +53,7 @@ class TailboneSessionDataManager(datamanager.SessionDataManager): if self.tx is not None: # there may have been no work to do # Force creation of Continuum versions for current session. - mgr = continuum.get_versioning_manager(model.Product) # any ol' model will do - uow = mgr.unit_of_work(self.session) + uow = versioning_manager.unit_of_work(self.session) uow.make_versions(self.session) self.tx.commit() diff --git a/tailbone/templates/brands/crud.mako b/tailbone/templates/brands/crud.mako index dea13003..055dc58f 100644 --- a/tailbone/templates/brands/crud.mako +++ b/tailbone/templates/brands/crud.mako @@ -8,7 +8,7 @@ % elif form.updating:
  • ${h.link_to("View this Brand", url('brand.read', uuid=form.fieldset.model.uuid))}
  • % endif - % if not form.creating and request.has_perm('brand.versions.view'): + % if version_count is not Undefined and request.has_perm('brand.versions.view'):
  • ${h.link_to("View Change History ({0})".format(version_count), url('brand.versions', uuid=form.fieldset.model.uuid))}
  • % endif diff --git a/tailbone/templates/departments/crud.mako b/tailbone/templates/departments/crud.mako index 8d819021..b52d123f 100644 --- a/tailbone/templates/departments/crud.mako +++ b/tailbone/templates/departments/crud.mako @@ -8,7 +8,7 @@ % elif form.updating:
  • ${h.link_to("View this Department", url('department.read', uuid=form.fieldset.model.uuid))}
  • % endif - % if not form.creating and request.has_perm('department.versions.view'): + % if version_count is not Undefined and request.has_perm('department.versions.view'):
  • ${h.link_to("View Change History ({0})".format(version_count), url('department.versions', uuid=form.fieldset.model.uuid))}
  • % endif diff --git a/tailbone/templates/products/crud.mako b/tailbone/templates/products/crud.mako index 68370002..75a0421c 100644 --- a/tailbone/templates/products/crud.mako +++ b/tailbone/templates/products/crud.mako @@ -8,7 +8,7 @@ % elif form.updating:
  • ${h.link_to("View this Product", url('product.read', uuid=form.fieldset.model.uuid))}
  • % endif - % if not form.creating and request.has_perm('product.versions.view'): + % if version_count is not Undefined and request.has_perm('product.versions.view'):
  • ${h.link_to("View Change History ({0})".format(version_count), url('product.versions', uuid=form.fieldset.model.uuid))}
  • % endif diff --git a/tailbone/templates/subdepartments/crud.mako b/tailbone/templates/subdepartments/crud.mako index e64a1b7f..bfc12614 100644 --- a/tailbone/templates/subdepartments/crud.mako +++ b/tailbone/templates/subdepartments/crud.mako @@ -8,7 +8,7 @@ % elif form.updating:
  • ${h.link_to("View this Subdepartment", url('subdepartment.read', uuid=form.fieldset.model.uuid))}
  • % endif - % if not form.creating and request.has_perm('subdepartment.versions.view'): + % if version_count is not Undefined and request.has_perm('subdepartment.versions.view'):
  • ${h.link_to("View Change History ({0})".format(version_count), url('subdepartment.versions', uuid=form.fieldset.model.uuid))}
  • % endif diff --git a/tailbone/templates/vendors/crud.mako b/tailbone/templates/vendors/crud.mako index b3b2fd2a..332fbecf 100644 --- a/tailbone/templates/vendors/crud.mako +++ b/tailbone/templates/vendors/crud.mako @@ -8,7 +8,7 @@ % elif form.updating:
  • ${h.link_to("View this Vendor", url('vendor.read', uuid=form.fieldset.model.uuid))}
  • % endif - % if not form.creating and request.has_perm('vendor.versions.view'): + % if version_count is not Undefined and request.has_perm('vendor.versions.view'):
  • ${h.link_to("View Change History ({0})".format(version_count), url('vendor.versions', uuid=form.fieldset.model.uuid))}
  • % endif diff --git a/tailbone/views/continuum.py b/tailbone/views/continuum.py index d7f0bdb2..3c915f27 100644 --- a/tailbone/views/continuum.py +++ b/tailbone/views/continuum.py @@ -30,7 +30,7 @@ import sqlalchemy as sa import sqlalchemy_continuum as continuum from rattail.db import model -from rattail.db.model.continuum import model_transaction_query +from rattail.db.continuum import model_transaction_query import formalchemy from pyramid.httpexceptions import HTTPNotFound diff --git a/tailbone/views/crud.py b/tailbone/views/crud.py index 924c91de..c54ce13f 100644 --- a/tailbone/views/crud.py +++ b/tailbone/views/crud.py @@ -37,7 +37,7 @@ from sqlalchemy_continuum import transaction_class, version_class from sqlalchemy_continuum.utils import is_versioned from rattail.db import model -from rattail.db.model.continuum import count_versions, model_transaction_query +from rattail.db.continuum import count_versions, model_transaction_query from pyramid.httpexceptions import HTTPFound, HTTPNotFound From 994af9dd3fd94eb22bc60e9d836d7819ec67d268 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 10 Feb 2015 19:15:51 -0600 Subject: [PATCH 0184/3860] Update changelog. --- CHANGES.rst | 6 ++++++ setup.py | 2 +- tailbone/_version.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 1ca83677..ec00049d 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,5 +1,11 @@ .. -*- coding: utf-8 -*- +0.4.2 +----- + +* Rework versioning support to allow it to be on or off. + + 0.4.1 ----- diff --git a/setup.py b/setup.py index bfba6388..329ff16e 100644 --- a/setup.py +++ b/setup.py @@ -82,7 +82,7 @@ requires = [ 'pyramid_exclog', # 0.6 'pyramid_simpleform', # 0.6.1 'pyramid_tm', # 0.3 - 'rattail[auth]>=0.4.0', # 0.4.0 + 'rattail[auth]>=0.4.1', # 0.4.1 'transaction', # 1.2.0 'waitress', # 0.8.1 'WebHelpers', # 1.3 diff --git a/tailbone/_version.py b/tailbone/_version.py index cae59458..98dc598f 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- -__version__ = u'0.4.1' +__version__ = u'0.4.2' From e6b448f298e12b48faf3141483fffbc6d81c8732 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 10 Feb 2015 20:10:03 -0600 Subject: [PATCH 0185/3860] More versioning support fixes, to allow on or off. --- tailbone/templates/categories/crud.mako | 2 +- tailbone/templates/labels/profiles/read.mako | 2 +- tailbone/templates/roles/crud.mako | 2 +- tailbone/templates/users/crud.mako | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tailbone/templates/categories/crud.mako b/tailbone/templates/categories/crud.mako index b1ce53e7..4aace32a 100644 --- a/tailbone/templates/categories/crud.mako +++ b/tailbone/templates/categories/crud.mako @@ -8,7 +8,7 @@ % elif form.updating:
  • ${h.link_to("View this Category", url('category.read', uuid=form.fieldset.model.uuid))}
  • % endif - % if not form.creating and request.has_perm('category.versions.view'): + % if version_count is not Undefined and request.has_perm('category.versions.view'):
  • ${h.link_to("View Change History ({0})".format(version_count), url('category.versions', uuid=form.fieldset.model.uuid))}
  • % endif diff --git a/tailbone/templates/labels/profiles/read.mako b/tailbone/templates/labels/profiles/read.mako index 8af13c45..c48a21b6 100644 --- a/tailbone/templates/labels/profiles/read.mako +++ b/tailbone/templates/labels/profiles/read.mako @@ -11,7 +11,7 @@
  • ${h.link_to("Edit Printer Settings", url('label_profile.printer_settings', uuid=profile.uuid))}
  • % endif % endif - % if not form.creating and request.has_perm('labelprofile.versions.view'): + % if version_count is not Undefined and request.has_perm('labelprofile.versions.view'):
  • ${h.link_to("View Change History ({0})".format(version_count), url('labelprofile.versions', uuid=form.fieldset.model.uuid))}
  • % endif diff --git a/tailbone/templates/roles/crud.mako b/tailbone/templates/roles/crud.mako index 0965e937..38e66f7a 100644 --- a/tailbone/templates/roles/crud.mako +++ b/tailbone/templates/roles/crud.mako @@ -14,7 +14,7 @@
  • ${h.link_to("View this Role", url('role.read', uuid=form.fieldset.model.uuid))}
  • % endif
  • ${h.link_to("Delete this Role", url('role.delete', uuid=form.fieldset.model.uuid), class_='delete')}
  • - % if not form.creating and request.has_perm('role.versions.view'): + % if version_count is not Undefined and request.has_perm('role.versions.view'):
  • ${h.link_to("View Change History ({0})".format(version_count), url('role.versions', uuid=form.fieldset.model.uuid))}
  • % endif diff --git a/tailbone/templates/users/crud.mako b/tailbone/templates/users/crud.mako index e18c1b27..b46ca1ed 100644 --- a/tailbone/templates/users/crud.mako +++ b/tailbone/templates/users/crud.mako @@ -8,7 +8,7 @@ % elif form.updating:
  • ${h.link_to("View this User", url('user.read', uuid=form.fieldset.model.uuid))}
  • % endif - % if not form.creating and request.has_perm('user.versions.view'): + % if version_count is not Undefined and request.has_perm('user.versions.view'):
  • ${h.link_to("View Change History ({0})".format(version_count), url('user.versions', uuid=form.fieldset.model.uuid))}
  • % endif From 6434e64f5e1bd48386496f90ebcdc90c14305a51 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 10 Feb 2015 20:10:49 -0600 Subject: [PATCH 0186/3860] Update changelog. --- CHANGES.rst | 6 ++++++ tailbone/_version.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index ec00049d..205c0a26 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,5 +1,11 @@ .. -*- coding: utf-8 -*- +0.4.3 +----- + +* More versioning support fixes, to allow on or off. + + 0.4.2 ----- diff --git a/tailbone/_version.py b/tailbone/_version.py index 98dc598f..1f0a173f 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- -__version__ = u'0.4.2' +__version__ = u'0.4.3' From f3d449c9f3f4663b8f8b9aae0e6de0cc03be7885 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 11 Feb 2015 00:52:23 -0600 Subject: [PATCH 0187/3860] Add UI support for `Product.deleted` column. This leverages the 'products.view_deleted' permission to hide products which are marked as deleted from various views. Also adds a 'deleted' class to product grid rows where the flag is set, and adds a flash warning when viewing a deleted product. --- tailbone/views/crud.py | 7 ++++++ tailbone/views/products.py | 47 ++++++++++++++++++++++++++++++++++---- 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/tailbone/views/crud.py b/tailbone/views/crud.py index c54ce13f..ce2f6203 100644 --- a/tailbone/views/crud.py +++ b/tailbone/views/crud.py @@ -162,6 +162,10 @@ class CrudView(View): self.validation_failed(form) + result = self.post_crud(model, form) + if result is not None: + return result + kwargs = self.template_kwargs(form) kwargs['form'] = form return kwargs @@ -169,6 +173,9 @@ class CrudView(View): def pre_crud(self, model): pass + def post_crud(self, model, form): + pass + def template_kwargs(self, form): if not form.creating and is_versioned(self.mapped_class): return {'version_count': self.count_versions()} diff --git a/tailbone/views/products.py b/tailbone/views/products.py index d30aa2cf..a322edd5 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -184,6 +184,8 @@ class ProductsGrid(SearchableAlchemyGridView): def query(self): q = self.make_query() + if not self.request.has_perm('products.view_deleted'): + q = q.filter(model.Product.deleted == False) q = q.options(joinedload(Product.brand)) q = q.options(joinedload(Product.department)) q = q.options(joinedload(Product.subdepartment)) @@ -194,7 +196,12 @@ class ProductsGrid(SearchableAlchemyGridView): def grid(self): def extra_row_class(row, i): - return 'not-for-sale' if row.not_for_sale else None + cls = [] + if row.not_for_sale: + cls.append('not-for-sale') + if row.deleted: + cls.append('deleted') + return ' '.join(cls) if cls else None g = self.make_grid(extra_row_class=extra_row_class) g.upc.set(renderer=GPCFieldRenderer) g.regular_price.set(renderer=PriceFieldRenderer) @@ -278,12 +285,20 @@ class ProductCrud(CrudView): fs.regular_price, fs.current_price, fs.not_for_sale, + fs.deleted, ]) if not self.readonly: del fs.regular_price del fs.current_price return fs + def pre_crud(self, product): + self.product_deleted = not self.creating and product.deleted + + def post_crud(self, product, form): + if self.product_deleted: + self.request.session.flash("This product is marked as deleted.", 'error') + def template_kwargs(self, form): kwargs = super(ProductCrud, self).template_kwargs(form) kwargs['image'] = False @@ -310,6 +325,25 @@ class ProductVersionView(VersionView): (model.ProductPrice, 'product_uuid'), ] + def warn_if_deleted(self): + """ + Maybe set flash warning if product is marked deleted. + """ + uuid = self.request.matchdict['uuid'] + product = Session.query(model.Product).get(uuid) + assert product, "No product found for UUID: {0}".format(repr(uuid)) + if product.deleted: + self.request.session.flash("This product is marked as deleted.", 'error') + + def list(self): + self.warn_if_deleted() + return super(ProductVersionView, self).list() + + def details(self): + self.warn_if_deleted() + return super(ProductVersionView, self).details() + + def products_search(request): """ @@ -328,10 +362,13 @@ def products_search(request): upc = GPC(upc, calc_check_digit='upc') product = get_product_by_upc(Session, upc) if product: - product = { - 'uuid': product.uuid, - 'full_description': product.full_description, - } + if product.deleted and not request.has_perm('products.view_deleted'): + product = None + else: + product = { + 'uuid': product.uuid, + 'full_description': product.full_description, + } return {'product': product} From bf18bab9093cd4aabf440b7a77656caa0f789865 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 11 Feb 2015 00:55:37 -0600 Subject: [PATCH 0188/3860] Update changelog. --- CHANGES.rst | 6 ++++++ tailbone/_version.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 205c0a26..cb56d1ad 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,5 +1,11 @@ .. -*- coding: utf-8 -*- +0.4.4 +----- + +* Add UI support for ``Product.deleted`` column. + + 0.4.3 ----- diff --git a/tailbone/_version.py b/tailbone/_version.py index 1f0a173f..f4c707d8 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- -__version__ = u'0.4.3' +__version__ = u'0.4.4' From bc06a7299305e94b60b733169996dfe0154f090e Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 11 Feb 2015 03:27:31 -0600 Subject: [PATCH 0189/3860] Add prettier UPCs to ordering worksheet report. --- tailbone/views/reports.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/tailbone/views/reports.py b/tailbone/views/reports.py index 536303d4..2cb2ae88 100644 --- a/tailbone/views/reports.py +++ b/tailbone/views/reports.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2014 Lance Edgar +# Copyright © 2010-2015 Lance Edgar # # This file is part of Rattail. # @@ -20,7 +20,6 @@ # along with Rattail. If not, see . # ################################################################################ - """ Report Views """ @@ -46,14 +45,18 @@ plu_upc_pattern = re.compile(r'^000000000(\d{5})$') weighted_upc_pattern = re.compile(r'^002(\d{5})00000\d$') def get_upc(product): - upc = '%014u' % product.upc + """ + UPC formatter. Strips PLUs to bare number, and adds "minus check digit" + for non-PLU UPCs. + """ + upc = unicode(product.upc) m = plu_upc_pattern.match(upc) if m: - return str(int(m.group(1))) + return unicode(int(m.group(1))) m = weighted_upc_pattern.match(upc) if m: - return str(int(m.group(1))) - return upc + return unicode(int(m.group(1))) + return '{0}-{1}'.format(upc[:-1], upc[-1]) class OrderingWorksheet(View): From 7b7ec2ccbd433c351d9428e83d8c338ca8c8b14f Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 11 Feb 2015 20:25:45 -0600 Subject: [PATCH 0190/3860] Change rattail dependency to include 'db' feature (again)... --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 329ff16e..5e7bb602 100644 --- a/setup.py +++ b/setup.py @@ -82,7 +82,7 @@ requires = [ 'pyramid_exclog', # 0.6 'pyramid_simpleform', # 0.6.1 'pyramid_tm', # 0.3 - 'rattail[auth]>=0.4.1', # 0.4.1 + 'rattail[auth,db]>=0.4.1', # 0.4.1 'transaction', # 1.2.0 'waitress', # 0.8.1 'WebHelpers', # 1.3 From 8409d24273312180eb0a0e79af487995194bde4c Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 11 Feb 2015 20:26:56 -0600 Subject: [PATCH 0191/3860] Add case pack field to product CRUD form. --- tailbone/views/products.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index a322edd5..c9c69902 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -277,6 +277,7 @@ class ProductCrud(CrudView): fs.brand.with_renderer(BrandFieldRenderer), fs.description, fs.size, + fs.case_pack, fs.department, fs.subdepartment, fs.category, From c28a6b2e094154df87450a9a8dc5fdab37aaf5d1 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 12 Feb 2015 11:37:32 -0600 Subject: [PATCH 0192/3860] Update changelog. --- CHANGES.rst | 8 ++++++++ tailbone/_version.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index cb56d1ad..7f8fa6ab 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,5 +1,13 @@ .. -*- coding: utf-8 -*- +0.4.5 +----- + +* Add prettier UPCs to ordering worksheet report. + +* Add case pack field to product CRUD form. + + 0.4.4 ----- diff --git a/tailbone/_version.py b/tailbone/_version.py index f4c707d8..0862794a 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- -__version__ = u'0.4.4' +__version__ = u'0.4.5' From 16be06821aa70d522fa03d17c6cab07acb7d4358 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 12 Feb 2015 21:35:28 -0600 Subject: [PATCH 0193/3860] Wrap up initial vendor catalog batch support etc. * Adds the ability to delete all batch rows matching current query. * Refactors some progress factory args. * If batch initialization fails, don't persist batch. --- tailbone/templates/batch/rows.mako | 5 +++ tailbone/views/batch.py | 58 +++++++++++++++++++++++++----- tailbone/views/grids/alchemy.py | 2 ++ tailbone/views/vendors/catalogs.py | 9 ++++- 4 files changed, 65 insertions(+), 9 deletions(-) diff --git a/tailbone/templates/batch/rows.mako b/tailbone/templates/batch/rows.mako index 756606e6..c7d04e57 100644 --- a/tailbone/templates/batch/rows.mako +++ b/tailbone/templates/batch/rows.mako @@ -7,6 +7,11 @@ ${search.render()} + + +

    ${h.link_to("Delete all rows matching current search", url('{0}.rows.bulk_delete'.format(route_prefix), uuid=batch.uuid))}

    + + ${grid} diff --git a/tailbone/views/batch.py b/tailbone/views/batch.py index 57bcc404..7329d3bf 100644 --- a/tailbone/views/batch.py +++ b/tailbone/views/batch.py @@ -473,11 +473,11 @@ class BatchCrud(BaseCrud): } return render_to_response('/progress.mako', kwargs, request=self.request) - def refresh_data(self, session, batch, progress_factory=None): + def refresh_data(self, session, batch, progress=None): """ Instruct the batch handler to refresh all data for the batch. """ - self.handler.refresh_data(session, batch, progress_factory=progress_factory) + self.handler.refresh_data(session, batch, progress=progress) batch.cognized = datetime.datetime.utcnow() batch.cognized_by = self.request.user @@ -490,7 +490,7 @@ class BatchCrud(BaseCrud): # transaction binding etc. session = RatSession() batch = session.query(self.batch_class).get(batch_uuid) - self.refresh_data(session, batch, progress_factory=progress) + self.refresh_data(session, batch, progress=progress) session.commit() session.refresh(batch) session.close() @@ -591,7 +591,9 @@ class FileBatchCrud(BatchCrud): def save_form(self, form): """ - Save the uploaded data file if necessary, etc. + Save the uploaded data file if necessary, etc. If batch initialization + fails, don't persist the batch at all; the user will be sent back to + the "create batch" page in that case. """ # Transfer form data to batch instance. form.fieldset.sync() @@ -603,9 +605,10 @@ class FileBatchCrud(BatchCrud): batch.filename = form.fieldset.data_file.renderer._filename # Expunge batch from session to prevent it from being flushed. Session.expunge(batch) - self.init_batch(batch) - Session.add(batch) - batch.write_file(self.request.rattail_config, form.fieldset.data_file.value) + self.batch_inited = self.init_batch(batch) + if self.batch_inited: + Session.add(batch) + batch.write_file(self.request.rattail_config, form.fieldset.data_file.value) def init_batch(self, batch): """ @@ -613,7 +616,24 @@ class FileBatchCrud(BatchCrud): effectively provide default values for a batch, etc. This method is invoked after a batch has been fully prepared for insertion to the database, but before the push to the database occurs. + + Note that the return value of this function matters; if it is boolean + false then the batch will not be persisted at all, and the user will be + redirected to the "create batch" page. """ + return True + + def post_save(self, form): + """ + This checks for failed batch initialization when creating a new batch. + If a failure is detected, the user is redirected to the page for + creating new batches. The assumption here is that the + :meth:`init_batch()` method responsible for indicating the failure will + have set a flash message for the user with more info. + """ + if self.creating and not self.batch_inited: + return HTTPFound(location=self.request.route_url( + '{0}.create'.format(self.route_prefix))) def post_save_url(self, form): """ @@ -632,7 +652,8 @@ class FileBatchCrud(BatchCrud): class BatchRowGrid(BaseGrid): """ - Base grid view for batch rows, which can be filtered and sorted. + Base grid view for batch rows, which can be filtered and sorted. Also it + can delete all rows matching the current list view query. """ @property @@ -734,6 +755,22 @@ class BatchRowGrid(BaseGrid): def tr_class(self, row, i): pass + def render_kwargs(self): + """ + Add the current batch and route prefix to the template context. + """ + return {'batch': self.current_batch(), + 'route_prefix': self.route_prefix} + + def bulk_delete(self): + """ + Delete all rows matching the current row grid view query. + """ + self.query().delete() + return HTTPFound(location=self.request.route_url( + '{0}.view'.format(self.route_prefix), + uuid=self.request.matchdict['uuid'])) + class ProductBatchRowGrid(BatchRowGrid): """ @@ -851,6 +888,11 @@ def defaults(config, batch_grid, batch_crud, row_grid, row_crud, url_prefix, renderer='/batch/rows.mako', permission='{0}.view'.format(permission_prefix)) + # Bulk delete batch rows + config.add_route('{0}.rows.bulk_delete'.format(route_prefix), '{0}{{uuid}}/rows/delete'.format(url_prefix)) + config.add_view(row_grid, attr='bulk_delete', route_name='{0}.rows.bulk_delete'.format(route_prefix), + permission='{0}.delete'.format(permission_prefix)) + # Delete batch row config.add_route('{0}.rows.delete'.format(route_prefix), '{0}delete-row/{{uuid}}'.format(url_prefix)) config.add_view(row_crud, attr='delete', route_name='{0}.rows.delete'.format(route_prefix), diff --git a/tailbone/views/grids/alchemy.py b/tailbone/views/grids/alchemy.py index ffb4ed48..389f1332 100644 --- a/tailbone/views/grids/alchemy.py +++ b/tailbone/views/grids/alchemy.py @@ -169,6 +169,8 @@ class SearchableAlchemyGridView(PagedAlchemyGridView): def modify_query(self, query): join_map = self.join_map() + if not hasattr(self, '_filter_config'): + self._filter_config = self.filter_config() query = grids.search.filter_query( query, self._filter_config, self.filter_map(), join_map) if hasattr(self, '_sort_config'): diff --git a/tailbone/views/vendors/catalogs.py b/tailbone/views/vendors/catalogs.py index e790ee68..e31e7572 100644 --- a/tailbone/views/vendors/catalogs.py +++ b/tailbone/views/vendors/catalogs.py @@ -26,6 +26,8 @@ Views for maintaining vendor catalogs from __future__ import unicode_literals +from pyramid.httpexceptions import HTTPFound + from rattail.db import model from rattail.db.api import get_setting, get_vendor from rattail.db.batch.vendorcatalog import VendorCatalog, VendorCatalogRow @@ -129,7 +131,12 @@ class VendorCatalogCrud(FileBatchCrud): def init_batch(self, batch): parser = require_catalog_parser(batch.parser_key) - batch.vendor = get_vendor(Session, parser.vendor_key) + vendor = get_vendor(Session, parser.vendor_key) + if not vendor: + self.request.session.flash("No vendor setting found in database for key: {0}".format(parser.vendor_key)) + return False + batch.vendor = vendor + return True class VendorCatalogRowGrid(BatchRowGrid): From 23addae8185ba910551a669980c05c034dc965a0 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 12 Feb 2015 23:18:17 -0600 Subject: [PATCH 0194/3860] Rearrange primary batch fields for vendor catalogs. Catalog info seemed more important than who uploaded it. --- tailbone/views/vendors/catalogs.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tailbone/views/vendors/catalogs.py b/tailbone/views/vendors/catalogs.py index e31e7572..5afb4b2a 100644 --- a/tailbone/views/vendors/catalogs.py +++ b/tailbone/views/vendors/catalogs.py @@ -113,13 +113,13 @@ class VendorCatalogCrud(FileBatchCrud): options=parser_options) fs.configure( include=[ - fs.created, - fs.created_by, fs.vendor, fs.data_file.label("Catalog File"), - fs.filename, fs.parser_key.label("File Type"), fs.effective, + fs.filename, + fs.created, + fs.created_by, fs.executed, fs.executed_by, ]) From eedbcb81f8cd27ef3c0825c601b81ac96fe03417 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 12 Feb 2015 23:19:01 -0600 Subject: [PATCH 0195/3860] Add download feature for file batches. --- tailbone/views/batch.py | 47 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/tailbone/views/batch.py b/tailbone/views/batch.py index 7329d3bf..45fd18c3 100644 --- a/tailbone/views/batch.py +++ b/tailbone/views/batch.py @@ -35,7 +35,9 @@ import logging import formalchemy from pyramid.renderers import render_to_response +from pyramid.response import FileResponse from pyramid.httpexceptions import HTTPFound, HTTPNotFound +from webhelpers.html.tags import link_to from rattail.db import model from rattail.db import Session as RatSession @@ -517,6 +519,28 @@ class BatchCrud(BaseCrud): return HTTPFound(location=self.view_url(batch.uuid)) +class DownloadLinkRenderer(formalchemy.FieldRenderer): + """ + Field renderer for batch filenames, shows a link to download the file. + """ + + def __init__(self, route_prefix): + self.route_prefix = route_prefix + + def __call__(self, field): + super(DownloadLinkRenderer, self).__init__(field) + return self + + def render_readonly(self, **kwargs): + filename = self.value + if not filename: + return '' + batch = self.field.parent.model + return link_to(filename, self.request.route_url( + '{0}.download'.format(self.route_prefix), + uuid=batch.uuid)) + + class FileBatchCrud(BatchCrud): """ Base CRUD view for batches which involve a file upload as the first step. @@ -545,6 +569,7 @@ class FileBatchCrud(BatchCrud): fs.executed_by.set(label="Executed by", renderer=UserFieldRenderer) fs.append(formalchemy.Field('data_file')) fs.data_file.set(renderer=formalchemy.fields.FileFieldRenderer) + fs.filename.set(renderer=DownloadLinkRenderer(self.route_prefix)) self.configure_fieldset(fs) if self.creating: del fs.created @@ -649,6 +674,20 @@ class FileBatchCrud(BatchCrud): batch.delete_data(self.request.rattail_config) del batch.data_rows[:] + def download(self): + """ + View for downloading the data file associated with a batch. + """ + batch = self.current_batch() + if not batch: + return HTTPNotFound() + config = self.request.rattail_config + path = batch.filepath(config) + response = FileResponse(path, request=self.request) + response.headers[b'Content-Length'] = str(batch.filesize(config)) + response.headers[b'Content-Disposition'] = b'attachment; filename={0}'.format(batch.filename) + return response + class BatchRowGrid(BaseGrid): """ @@ -877,6 +916,12 @@ def defaults(config, batch_grid, batch_crud, row_grid, row_crud, url_prefix, config.add_view(batch_crud, attr='execute', route_name='{0}.execute'.format(route_prefix), permission='{0}.execute'.format(permission_prefix)) + # Download batch data file + if hasattr(batch_crud, 'download'): + config.add_route('{0}.download'.format(route_prefix), '{0}{{uuid}}/download'.format(url_prefix)) + config.add_view(batch_crud, attr='download', route_name='{0}.download'.format(route_prefix), + permission='{0}.download'.format(permission_prefix)) + # Delete batch config.add_route('{0}.delete'.format(route_prefix), '{0}{{uuid}}/delete'.format(url_prefix)) config.add_view(batch_crud, attr='delete', route_name='{0}.delete'.format(route_prefix), @@ -891,7 +936,7 @@ def defaults(config, batch_grid, batch_crud, row_grid, row_crud, url_prefix, # Bulk delete batch rows config.add_route('{0}.rows.bulk_delete'.format(route_prefix), '{0}{{uuid}}/rows/delete'.format(url_prefix)) config.add_view(row_grid, attr='bulk_delete', route_name='{0}.rows.bulk_delete'.format(route_prefix), - permission='{0}.delete'.format(permission_prefix)) + permission='{0}.edit'.format(permission_prefix)) # Delete batch row config.add_route('{0}.rows.delete'.format(route_prefix), '{0}delete-row/{{uuid}}'.format(url_prefix)) From 26e5be9897f7af9e0540b23e3dafd9792c2bb1f6 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 12 Feb 2015 23:24:36 -0600 Subject: [PATCH 0196/3860] Fix filename when downloading batch file. --- tailbone/views/batch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/views/batch.py b/tailbone/views/batch.py index 45fd18c3..1fea83cb 100644 --- a/tailbone/views/batch.py +++ b/tailbone/views/batch.py @@ -685,7 +685,7 @@ class FileBatchCrud(BatchCrud): path = batch.filepath(config) response = FileResponse(path, request=self.request) response.headers[b'Content-Length'] = str(batch.filesize(config)) - response.headers[b'Content-Disposition'] = b'attachment; filename={0}'.format(batch.filename) + response.headers[b'Content-Disposition'] = b'attachment; filename="{0}"'.format(batch.filename) return response From 6c7f1afcf4f6540834ef43a168464361d8ba7724 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 12 Feb 2015 23:32:17 -0600 Subject: [PATCH 0197/3860] Fix filename in batch file download link (again). This hopefully prevents encoding errors which were bound to happen... --- tailbone/views/batch.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tailbone/views/batch.py b/tailbone/views/batch.py index 1fea83cb..563540fc 100644 --- a/tailbone/views/batch.py +++ b/tailbone/views/batch.py @@ -685,7 +685,8 @@ class FileBatchCrud(BatchCrud): path = batch.filepath(config) response = FileResponse(path, request=self.request) response.headers[b'Content-Length'] = str(batch.filesize(config)) - response.headers[b'Content-Disposition'] = b'attachment; filename="{0}"'.format(batch.filename) + response.headers[b'Content-Disposition'] = b'attachment; filename="{0}"'.format( + batch.filename.encode('ascii', 'replace')) return response From 937a55c14da2181876c3c2f5786f4ce0102cde44 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 13 Feb 2015 01:12:20 -0600 Subject: [PATCH 0198/3860] Add docs for new batch system. And some other tweak(s). --- .gitignore | 1 + docs/api/views/batch.rst | 29 +++++++++++ docs/api/views/vendors.catalogs.rst | 20 ++++++++ docs/conf.py | 1 + docs/index.rst | 25 ++++++++-- docs/narr/batches.rst | 74 +++++++++++++++++++++++++++++ tailbone/views/vendors/catalogs.py | 3 ++ 7 files changed, 148 insertions(+), 5 deletions(-) create mode 100644 docs/api/views/batch.rst create mode 100644 docs/api/views/vendors.catalogs.rst create mode 100644 docs/narr/batches.rst diff --git a/.gitignore b/.gitignore index 35ebd7fd..906dc226 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .coverage .tox/ +docs/_build/ htmlcov/ Tailbone.egg-info/ diff --git a/docs/api/views/batch.rst b/docs/api/views/batch.rst new file mode 100644 index 00000000..a98fc39d --- /dev/null +++ b/docs/api/views/batch.rst @@ -0,0 +1,29 @@ +.. -*- coding: utf-8 -*- + +``tailbone.views.batch`` +======================== + +.. automodule:: tailbone.views.batch + +.. autoclass:: BatchGrid + :members: + +.. autoclass:: FileBatchGrid + :members: + +.. autoclass:: BatchCrud + :members: + +.. autoclass:: FileBatchCrud + :members: + +.. autoclass:: BatchRowGrid + :members: + +.. autoclass:: ProductBatchRowGrid + :members: + +.. autoclass:: BatchRowCrud + :members: + +.. autofunction:: defaults diff --git a/docs/api/views/vendors.catalogs.rst b/docs/api/views/vendors.catalogs.rst new file mode 100644 index 00000000..f45cf27c --- /dev/null +++ b/docs/api/views/vendors.catalogs.rst @@ -0,0 +1,20 @@ +.. -*- coding: utf-8 -*- + +``tailbone.views.vendors.catalogs`` +=================================== + +.. automodule:: tailbone.views.vendors.catalogs + +.. autoclass:: VendorCatalogGrid + :members: + +.. autoclass:: VendorCatalogCrud + :members: + +.. autoclass:: VendorCatalogRowGrid + :members: + +.. autoclass:: VendorCatalogRowCrud + :members: + +.. autofunction:: includeme diff --git a/docs/conf.py b/docs/conf.py index 09bbd359..572eaceb 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -34,6 +34,7 @@ execfile(os.path.join(os.pardir, 'tailbone', '_version.py')) extensions = [ 'sphinx.ext.autodoc', 'sphinx.ext.todo', + 'sphinx.ext.viewcode', ] # Add any paths that contain templates here, relative to this directory. diff --git a/docs/index.rst b/docs/index.rst index 19c21af6..f2af6f62 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -5,17 +5,32 @@ Tailbone Welcome to Tailbone, part of the Rattail project. The documentation you are currently reading is for the Tailbone web application -package. More information is (sort of) available at http://rattailproject.org/. +package. Some additional information is available on the `website`_. Clearly +not everything is documented yet. Below you can see what has received some +attention thus far. -Clearly not everything is documented yet. Below you can see what has received -some attention thus far. +.. _website: https://rattailproject.org/ -API: +Narrative Documentation: .. toctree:: - :maxdepth: 2 + + narr/batches + +Package API: + +.. toctree:: + :maxdepth: 1 api/subscribers + api/views/batch + api/views/vendors.catalogs + + +Documentation To-Do +=================== + +.. todolist:: Indices and tables diff --git a/docs/narr/batches.rst b/docs/narr/batches.rst new file mode 100644 index 00000000..a3c9b372 --- /dev/null +++ b/docs/narr/batches.rst @@ -0,0 +1,74 @@ +.. -*- coding: utf-8 -*- + +Data Batches +============ + +This document briefly outlines what comprises a batch in terms of the Tailbone +user interface etc. + + +Batch Views +----------- + +Adding support for a new batch type is mostly a matter of providing some custom +views for the batch and its rows. In fact you must define four different view +classes, inheriting from each of the following: + +* :class:`tailbone.views.batch.BatchGrid` +* :class:`tailbone.views.batch.BatchCrud` +* :class:`tailbone.views.batch.BatchRowGrid` +* :class:`tailbone.views.batch.BatchRowCrud` + +It would sure be nice to only require two view classes instead of four, hopefully +that can happen "soon". In the meantime that's what it takes. Note that as with +batch data models, there are some more specialized parent classes which you may +want to inherit from instead of the core classes mentioned above: + +* :class:`tailbone.views.batch.FileBatchGrid` +* :class:`tailbone.views.batch.FileBatchCrud` +* :class:`tailbone.views.batch.ProductBatchRowGrid` + +Here are the vendor catalog views as examples: + +* :class:`tailbone.views.vendors.catalogs.VendorCatalogGrid` +* :class:`tailbone.views.vendors.catalogs.VendorCatalogCrud` +* :class:`tailbone.views.vendors.catalogs.VendorCatalogRowGrid` +* :class:`tailbone.views.vendors.catalogs.VendorCatalogRowCrud` + + +Pyramid Config +-------------- + +In addition to defining the batch views, the Pyramid Configurator object must be +told of the views and their routes. This also could probably stand to be simpler +somehow, but for now the easiest thing is to apply default configuration with: + +* :func:`tailbone.views.batch.defaults()` + +See the source behind the vendor catalog for an example: + +* :func:`tailbone.views.vendors.catalogs.includeme()` + +Note of course that your view config must be included by the core/upstream +config process of your application's startup to take effect. At this point +your views should be accessible by navigating to the URLs directly, e.g. for +the vendor catalog views: + +* List Uploaded Catalogs - http://example.com/vendors/catalogs/ +* Upload New Catlaog - http://example.com/vendors/catalogs/new + + +Menu and Templates +------------------ + +Providing access to the batch views is (I think) the last step. You must add +links to the views, wherever that makes sense for your app. In case it's +helpful, here's a Mako template snippet which would show some links to the main +vendor catalog views: + +.. code-block:: mako + +
      +
    • ${h.link_to("Vendor Catalogs", url('vendors.catalogs'))}
    • +
    • ${h.link_to("Upload new Vendor Catalog", url('vendors.catalogs.create'))}
    • +
    diff --git a/tailbone/views/vendors/catalogs.py b/tailbone/views/vendors/catalogs.py index 5afb4b2a..2a235b5f 100644 --- a/tailbone/views/vendors/catalogs.py +++ b/tailbone/views/vendors/catalogs.py @@ -182,4 +182,7 @@ class VendorCatalogRowCrud(BatchRowCrud): def includeme(config): + """ + Add configuration for the vendor catalog views. + """ defaults(config, VendorCatalogGrid, VendorCatalogCrud, VendorCatalogRowGrid, VendorCatalogRowCrud, '/vendors/catalogs/') From ae5ff89c7f8a1ab408c6aaf2717520dcd0a52b78 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 13 Feb 2015 21:22:01 -0600 Subject: [PATCH 0199/3860] Refactor `app` module to promote code sharing. Hopefully this is a good approach, we'll see. --- tailbone/app.py | 102 +++++++++++++++++++++++++++++++++++------------- 1 file changed, 74 insertions(+), 28 deletions(-) diff --git a/tailbone/app.py b/tailbone/app.py index d9c791e4..20ce4b80 100644 --- a/tailbone/app.py +++ b/tailbone/app.py @@ -27,64 +27,110 @@ Application Entry Point from __future__ import unicode_literals import os - -from sqlalchemy import engine_from_config +import logging import edbob from edbob.pyramid.forms.formalchemy import TemplateEngine +import rattail.db +from rattail.config import RattailConfig +from rattail.exceptions import ConfigurationError +from rattail.db.util import get_engines +from rattail.db.continuum import configure_versioning from rattail.db.types import GPCType import formalchemy from pyramid.config import Configurator from pyramid.authentication import SessionAuthenticationPolicy -from zope.sqlalchemy import ZopeTransactionExtension -from tailbone.db import Session +import tailbone.db from tailbone.auth import TailboneAuthorizationPolicy from tailbone.forms import GPCFieldRenderer -def main(global_config, **settings): - """ - This function returns a Pyramid WSGI application. - """ +log = logging.getLogger(__name__) - # Use Tailbone templates by default. - settings.setdefault('mako.directories', ['tailbone:templates']) - # Make two attempts when "retryable" errors happen during transactions. - # This is intended to gracefully handle database restarts. +def make_rattail_config(settings): + """ + Make a Rattail config object from the given settings. + """ + # Initialize rattail config and embed it in the settings dict, to make it + # available to web requests later. + path = settings.get('edbob.config') + if not path or not os.path.exists(path): + raise ConfigurationError("Please set 'edbob.config' in [app:main] section of config " + "to the path of your config file. Lame, but necessary.") + edbob.init('rattail', path) + log.info("using rattail config file: {0}".format(path)) + rattail_config = RattailConfig(edbob.config) + settings['rattail_config'] = rattail_config + + # Load all Rattail database engines from config, and store in settings + # dict. This is necessary e.g. in the case of a host server, to have + # access to its subordinate store servers. + rattail_engines = get_engines(rattail_config) + settings['rattail_engines'] = rattail_engines + + # Configure the database session classes. Note that most of the time we'll + # be using the Tailbone Session, but occasionally (e.g. within batch + # processing threads) we want the Rattail Session. The reason is that + # during normal request processing, the Tailbone Session is preferable as + # it includes Zope Transaction magic. Within an explicitly-spawned thread + # however, this is *not* desirable. + rattail.db.Session.configure(bind=rattail_engines['default']) + tailbone.db.Session.configure(bind=rattail_engines['default']) + + # Configure (or not) Continuum versioning. + configure_versioning(rattail_config) + + return rattail_config + + +def provide_postgresql_settings(settings): + """ + Add some PostgreSQL-specific settings to the app config. Specifically, + this enables retrying transactions a second time, in an attempt to + gracefully handle database restarts. + """ settings.setdefault('tm.attempts', 2) + +def make_pyramid_config(settings): + """ + Make a Pyramid config object from the given settings. + """ config = Configurator(settings=settings) - # Initialize edbob, dammit. - edbob.init('rattail', os.path.abspath(settings['edbob.config'])) - edbob.init_modules(['edbob.time']) - - # Configure the primary database session. - engine = engine_from_config(settings) - Session.configure(bind=engine) - Session.configure(extension=ZopeTransactionExtension()) - # Configure user authentication / authorization. config.set_authentication_policy(SessionAuthenticationPolicy()) config.set_authorization_policy(TailboneAuthorizationPolicy()) # Bring in some Pyramid goodies. config.include('pyramid_beaker') + config.include('pyramid_mako') config.include('pyramid_tm') - # Bring in the rest of Tailbone. - config.include('tailbone') - # Configure FormAlchemy. formalchemy.config.engine = TemplateEngine() formalchemy.FieldSet.default_renderers[GPCType] = GPCFieldRenderer - # Consider PostgreSQL server restart errors to be "retryable." - config.add_tween('edbob.pyramid.tweens.sqlerror_tween_factory', - under='pyramid_tm.tm_tween_factory') + return config - return config.make_wsgi_app() + +def configure_postgresql(pyramid_config): + """ + Add some PostgreSQL-specific tweaks to the final app config. Specifically, + adds the tween necessary for graceful handling of database restarts. + """ + pyramid_config.add_tween('edbob.pyramid.tweens.sqlerror_tween_factory', + under='pyramid_tm.tm_tween_factory') + + +def main(global_config, **settings): + """ + This function returns a Pyramid WSGI application. + """ + rattail_config = make_rattail_config(settings) + pyramid_config = make_pyramid_config(settings) + return pyramid_config.make_wsgi_app() From 730a2a2f014b8eddf01b8999df21d1cb1f51a6a7 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 13 Feb 2015 21:22:36 -0600 Subject: [PATCH 0200/3860] Let custom vendor catalog batch handler be specified in config file. This was using database settings exclusively. --- tailbone/views/vendors/catalogs.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tailbone/views/vendors/catalogs.py b/tailbone/views/vendors/catalogs.py index 2a235b5f..8aabc98b 100644 --- a/tailbone/views/vendors/catalogs.py +++ b/tailbone/views/vendors/catalogs.py @@ -99,6 +99,8 @@ class VendorCatalogCrud(FileBatchCrud): custom handler. """ handler = get_setting(Session, 'rattail.batch.vendorcatalog.handler') + if not handler: + handler = self.request.rattail_config.get('rattail.batch', 'vendorcatalog.handler') if handler: handler = load_object(handler)(self.request.rattail_config) if not handler: From aee69f5a2c1e8663b82a3e200cab123c4a547640 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 16 Feb 2015 17:51:47 -0600 Subject: [PATCH 0201/3860] Force grid table background to white. This can be helpful if the overall page background is not white, in the case of batch rows etc. which use color-coding to help indicate status. --- tailbone/static/css/grids.css | 1 + 1 file changed, 1 insertion(+) diff --git a/tailbone/static/css/grids.css b/tailbone/static/css/grids.css index 8c558901..b192b1b3 100644 --- a/tailbone/static/css/grids.css +++ b/tailbone/static/css/grids.css @@ -60,6 +60,7 @@ div.grid { } div.grid table { + background-color: White; border-top: 1px solid black; border-left: 1px solid black; border-collapse: collapse; From 2e8db05717e97f1e076221d4e203a771bc67b513 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 16 Feb 2015 18:00:45 -0600 Subject: [PATCH 0202/3860] Add initial support for vendor invoice batch feature, etc. Also included: * Add "edit batch" template, refactor "view batch" template. * Tweak form templates to allow specifying form ID and buttons HTML. * Make deleting batch rows only work when editing a batch. --- tailbone/templates/batch/crud.mako | 78 +++++++ tailbone/templates/batch/edit.mako | 3 + tailbone/templates/batch/rows.mako | 5 +- tailbone/templates/batch/view.mako | 66 +----- tailbone/templates/forms/form.mako | 20 +- tailbone/templates/forms/form_readonly.mako | 3 + .../templates/vendors/invoices/create.mako | 3 + tailbone/templates/vendors/invoices/edit.mako | 3 + .../templates/vendors/invoices/index.mako | 3 + tailbone/templates/vendors/invoices/view.mako | 3 + tailbone/views/batch.py | 86 ++++++-- tailbone/views/crud.py | 12 +- tailbone/views/vendors/__init__.py | 1 + tailbone/views/vendors/catalogs.py | 2 - tailbone/views/vendors/invoices.py | 191 ++++++++++++++++++ 15 files changed, 387 insertions(+), 92 deletions(-) create mode 100644 tailbone/templates/batch/crud.mako create mode 100644 tailbone/templates/batch/edit.mako create mode 100644 tailbone/templates/vendors/invoices/create.mako create mode 100644 tailbone/templates/vendors/invoices/edit.mako create mode 100644 tailbone/templates/vendors/invoices/index.mako create mode 100644 tailbone/templates/vendors/invoices/view.mako create mode 100644 tailbone/views/vendors/invoices.py diff --git a/tailbone/templates/batch/crud.mako b/tailbone/templates/batch/crud.mako new file mode 100644 index 00000000..b7ed9353 --- /dev/null +++ b/tailbone/templates/batch/crud.mako @@ -0,0 +1,78 @@ +## -*- coding: utf-8 -*- +<%inherit file="/crud.mako" /> + +<%def name="title()">${"View" if form.readonly else "Edit"} ${batch_display} + +<%def name="head_tags()"> + + + + +
    + +
      +
    • ${h.link_to("Back to {0}".format(batch_display_plural), url(route_prefix))}
    • + % if not batch.executed: + % if form.updating: +
    • ${h.link_to("View this {0}".format(batch_display), url('{0}.view'.format(route_prefix), uuid=batch.uuid))}
    • + % endif + % if form.readonly and request.has_perm('{0}.edit'.format(permission_prefix)): +
    • ${h.link_to("Edit this {0}".format(batch_display), url('{0}.edit'.format(route_prefix), uuid=batch.uuid))}
    • + % endif + % endif + % if request.has_perm('{0}.delete'.format(permission_prefix)): +
    • ${h.link_to("Delete this {0}".format(batch_display), url('{0}.delete'.format(route_prefix), uuid=batch.uuid))}
    • + % endif +
    + + ${form.render(form_id='batch-form', buttons=capture(buttons))|n} + +
    + +<%def name="buttons()"> +
    + % if not form.readonly and batch.refreshable: + ${h.submit('save-refresh', "Save & Refresh Data")} + % endif + % if not batch.executed and request.has_perm('{0}.execute'.format(permission_prefix)): + ## ${h.link_to(execute_title, url('{0}.execute'.format(route_prefix), uuid=batch.uuid))} + + % endif +
    + + +
    diff --git a/tailbone/templates/batch/edit.mako b/tailbone/templates/batch/edit.mako new file mode 100644 index 00000000..d6922b7c --- /dev/null +++ b/tailbone/templates/batch/edit.mako @@ -0,0 +1,3 @@ +## -*- coding: utf-8 -*- +<%inherit file="/batch/crud.mako" /> +${parent.body()} diff --git a/tailbone/templates/batch/rows.mako b/tailbone/templates/batch/rows.mako index c7d04e57..616fc694 100644 --- a/tailbone/templates/batch/rows.mako +++ b/tailbone/templates/batch/rows.mako @@ -9,7 +9,10 @@ -

    ${h.link_to("Delete all rows matching current search", url('{0}.rows.bulk_delete'.format(route_prefix), uuid=batch.uuid))}

    + ## TODO: Fix this check for edit mode. + % if not batch.executed and request.referrer.endswith('/edit'): +

    ${h.link_to("Delete all rows matching current search", url('{0}.rows.bulk_delete'.format(route_prefix), uuid=batch.uuid))}

    + % endif diff --git a/tailbone/templates/batch/view.mako b/tailbone/templates/batch/view.mako index 63e5e16d..d6922b7c 100644 --- a/tailbone/templates/batch/view.mako +++ b/tailbone/templates/batch/view.mako @@ -1,65 +1,3 @@ ## -*- coding: utf-8 -*- -<%inherit file="/crud.mako" /> - -<%def name="title()">View ${batch_display} - -<%def name="head_tags()"> - - - - -
    - -
      -
    • ${h.link_to("Back to {0}".format(batch_display_plural), url(route_prefix))}
    • - % if not batch.executed: - % if request.has_perm('{0}.edit'.format(permission_prefix)): - ##
    • ${h.link_to("Edit this {0}".format(batch_display), url('{0}.edit'.format(route_prefix), uuid=batch.uuid))}
    • - % if batch.refreshable: -
    • ${h.link_to("Refresh Data for this {0}".format(batch_display), url('{0}.refresh'.format(route_prefix), uuid=batch.uuid))}
    • - % endif - % endif - % if request.has_perm('{0}.execute'.format(permission_prefix)): -
    • ${h.link_to("Execute this {0}".format(batch_display), url('{0}.execute'.format(route_prefix), uuid=batch.uuid))}
    • - % endif - % endif - % if request.has_perm('{0}.delete'.format(permission_prefix)): -
    • ${h.link_to("Delete this {0}".format(batch_display), url('{0}.delete'.format(route_prefix), uuid=batch.uuid))}
    • - % endif -
    - - ${form.render()|n} - -
    - -
    +<%inherit file="/batch/crud.mako" /> +${parent.body()} diff --git a/tailbone/templates/forms/form.mako b/tailbone/templates/forms/form.mako index e1407fc3..60cb633a 100644 --- a/tailbone/templates/forms/form.mako +++ b/tailbone/templates/forms/form.mako @@ -1,16 +1,20 @@ ## -*- coding: utf-8 -*-
    - ${h.form(form.action_url, enctype='multipart/form-data')} + ${h.form(form.action_url, id=form_id or None, method='post', enctype='multipart/form-data')} ${form.fieldset.render()|n} -
    - ${h.submit('create', form.create_label if form.creating else form.update_label)} - % if form.creating and form.allow_successive_creates: - ${h.submit('create_and_continue', form.successive_create_label)} - % endif - Cancel -
    + % if buttons: + ${buttons|n} + % else: +
    + ${h.submit('create', form.create_label if form.creating else form.update_label)} + % if form.creating and form.allow_successive_creates: + ${h.submit('create_and_continue', form.successive_create_label)} + % endif + Cancel +
    + % endif ${h.end_form()}
    diff --git a/tailbone/templates/forms/form_readonly.mako b/tailbone/templates/forms/form_readonly.mako index f8715630..0e4a73f8 100644 --- a/tailbone/templates/forms/form_readonly.mako +++ b/tailbone/templates/forms/form_readonly.mako @@ -1,4 +1,7 @@ ## -*- coding: utf-8 -*-
    ${form.fieldset.render()|n} + % if buttons: + ${buttons|n} + % endif
    diff --git a/tailbone/templates/vendors/invoices/create.mako b/tailbone/templates/vendors/invoices/create.mako new file mode 100644 index 00000000..2e46901c --- /dev/null +++ b/tailbone/templates/vendors/invoices/create.mako @@ -0,0 +1,3 @@ +## -*- coding: utf-8 -*- +<%inherit file="/batch/create.mako" /> +${parent.body()} diff --git a/tailbone/templates/vendors/invoices/edit.mako b/tailbone/templates/vendors/invoices/edit.mako new file mode 100644 index 00000000..d0eea0a6 --- /dev/null +++ b/tailbone/templates/vendors/invoices/edit.mako @@ -0,0 +1,3 @@ +## -*- coding: utf-8 -*- +<%inherit file="/batch/edit.mako" /> +${parent.body()} diff --git a/tailbone/templates/vendors/invoices/index.mako b/tailbone/templates/vendors/invoices/index.mako new file mode 100644 index 00000000..acddd2fb --- /dev/null +++ b/tailbone/templates/vendors/invoices/index.mako @@ -0,0 +1,3 @@ +## -*- coding: utf-8 -*- +<%inherit file="/batch/index.mako" /> +${parent.body()} diff --git a/tailbone/templates/vendors/invoices/view.mako b/tailbone/templates/vendors/invoices/view.mako new file mode 100644 index 00000000..9b89af91 --- /dev/null +++ b/tailbone/templates/vendors/invoices/view.mako @@ -0,0 +1,3 @@ +## -*- coding: utf-8 -*- +<%inherit file="/batch/view.mako" /> +${parent.body()} diff --git a/tailbone/views/batch.py b/tailbone/views/batch.py index 563540fc..c1bf97e7 100644 --- a/tailbone/views/batch.py +++ b/tailbone/views/batch.py @@ -37,7 +37,7 @@ import formalchemy from pyramid.renderers import render_to_response from pyramid.response import FileResponse from pyramid.httpexceptions import HTTPFound, HTTPNotFound -from webhelpers.html.tags import link_to +from webhelpers.html.tags import link_to, HTML from rattail.db import model from rattail.db import Session as RatSession @@ -234,9 +234,9 @@ class BatchGrid(BaseGrid): if self.request.has_perm('{0}.view'.format(self.permission_prefix)): g.viewable = True g.view_route_name = '{0}.view'.format(self.route_prefix) - # if self.request.has_perm('{0}.edit'.format(self.permission_prefix)): - # g.editable = True - # g.edit_route_name = '{0}.edit'.format(self.route_prefix) + if self.request.has_perm('{0}.edit'.format(self.permission_prefix)): + g.editable = True + g.edit_route_name = '{0}.edit'.format(self.route_prefix) if self.request.has_perm('{0}.delete'.format(self.permission_prefix)): g.deletable = True g.delete_route_name = '{0}.delete'.format(self.route_prefix) @@ -321,6 +321,12 @@ class BaseCrud(CrudView): else: super(BaseCrud, self).flash_create(model) + def flash_update(self, model): + if 'update' in self.flash: + self.request.session.flash(self.flash['update']) + else: + super(BaseCrud, self).flash_update(model) + def flash_delete(self, model): if 'delete' in self.flash: self.request.session.flash(self.flash['delete']) @@ -414,6 +420,33 @@ class BatchCrud(BaseCrud): fs.executed_by, ]) + def update(self): + """ + Don't allow editing a batch which has already been executed. + """ + batch = self.get_model_from_request() + if not batch: + return HTTPNotFound() + if batch.executed: + return HTTPFound(location=self.view_url(batch.uuid)) + return self.crud(batch) + + def post_create_url(self, form): + """ + Redirect to view batch after creating a batch. + """ + batch = form.fieldset.model + return self.view_url(batch.uuid) + + def post_update_url(self, form): + """ + Redirect back to edit batch page after editing a batch, unless the + refresh flag is set, in which case do that. + """ + if self.request.params.get('refresh') == 'true': + return self.refresh_url() + return self.request.current_route_url() + def template_kwargs(self, form): """ Add some things to the template context: current batch model, batch @@ -425,6 +458,7 @@ class BatchCrud(BaseCrud): 'batch': batch, 'batch_display': self.batch_display, 'batch_display_plural': self.batch_display_plural, + 'execute_title': self.handler.get_execute_title(batch), 'route_prefix': self.route_prefix, 'permission_prefix': self.permission_prefix, } @@ -511,6 +545,14 @@ class BatchCrud(BaseCrud): uuid = self.request.matchdict['uuid'] return self.request.route_url('{0}.view'.format(self.route_prefix), uuid=uuid) + def refresh_url(self, uuid=None): + """ + Returns the URL for refreshing a batch; defaults to current batch. + """ + if uuid is None: + uuid = self.request.matchdict['uuid'] + return self.request.route_url('{0}.refresh'.format(self.route_prefix), uuid=uuid) + def execute(self): batch = self.current_batch() if self.handler.execute(batch): @@ -561,15 +603,15 @@ class FileBatchCrud(BatchCrud): override this, but :meth:`configure_fieldset()` instead. """ fs = self.make_fieldset(model) - fs.created.set(label="Uploaded", renderer=DateTimeFieldRenderer(self.request.rattail_config)) - fs.created_by.set(label="Uploaded by", renderer=UserFieldRenderer) + fs.created.set(label="Uploaded", renderer=DateTimeFieldRenderer(self.request.rattail_config), readonly=True) + fs.created_by.set(label="Uploaded by", renderer=UserFieldRenderer, readonly=True) fs.cognized.set(renderer=DateTimeFieldRenderer(self.request.rattail_config)) fs.cognized_by.set(label="Cognized by", renderer=UserFieldRenderer) fs.executed.set(renderer=DateTimeFieldRenderer(self.request.rattail_config)) fs.executed_by.set(label="Executed by", renderer=UserFieldRenderer) fs.append(formalchemy.Field('data_file')) fs.data_file.set(renderer=formalchemy.fields.FileFieldRenderer) - fs.filename.set(renderer=DownloadLinkRenderer(self.route_prefix)) + fs.filename.set(renderer=DownloadLinkRenderer(self.route_prefix), readonly=True) self.configure_fieldset(fs) if self.creating: del fs.created @@ -660,13 +702,6 @@ class FileBatchCrud(BatchCrud): return HTTPFound(location=self.request.route_url( '{0}.create'.format(self.route_prefix))) - def post_save_url(self, form): - """ - Redirect to "view batch" after creating or updating a batch. - """ - batch = form.fieldset.model - return self.view_url(batch.uuid) - def pre_delete(self, batch): """ Delete all data (files etc.) for the batch. @@ -690,6 +725,23 @@ class FileBatchCrud(BatchCrud): return response +class StatusRenderer(EnumFieldRenderer): + """ + Custom renderer for ``status_code`` fields. Adds ``status_text`` value as + title attribute if it exists. + """ + + def render_readonly(self, **kwargs): + value = self.raw_value + if value is None: + return '' + status_code_text = self.enumeration.get(value, unicode(value)) + row = self.field.parent.model + if row.status_text: + return HTML.tag('span', title=row.status_text, c=status_code_text) + return status_code_text + + class BatchRowGrid(BaseGrid): """ Base grid view for batch rows, which can be filtered and sorted. Also it @@ -778,14 +830,16 @@ class BatchRowGrid(BaseGrid): g = self.make_grid() g.extra_row_class = self.tr_class g.sequence.set(label="Seq.") - g.status_code.set(label="Status", renderer=EnumFieldRenderer(self.row_class.STATUS)) + g.status_code.set(label="Status", renderer=StatusRenderer(self.row_class.STATUS)) self._configure_grid(g) self.configure_grid(g) batch = self.current_batch() # g.viewable = True # g.view_route_name = '{0}.rows.view'.format(self.route_prefix) - if not batch.executed and self.request.has_perm('{0}.edit'.format(self.permission_prefix)): + # TODO: Fix this check for edit mode. + edit_mode = self.request.referrer.endswith('/edit') + if edit_mode and not batch.executed and self.request.has_perm('{0}.edit'.format(self.permission_prefix)): # g.editable = True # g.edit_route_name = '{0}.rows.edit'.format(self.route_prefix) g.deletable = True diff --git a/tailbone/views/crud.py b/tailbone/views/crud.py index ce2f6203..31f03b7d 100644 --- a/tailbone/views/crud.py +++ b/tailbone/views/crud.py @@ -158,7 +158,11 @@ class CrudView(View): and self.request.params.get('create_and_continue')): return HTTPFound(location=self.request.current_route_url()) - return HTTPFound(location=self.post_save_url(form)) + if form.creating: + url = self.post_create_url(form) + else: + url = self.post_update_url(form) + return HTTPFound(location=url) self.validation_failed(form) @@ -199,6 +203,12 @@ class CrudView(View): def post_save_url(self, form): return self.home_url + def post_create_url(self, form): + return self.post_save_url(form) + + def post_update_url(self, form): + return self.post_save_url(form) + def validation_failed(self, form): pass diff --git a/tailbone/views/vendors/__init__.py b/tailbone/views/vendors/__init__.py index f41ba844..79e9d0f3 100644 --- a/tailbone/views/vendors/__init__.py +++ b/tailbone/views/vendors/__init__.py @@ -33,3 +33,4 @@ from .core import (VendorsGrid, VendorCrud, VendorVersionView, def includeme(config): config.include('tailbone.views.vendors.core') config.include('tailbone.views.vendors.catalogs') + config.include('tailbone.views.vendors.invoices') diff --git a/tailbone/views/vendors/catalogs.py b/tailbone/views/vendors/catalogs.py index 8aabc98b..46439a01 100644 --- a/tailbone/views/vendors/catalogs.py +++ b/tailbone/views/vendors/catalogs.py @@ -26,8 +26,6 @@ Views for maintaining vendor catalogs from __future__ import unicode_literals -from pyramid.httpexceptions import HTTPFound - from rattail.db import model from rattail.db.api import get_setting, get_vendor from rattail.db.batch.vendorcatalog import VendorCatalog, VendorCatalogRow diff --git a/tailbone/views/vendors/invoices.py b/tailbone/views/vendors/invoices.py new file mode 100644 index 00000000..b3df35a4 --- /dev/null +++ b/tailbone/views/vendors/invoices.py @@ -0,0 +1,191 @@ +# -*- coding: utf-8 -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2015 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 . +# +################################################################################ +""" +Views for maintaining vendor invoices +""" + +from __future__ import unicode_literals + +from rattail.db import model +from rattail.db.api import get_setting, get_vendor +from rattail.db.batch.vendorinvoice import VendorInvoice, VendorInvoiceRow +from rattail.db.batch.vendorinvoice.handler import VendorInvoiceHandler +from rattail.vendors.invoices import iter_invoice_parsers, require_invoice_parser +from rattail.util import load_object + +import formalchemy + +from tailbone.db import Session +from tailbone.views.batch import FileBatchGrid, FileBatchCrud, BatchRowGrid, BatchRowCrud, defaults + + +class VendorInvoiceGrid(FileBatchGrid): + """ + Grid view for vendor invoices. + """ + batch_class = VendorInvoice + batch_display = "Vendor Invoice" + route_prefix = 'vendors.invoices' + + def join_map_extras(self): + return {'vendor': lambda q: q.join(model.Vendor)} + + def filter_map_extras(self): + return {'vendor': self.filter_ilike(model.Vendor.name)} + + def filter_config_extras(self): + return {'filter_type_vendor': 'lk', + 'include_filter_vendor': True} + + def sort_map_extras(self): + return {'vendor': self.sorter(model.Vendor.name)} + + def configure_grid(self, g): + g.configure( + include=[ + g.created, + g.created_by, + g.vendor, + g.filename, + g.executed, + ], + readonly=True) + + +class VendorInvoiceCrud(FileBatchCrud): + """ + CRUD view for vendor invoices. + """ + batch_class = VendorInvoice + batch_handler_class = VendorInvoiceHandler + route_prefix = 'vendors.invoices' + + batch_display = "Vendor Invoice" + flash = {'create': "New vendor invoice has been uploaded.", + 'update': "Vendor invoice has been updated.", + 'delete': "Vendor invoice has been deleted."} + + def get_handler(self): + """ + Returns a `BatchHandler` instance for the view. + + Derived classes may override this, but if you only need to replace the + handler (i.e. and not the view logic) then you can instead subclass + :class:`rattail.db.batch.vendorinvoice.handler.VendorInvoiceHandler` + and create a setting named "rattail.batch.vendorinvoice.handler" in the + database, the value of which should be a spec string pointed at your + custom handler. + """ + handler = get_setting(Session, 'rattail.batch.vendorinvoice.handler') + if not handler: + handler = self.request.rattail_config.get('rattail.batch', 'vendorinvoice.handler') + if handler: + handler = load_object(handler)(self.request.rattail_config) + if not handler: + handler = super(VendorInvoiceCrud, self).get_handler() + return handler + + def configure_fieldset(self, fs): + parsers = sorted(iter_invoice_parsers(), key=lambda p: p.display) + parser_options = [(p.display, p.key) for p in parsers] + parser_options.insert(0, ("(please choose)", '')) + fs.parser_key.set(renderer=formalchemy.fields.SelectFieldRenderer, + options=parser_options) + fs.configure( + include=[ + fs.vendor.readonly(), + fs.data_file.label("Invoice File"), + fs.parser_key.label("File Type"), + fs.filename, + fs.purchase_order_number.label(self.handler.po_number_title), + fs.invoice_date.readonly(), + fs.created, + fs.created_by, + fs.executed, + fs.executed_by, + ]) + if self.creating: + del fs.vendor + del fs.invoice_date + else: + del fs.parser_key + + def init_batch(self, batch): + parser = require_invoice_parser(batch.parser_key) + vendor = get_vendor(Session, parser.vendor_key) + if not vendor: + self.request.session.flash("No vendor setting found in database for key: {0}".format(parser.vendor_key)) + return False + batch.vendor = vendor + return True + + +class VendorInvoiceRowGrid(BatchRowGrid): + """ + Grid view for vendor invoice rows. + """ + row_class = VendorInvoiceRow + route_prefix = 'vendors.invoices' + + def filter_map_extras(self): + return {'ilike': ['upc', 'brand_name', 'description', 'size', 'vendor_code']} + + def filter_config_extras(self): + return {'filter_label_upc': "UPC", + 'filter_label_brand_name': "Brand"} + + def configure_grid(self, g): + g.configure( + include=[ + g.sequence, + g.upc.label("UPC"), + g.brand_name.label("Brand"), + g.description, + g.size, + g.vendor_code, + g.shipped_cases.label("Cases"), + g.shipped_units.label("Units"), + g.unit_cost, + g.status_code, + ], + readonly=True) + + def tr_class(self, row, i): + if row.status_code in ((row.STATUS_NOT_IN_PURCHASE, + row.STATUS_NOT_IN_INVOICE, + row.STATUS_DIFFERS_FROM_PURCHASE)): + return 'notice' + if row.status_code == row.STATUS_NOT_IN_DB: + return 'warning' + + +class VendorInvoiceRowCrud(BatchRowCrud): + row_class = VendorInvoiceRow + route_prefix = 'vendors.invoices' + + +def includeme(config): + """ + Add configuration for the vendor invoice views. + """ + defaults(config, VendorInvoiceGrid, VendorInvoiceCrud, VendorInvoiceRowGrid, VendorInvoiceRowCrud, '/vendors/invoices/') From 3614254804528c9b97ae134cc886de5e4c38dbb7 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 22 Feb 2015 00:00:00 -0600 Subject: [PATCH 0203/3860] Improve data file handling for file batches. Leverages a FormAlchemy "extension" of sorts. --- tailbone/forms/renderers/batch.py | 64 ++++++++++++++++++++++++++ tailbone/views/batch.py | 74 +++++++++++++++--------------- tailbone/views/vendors/catalogs.py | 3 +- tailbone/views/vendors/invoices.py | 3 +- 4 files changed, 103 insertions(+), 41 deletions(-) create mode 100644 tailbone/forms/renderers/batch.py diff --git a/tailbone/forms/renderers/batch.py b/tailbone/forms/renderers/batch.py new file mode 100644 index 00000000..f2be63ed --- /dev/null +++ b/tailbone/forms/renderers/batch.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2015 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 . +# +################################################################################ +""" +Batch Field Renderers +""" + +from __future__ import unicode_literals + +import os +import stat +import random + +from formalchemy.ext import fsblob + + +class FileFieldRenderer(fsblob.FileFieldRenderer): + """ + Custom file field renderer for batches based on a single source data file. + In edit mode, shows a file upload field. In readonly mode, shows the + filename and its size. + """ + + @classmethod + def new(cls, view): + name = 'Configured%s_%s' % (cls.__name__, str(random.random())[2:]) + return type(str(name), (cls,), dict(view=view)) + + @property + def storage_path(self): + return self.view.upload_dir + + def get_size(self): + size = super(FileFieldRenderer, self).get_size() + if size: + return size + batch = self.field.parent.model + path = os.path.join(self.view.handler.datadir(batch), self.field.value) + if os.path.isfile(path): + return os.stat(path)[stat.ST_SIZE] + return 0 + + def get_url(self, filename): + batch = self.field.parent.model + return self.view.request.route_url('{0}.download'.format(self.view.route_prefix), uuid=batch.uuid) diff --git a/tailbone/views/batch.py b/tailbone/views/batch.py index c1bf97e7..f69e7653 100644 --- a/tailbone/views/batch.py +++ b/tailbone/views/batch.py @@ -46,6 +46,7 @@ from rattail.threads import Thread from tailbone.db import Session from tailbone.views import SearchableAlchemyGridView, CrudView from tailbone.forms import DateTimeFieldRenderer, UserFieldRenderer, EnumFieldRenderer +from tailbone.forms.renderers.batch import FileFieldRenderer from tailbone.grids.search import BooleanSearchFilter, EnumSearchFilter from tailbone.progress import SessionProgress @@ -436,6 +437,7 @@ class BatchCrud(BaseCrud): Redirect to view batch after creating a batch. """ batch = form.fieldset.model + Session.flush() return self.view_url(batch.uuid) def post_update_url(self, form): @@ -561,28 +563,6 @@ class BatchCrud(BaseCrud): return HTTPFound(location=self.view_url(batch.uuid)) -class DownloadLinkRenderer(formalchemy.FieldRenderer): - """ - Field renderer for batch filenames, shows a link to download the file. - """ - - def __init__(self, route_prefix): - self.route_prefix = route_prefix - - def __call__(self, field): - super(DownloadLinkRenderer, self).__init__(field) - return self - - def render_readonly(self, **kwargs): - filename = self.value - if not filename: - return '' - batch = self.field.parent.model - return link_to(filename, self.request.route_url( - '{0}.download'.format(self.route_prefix), - uuid=batch.uuid)) - - class FileBatchCrud(BatchCrud): """ Base CRUD view for batches which involve a file upload as the first step. @@ -597,6 +577,21 @@ class FileBatchCrud(BatchCrud): return HTTPFound(location=self.request.route_url( '{0}.refresh'.format(self.route_prefix), uuid=batch.uuid)) + @property + def upload_dir(self): + """ + The path to the root upload folder, to be used as the ``storage_path`` + argument for the file field renderer. + """ + uploads = os.path.join( + self.request.rattail_config.require('rattail', 'batch.files'), + 'uploads') + uploads = self.request.rattail_config.get( + 'tailbone', 'batch.uploads', default=uploads) + if not os.path.exists(uploads): + os.makedirs(uploads) + return uploads + def fieldset(self, model): """ Creates the fieldset for the view. Derived classes should *not* @@ -609,14 +604,11 @@ class FileBatchCrud(BatchCrud): fs.cognized_by.set(label="Cognized by", renderer=UserFieldRenderer) fs.executed.set(renderer=DateTimeFieldRenderer(self.request.rattail_config)) fs.executed_by.set(label="Executed by", renderer=UserFieldRenderer) - fs.append(formalchemy.Field('data_file')) - fs.data_file.set(renderer=formalchemy.fields.FileFieldRenderer) - fs.filename.set(renderer=DownloadLinkRenderer(self.route_prefix), readonly=True) + fs.filename.set(renderer=FileFieldRenderer.new(self), label="Data File") self.configure_fieldset(fs) if self.creating: del fs.created del fs.created_by - del fs.filename if 'cognized' in fs.render_fields: del fs.cognized if 'cognized_by' in fs.render_fields: @@ -628,8 +620,8 @@ class FileBatchCrud(BatchCrud): if 'data_rows' in fs.render_fields: del fs.data_rows else: - if 'data_file' in fs.render_fields: - del fs.data_file + if self.updating and 'filename' in fs.render_fields: + fs.filename.set(readonly=True) batch = fs.model if not batch.executed: if 'executed' in fs.render_fields: @@ -648,7 +640,6 @@ class FileBatchCrud(BatchCrud): include=[ fs.created, fs.created_by, - fs.data_file, fs.filename, # fs.cognized, # fs.cognized_by, @@ -669,13 +660,23 @@ class FileBatchCrud(BatchCrud): # For new batches, assign current user as creator, save file etc. if self.creating: batch.created_by = self.request.user - batch.filename = form.fieldset.data_file.renderer._filename - # Expunge batch from session to prevent it from being flushed. + + # Expunge batch from session to prevent it from being flushed + # during init. This is done as a convenience to views which + # provide an init method. Some batches may have required fields + # which aren't filled in yet, but the view may need to query the + # database to obtain the values. This will cause a session flush, + # and the missing fields will trigger data integrity errors. Session.expunge(batch) self.batch_inited = self.init_batch(batch) if self.batch_inited: Session.add(batch) - batch.write_file(self.request.rattail_config, form.fieldset.data_file.value) + Session.flush() + + # Handler saves a copy of the file and updates the batch filename. + path = os.path.join(self.upload_dir, batch.filename) + self.handler.set_data_file(batch, path) + os.remove(path) def init_batch(self, batch): """ @@ -716,12 +717,11 @@ class FileBatchCrud(BatchCrud): batch = self.current_batch() if not batch: return HTTPNotFound() - config = self.request.rattail_config - path = batch.filepath(config) + path = self.handler.data_path(batch) response = FileResponse(path, request=self.request) - response.headers[b'Content-Length'] = str(batch.filesize(config)) - response.headers[b'Content-Disposition'] = b'attachment; filename="{0}"'.format( - batch.filename.encode('ascii', 'replace')) + response.headers[b'Content-Length'] = str(os.path.getsize(path)) + filename = os.path.basename(batch.filename).encode('ascii', 'replace') + response.headers[b'Content-Disposition'] = b'attachment; filename="{0}"'.format(filename) return response diff --git a/tailbone/views/vendors/catalogs.py b/tailbone/views/vendors/catalogs.py index 46439a01..6f1c0b35 100644 --- a/tailbone/views/vendors/catalogs.py +++ b/tailbone/views/vendors/catalogs.py @@ -114,10 +114,9 @@ class VendorCatalogCrud(FileBatchCrud): fs.configure( include=[ fs.vendor, - fs.data_file.label("Catalog File"), + fs.filename.label("Catalog File"), fs.parser_key.label("File Type"), fs.effective, - fs.filename, fs.created, fs.created_by, fs.executed, diff --git a/tailbone/views/vendors/invoices.py b/tailbone/views/vendors/invoices.py index b3df35a4..e84106d2 100644 --- a/tailbone/views/vendors/invoices.py +++ b/tailbone/views/vendors/invoices.py @@ -114,9 +114,8 @@ class VendorInvoiceCrud(FileBatchCrud): fs.configure( include=[ fs.vendor.readonly(), - fs.data_file.label("Invoice File"), + fs.filename.label("Invoice File"), fs.parser_key.label("File Type"), - fs.filename, fs.purchase_order_number.label(self.handler.po_number_title), fs.invoice_date.readonly(), fs.created, From dba0f1fd515c9379880741de0714de7e0d882a35 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 22 Feb 2015 00:21:14 -0600 Subject: [PATCH 0204/3860] Add edit template for vendor catalog batches. --- tailbone/templates/vendors/catalogs/edit.mako | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 tailbone/templates/vendors/catalogs/edit.mako diff --git a/tailbone/templates/vendors/catalogs/edit.mako b/tailbone/templates/vendors/catalogs/edit.mako new file mode 100644 index 00000000..d0eea0a6 --- /dev/null +++ b/tailbone/templates/vendors/catalogs/edit.mako @@ -0,0 +1,3 @@ +## -*- coding: utf-8 -*- +<%inherit file="/batch/edit.mako" /> +${parent.body()} From 3e940e3c146cb162157711c53fa588e6d312f949 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 23 Feb 2015 18:47:49 -0600 Subject: [PATCH 0205/3860] Fix bug when sorting batches by 'executed by' field. Hopefully this gets it, seems like I may need an alias in there somewhere... --- tailbone/views/batch.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tailbone/views/batch.py b/tailbone/views/batch.py index f69e7653..b50c7d55 100644 --- a/tailbone/views/batch.py +++ b/tailbone/views/batch.py @@ -162,6 +162,8 @@ class BatchGrid(BaseGrid): map_ = { 'created_by': lambda q: q.join(model.User, model.User.uuid == self.batch_class.created_by_uuid), + 'executed_by': + lambda q: q.outerjoin(model.User, model.User.uuid == self.batch_class.executed_by_uuid), } map_.update(self.join_map_extras()) return map_ @@ -207,7 +209,8 @@ class BatchGrid(BaseGrid): should *not* override this, but :meth:`sort_map_extras()` instead. """ map_ = self.make_sort_map( - created_by=self.sorter(model.User.username)) + created_by=self.sorter(model.User.username), + executed_by=self.sorter(model.User.username)) map_.update(self.sort_map_extras()) return map_ From 50430e89db693c16973b07609373f42f1f5591f2 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 23 Feb 2015 20:10:21 -0600 Subject: [PATCH 0206/3860] Add better error handling when batch refresh fails, etc. Also don't force refresh when view is requested; instead just do a refresh after batch is first created. --- tailbone/views/batch.py | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/tailbone/views/batch.py b/tailbone/views/batch.py index b50c7d55..ce15223c 100644 --- a/tailbone/views/batch.py +++ b/tailbone/views/batch.py @@ -531,7 +531,18 @@ class BatchCrud(BaseCrud): # transaction binding etc. session = RatSession() batch = session.query(self.batch_class).get(batch_uuid) - self.refresh_data(session, batch, progress=progress) + try: + self.refresh_data(session, batch, progress=progress) + except Exception as error: + session.rollback() + log.exception("refreshing data for batch failed: {0}".format(batch)) + session.close() + progress.session.load() + progress.session['error'] = True + progress.session['error_msg'] = "Data refresh failed: {0}".format(error) + progress.session.save() + return + session.commit() session.refresh(batch) session.close() @@ -572,13 +583,13 @@ class FileBatchCrud(BatchCrud): """ refreshable = True - def pre_crud(self, batch): + def post_create_url(self, form): """ - Force refresh if batch has yet to be cognized. + Redirect to refresh batch after creating a batch. """ - if not self.creating and not batch.cognized: - return HTTPFound(location=self.request.route_url( - '{0}.refresh'.format(self.route_prefix), uuid=batch.uuid)) + batch = form.fieldset.model + Session.flush() + return self.refresh_url(batch.uuid) @property def upload_dir(self): From ce2b29433da6db631c1f58be03cc8af52c01732f Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 24 Feb 2015 18:57:07 -0600 Subject: [PATCH 0207/3860] Exclude 'deleted' items from reports. --- tailbone/views/reports.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tailbone/views/reports.py b/tailbone/views/reports.py index 2cb2ae88..377251c5 100644 --- a/tailbone/views/reports.py +++ b/tailbone/views/reports.py @@ -95,6 +95,7 @@ class OrderingWorksheet(View): q = Session.query(model.ProductCost) q = q.join(model.Product) + q = q.filter(model.Product.deleted == False) q = q.filter(model.ProductCost.vendor == vendor) q = q.filter(model.Product.department_uuid.in_([x.uuid for x in departments])) if preferred_only: @@ -168,6 +169,7 @@ class InventoryWorksheet(View): def get_products(subdepartment): q = Session.query(model.Product) q = q.outerjoin(model.Brand) + q = q.filter(model.Product.deleted == False) q = q.filter(model.Product.subdepartment == subdepartment) if self.request.params.get('weighted-only'): q = q.filter(model.Product.unit_of_measure == enum.UNIT_OF_MEASURE_POUND) From e11a599f923fd0d423a810208fbcc10771289a91 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 24 Feb 2015 19:55:29 -0600 Subject: [PATCH 0208/3860] Add warning status for products with missing cost in vendor invoices. --- tailbone/views/vendors/invoices.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tailbone/views/vendors/invoices.py b/tailbone/views/vendors/invoices.py index e84106d2..3836a1a2 100644 --- a/tailbone/views/vendors/invoices.py +++ b/tailbone/views/vendors/invoices.py @@ -174,7 +174,8 @@ class VendorInvoiceRowGrid(BatchRowGrid): row.STATUS_NOT_IN_INVOICE, row.STATUS_DIFFERS_FROM_PURCHASE)): return 'notice' - if row.status_code == row.STATUS_NOT_IN_DB: + if row.status_code in (row.STATUS_NOT_IN_DB, + row.STATUS_COST_NOT_IN_DB): return 'warning' From e216ed9281f823b6544b224100c2060206b9497c Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 24 Feb 2015 22:50:59 -0600 Subject: [PATCH 0209/3860] Add validation to PO number for vendor invoices. --- tailbone/views/vendors/invoices.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/tailbone/views/vendors/invoices.py b/tailbone/views/vendors/invoices.py index 3836a1a2..1fef903d 100644 --- a/tailbone/views/vendors/invoices.py +++ b/tailbone/views/vendors/invoices.py @@ -105,18 +105,34 @@ class VendorInvoiceCrud(FileBatchCrud): handler = super(VendorInvoiceCrud, self).get_handler() return handler + def validate_po_number(self, value, field): + """ + Let the invoice handler in effect determine if the user-provided + purchase order number is valid. + """ + parser = require_invoice_parser(field.parent.parser_key.value) + vendor = get_vendor(Session(), parser.vendor_key) + try: + self.handler.validate_po_number(value, vendor) + except ValueError as error: + raise formalchemy.ValidationError(unicode(error)) + def configure_fieldset(self, fs): parsers = sorted(iter_invoice_parsers(), key=lambda p: p.display) parser_options = [(p.display, p.key) for p in parsers] parser_options.insert(0, ("(please choose)", '')) fs.parser_key.set(renderer=formalchemy.fields.SelectFieldRenderer, options=parser_options) + + fs.purchase_order_number.set(label=self.handler.po_number_title) + fs.purchase_order_number.set(validate=self.validate_po_number) + fs.configure( include=[ fs.vendor.readonly(), fs.filename.label("Invoice File"), fs.parser_key.label("File Type"), - fs.purchase_order_number.label(self.handler.po_number_title), + fs.purchase_order_number, fs.invoice_date.readonly(), fs.created, fs.created_by, From 364a38a936c8465d0320140d406b07a748e91053 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 24 Feb 2015 22:51:13 -0600 Subject: [PATCH 0210/3860] Make readonly version of batch file field not show download link. --- tailbone/forms/renderers/batch.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tailbone/forms/renderers/batch.py b/tailbone/forms/renderers/batch.py index f2be63ed..3c5df44c 100644 --- a/tailbone/forms/renderers/batch.py +++ b/tailbone/forms/renderers/batch.py @@ -31,6 +31,8 @@ import stat import random from formalchemy.ext import fsblob +from formalchemy.fields import FileFieldRenderer as Base +from formalchemy.helpers import hidden_field class FileFieldRenderer(fsblob.FileFieldRenderer): @@ -62,3 +64,6 @@ class FileFieldRenderer(fsblob.FileFieldRenderer): def get_url(self, filename): batch = self.field.parent.model return self.view.request.route_url('{0}.download'.format(self.view.route_prefix), uuid=batch.uuid) + + def render(self, **kwargs): + return Base.render(self, **kwargs) From 9e7d0e177d48498daf0aaa39d1ee1d3a9d820fc7 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 24 Feb 2015 23:53:22 -0600 Subject: [PATCH 0211/3860] Don't include query string in embedded grid URL. This was causing the param list to grow each time a search happened at least, maybe more. --- tailbone/grids/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index 1fad4b14..3d9b5ef3 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -88,7 +88,7 @@ class Grid(Object): classes.append('hoverable') return format_attrs( class_=' '.join(classes), - url=self.request.current_route_url()) + url=self.request.current_route_url(_query=None)) def get_view_url(self, row): kwargs = {} From 99e11fe8d8d015e3be34fd2be98058e55fa6300d Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 26 Feb 2015 18:01:55 -0600 Subject: [PATCH 0212/3860] Hide deleted field from product details, according to permissions. --- tailbone/views/products.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index c9c69902..ef6bf258 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -291,6 +291,8 @@ class ProductCrud(CrudView): if not self.readonly: del fs.regular_price del fs.current_price + if not self.request.has_perm('products.view_deleted'): + del fs.deleted return fs def pre_crud(self, product): From d30d6f84e691f41755f9ea5273fffd040a56ebaa Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 26 Feb 2015 18:06:33 -0600 Subject: [PATCH 0213/3860] Update changelog. --- CHANGES.rst | 26 ++++++++++++++++++++++++++ setup.py | 2 +- tailbone/_version.py | 2 +- 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 7f8fa6ab..2095f21f 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,5 +1,31 @@ .. -*- coding: utf-8 -*- +0.4.6 +----- + +* Add vendor catalog batch importer. + +* Add vendor invoice batch importer. + +* Improve data file handling for file batches. + +* Add download feature for file batches. + +* Add better error handling when batch refresh fails, etc. + +* Add some docs for new batch system. + +* Refactor ``app`` module to promote code sharing. + +* Force grid table background to white. + +* Exclude 'deleted' items from reports. + +* Hide deleted field from product details, according to permissions. + +* Fix embedded grid URL query string bug. + + 0.4.5 ----- diff --git a/setup.py b/setup.py index 5e7bb602..d1391126 100644 --- a/setup.py +++ b/setup.py @@ -82,7 +82,7 @@ requires = [ 'pyramid_exclog', # 0.6 'pyramid_simpleform', # 0.6.1 'pyramid_tm', # 0.3 - 'rattail[auth,db]>=0.4.1', # 0.4.1 + 'rattail[auth,db]>=0.4.5', # 0.4.5 'transaction', # 1.2.0 'waitress', # 0.8.1 'WebHelpers', # 1.3 diff --git a/tailbone/_version.py b/tailbone/_version.py index 0862794a..a6311d48 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- -__version__ = u'0.4.5' +__version__ = u'0.4.6' From d50aef4e49717b892635364b0979e0db99789e73 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 26 Feb 2015 19:06:20 -0600 Subject: [PATCH 0214/3860] Add views for deposit links, taxes; update product view. --- tailbone/templates/depositlinks/crud.mako | 13 +++ tailbone/templates/depositlinks/index.mako | 12 +++ tailbone/templates/taxes/crud.mako | 13 +++ tailbone/templates/taxes/index.mako | 12 +++ tailbone/views/__init__.py | 2 + tailbone/views/depositlinks.py | 108 +++++++++++++++++++++ tailbone/views/products.py | 3 + tailbone/views/taxes.py | 107 ++++++++++++++++++++ 8 files changed, 270 insertions(+) create mode 100644 tailbone/templates/depositlinks/crud.mako create mode 100644 tailbone/templates/depositlinks/index.mako create mode 100644 tailbone/templates/taxes/crud.mako create mode 100644 tailbone/templates/taxes/index.mako create mode 100644 tailbone/views/depositlinks.py create mode 100644 tailbone/views/taxes.py diff --git a/tailbone/templates/depositlinks/crud.mako b/tailbone/templates/depositlinks/crud.mako new file mode 100644 index 00000000..d060d295 --- /dev/null +++ b/tailbone/templates/depositlinks/crud.mako @@ -0,0 +1,13 @@ +## -*- coding: utf-8 -*- +<%inherit file="/crud.mako" /> + +<%def name="context_menu_items()"> +
  • ${h.link_to("Back to Deposit Links", url('depositlinks'))}
  • + % if form.readonly: +
  • ${h.link_to("Edit this Deposit Link", url('depositlink.edit', uuid=form.fieldset.model.uuid))}
  • + % elif form.updating: +
  • ${h.link_to("View this Deposit Link", url('depositlink.view', uuid=form.fieldset.model.uuid))}
  • + % endif + + +${parent.body()} diff --git a/tailbone/templates/depositlinks/index.mako b/tailbone/templates/depositlinks/index.mako new file mode 100644 index 00000000..96dd7cd9 --- /dev/null +++ b/tailbone/templates/depositlinks/index.mako @@ -0,0 +1,12 @@ +## -*- coding: utf-8 -*- +<%inherit file="/grid.mako" /> + +<%def name="title()">Deposit Links + +<%def name="context_menu_items()"> + % if request.has_perm('depositlinks.create'): +
  • ${h.link_to("Create a new Deposit Link", url('depositlink.new'))}
  • + % endif + + +${parent.body()} diff --git a/tailbone/templates/taxes/crud.mako b/tailbone/templates/taxes/crud.mako new file mode 100644 index 00000000..471a1397 --- /dev/null +++ b/tailbone/templates/taxes/crud.mako @@ -0,0 +1,13 @@ +## -*- coding: utf-8 -*- +<%inherit file="/crud.mako" /> + +<%def name="context_menu_items()"> +
  • ${h.link_to("Back to Taxes", url('taxes'))}
  • + % if form.readonly: +
  • ${h.link_to("Edit this Tax", url('tax.edit', uuid=form.fieldset.model.uuid))}
  • + % elif form.updating: +
  • ${h.link_to("View this Tax", url('tax.view', uuid=form.fieldset.model.uuid))}
  • + % endif + + +${parent.body()} diff --git a/tailbone/templates/taxes/index.mako b/tailbone/templates/taxes/index.mako new file mode 100644 index 00000000..45ccc728 --- /dev/null +++ b/tailbone/templates/taxes/index.mako @@ -0,0 +1,12 @@ +## -*- coding: utf-8 -*- +<%inherit file="/grid.mako" /> + +<%def name="title()">Taxes + +<%def name="context_menu_items()"> + % if request.has_perm('taxes.create'): +
  • ${h.link_to("Create a new Tax", url('tax.new'))}
  • + % endif + + +${parent.body()} diff --git a/tailbone/views/__init__.py b/tailbone/views/__init__.py index 6a8ae08b..73514093 100644 --- a/tailbone/views/__init__.py +++ b/tailbone/views/__init__.py @@ -56,6 +56,7 @@ def includeme(config): config.include('tailbone.views.customergroups') config.include('tailbone.views.customers') config.include('tailbone.views.departments') + config.include('tailbone.views.depositlinks') config.include('tailbone.views.employees') config.include('tailbone.views.families') config.include('tailbone.views.labels') @@ -66,5 +67,6 @@ def includeme(config): config.include('tailbone.views.roles') config.include('tailbone.views.stores') config.include('tailbone.views.subdepartments') + config.include('tailbone.views.taxes') config.include('tailbone.views.users') config.include('tailbone.views.vendors') diff --git a/tailbone/views/depositlinks.py b/tailbone/views/depositlinks.py new file mode 100644 index 00000000..b7a038ea --- /dev/null +++ b/tailbone/views/depositlinks.py @@ -0,0 +1,108 @@ +# -*- coding: utf-8 -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2015 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 . +# +################################################################################ +""" +Deposit Link Views +""" + +from __future__ import unicode_literals + +from rattail.db import model + +from tailbone.views import SearchableAlchemyGridView, CrudView + + +class DepositLinksGrid(SearchableAlchemyGridView): + + mapped_class = model.DepositLink + config_prefix = 'depositlinks' + sort = 'code' + + def filter_map(self): + return self.make_filter_map(exact=['code', 'amount'], + ilike=['description']) + + def filter_config(self): + return self.make_filter_config(include_filter_description=True, + filter_type_description='lk') + + def grid(self): + g = self.make_grid() + g.configure( + include=[ + g.code, + g.description, + g.amount, + ], + readonly=True) + if self.request.has_perm('depositlinks.view'): + g.viewable = True + g.view_route_name = 'depositlink.view' + if self.request.has_perm('depositlinks.edit'): + g.editable = True + g.edit_route_name = 'depositlink.edit' + if self.request.has_perm('depositlinks.delete'): + g.deletable = True + g.delete_route_name = 'depositlink.delete' + return g + + +class DepositLinkCrud(CrudView): + + mapped_class = model.DepositLink + home_route = 'depositlinks' + + def fieldset(self, model): + fs = self.make_fieldset(model) + fs.configure( + include=[ + fs.code, + fs.description, + fs.amount, + ]) + return fs + + +def add_routes(config): + config.add_route('depositlinks', '/depositlinks') + config.add_route('depositlink.new', '/depositlinks/new') + config.add_route('depositlink.view', '/depositlinks/{uuid}') + config.add_route('depositlink.edit', '/depositlinks/{uuid}/edit') + config.add_route('depositlink.delete', '/depositlinks/{uuid}/delete') + + +def includeme(config): + add_routes(config) + + # list deposit links + config.add_view(DepositLinksGrid, route_name='depositlinks', + renderer='/depositlinks/index.mako', permission='depositlinks.view') + + # deposit link crud + config.add_view(DepositLinkCrud, attr='create', route_name='depositlink.new', + renderer='/depositlinks/crud.mako', permission='depositlinks.create') + config.add_view(DepositLinkCrud, attr='read', route_name='depositlink.view', + renderer='/depositlinks/crud.mako', permission='depositlinks.view') + config.add_view(DepositLinkCrud, attr='update', route_name='depositlink.edit', + renderer='/depositlinks/crud.mako', permission='depositlinks.edit') + config.add_view(DepositLinkCrud, attr='delete', route_name='depositlink.delete', + permission='depositlinks.delete') diff --git a/tailbone/views/products.py b/tailbone/views/products.py index ef6bf258..c270d307 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -285,6 +285,9 @@ class ProductCrud(CrudView): fs.report_code, fs.regular_price, fs.current_price, + fs.deposit_link, + fs.tax, + fs.organic, fs.not_for_sale, fs.deleted, ]) diff --git a/tailbone/views/taxes.py b/tailbone/views/taxes.py new file mode 100644 index 00000000..5696cc19 --- /dev/null +++ b/tailbone/views/taxes.py @@ -0,0 +1,107 @@ +# -*- coding: utf-8 -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2015 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 . +# +################################################################################ +""" +Tax Views +""" + +from __future__ import unicode_literals + +from rattail.db import model + +from tailbone.views import SearchableAlchemyGridView, CrudView + + +class TaxesGrid(SearchableAlchemyGridView): + + mapped_class = model.Tax + config_prefix = 'taxes' + sort = 'code' + + def filter_map(self): + return self.make_filter_map(exact=['code'], ilike=['description']) + + def filter_config(self): + return self.make_filter_config(include_filter_description=True, + filter_type_description='lk') + + def grid(self): + g = self.make_grid() + g.configure( + include=[ + g.code, + g.description, + g.rate, + ], + readonly=True) + if self.request.has_perm('taxes.view'): + g.viewable = True + g.view_route_name = 'tax.view' + if self.request.has_perm('taxes.edit'): + g.editable = True + g.edit_route_name = 'tax.edit' + if self.request.has_perm('taxes.delete'): + g.deletable = True + g.delete_route_name = 'tax.delete' + return g + + +class TaxCrud(CrudView): + + mapped_class = model.Tax + home_route = 'taxes' + + def fieldset(self, model): + fs = self.make_fieldset(model) + fs.configure( + include=[ + fs.code, + fs.description, + fs.rate, + ]) + return fs + + +def add_routes(config): + config.add_route('taxes', '/taxes') + config.add_route('tax.new', '/taxes/new') + config.add_route('tax.view', '/taxes/{uuid}') + config.add_route('tax.edit', '/taxes/{uuid}/edit') + config.add_route('tax.delete', '/taxes/{uuid}/delete') + + +def includeme(config): + add_routes(config) + + # list taxes + config.add_view(TaxesGrid, route_name='taxes', + renderer='/taxes/index.mako', permission='taxes.view') + + # tax crud + config.add_view(TaxCrud, attr='create', route_name='tax.new', + renderer='/taxes/crud.mako', permission='taxes.create') + config.add_view(TaxCrud, attr='read', route_name='tax.view', + renderer='/taxes/crud.mako', permission='taxes.view') + config.add_view(TaxCrud, attr='update', route_name='tax.edit', + renderer='/taxes/crud.mako', permission='taxes.edit') + config.add_view(TaxCrud, attr='delete', route_name='tax.delete', + permission='taxes.delete') From 6ea032c591638dc95c0ca53925c4d7973f50c616 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 26 Feb 2015 21:10:08 -0600 Subject: [PATCH 0215/3860] Add `unit_of_measure` to product detail view. --- tailbone/views/products.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index c270d307..75753dc6 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -37,9 +37,11 @@ from webhelpers.html.tags import link_to from pyramid.httpexceptions import HTTPFound from pyramid.renderers import render_to_response -from . import SearchableAlchemyGridView +from tailbone.views import SearchableAlchemyGridView +from tailbone.forms import EnumFieldRenderer import rattail.labels +from rattail import enum from rattail import sil from rattail import batches from rattail.threads import Thread @@ -269,6 +271,7 @@ class ProductCrud(CrudView): fs = self.make_fieldset(model) fs.upc.set(renderer=GPCFieldRenderer) fs.brand.set(options=[]) + fs.unit_of_measure.set(renderer=EnumFieldRenderer(enum.UNIT_OF_MEASURE)) fs.regular_price.set(renderer=PriceFieldRenderer) fs.current_price.set(renderer=PriceFieldRenderer) fs.configure( @@ -277,6 +280,7 @@ class ProductCrud(CrudView): fs.brand.with_renderer(BrandFieldRenderer), fs.description, fs.size, + fs.unit_of_measure.label("Unit of Measure"), fs.case_pack, fs.department, fs.subdepartment, From 86db5181b864e5af747a46463b6074307528ee1e Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 27 Feb 2015 01:53:36 -0600 Subject: [PATCH 0216/3860] Add some new vendor and product fields. --- tailbone/views/products.py | 3 +++ tailbone/views/vendors/core.py | 2 ++ 2 files changed, 5 insertions(+) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 75753dc6..d16759da 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -281,6 +281,7 @@ class ProductCrud(CrudView): fs.description, fs.size, fs.unit_of_measure.label("Unit of Measure"), + fs.weighed, fs.case_pack, fs.department, fs.subdepartment, @@ -292,6 +293,8 @@ class ProductCrud(CrudView): fs.deposit_link, fs.tax, fs.organic, + fs.discountable, + fs.special_order, fs.not_for_sale, fs.deleted, ]) diff --git a/tailbone/views/vendors/core.py b/tailbone/views/vendors/core.py index 96146b1b..0f2af425 100644 --- a/tailbone/views/vendors/core.py +++ b/tailbone/views/vendors/core.py @@ -89,6 +89,8 @@ class VendorCrud(CrudView): fs.id.label("ID"), fs.name, fs.special_discount, + fs.lead_time_days.label("Lead Time in Days"), + fs.order_interval_days.label("Order Interval in Days"), fs.phone.label("Phone Number").readonly(), fs.email.label("Email Address").readonly(), fs.contact.with_renderer(PersonFieldRenderer).readonly(), From 3b9efe0ffbb99b23d553977f5ea40304367a6914 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 27 Feb 2015 03:07:49 -0600 Subject: [PATCH 0217/3860] Add panels to product details view, etc. --- tailbone/static/css/layout.css | 25 +++ .../templates/forms/fieldset_readonly.mako | 10 +- tailbone/templates/forms/lib.mako | 12 ++ tailbone/templates/products/read.mako | 199 ++++++++++++------ tailbone/views/products.py | 3 +- 5 files changed, 181 insertions(+), 68 deletions(-) create mode 100644 tailbone/templates/forms/lib.mako diff --git a/tailbone/static/css/layout.css b/tailbone/static/css/layout.css index f741951e..9f087952 100644 --- a/tailbone/static/css/layout.css +++ b/tailbone/static/css/layout.css @@ -62,3 +62,28 @@ body > #body-wrapper { display: block; margin: 40px auto; } + + +/****************************** + * Panels + ******************************/ + +.panel { + border-bottom: 1px solid Black; + border-left: 1px solid Black; + border-right: 1px solid Black; + margin-bottom: 15px; + padding: 0px; +} + +.panel h2 { + border-top: 1px solid Black; + border-bottom: 1px solid Black; + margin: 0px; + padding: 5px; +} + +.panel-body { + overflow: auto; + padding: 5px; +} diff --git a/tailbone/templates/forms/fieldset_readonly.mako b/tailbone/templates/forms/fieldset_readonly.mako index b3068b3a..58aef14c 100644 --- a/tailbone/templates/forms/fieldset_readonly.mako +++ b/tailbone/templates/forms/fieldset_readonly.mako @@ -1,13 +1,7 @@ ## -*- coding: utf-8 -*- +<%namespace file="/forms/lib.mako" import="render_field_readonly" />
    % for field in fieldset.render_fields.itervalues(): - % if field.requires_label: -
    - ${field.label_tag()|n} -
    - ${field.render_readonly()} -
    -
    - % endif + ${render_field_readonly(field)} % endfor
    diff --git a/tailbone/templates/forms/lib.mako b/tailbone/templates/forms/lib.mako new file mode 100644 index 00000000..fb6067d9 --- /dev/null +++ b/tailbone/templates/forms/lib.mako @@ -0,0 +1,12 @@ +## -*- coding: utf-8 -*- + +<%def name="render_field_readonly(field)"> + % if field.requires_label: +
    + ${field.label_tag()|n} +
    + ${field.render_readonly()} +
    +
    + % endif + diff --git a/tailbone/templates/products/read.mako b/tailbone/templates/products/read.mako index f6353a61..04260832 100644 --- a/tailbone/templates/products/read.mako +++ b/tailbone/templates/products/read.mako @@ -1,81 +1,162 @@ ## -*- coding: utf-8 -*- <%inherit file="/products/crud.mako" /> +<%namespace file="/forms/lib.mako" import="render_field_readonly" /> <%def name="head_tags()"> ${parent.head_tags()} +<% product = form.fieldset.model %> + +<%def name="render_organization_fields(form)"> + ${render_field_readonly(form.fieldset.department)} + ${render_field_readonly(form.fieldset.subdepartment)} + ${render_field_readonly(form.fieldset.category)} + ${render_field_readonly(form.fieldset.family)} + ${render_field_readonly(form.fieldset.report_code)} + + +<%def name="render_price_fields(form)"> + ${render_field_readonly(form.fieldset.regular_price)} + ${render_field_readonly(form.fieldset.current_price)} + ${render_field_readonly(form.fieldset.deposit_link)} + ${render_field_readonly(form.fieldset.tax)} + + +<%def name="render_flag_fields(form)"> + ${render_field_readonly(form.fieldset.weighed)} + ${render_field_readonly(form.fieldset.discountable)} + ${render_field_readonly(form.fieldset.special_order)} + ${render_field_readonly(form.fieldset.organic)} + ${render_field_readonly(form.fieldset.not_for_sale)} + ${render_field_readonly(form.fieldset.deleted)} + +
      ${self.context_menu_items()}
    - ${form.render()|n} - - % if image: - ${h.image(image_url, "Product Image", id='product-image', path=image_path, use_pil=True)} - % endif -
    - -<% product = form.fieldset.model %> - -
    -

    Product Codes:

    - % if product.codes: -
    - - - - - - % for i, code in enumerate(product.codes, 1): - - - - % endfor - -
    Code
    ${code}
    +
    +

    Product

    +
    +
    + ${render_field_readonly(form.fieldset.upc)} + ${render_field_readonly(form.fieldset.brand)} + ${render_field_readonly(form.fieldset.description)} + ${render_field_readonly(form.fieldset.unit_size)} + ${render_field_readonly(form.fieldset.unit_of_measure)} + ${render_field_readonly(form.fieldset.size)} + ${render_field_readonly(form.fieldset.case_pack)}
    - % else: -

    None on file.

    - % endif -
    + % if image: + ${h.image(image_url, "Product Image", id='product-image', path=image_path, use_pil=True)} + % endif +
    +
    -
    -

    Product Costs:

    - % if product.costs: -
    - - - - - - - - - - - % for i, cost in enumerate(product.costs, 1): - - - - - - - - - % endfor - -
    Pref.VendorCodeCase SizeCase CostUnit Cost
    ${'X' if cost.preference == 1 else ''}${cost.vendor}${cost.code}${cost.case_size}${'$ %0.2f' % cost.case_cost if cost.case_cost is not None else ''}${'$ %0.4f' % cost.unit_cost if cost.unit_cost is not None else ''}
    +
    + +
    +

    Organization

    +
    + ${self.render_organization_fields(form)}
    - % else: -

    None on file.

    +
    + +
    +

    Flags

    +
    + ${self.render_flag_fields(form)} +
    +
    + +
    + +
    + +
    +

    Pricing

    +
    + ${self.render_price_fields(form)} +
    +
    + +
    +

    Vendor Sources

    +
    + % if product.costs: +
    + + + + + + + + + + + % for i, cost in enumerate(product.costs, 1): + + + + + + + + + % endfor + +
    Pref.VendorCodeCase SizeCase CostUnit Cost
    ${'X' if cost.preference == 1 else ''}${cost.vendor}${cost.code}${cost.case_size}${'$ %0.2f' % cost.case_cost if cost.case_cost is not None else ''}${'$ %0.4f' % cost.unit_cost if cost.unit_cost is not None else ''}
    +
    + % else: +

    None on file.

    + % endif +
    +
    + +
    +

    Additional Lookup Codes

    +
    + % if product.codes: +
    + + + + + + % for i, code in enumerate(product.codes, 1): + + + + % endfor + +
    Code
    ${code}
    +
    + % else: +

    None on file.

    + % endif +
    +
    + +
    + + % if buttons: + ${buttons|n} % endif
    diff --git a/tailbone/views/products.py b/tailbone/views/products.py index d16759da..681d4dd1 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -279,8 +279,9 @@ class ProductCrud(CrudView): fs.upc.label("UPC"), fs.brand.with_renderer(BrandFieldRenderer), fs.description, - fs.size, + fs.unit_size, fs.unit_of_measure.label("Unit of Measure"), + fs.size, fs.weighed, fs.case_pack, fs.department, From 6252c3f777857fa1441f1009032f2dd087d37708 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 27 Feb 2015 03:24:15 -0600 Subject: [PATCH 0218/3860] More tweaks to product details view. --- tailbone/static/css/grids.css | 5 ++ tailbone/static/css/layout.css | 19 +++-- tailbone/templates/products/read.mako | 104 +++++++++++++------------- 3 files changed, 71 insertions(+), 57 deletions(-) diff --git a/tailbone/static/css/grids.css b/tailbone/static/css/grids.css index b192b1b3..bb565464 100644 --- a/tailbone/static/css/grids.css +++ b/tailbone/static/css/grids.css @@ -73,6 +73,11 @@ div.grid.full table { width: 100%; } +div.grid.no-border table { + border-left: none; + border-top: none; +} + div.grid table th, div.grid table td { border-right: 1px solid black; diff --git a/tailbone/static/css/layout.css b/tailbone/static/css/layout.css index 9f087952..758faba9 100644 --- a/tailbone/static/css/layout.css +++ b/tailbone/static/css/layout.css @@ -68,19 +68,28 @@ body > #body-wrapper { * Panels ******************************/ +.panel, +.panel-grid { + border-left: 1px solid Black; + margin-bottom: 15px; +} + .panel { border-bottom: 1px solid Black; - border-left: 1px solid Black; border-right: 1px solid Black; - margin-bottom: 15px; padding: 0px; } -.panel h2 { - border-top: 1px solid Black; +.panel h2, +.panel-grid h2 { border-bottom: 1px solid Black; - margin: 0px; + border-top: 1px solid Black; padding: 5px; + margin: 0px; +} + +.panel-grid h2 { + border-right: 1px solid Black; } .panel-body { diff --git a/tailbone/templates/products/read.mako b/tailbone/templates/products/read.mako index 04260832..74e9954d 100644 --- a/tailbone/templates/products/read.mako +++ b/tailbone/templates/products/read.mako @@ -15,7 +15,7 @@ .panel-wrapper { float: left; margin-right: 15px; - width: 45%; + min-width: 45%; } @@ -96,62 +96,62 @@
    -
    +

    Vendor Sources

    -
    - % if product.costs: -
    - - - - - - - - - - - % for i, cost in enumerate(product.costs, 1): - - - - - - - - - % endfor - -
    Pref.VendorCodeCase SizeCase CostUnit Cost
    ${'X' if cost.preference == 1 else ''}${cost.vendor}${cost.code}${cost.case_size}${'$ %0.2f' % cost.case_cost if cost.case_cost is not None else ''}${'$ %0.4f' % cost.unit_cost if cost.unit_cost is not None else ''}
    -
    - % else: + % if product.costs: +
    + + + + + + + + + + + % for i, cost in enumerate(product.costs, 1): + + + + + + + + + % endfor + +
    Pref.VendorCodeCase SizeCase CostUnit Cost
    ${'X' if cost.preference == 1 else ''}${cost.vendor}${cost.code}${cost.case_size}${'$ %0.2f' % cost.case_cost if cost.case_cost is not None else ''}${'$ %0.4f' % cost.unit_cost if cost.unit_cost is not None else ''}
    +
    + % else: +

    None on file.

    - % endif -
    +
    + % endif
    -
    +

    Additional Lookup Codes

    -
    - % if product.codes: -
    - - - - - - % for i, code in enumerate(product.codes, 1): - - - - % endfor - -
    Code
    ${code}
    -
    - % else: -

    None on file.

    - % endif -
    + % if product.codes: +
    + + + + + + % for i, code in enumerate(product.codes, 1): + + + + % endfor + +
    Code
    ${code}
    +
    + % else: +
    +

    None on file.

    +
    + % endif
    From 2a8dc14e1c6bd57f78ebc945b3c14282b954fe2a Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 27 Feb 2015 03:26:27 -0600 Subject: [PATCH 0219/3860] And some more tweaks.. --- tailbone/templates/products/read.mako | 88 ++++++++++++--------------- 1 file changed, 38 insertions(+), 50 deletions(-) diff --git a/tailbone/templates/products/read.mako b/tailbone/templates/products/read.mako index 74e9954d..5e9e0aa2 100644 --- a/tailbone/templates/products/read.mako +++ b/tailbone/templates/products/read.mako @@ -98,60 +98,48 @@

    Vendor Sources

    - % if product.costs: -
    - - - - - - - - - - - % for i, cost in enumerate(product.costs, 1): - - - - - - - - - % endfor - -
    Pref.VendorCodeCase SizeCase CostUnit Cost
    ${'X' if cost.preference == 1 else ''}${cost.vendor}${cost.code}${cost.case_size}${'$ %0.2f' % cost.case_cost if cost.case_cost is not None else ''}${'$ %0.4f' % cost.unit_cost if cost.unit_cost is not None else ''}
    -
    - % else: -
    -

    None on file.

    -
    - % endif +
    + + + + + + + + + + + % for i, cost in enumerate(product.costs, 1): + + + + + + + + + % endfor + +
    Pref.VendorCodeCase SizeCase CostUnit Cost
    ${'X' if cost.preference == 1 else ''}${cost.vendor}${cost.code}${cost.case_size}${'$ %0.2f' % cost.case_cost if cost.case_cost is not None else ''}${'$ %0.4f' % cost.unit_cost if cost.unit_cost is not None else ''}
    +

    Additional Lookup Codes

    - % if product.codes: -
    - - - - - - % for i, code in enumerate(product.codes, 1): - - - - % endfor - -
    Code
    ${code}
    -
    - % else: -
    -

    None on file.

    -
    - % endif +
    + + + + + + % for i, code in enumerate(product.codes, 1): + + + + % endfor + +
    Code
    ${code}
    +
    From 2762e8e072769243c25df9b3776b844518dce2eb Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 27 Feb 2015 17:02:12 -0600 Subject: [PATCH 0220/3860] Tweak product detail layout some more. --- tailbone/templates/products/read.mako | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tailbone/templates/products/read.mako b/tailbone/templates/products/read.mako index 5e9e0aa2..8f9bcae8 100644 --- a/tailbone/templates/products/read.mako +++ b/tailbone/templates/products/read.mako @@ -15,7 +15,7 @@ .panel-wrapper { float: left; margin-right: 15px; - min-width: 45%; + min-width: 40%; } @@ -72,9 +72,9 @@
    -

    Organization

    +

    Pricing

    - ${self.render_organization_fields(form)} + ${self.render_price_fields(form)}
    @@ -90,9 +90,9 @@
    -

    Pricing

    +

    Organization

    - ${self.render_price_fields(form)} + ${self.render_organization_fields(form)}
    From d296b5bde51210436f4167b7242debd1f22c8b87 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 5 Mar 2015 16:19:38 -0600 Subject: [PATCH 0221/3860] Fix login so user is sent to their target page after authentication. --- tailbone/views/auth.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tailbone/views/auth.py b/tailbone/views/auth.py index d99fe6f0..aec51e01 100644 --- a/tailbone/views/auth.py +++ b/tailbone/views/auth.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2014 Lance Edgar +# Copyright © 2010-2015 Lance Edgar # # This file is part of Rattail. # @@ -49,11 +49,12 @@ def forbidden(request): This is triggered whenever access is not allowed for an otherwise appropriate view. """ - msg = literal("You do not have permission to do that.") if not authenticated_userid(request): msg += literal("  (Perhaps you should %s?)" % tags.link_to("log in", request.route_url('login'))) + # Store current URL in session, for smarter redirect after login. + request.session['next_url'] = request.current_route_url() request.session.flash(msg, allow_duplicate=False) url = request.referer @@ -73,7 +74,6 @@ def login(request): """ The login view, responsible for displaying and handling the login form. """ - referrer = request.get_referrer() # Redirect if already logged in. @@ -89,6 +89,8 @@ def login(request): request.session.flash("{0} logged in at {1}".format( user, localtime(request.rattail_config).strftime('%I:%M %p'))) headers = remember(request, user.uuid) + # Treat URL from session as referrer, if available. + referrer = request.session.pop('next_url', referrer) return HTTPFound(location=referrer, headers=headers) request.session.flash("Invalid username or password") From a7ecf445db63f179bae25b671a29013d511460af Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 5 Mar 2015 16:55:09 -0600 Subject: [PATCH 0222/3860] Fix login redirect if referrer is not internal to site. --- tailbone/subscribers.py | 8 +++----- tailbone/views/auth.py | 6 +----- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index fb7c5d36..23e31f3b 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.py @@ -117,11 +117,9 @@ def context_found(event): if request.session.get('referrer'): return request.session.pop('referrer') referrer = request.referrer - if not referrer or referrer == request.current_route_url(): - if default: - referrer = default - else: - referrer = request.route_url('home') + if (not referrer or referrer == request.current_route_url() + or not referrer.startswith(request.host_url)): + referrer = default or request.route_url('home') return referrer request.get_referrer = get_referrer diff --git a/tailbone/views/auth.py b/tailbone/views/auth.py index aec51e01..cc03122f 100644 --- a/tailbone/views/auth.py +++ b/tailbone/views/auth.py @@ -56,11 +56,7 @@ def forbidden(request): # Store current URL in session, for smarter redirect after login. request.session['next_url'] = request.current_route_url() request.session.flash(msg, allow_duplicate=False) - - url = request.referer - if not url or url == request.current_route_url(): - url = request.route_url('home') - return HTTPFound(location=url) + return HTTPFound(location=request.get_referrer()) class UserLogin(formencode.Schema): From d83ca4456a139cc84ba7ace8a35fe6c1ec5c3d60 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 6 Mar 2015 19:13:45 -0600 Subject: [PATCH 0223/3860] Fix bulk delete of batch rows. Actually I didn't see this fail, but I've seen one instance where someone else did. This should hopefully be a safe approach. --- tailbone/views/batch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/views/batch.py b/tailbone/views/batch.py index ce15223c..5c52a7f8 100644 --- a/tailbone/views/batch.py +++ b/tailbone/views/batch.py @@ -874,7 +874,7 @@ class BatchRowGrid(BaseGrid): """ Delete all rows matching the current row grid view query. """ - self.query().delete() + self.query().delete(synchronize_session=False) return HTTPFound(location=self.request.route_url( '{0}.view'.format(self.route_prefix), uuid=self.request.matchdict['uuid'])) From 51e4eda6624aa5f5e9bce0e51b2bfcc2749e1ade Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 6 Mar 2015 19:15:03 -0600 Subject: [PATCH 0224/3860] Don't allow edit of vendor and effective date in catalog batches. This may need to be tweaked in the future, but until then we'll be conservative about it. --- tailbone/views/vendors/catalogs.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tailbone/views/vendors/catalogs.py b/tailbone/views/vendors/catalogs.py index 6f1c0b35..5f0be4bf 100644 --- a/tailbone/views/vendors/catalogs.py +++ b/tailbone/views/vendors/catalogs.py @@ -127,6 +127,8 @@ class VendorCatalogCrud(FileBatchCrud): del fs.effective else: del fs.parser_key + fs.vendor.set(readonly=True) + fs.effective.set(readonly=True) def init_batch(self, batch): parser = require_catalog_parser(batch.parser_key) From 69a5eed83bb8d1762ceabfca68060e0fa104c776 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 7 Mar 2015 14:19:19 -0600 Subject: [PATCH 0225/3860] Add shared GPC search filter, use it for product batch rows. --- tailbone/grids/search.py | 30 +++++++++++++++++++++++++++++- tailbone/views/batch.py | 5 +++-- tailbone/views/grids/alchemy.py | 3 +++ tailbone/views/products.py | 25 +------------------------ tailbone/views/vendors/catalogs.py | 12 +++++------- 5 files changed, 41 insertions(+), 34 deletions(-) diff --git a/tailbone/grids/search.py b/tailbone/grids/search.py index de519628..2b0c01df 100644 --- a/tailbone/grids/search.py +++ b/tailbone/grids/search.py @@ -37,9 +37,11 @@ from pyramid.renderers import render from pyramid_simpleform import Form from pyramid_simpleform.renderers import FormRenderer -from rattail.core import Object from edbob.util import prettify +from rattail.core import Object +from rattail.gpc import GPC + class SearchFilter(Object): """ @@ -228,6 +230,32 @@ def filter_ilike_and_soundex(field): return filters +def filter_gpc(field): + """ + Returns a filter suitable for a GPC field. + """ + + def filter_is(q, v): + if not v: + return q + try: + return q.filter(field.in_(( + GPC(v), GPC(v, calc_check_digit='upc')))) + except ValueError: + return q + + def filter_not(q, v): + if not v: + return q + try: + return q.filter(~field.in_(( + GPC(v), GPC(v, calc_check_digit='upc')))) + except ValueError: + return q + + return {'is': filter_is, 'nt': filter_not} + + def get_filter_config(prefix, request, filter_map, **kwargs): """ Returns a configuration dictionary for a search form. diff --git a/tailbone/views/batch.py b/tailbone/views/batch.py index 5c52a7f8..1252ec00 100644 --- a/tailbone/views/batch.py +++ b/tailbone/views/batch.py @@ -891,8 +891,9 @@ class ProductBatchRowGrid(BatchRowGrid): classes should *not* override this, but :meth:`filter_map_extras()` instead. """ - return self.make_filter_map(exact=['upc', 'status_code'], - ilike=['brand_name', 'description', 'size']) + return self.make_filter_map(exact=['status_code'], + ilike=['brand_name', 'description', 'size'], + upc=self.filter_gpc(self.row_class.upc)) def filter_config(self): """ diff --git a/tailbone/views/grids/alchemy.py b/tailbone/views/grids/alchemy.py index 389f1332..ee564b8d 100644 --- a/tailbone/views/grids/alchemy.py +++ b/tailbone/views/grids/alchemy.py @@ -147,6 +147,9 @@ class SearchableAlchemyGridView(PagedAlchemyGridView): def filter_ilike_and_soundex(self, field): return grids.search.filter_ilike_and_soundex(field) + def filter_gpc(self, field): + return grids.search.filter_gpc(field) + def make_filter_map(self, **kwargs): return grids.search.get_filter_map(self.mapped_class, **kwargs) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 681d4dd1..7ebc35ad 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -123,32 +123,9 @@ class ProductsGrid(SearchableAlchemyGridView): } def filter_map(self): - - def filter_upc(): - - def filter_is(q, v): - if not v: - return q - try: - return q.filter(Product.upc.in_(( - GPC(v), GPC(v, calc_check_digit='upc')))) - except ValueError: - return q - - def filter_not(q, v): - if not v: - return q - try: - return q.filter(~Product.upc.in_(( - GPC(v), GPC(v, calc_check_digit='upc')))) - except ValueError: - return q - - return {'is': filter_is, 'nt': filter_not} - return self.make_filter_map( ilike=['description', 'size'], - upc=filter_upc(), + upc=self.filter_gpc(model.Product.upc), brand=self.filter_ilike(Brand.name), family=self.filter_ilike(model.Family.name), department=self.filter_ilike(Department.name), diff --git a/tailbone/views/vendors/catalogs.py b/tailbone/views/vendors/catalogs.py index 5f0be4bf..0d5aa8d9 100644 --- a/tailbone/views/vendors/catalogs.py +++ b/tailbone/views/vendors/catalogs.py @@ -36,7 +36,7 @@ from rattail.util import load_object import formalchemy from tailbone.db import Session -from tailbone.views.batch import FileBatchGrid, FileBatchCrud, BatchRowGrid, BatchRowCrud, defaults +from tailbone.views.batch import FileBatchGrid, FileBatchCrud, ProductBatchRowGrid, BatchRowCrud, defaults class VendorCatalogGrid(FileBatchGrid): @@ -140,7 +140,7 @@ class VendorCatalogCrud(FileBatchCrud): return True -class VendorCatalogRowGrid(BatchRowGrid): +class VendorCatalogRowGrid(ProductBatchRowGrid): """ Grid view for vendor catalog rows. """ @@ -148,11 +148,9 @@ class VendorCatalogRowGrid(BatchRowGrid): route_prefix = 'vendors.catalogs' def filter_map_extras(self): - return {'ilike': ['upc', 'brand_name', 'description', 'size', 'vendor_code']} - - def filter_config_extras(self): - return {'filter_label_upc': "UPC", - 'filter_label_brand_name': "Brand"} + map_ = super(VendorCatalogRowGrid, self).filter_map_extras() + map_.setdefault('ilike', []).append('vendor_code') + return map_ def configure_grid(self, g): g.configure( From 7c9e7cd138ecc2847811cad823fd7d844ec4fae2 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 7 Mar 2015 14:21:48 -0600 Subject: [PATCH 0226/3860] Clean up some imports. --- tailbone/views/products.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 7ebc35ad..f82f98fe 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -37,9 +37,6 @@ from webhelpers.html.tags import link_to from pyramid.httpexceptions import HTTPFound from pyramid.renderers import render_to_response -from tailbone.views import SearchableAlchemyGridView -from tailbone.forms import EnumFieldRenderer - import rattail.labels from rattail import enum from rattail import sil @@ -55,11 +52,12 @@ from rattail.db.api import get_product_by_upc from rattail.db.util import configure_session from rattail.pod import get_image_url, get_image_path -from ..db import Session -from ..forms import GPCFieldRenderer, BrandFieldRenderer, PriceFieldRenderer -from . import CrudView -from .continuum import VersionView, version_defaults -from ..progress import SessionProgress +from tailbone.views import SearchableAlchemyGridView, CrudView +from tailbone.views.continuum import VersionView, version_defaults +from tailbone.forms import EnumFieldRenderer +from tailbone.db import Session +from tailbone.forms import GPCFieldRenderer, BrandFieldRenderer, PriceFieldRenderer +from tailbone.progress import SessionProgress class ProductsGrid(SearchableAlchemyGridView): From d960738578232bf4f2ddfb93ee9f51e8c718fa16 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 10 Mar 2015 12:44:58 -0500 Subject: [PATCH 0227/3860] Add default `Grid.iter_rows()` implementation. --- tailbone/grids/core.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index 3d9b5ef3..911441ae 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -139,7 +139,13 @@ class Grid(Object): return self.fields.itervalues() def iter_rows(self): - raise NotImplementedError + """ + Iterate over the grid rows. The default implementation simply returns + an iterator over ``self.rows``; note however that by default there is + no such attribute. You must either populate that, or overrirde this + method. + """ + return iter(self.rows) def render(self, template='/grids/grid.mako', **kwargs): kwargs.setdefault('grid', self) From 666b553255a09e54e41d12ec1ac2b0daa05cfec6 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 10 Mar 2015 12:45:44 -0500 Subject: [PATCH 0228/3860] Add "save" icon and grid column style. --- tailbone/static/css/grids.css | 5 +++++ tailbone/static/img/save.png | Bin 0 -> 542 bytes 2 files changed, 5 insertions(+) create mode 100644 tailbone/static/img/save.png diff --git a/tailbone/static/css/grids.css b/tailbone/static/css/grids.css index bb565464..094effb0 100644 --- a/tailbone/static/css/grids.css +++ b/tailbone/static/css/grids.css @@ -130,6 +130,7 @@ div.grid table tbody tr td.checkbox { div.grid table tbody tr td.view, div.grid table tbody tr td.edit, +div.grid table tbody tr td.save, div.grid table tbody tr td.delete { background-repeat: no-repeat; background-position: center; @@ -147,6 +148,10 @@ div.grid table tbody tr td.edit { background-image: url(../img/edit.png); } +div.grid table tbody tr td.save { + background-image: url(../img/save.png); +} + div.grid table tbody tr td.delete { background-image: url(../img/delete.png); } diff --git a/tailbone/static/img/save.png b/tailbone/static/img/save.png new file mode 100644 index 0000000000000000000000000000000000000000..6d1463924828f4f71402dfa10f6346073687d7a5 GIT binary patch literal 542 zcmV+(0^$9MP)Px#32;bRa{vGf6951U69E94oEQKA00(qQO+^Ra0}28PDZF?(W&i*Iut`KgR4C7l zk3DM?Q51%sbMD>QS(B(ph#y!9et=+OWg&>YKf!-sp&B(0cP<;VCeo&E$MO1e;N7b? zkL1D2Uv_qFnTIz$)w~_-WsWEBXZ-l|1;1Y;VmC1@OD5I46|`Q)){$~Bjrjd(qI7|! zl_jK;w}QuV$IssdRc-jHfo8^kYdnM4fUg@v=SE$YjLY__8Z)MILxSO}#)!3%uPk$> z*IpIxnABskVE86M?uCnw3seME5CP%e3<*+#WXADK2nHS&=80V3Z&*MKL>Grb1Oo(t zWMJT(f>#hRLRqrv3Vg@o)RAQwP8AR~j=-`m07=2CfU2P)tab{n?LA{>Z$!V}=k(es zI{6X+cVFxV!4SR0;)0KEFsCj5@w4Z zVu*w$5JLnOo=Xu|7icrbb@MB+i@zNYM}*-n7f$A!JAajN;o01p@NICwU@+vv`Zkl{ gAz?IPxc>I}A9mZ$168|;0ssI207*qoM6N<$f|VfZ2LJ#7 literal 0 HcmV?d00001 From 085ce7082072a59141c088f7d74f4c942c116adf Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 10 Mar 2015 12:46:10 -0500 Subject: [PATCH 0229/3860] Add `numeric.js` script for numeric-only text inputs. --- tailbone/static/js/numeric.js | 69 +++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 tailbone/static/js/numeric.js diff --git a/tailbone/static/js/numeric.js b/tailbone/static/js/numeric.js new file mode 100644 index 00000000..50bf5eb0 --- /dev/null +++ b/tailbone/static/js/numeric.js @@ -0,0 +1,69 @@ + + +/* + * Determine if a keypress would modify the value of a textbox. + * + * Note that this implies that the keypress is also *valid* in the context of a + * numeric textbox. + * + * Returns `true` if the keypress is valid and would modify the textbox value, + * or `false` otherwise. + */ +function key_modifies(event) { + + if (event.which >= 48 && event.which <= 57) { // Numeric (QWERTY) + if (! event.shiftKey) { // shift key means punctuation instead of numeric + return true; + } + + } else if (event.which >= 96 && event.which <= 105) { // Numeric (10-Key) + return true; + + } else if (event.which == 8) { // Backspace + return true; + + } else if (event.which == 46) { // Delete + return true; + } + + return false; +} + + +/* + * Determine if a keypress is allowed in the context of a textbox. + * + * The purpose of this function is to let certain "special" keys (e.g. function + * and navigational keys) to pass through, so they may be processed as they + * would for a normal textbox. + * + * Note that this function does *not* check for keys which would actually + * modify the value of the textbox. It is assumed that the caller will have + * already used `key_modifies()` for that. + * + * Returns `true` if the keypress is allowed, or `false` otherwise. + */ +function key_allowed(event) { + + // Allow anything with modifiers (except Shift). + if (event.altKey || event.ctrlKey || event.metaKey) { + return true; + } + + // Allow function keys. + if (event.which >= 112 && event.which <= 123) { + return true; + } + + // Allow Home/End/arrow keys. + if (event.which >= 35 && event.which <= 40) { + return true; + } + + // Allow Tab key. + if (event.which == 9) { + return true; + } + + return false; +} From 42da24a047471af8865ed9b660d8d819901aeb9c Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 10 Mar 2015 15:22:39 -0500 Subject: [PATCH 0230/3860] Add product UPC to JSON output of 'products.search' view. --- tailbone/views/products.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index f82f98fe..8d46ae28 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -355,8 +355,9 @@ def products_search(request): product = None else: product = { - 'uuid': product.uuid, - 'full_description': product.full_description, + 'uuid': product.uuid, + 'upc': unicode(product.upc or ''), + 'full_description': product.full_description, } return {'product': product} From 3fed317805e8b68b880356b24916164b3426e088 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 10 Mar 2015 17:51:12 -0500 Subject: [PATCH 0231/3860] Add UI support for `Product.last_sold` and `current_price_ends` pseudo-field. --- tailbone/templates/products/read.mako | 12 ++++++++++++ tailbone/views/products.py | 12 ++++++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/tailbone/templates/products/read.mako b/tailbone/templates/products/read.mako index 8f9bcae8..1bdac00b 100644 --- a/tailbone/templates/products/read.mako +++ b/tailbone/templates/products/read.mako @@ -33,6 +33,7 @@ <%def name="render_price_fields(form)"> ${render_field_readonly(form.fieldset.regular_price)} ${render_field_readonly(form.fieldset.current_price)} + ${render_field_readonly(form.fieldset.current_price_ends)} ${render_field_readonly(form.fieldset.deposit_link)} ${render_field_readonly(form.fieldset.tax)} @@ -46,6 +47,10 @@ ${render_field_readonly(form.fieldset.deleted)} +<%def name="render_movement_fields(form)"> + ${render_field_readonly(form.fieldset.last_sold)} + +
      ${self.context_menu_items()} @@ -96,6 +101,13 @@
    +
    +

    Movement

    +
    + ${self.render_movement_fields(form)} +
    +
    +

    Vendor Sources

    diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 8d46ae28..3f7b80ec 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -32,8 +32,8 @@ import re from sqlalchemy import and_ from sqlalchemy.orm import joinedload, aliased +import formalchemy from webhelpers.html.tags import link_to - from pyramid.httpexceptions import HTTPFound from pyramid.renderers import render_to_response @@ -54,7 +54,7 @@ from rattail.pod import get_image_url, get_image_path from tailbone.views import SearchableAlchemyGridView, CrudView from tailbone.views.continuum import VersionView, version_defaults -from tailbone.forms import EnumFieldRenderer +from tailbone.forms import EnumFieldRenderer, DateTimeFieldRenderer from tailbone.db import Session from tailbone.forms import GPCFieldRenderer, BrandFieldRenderer, PriceFieldRenderer from tailbone.progress import SessionProgress @@ -249,6 +249,12 @@ class ProductCrud(CrudView): fs.unit_of_measure.set(renderer=EnumFieldRenderer(enum.UNIT_OF_MEASURE)) fs.regular_price.set(renderer=PriceFieldRenderer) fs.current_price.set(renderer=PriceFieldRenderer) + + fs.append(formalchemy.Field('current_price_ends')) + fs.current_price_ends.set(value=lambda p: p.current_price.ends if p.current_price else None) + fs.current_price_ends.set(renderer=DateTimeFieldRenderer(self.request.rattail_config)) + + fs.last_sold.set(renderer=DateTimeFieldRenderer(self.request.rattail_config)) fs.configure( include=[ fs.upc.label("UPC"), @@ -266,6 +272,7 @@ class ProductCrud(CrudView): fs.report_code, fs.regular_price, fs.current_price, + fs.current_price_ends, fs.deposit_link, fs.tax, fs.organic, @@ -273,6 +280,7 @@ class ProductCrud(CrudView): fs.special_order, fs.not_for_sale, fs.deleted, + fs.last_sold, ]) if not self.readonly: del fs.regular_price From a93b8a33fba06f0533738f491408a69fd8b1d6dd Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 11 Mar 2015 21:29:09 -0500 Subject: [PATCH 0232/3860] Update changelog. --- CHANGES.rst | 24 ++++++++++++++++++++++++ tailbone/_version.py | 2 +- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 2095f21f..29981ce6 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,5 +1,29 @@ .. -*- coding: utf-8 -*- +0.4.7 +----- + +* Add views for deposit links, taxes; update product view. + +* Add some new vendor and product fields. + +* Add panels to product details view, etc. + +* Fix login so user is sent to their target page after authentication. + +* Don't allow edit of vendor and effective date in catalog batches. + +* Add shared GPC search filter, use it for product batch rows. + +* Add default ``Grid.iter_rows()`` implementation. + +* Add "save" icon and grid column style. + +* Add ``numeric.js`` script for numeric-only text inputs. + +* Add product UPC to JSON output of 'products.search' view. + + 0.4.6 ----- diff --git a/tailbone/_version.py b/tailbone/_version.py index a6311d48..de79d584 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- -__version__ = u'0.4.6' +__version__ = u'0.4.7' From ab0c5bb45fdef893059a389d40b784e6c439ee9d Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 11 Mar 2015 23:42:40 -0500 Subject: [PATCH 0233/3860] Fix permission for deposit link list/search view. --- tailbone/views/depositlinks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/views/depositlinks.py b/tailbone/views/depositlinks.py index b7a038ea..48ffc040 100644 --- a/tailbone/views/depositlinks.py +++ b/tailbone/views/depositlinks.py @@ -95,7 +95,7 @@ def includeme(config): # list deposit links config.add_view(DepositLinksGrid, route_name='depositlinks', - renderer='/depositlinks/index.mako', permission='depositlinks.view') + renderer='/depositlinks/index.mako', permission='depositlinks.list') # deposit link crud config.add_view(DepositLinkCrud, attr='create', route_name='depositlink.new', From 5e79b132f9f1fbfe8e3fd8175f24982e67c3d9bd Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 11 Mar 2015 23:44:27 -0500 Subject: [PATCH 0234/3860] Fix permission for taxes list/search view. --- tailbone/views/taxes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/views/taxes.py b/tailbone/views/taxes.py index 5696cc19..941f2a42 100644 --- a/tailbone/views/taxes.py +++ b/tailbone/views/taxes.py @@ -94,7 +94,7 @@ def includeme(config): # list taxes config.add_view(TaxesGrid, route_name='taxes', - renderer='/taxes/index.mako', permission='taxes.view') + renderer='/taxes/index.mako', permission='taxes.list') # tax crud config.add_view(TaxCrud, attr='create', route_name='tax.new', From e82b8152a4916799106b71dac7f2fc9aa0b09a5b Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 12 Mar 2015 10:53:54 -0500 Subject: [PATCH 0235/3860] Bump rattail dependency. --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index d1391126..9c570956 100644 --- a/setup.py +++ b/setup.py @@ -82,7 +82,7 @@ requires = [ 'pyramid_exclog', # 0.6 'pyramid_simpleform', # 0.6.1 'pyramid_tm', # 0.3 - 'rattail[auth,db]>=0.4.5', # 0.4.5 + 'rattail[auth,db]>=0.4.6', # 0.4.6 'transaction', # 1.2.0 'waitress', # 0.8.1 'WebHelpers', # 1.3 From f34ae88c3993006b08eeb4dc0be4d47c98e89b26 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 12 Mar 2015 18:36:51 -0500 Subject: [PATCH 0236/3860] Update changelog. --- CHANGES.rst | 8 ++++++++ tailbone/_version.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 29981ce6..1fd3cec1 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,5 +1,13 @@ .. -*- coding: utf-8 -*- +0.4.8 +----- + +* Fix permission for deposit link list/search view. + +* Fix permission for taxes list/search view. + + 0.4.7 ----- diff --git a/tailbone/_version.py b/tailbone/_version.py index de79d584..8f38e1e7 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- -__version__ = u'0.4.7' +__version__ = u'0.4.8' From 62a93d1cd12ea45652d5794ec6bcde6929340b38 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 12 Mar 2015 19:22:06 -0500 Subject: [PATCH 0237/3860] Hide "print labels" column on products list view if so configured. --- tailbone/views/products.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 3f7b80ec..6aa394f8 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -206,11 +206,13 @@ class ProductsGrid(SearchableAlchemyGridView): g.deletable = True g.delete_route_name = 'product.delete' - q = Session.query(LabelProfile) - if q.count(): - def labels(row): - return link_to("Print", '#', class_='print-label') - g.add_column('labels', "Labels", labels) + # Maybe add Print Label column. + if self.request.rattail_config.getboolean('tailbone', 'products.print_labels', default=True): + q = Session.query(LabelProfile) + if q.count(): + def labels(row): + return link_to("Print", '#', class_='print-label') + g.add_column('labels', "Labels", labels) return g From c6ca64574b61b11bab2d22a16ffb37997e046927 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 14 Mar 2015 18:21:36 -0500 Subject: [PATCH 0238/3860] Update changelog. --- CHANGES.rst | 6 ++++++ tailbone/_version.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 1fd3cec1..9df5ba3d 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,5 +1,11 @@ .. -*- coding: utf-8 -*- +0.4.9 +----- + +* Hide "print labels" column on products list view if so configured. + + 0.4.8 ----- diff --git a/tailbone/_version.py b/tailbone/_version.py index 8f38e1e7..3a825984 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- -__version__ = u'0.4.8' +__version__ = u'0.4.9' From be41d0bb1ec59e40ead8ca68fce0d164f8b1256a Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 17 Mar 2015 14:49:20 -0500 Subject: [PATCH 0239/3860] Add 'fake_error' view to test exception handling. --- tailbone/views/__init__.py | 8 ++++++-- tailbone/views/core.py | 13 +++++++++++++ tailbone/views/grids/__init__.py | 6 ++++-- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/tailbone/views/__init__.py b/tailbone/views/__init__.py index 73514093..6d0b0c80 100644 --- a/tailbone/views/__init__.py +++ b/tailbone/views/__init__.py @@ -25,8 +25,10 @@ Pyramid Views """ -from .core import * -from .grids import * +from tailbone.views.core import View +from tailbone.views.grids import ( + GridView, AlchemyGridView, SortableAlchemyGridView, + PagedAlchemyGridView, SearchableAlchemyGridView) from .crud import * from tailbone.views.autocomplete import AutocompleteView @@ -49,6 +51,8 @@ def includeme(config): config.add_view(home, route_name='home', renderer='/home.mako') + config.include('tailbone.views.core') + config.include('tailbone.views.auth') config.include('tailbone.views.batches') config.include('tailbone.views.brands') diff --git a/tailbone/views/core.py b/tailbone/views/core.py index 0ee31566..80f94f60 100644 --- a/tailbone/views/core.py +++ b/tailbone/views/core.py @@ -37,3 +37,16 @@ class View(object): def __init__(self, request): self.request = request + + +def fake_error(request): + """ + View which raises a fake error, to test exception handling. + """ + raise Exception("Fake error, to test exception handling.") + + +def includeme(config): + config.add_route('fake_error', '/fake-error') + config.add_view(fake_error, route_name='fake_error', + permission='admin') diff --git a/tailbone/views/grids/__init__.py b/tailbone/views/grids/__init__.py index 01bfcb4e..ba3106ed 100644 --- a/tailbone/views/grids/__init__.py +++ b/tailbone/views/grids/__init__.py @@ -26,5 +26,7 @@ Grid Views """ -from .core import * -from .alchemy import * +from tailbone.views.grids.core import GridView +from tailbone.views.grids.alchemy import ( + AlchemyGridView, SortableAlchemyGridView, + PagedAlchemyGridView, SearchableAlchemyGridView) From 8285993fa694268da7307aaacab6add9108a408d Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 19 Mar 2015 01:25:38 -0500 Subject: [PATCH 0240/3860] Add ability to view details (i.e. all fields) of a batch row. --- tailbone/templates/batch/row.view.mako | 10 +++ .../templates/vendors/invoices/row.view.mako | 3 + tailbone/views/batch.py | 80 ++++++++++++++++--- tailbone/views/vendors/invoices.py | 1 + 4 files changed, 82 insertions(+), 12 deletions(-) create mode 100644 tailbone/templates/batch/row.view.mako create mode 100644 tailbone/templates/vendors/invoices/row.view.mako diff --git a/tailbone/templates/batch/row.view.mako b/tailbone/templates/batch/row.view.mako new file mode 100644 index 00000000..7fc33199 --- /dev/null +++ b/tailbone/templates/batch/row.view.mako @@ -0,0 +1,10 @@ +## -*- coding: utf-8 -*- +<%inherit file="/crud.mako" /> + +<%def name="title()">${batch_display} Row + +<%def name="context_menu_items()"> +
  • ${h.link_to("Back to {0}".format(batch_display), url('{0}.view'.format(route_prefix), uuid=row.batch_uuid))}
  • + + +${parent.body()} diff --git a/tailbone/templates/vendors/invoices/row.view.mako b/tailbone/templates/vendors/invoices/row.view.mako new file mode 100644 index 00000000..4fb9847f --- /dev/null +++ b/tailbone/templates/vendors/invoices/row.view.mako @@ -0,0 +1,3 @@ +## -*- coding: utf-8 -*- +<%inherit file="/batch/row.view.mako" /> +${parent.body()} diff --git a/tailbone/views/batch.py b/tailbone/views/batch.py index 1252ec00..8261eba0 100644 --- a/tailbone/views/batch.py +++ b/tailbone/views/batch.py @@ -147,10 +147,19 @@ class BatchGrid(BaseGrid): def mapped_class(self): return self.batch_class + @property + def batch_display(self): + """ + Singular display text for the batch type, e.g. "Vendor Invoice". + Override this as necessary. + """ + return self.batch_class.__name__ + @property def batch_display_plural(self): """ - Plural display text for the batch type. + Plural display text for the batch type, e.g. "Vendor Invoices". + Override this as necessary. """ return "{0}s".format(self.batch_display) @@ -311,6 +320,13 @@ class BaseCrud(CrudView): """ flash = {} + @property + def home_route(self): + """ + The "home" route for the batch type, i.e. its grid view. + """ + return self.route_prefix + @property def permission_prefix(self): """ @@ -362,13 +378,6 @@ class BatchCrud(BaseCrud): """ return self.route_prefix - @property - def home_route(self): - """ - The "home" route for the batch type, i.e. its grid view. - """ - return self.route_prefix - @property def batch_display_plural(self): """ @@ -780,12 +789,26 @@ class BatchRowGrid(BaseGrid): return '{0}.{1}'.format(self.mapped_class.__name__.lower(), self.request.matchdict['uuid']) + @property + def batch_class(self): + """ + Model class of the batch to which the rows belong. + """ + return self.row_class.__batch_class__ + + @property + def batch_display(self): + """ + Singular display text for the batch type, e.g. "Vendor Invoice". + Override this as necessary. + """ + return self.batch_class.__name__ + def current_batch(self): """ Return the current batch, based on the UUID within the URL. """ - batch_class = self.row_class.__batch_class__ - return Session.query(batch_class).get(self.request.matchdict['uuid']) + return Session.query(self.batch_class).get(self.request.matchdict['uuid']) def modify_query(self, q): q = super(BatchRowGrid, self).modify_query(q) @@ -849,8 +872,8 @@ class BatchRowGrid(BaseGrid): self.configure_grid(g) batch = self.current_batch() - # g.viewable = True - # g.view_route_name = '{0}.rows.view'.format(self.route_prefix) + g.viewable = True + g.view_route_name = '{0}.row.view'.format(self.route_prefix) # TODO: Fix this check for edit mode. edit_mode = self.request.referrer.endswith('/edit') if edit_mode and not batch.executed and self.request.has_perm('{0}.edit'.format(self.permission_prefix)): @@ -922,6 +945,33 @@ class BatchRowCrud(BaseCrud): def mapped_class(self): return self.row_class + @property + def batch_class(self): + """ + Model class of the batch to which the rows belong. + """ + return self.row_class.__batch_class__ + + @property + def batch_display(self): + """ + Singular display text for the batch type, e.g. "Vendor Invoice". + Override this as necessary. + """ + return self.batch_class.__name__ + + def template_kwargs(self, form): + """ + Add batch row instance etc. to template context. + """ + row = form.fieldset.model + return { + 'row': row, + 'batch_display': self.batch_display, + 'route_prefix': self.route_prefix, + 'permission_prefix': self.permission_prefix, + } + def delete(self): """ "Delete" a row from the batch. This sets the ``removed`` flag on the @@ -1003,6 +1053,12 @@ def defaults(config, batch_grid, batch_crud, row_grid, row_crud, url_prefix, renderer='/batch/rows.mako', permission='{0}.view'.format(permission_prefix)) + # view batch row + config.add_route('{0}.row.view'.format(route_prefix), '{0}row/{{uuid}}'.format(url_prefix)) + config.add_view(row_crud, attr='read', route_name='{0}.row.view'.format(route_prefix), + renderer='{0}/row.view.mako'.format(template_prefix), + permission='{0}.view'.format(permission_prefix)) + # Bulk delete batch rows config.add_route('{0}.rows.bulk_delete'.format(route_prefix), '{0}{{uuid}}/rows/delete'.format(url_prefix)) config.add_view(row_grid, attr='bulk_delete', route_name='{0}.rows.bulk_delete'.format(route_prefix), diff --git a/tailbone/views/vendors/invoices.py b/tailbone/views/vendors/invoices.py index 1fef903d..8293c2c9 100644 --- a/tailbone/views/vendors/invoices.py +++ b/tailbone/views/vendors/invoices.py @@ -198,6 +198,7 @@ class VendorInvoiceRowGrid(BatchRowGrid): class VendorInvoiceRowCrud(BatchRowCrud): row_class = VendorInvoiceRow route_prefix = 'vendors.invoices' + batch_display = "Vendor Invoice" def includeme(config): From d0bc348ce4a8ead0de4449afb8eb3b4bcc7f206e Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 19 Mar 2015 02:02:07 -0500 Subject: [PATCH 0241/3860] Fix bulk delete of batch rows, to set 'removed' flag instead. --- tailbone/views/batch.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tailbone/views/batch.py b/tailbone/views/batch.py index 8261eba0..b854fb83 100644 --- a/tailbone/views/batch.py +++ b/tailbone/views/batch.py @@ -895,9 +895,10 @@ class BatchRowGrid(BaseGrid): def bulk_delete(self): """ - Delete all rows matching the current row grid view query. + "Delete" all rows matching the current row grid view query. This sets + the ``removed`` flag on the rows but does not truly delete them. """ - self.query().delete(synchronize_session=False) + self.query().update({'removed': True}, synchronize_session=False) return HTTPFound(location=self.request.route_url( '{0}.view'.format(self.route_prefix), uuid=self.request.matchdict['uuid'])) From 366572e0a7f4920003d2f5b8f4542154e8a20a70 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 19 Mar 2015 02:25:03 -0500 Subject: [PATCH 0242/3860] Add view template for vendor catalog batch rows. --- tailbone/templates/vendors/catalogs/row.view.mako | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 tailbone/templates/vendors/catalogs/row.view.mako diff --git a/tailbone/templates/vendors/catalogs/row.view.mako b/tailbone/templates/vendors/catalogs/row.view.mako new file mode 100644 index 00000000..4fb9847f --- /dev/null +++ b/tailbone/templates/vendors/catalogs/row.view.mako @@ -0,0 +1,3 @@ +## -*- coding: utf-8 -*- +<%inherit file="/batch/row.view.mako" /> +${parent.body()} From 8a21fe7cfcbca83430fd86d5b73e2951434e6d49 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 21 Mar 2015 17:25:02 -0500 Subject: [PATCH 0243/3860] Fix vendor invoice validation bug. If user provided a PO number but no parser, an error was raised. --- tailbone/views/vendors/invoices.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tailbone/views/vendors/invoices.py b/tailbone/views/vendors/invoices.py index 8293c2c9..2bd9e624 100644 --- a/tailbone/views/vendors/invoices.py +++ b/tailbone/views/vendors/invoices.py @@ -110,7 +110,10 @@ class VendorInvoiceCrud(FileBatchCrud): Let the invoice handler in effect determine if the user-provided purchase order number is valid. """ - parser = require_invoice_parser(field.parent.parser_key.value) + parser_key = field.parent.parser_key.value + if not parser_key: + raise formalchemy.ValidationError("Cannot validate PO number until File Type is chosen") + parser = require_invoice_parser(parser_key) vendor = get_vendor(Session(), parser.vendor_key) try: self.handler.validate_po_number(value, vendor) From 84c5f0a327d14b4429f0767974c9782260a31788 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 23 Mar 2015 02:36:18 -0500 Subject: [PATCH 0244/3860] Add dept. number and friends to product details page. --- tailbone/forms/renderers/products.py | 35 +++++++++++++++++++++++++++- tailbone/views/products.py | 7 +++--- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/tailbone/forms/renderers/products.py b/tailbone/forms/renderers/products.py index f7e3580b..c1b7ce35 100644 --- a/tailbone/forms/renderers/products.py +++ b/tailbone/forms/renderers/products.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2014 Lance Edgar +# Copyright © 2010-2015 Lance Edgar # # This file is part of Rattail. # @@ -72,6 +72,39 @@ class GPCFieldRenderer(TextFieldRenderer): return '{0}-{1}'.format(gpc[:-1], gpc[-1]) +class DepartmentFieldRenderer(TextFieldRenderer): + """ + Shows the department number as well as the name. + """ + def render_readonly(self, **kwargs): + dept = self.raw_value + if dept: + return "{0} {1}".format(dept.number, dept.name) + return "" + + +class SubdepartmentFieldRenderer(TextFieldRenderer): + """ + Shows the subdepartment number as well as the name. + """ + def render_readonly(self, **kwargs): + sub = self.raw_value + if sub: + return "{0} {1}".format(sub.number, sub.name) + return "" + + +class CategoryFieldRenderer(TextFieldRenderer): + """ + Shows the category number as well as the name. + """ + def render_readonly(self, **kwargs): + cat = self.raw_value + if cat: + return "{0} {1}".format(cat.number, cat.name) + return "" + + class BrandFieldRenderer(AutocompleteFieldRenderer): """ Renderer for :class:`rattail.db.model.Brand` instance fields. diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 6aa394f8..1e5e0716 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -57,6 +57,7 @@ from tailbone.views.continuum import VersionView, version_defaults from tailbone.forms import EnumFieldRenderer, DateTimeFieldRenderer from tailbone.db import Session from tailbone.forms import GPCFieldRenderer, BrandFieldRenderer, PriceFieldRenderer +from tailbone.forms.renderers import products as forms from tailbone.progress import SessionProgress @@ -267,9 +268,9 @@ class ProductCrud(CrudView): fs.size, fs.weighed, fs.case_pack, - fs.department, - fs.subdepartment, - fs.category, + fs.department.with_renderer(forms.DepartmentFieldRenderer), + fs.subdepartment.with_renderer(forms.SubdepartmentFieldRenderer), + fs.category.with_renderer(forms.CategoryFieldRenderer), fs.family, fs.report_code, fs.regular_price, From d8790c7c4f5d321df46d411a6cea803702a25392 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 23 Mar 2015 17:23:24 -0500 Subject: [PATCH 0245/3860] Tweak display for some product fields. --- tailbone/forms/renderers/products.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tailbone/forms/renderers/products.py b/tailbone/forms/renderers/products.py index c1b7ce35..eda3f240 100644 --- a/tailbone/forms/renderers/products.py +++ b/tailbone/forms/renderers/products.py @@ -79,7 +79,7 @@ class DepartmentFieldRenderer(TextFieldRenderer): def render_readonly(self, **kwargs): dept = self.raw_value if dept: - return "{0} {1}".format(dept.number, dept.name) + return "{0} - {1}".format(dept.number, dept.name) return "" @@ -90,7 +90,7 @@ class SubdepartmentFieldRenderer(TextFieldRenderer): def render_readonly(self, **kwargs): sub = self.raw_value if sub: - return "{0} {1}".format(sub.number, sub.name) + return "{0} - {1}".format(sub.number, sub.name) return "" @@ -101,7 +101,7 @@ class CategoryFieldRenderer(TextFieldRenderer): def render_readonly(self, **kwargs): cat = self.raw_value if cat: - return "{0} {1}".format(cat.number, cat.name) + return "{0} - {1}".format(cat.number, cat.name) return "" From ef2dcee4c541fc6301bdcb220848f995952c265e Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 24 Mar 2015 13:12:24 -0500 Subject: [PATCH 0246/3860] Add "extra panels" customization hook to product details template. --- tailbone/templates/products/read.mako | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tailbone/templates/products/read.mako b/tailbone/templates/products/read.mako index 1bdac00b..e084def3 100644 --- a/tailbone/templates/products/read.mako +++ b/tailbone/templates/products/read.mako @@ -90,6 +90,8 @@
    + ${self.extra_left_panels()} +
    @@ -154,9 +156,15 @@
    + ${self.extra_right_panels()} + % if buttons: ${buttons|n} % endif + +<%def name="extra_left_panels()"> + +<%def name="extra_right_panels()"> From fd74fb041b1dacb195e827376e2540fb27be44b3 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 24 Mar 2015 13:21:07 -0500 Subject: [PATCH 0247/3860] Update changelog. --- CHANGES.rst | 16 ++++++++++++++++ tailbone/_version.py | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 9df5ba3d..40d6f7c1 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,5 +1,21 @@ .. -*- coding: utf-8 -*- +0.4.10 +------ + +* Add 'fake_error' view to test exception handling. + +* Add ability to view details (i.e. all fields) of a batch row. + +* Fix bulk delete of batch rows, to set 'removed' flag instead. + +* Fix vendor invoice validation bug. + +* Add dept. number and friends to product details page. + +* Add "extra panels" customization hook to product details template. + + 0.4.9 ----- diff --git a/tailbone/_version.py b/tailbone/_version.py index 3a825984..9473323c 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- -__version__ = u'0.4.9' +__version__ = u'0.4.10' From e43ceda6bc23d4c34b213c656944ea870aa317f5 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 6 Apr 2015 20:43:55 -0500 Subject: [PATCH 0248/3860] Fix query bugs for batch row grid views. It worked until we needed to join a table (vendor in this case). --- tailbone/views/batch.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tailbone/views/batch.py b/tailbone/views/batch.py index b854fb83..b2cc91b5 100644 --- a/tailbone/views/batch.py +++ b/tailbone/views/batch.py @@ -812,8 +812,8 @@ class BatchRowGrid(BaseGrid): def modify_query(self, q): q = super(BatchRowGrid, self).modify_query(q) - q = q.filter_by(batch=self.current_batch()) - q = q.filter_by(removed=False) + q = q.filter(self.row_class.batch == self.current_batch()) + q = q.filter(self.row_class.removed == False) return q def join_map(self): From d8ee09916aeaf945dca78b28acfd8b5276f18ce5 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 10 Apr 2015 20:25:45 -0500 Subject: [PATCH 0249/3860] Make vendor field renderer show ID in readonly mode. --- tailbone/forms/renderers/products.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tailbone/forms/renderers/products.py b/tailbone/forms/renderers/products.py index eda3f240..145659c9 100644 --- a/tailbone/forms/renderers/products.py +++ b/tailbone/forms/renderers/products.py @@ -117,9 +117,14 @@ class VendorFieldRenderer(AutocompleteFieldRenderer): """ Renderer for :class:`rattail.db.model.Vendor` instance fields. """ - service_route = 'vendors.autocomplete' + def render_readonly(self, **kwargs): + vendor = self.raw_value + if not vendor: + return '' + return "{0} - {1}".format(vendor.id, vendor.name) + class PriceFieldRenderer(TextFieldRenderer): """ From a79c89b4703e9861bbe45170fe1612db9fa2a158 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 10 Apr 2015 22:04:37 -0500 Subject: [PATCH 0250/3860] Change permission requirement for refreshing a batch's data. In the event of a create-only user role, refreshing sort of needs to be part of it. --- tailbone/views/batch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/views/batch.py b/tailbone/views/batch.py index b2cc91b5..eb048a48 100644 --- a/tailbone/views/batch.py +++ b/tailbone/views/batch.py @@ -1030,7 +1030,7 @@ def defaults(config, batch_grid, batch_crud, row_grid, row_crud, url_prefix, # Refresh batch row data config.add_route('{0}.refresh'.format(route_prefix), '{0}{{uuid}}/refresh'.format(url_prefix)) config.add_view(batch_crud, attr='refresh', route_name='{0}.refresh'.format(route_prefix), - permission='{0}.edit'.format(permission_prefix)) + permission='{0}.create'.format(permission_prefix)) # Execute batch config.add_route('{0}.execute'.format(route_prefix), '{0}{{uuid}}/execute'.format(url_prefix)) From 0c4ceefa2c71b4ce99d47f2fea97d31a29c1d80b Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 11 Apr 2015 00:22:34 -0500 Subject: [PATCH 0251/3860] Add flash message when any batch executes successfully. --- tailbone/views/batch.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tailbone/views/batch.py b/tailbone/views/batch.py index eb048a48..4ff29838 100644 --- a/tailbone/views/batch.py +++ b/tailbone/views/batch.py @@ -583,6 +583,7 @@ class BatchCrud(BaseCrud): if self.handler.execute(batch): batch.executed = datetime.datetime.utcnow() batch.executed_by = self.request.user + self.request.session.flash("Batch was executed successfully.") return HTTPFound(location=self.view_url(batch.uuid)) From 6db88edb6882ec308f9372c002b047cf9cb2fed6 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 11 Apr 2015 00:23:31 -0500 Subject: [PATCH 0252/3860] Add autocomplete view for current employees. --- tailbone/views/employees.py | 45 +++++++++++++++++++++++++++++-------- 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/tailbone/views/employees.py b/tailbone/views/employees.py index 042c3a50..3b630f51 100644 --- a/tailbone/views/employees.py +++ b/tailbone/views/employees.py @@ -1,9 +1,8 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- +# -*- coding: utf-8 -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2012 Lance Edgar +# Copyright © 2010-2015 Lance Edgar # # This file is part of Rattail. # @@ -21,13 +20,19 @@ # along with Rattail. If not, see . # ################################################################################ - """ Employee Views """ +from __future__ import unicode_literals + from sqlalchemy import and_ +from rattail.db import model +from rattail import enum + +from tailbone.views import AutocompleteView + from . import SearchableAlchemyGridView, CrudView from ..grids.search import EnumSearchFilter from ..forms import AssociationProxyField, EnumFieldRenderer @@ -36,6 +41,7 @@ from rattail.db.model import ( from rattail.enum import EMPLOYEE_STATUS, EMPLOYEE_STATUS_CURRENT + class EmployeesGrid(SearchableAlchemyGridView): mapped_class = Employee @@ -151,12 +157,29 @@ class EmployeeCrud(CrudView): return fs +class EmployeesAutocomplete(AutocompleteView): + """ + Autocomplete view for the Employee model, but restricted to return only + results for current employees. + """ + mapped_class = model.Person + fieldname = 'display_name' + + def filter_query(self, q): + return q.join(model.Employee)\ + .filter(model.Employee.status == enum.EMPLOYEE_STATUS_CURRENT) + + def value(self, person): + return person.employee.uuid + + def add_routes(config): - config.add_route('employees', '/employees') - config.add_route('employee.create', '/employees/new') - config.add_route('employee.read', '/employees/{uuid}') - config.add_route('employee.update', '/employees/{uuid}/edit') - config.add_route('employee.delete', '/employees/{uuid}/delete') + config.add_route('employees', '/employees') + config.add_route('employee.create', '/employees/new') + config.add_route('employees.autocomplete', '/employees/autocomplete') + config.add_route('employee.read', '/employees/{uuid}') + config.add_route('employee.update', '/employees/{uuid}/edit') + config.add_route('employee.delete', '/employees/{uuid}/delete') def includeme(config): @@ -176,3 +199,7 @@ def includeme(config): permission='employees.update') config.add_view(EmployeeCrud, attr='delete', route_name='employee.delete', permission='employees.delete') + + # autocomplete + config.add_view(EmployeesAutocomplete, route_name='employees.autocomplete', + renderer='json', permission='employees.list') From 8c5f03da8ca27d4273bce552ee31f6c5d578593f Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 11 Apr 2015 00:23:54 -0500 Subject: [PATCH 0253/3860] Add autocomplete employee field renderer. --- tailbone/forms/renderers/__init__.py | 1 + tailbone/forms/renderers/employees.py | 42 +++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 tailbone/forms/renderers/employees.py diff --git a/tailbone/forms/renderers/__init__.py b/tailbone/forms/renderers/__init__.py index 146fdaf0..c37b58fd 100644 --- a/tailbone/forms/renderers/__init__.py +++ b/tailbone/forms/renderers/__init__.py @@ -32,4 +32,5 @@ from tailbone.forms.renderers.common import ( ) from .people import * +from .employees import EmployeeFieldRenderer from .products import * diff --git a/tailbone/forms/renderers/employees.py b/tailbone/forms/renderers/employees.py new file mode 100644 index 00000000..0a9d7e55 --- /dev/null +++ b/tailbone/forms/renderers/employees.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2015 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 . +# +################################################################################ +""" +Employee Field Renderers +""" + +from __future__ import unicode_literals + +from .common import AutocompleteFieldRenderer + + +class EmployeeFieldRenderer(AutocompleteFieldRenderer): + """ + Renderer for :class:`rattail.db.model.Employee` instance fields. + """ + service_route = 'employees.autocomplete' + + def render_readonly(self, **kwargs): + employee = self.raw_value + if not employee: + return '' + return unicode(employee.person) From 7c2b406d0d24544a44853c031c96eafaaa8c3281 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 11 Apr 2015 03:33:01 -0500 Subject: [PATCH 0254/3860] Fix usage of `Product.unit_of_measure` vs. `Product.weighed`. --- tailbone/reports/ordering_worksheet.mako | 2 +- tailbone/views/reports.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tailbone/reports/ordering_worksheet.mako b/tailbone/reports/ordering_worksheet.mako index 7870fa3f..f6a97dc6 100644 --- a/tailbone/reports/ordering_worksheet.mako +++ b/tailbone/reports/ordering_worksheet.mako @@ -111,7 +111,7 @@ ${cost.product.brand or ''} ${cost.product.description} ${cost.product.size or ''} - ${cost.case_size} ${rattail.enum.UNIT_OF_MEASURE.get(cost.product.unit_of_measure, '')} + ${cost.case_size} ${"LB" if cost.product.weighed else "EA"} ${cost.code or ''} ${'X' if cost.preference == 1 else ''} % for i in range(14): diff --git a/tailbone/views/reports.py b/tailbone/views/reports.py index 377251c5..6a8df2a2 100644 --- a/tailbone/views/reports.py +++ b/tailbone/views/reports.py @@ -172,7 +172,7 @@ class InventoryWorksheet(View): q = q.filter(model.Product.deleted == False) q = q.filter(model.Product.subdepartment == subdepartment) if self.request.params.get('weighted-only'): - q = q.filter(model.Product.unit_of_measure == enum.UNIT_OF_MEASURE_POUND) + q = q.filter(model.Product.weighed == True) if self.request.params.get('exclude-not-for-sale'): q = q.filter(model.Product.not_for_sale == False) q = q.order_by(model.Brand.name, model.Product.description) From 2fe1d49ff9967c79aec763f703e153af81dbd904 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 14 Apr 2015 01:29:24 -0500 Subject: [PATCH 0255/3860] Tweak old-style batch execution call. Need to provide config so we can remove more edbob cruft. --- tailbone/views/batches/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/views/batches/core.py b/tailbone/views/batches/core.py index 6a0cf6cb..2e98dd85 100644 --- a/tailbone/views/batches/core.py +++ b/tailbone/views/batches/core.py @@ -143,7 +143,7 @@ class ExecuteBatch(View): configure_session(self.request.rattail_config, session) batch = session.merge(batch) - if not batch.execute(progress): + if not batch.execute(self.request.rattail_config, progress): session.rollback() session.close() return From 23f491c4415cfb3618c4faca954f6b294bd2ff4e Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 14 Apr 2015 01:29:24 -0500 Subject: [PATCH 0256/3860] Tweak old-style batch execution call. Need to provide config so we can remove more edbob cruft. --- tailbone/views/batches/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/views/batches/core.py b/tailbone/views/batches/core.py index 6a0cf6cb..2e98dd85 100644 --- a/tailbone/views/batches/core.py +++ b/tailbone/views/batches/core.py @@ -143,7 +143,7 @@ class ExecuteBatch(View): configure_session(self.request.rattail_config, session) batch = session.merge(batch) - if not batch.execute(progress): + if not batch.execute(self.request.rattail_config, progress): session.rollback() session.close() return From 1c15f96c65a245674dc67bd28f1cf2a52237d1ee Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 14 Apr 2015 15:08:05 -0500 Subject: [PATCH 0257/3860] Update changelog. --- CHANGES.rst | 6 ++++++ tailbone/_version.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 40d6f7c1..6ba29d22 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,5 +1,11 @@ .. -*- coding: utf-8 -*- +0.4.11 +------ + +* Tweak old-style batch execution call. + + 0.4.10 ------ diff --git a/tailbone/_version.py b/tailbone/_version.py index 9473323c..218dc802 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- -__version__ = u'0.4.10' +__version__ = u'0.4.11' From b30549cab615398bc46c430764e6035c13971435 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 14 Apr 2015 15:38:57 -0500 Subject: [PATCH 0258/3860] Fix bug when creating batch from product query. Caused by some refactoring to remove edbob cruft. --- tailbone/views/products.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 1e5e0716..634b2f90 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -431,7 +431,7 @@ class CreateProductsBatch(ProductsGrid): if self.request.POST: provider = self.request.POST.get('provider') if provider: - provider = batches.get_provider(provider) + provider = batches.get_provider(self.request.rattail_config, provider) if provider: if self.request.POST.get('params') == 'True': From 87708c755b5ac7703b3b0db1a546b8fd2f0415dd Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 14 Apr 2015 15:40:28 -0500 Subject: [PATCH 0259/3860] Update changelog. --- CHANGES.rst | 6 ++++++ tailbone/_version.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 6ba29d22..4b066a92 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,5 +1,11 @@ .. -*- coding: utf-8 -*- +0.4.12 +------ + +* Fix bug when creating batch from product query. + + 0.4.11 ------ diff --git a/tailbone/_version.py b/tailbone/_version.py index 218dc802..72061ae0 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- -__version__ = u'0.4.11' +__version__ = u'0.4.12' From 5161371e378aef1d73d3000f3c4b37eac52855f5 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 21 Apr 2015 21:34:46 -0500 Subject: [PATCH 0260/3860] Update changelog. --- CHANGES.rst | 18 ++++++++++++++++++ tailbone/_version.py | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 4b066a92..5a9d7fe6 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,5 +1,23 @@ .. -*- coding: utf-8 -*- +0.4.13 +------ + +* Fix query bugs for batch row grid views (add join support). + +* Make vendor field renderer show ID in readonly mode. + +* Change permission requirement for refreshing a batch's data. + +* Add flash message when any batch executes successfully. + +* Add autocomplete view for current employees. + +* Add autocomplete employee field renderer. + +* Fix usage of ``Product.unit_of_measure`` vs. ``Product.weighed``. + + 0.4.12 ------ diff --git a/tailbone/_version.py b/tailbone/_version.py index 72061ae0..cca1d6c1 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- -__version__ = u'0.4.12' +__version__ = u'0.4.13' From 2f5f9c8c3cca2ac0ed56ba644351feea7bd49d84 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 2 May 2015 20:39:03 -0500 Subject: [PATCH 0261/3860] Make anchor tags with 'button' class render as jQuery UI buttons. --- tailbone/static/js/tailbone.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/static/js/tailbone.js b/tailbone/static/js/tailbone.js index 515e2277..fb84dd4b 100644 --- a/tailbone/static/js/tailbone.js +++ b/tailbone/static/js/tailbone.js @@ -108,7 +108,7 @@ $(function() { /* * Fix buttons. */ - $('button').button(); + $('button, a.button').button(); $('input[type=submit]').button(); $('input[type=reset]').button(); From 4f5c0e6bd8c214ca17cc8e9f3515e32e754e718b Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 3 May 2015 19:36:19 -0500 Subject: [PATCH 0262/3860] Tweak `app.make_rattail_config()` to allow caller to define some settings. This is mostly for the sake of tests etc. --- tailbone/app.py | 36 ++++++++++++++++++++-------------- tests/data/tailbone.conf | 8 ++++++++ tests/test_app.py | 42 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 15 deletions(-) create mode 100644 tests/data/tailbone.conf create mode 100644 tests/test_app.py diff --git a/tailbone/app.py b/tailbone/app.py index 20ce4b80..28d64f00 100644 --- a/tailbone/app.py +++ b/tailbone/app.py @@ -55,22 +55,28 @@ def make_rattail_config(settings): """ Make a Rattail config object from the given settings. """ - # Initialize rattail config and embed it in the settings dict, to make it - # available to web requests later. - path = settings.get('edbob.config') - if not path or not os.path.exists(path): - raise ConfigurationError("Please set 'edbob.config' in [app:main] section of config " - "to the path of your config file. Lame, but necessary.") - edbob.init('rattail', path) - log.info("using rattail config file: {0}".format(path)) - rattail_config = RattailConfig(edbob.config) - settings['rattail_config'] = rattail_config + rattail_config = settings.get('rattail_config') + if not rattail_config: - # Load all Rattail database engines from config, and store in settings - # dict. This is necessary e.g. in the case of a host server, to have - # access to its subordinate store servers. - rattail_engines = get_engines(rattail_config) - settings['rattail_engines'] = rattail_engines + # Initialize rattail config and embed it in the settings dict, to make it + # available to web requests later. + path = settings.get('edbob.config') + if not path or not os.path.exists(path): + raise ConfigurationError("Please set 'edbob.config' in [app:main] section of config " + "to the path of your config file. Lame, but necessary.") + edbob.init('rattail', path) + log.info("using rattail config file: {0}".format(path)) + rattail_config = RattailConfig(edbob.config) + settings['rattail_config'] = rattail_config + + rattail_engines = settings.get('rattail_engines') + if not rattail_engines: + + # Load all Rattail database engines from config, and store in settings + # dict. This is necessary e.g. in the case of a host server, to have + # access to its subordinate store servers. + rattail_engines = get_engines(rattail_config) + settings['rattail_engines'] = rattail_engines # Configure the database session classes. Note that most of the time we'll # be using the Tailbone Session, but occasionally (e.g. within batch diff --git a/tests/data/tailbone.conf b/tests/data/tailbone.conf new file mode 100644 index 00000000..4bdf2b74 --- /dev/null +++ b/tests/data/tailbone.conf @@ -0,0 +1,8 @@ + +[app:main] +edbob.config = %(here)s/tailbone.conf + +[rattail.db] +keys = default, store +default.url = sqlite:// +store.url = sqlite:// diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 00000000..33962fd0 --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- + +from __future__ import unicode_literals + +import os +from unittest import TestCase + +from sqlalchemy import create_engine + +from rattail.config import RattailConfig +from rattail.exceptions import ConfigurationError +from rattail.db import Session as RattailSession + +from tailbone import app +from tailbone.db import Session as TailboneSession + + +class TestRattailConfig(TestCase): + + config_path = os.path.abspath( + os.path.join(os.path.dirname(__file__), 'data', 'tailbone.conf')) + + def tearDown(self): + # may or may not be necessary depending on test + TailboneSession.remove() + + def test_settings_arg_must_include_config_path_by_default(self): + # error raised if path not provided + self.assertRaises(ConfigurationError, app.make_rattail_config, {}) + # get a config object if path provided + result = app.make_rattail_config({'edbob.config': self.config_path}) + self.assertTrue(isinstance(result, RattailConfig)) + + def test_settings_arg_may_override_config_and_engines(self): + rattail_config = RattailConfig() + engine = create_engine('sqlite://') + result = app.make_rattail_config({ + 'rattail_config': rattail_config, + 'rattail_engines': {'default': engine}}) + self.assertTrue(result is rattail_config) + self.assertTrue(RattailSession.kw['bind'] is engine) + self.assertTrue(TailboneSession.bind is engine) From fcfe5f64428fbe710d2bccff2404e5b121138401 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 19 May 2015 22:18:21 -0500 Subject: [PATCH 0263/3860] Add `display_name` field to employee CRUD view. --- tailbone/views/employees.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tailbone/views/employees.py b/tailbone/views/employees.py index 3b630f51..f644c2ea 100644 --- a/tailbone/views/employees.py +++ b/tailbone/views/employees.py @@ -150,6 +150,7 @@ class EmployeeCrud(CrudView): fs.id.label("ID"), fs.first_name, fs.last_name, + fs.display_name, fs.phone.label("Phone Number").readonly(), fs.email.label("Email Address").readonly(), fs.status.with_renderer(EnumFieldRenderer(EMPLOYEE_STATUS)), From fb8fab1577c5bb0808f304c9629f91969ce1d832 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 3 Jul 2015 17:48:53 -0500 Subject: [PATCH 0264/3860] Tweak logic for removing certain form fields when creating a batch. Just to be a little more on the safe side. --- tailbone/views/batch.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tailbone/views/batch.py b/tailbone/views/batch.py index 4ff29838..91264cb5 100644 --- a/tailbone/views/batch.py +++ b/tailbone/views/batch.py @@ -631,8 +631,10 @@ class FileBatchCrud(BatchCrud): fs.filename.set(renderer=FileFieldRenderer.new(self), label="Data File") self.configure_fieldset(fs) if self.creating: - del fs.created - del fs.created_by + if 'created' in fs.render_fields: + del fs.created + if 'created_by' in fs.render_fields: + del fs.created_by if 'cognized' in fs.render_fields: del fs.cognized if 'cognized_by' in fs.render_fields: From 4290f0d8df7507af0503541537ea6928d97f118b Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 9 Jul 2015 01:42:19 -0500 Subject: [PATCH 0265/3860] Allow batch view to disable the Execute button in some cases. Although this only disables the UI button element, it doesn't really prevent anything beyond that... --- tailbone/templates/batch/crud.mako | 2 +- tailbone/views/batch.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/tailbone/templates/batch/crud.mako b/tailbone/templates/batch/crud.mako index b7ed9353..b4f1896d 100644 --- a/tailbone/templates/batch/crud.mako +++ b/tailbone/templates/batch/crud.mako @@ -70,7 +70,7 @@ % endif % if not batch.executed and request.has_perm('{0}.execute'.format(permission_prefix)): ## ${h.link_to(execute_title, url('{0}.execute'.format(route_prefix), uuid=batch.uuid))} - + % endif diff --git a/tailbone/views/batch.py b/tailbone/views/batch.py index 91264cb5..e6d1f9d7 100644 --- a/tailbone/views/batch.py +++ b/tailbone/views/batch.py @@ -473,6 +473,7 @@ class BatchCrud(BaseCrud): 'batch_display': self.batch_display, 'batch_display_plural': self.batch_display_plural, 'execute_title': self.handler.get_execute_title(batch), + 'execute_enabled': True, 'route_prefix': self.route_prefix, 'permission_prefix': self.permission_prefix, } From dc1ef65441863de9d2650ea3fa5923304ef38e54 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 9 Jul 2015 13:38:54 -0500 Subject: [PATCH 0266/3860] Let batch handler determine whether Execute button is enabled. --- tailbone/views/batch.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tailbone/views/batch.py b/tailbone/views/batch.py index e6d1f9d7..7d9face6 100644 --- a/tailbone/views/batch.py +++ b/tailbone/views/batch.py @@ -473,11 +473,14 @@ class BatchCrud(BaseCrud): 'batch_display': self.batch_display, 'batch_display_plural': self.batch_display_plural, 'execute_title': self.handler.get_execute_title(batch), - 'execute_enabled': True, + 'execute_enabled': self.executable(batch), 'route_prefix': self.route_prefix, 'permission_prefix': self.permission_prefix, } + def executable(self, batch): + return self.handler.executable(batch) + def flash_create(self, batch): if 'create' in self.flash: self.request.session.flash(self.flash['create']) From 5cbccb175a7cec23257cea2f34756f6e21ea378b Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 9 Jul 2015 15:45:54 -0500 Subject: [PATCH 0267/3860] Only check executability of a batch when not creating one. There is no batch yet when creating, so execution is not relevant. --- tailbone/views/batch.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tailbone/views/batch.py b/tailbone/views/batch.py index 7d9face6..99f84fab 100644 --- a/tailbone/views/batch.py +++ b/tailbone/views/batch.py @@ -468,15 +468,17 @@ class BatchCrud(BaseCrud): """ batch = form.fieldset.model batch.refreshable = self.refreshable - return { + kwargs = { 'batch': batch, 'batch_display': self.batch_display, 'batch_display_plural': self.batch_display_plural, 'execute_title': self.handler.get_execute_title(batch), - 'execute_enabled': self.executable(batch), 'route_prefix': self.route_prefix, 'permission_prefix': self.permission_prefix, } + if not self.creating: + kwargs['execute_enabled'] = self.executable(batch) + return kwargs def executable(self, batch): return self.handler.executable(batch) From 21486a5e55fdd28d24561658cf2b3ecbfaebcdf2 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 15 Jul 2015 23:18:53 -0500 Subject: [PATCH 0268/3860] Add `StoreFieldRenderer`. Also try to set a good pattern for going forward.. --- tailbone/app.py | 4 +-- tailbone/forms/__init__.py | 8 +++--- tailbone/forms/renderers/__init__.py | 6 +++- tailbone/forms/renderers/stores.py | 41 ++++++++++++++++++++++++++++ 4 files changed, 52 insertions(+), 7 deletions(-) create mode 100644 tailbone/forms/renderers/stores.py diff --git a/tailbone/app.py b/tailbone/app.py index 28d64f00..7ba66730 100644 --- a/tailbone/app.py +++ b/tailbone/app.py @@ -45,7 +45,7 @@ from pyramid.authentication import SessionAuthenticationPolicy import tailbone.db from tailbone.auth import TailboneAuthorizationPolicy -from tailbone.forms import GPCFieldRenderer +from tailbone.forms import renderers log = logging.getLogger(__name__) @@ -119,7 +119,7 @@ def make_pyramid_config(settings): # Configure FormAlchemy. formalchemy.config.engine = TemplateEngine() - formalchemy.FieldSet.default_renderers[GPCType] = GPCFieldRenderer + formalchemy.FieldSet.default_renderers[GPCType] = renderers.GPCFieldRenderer return config diff --git a/tailbone/forms/__init__.py b/tailbone/forms/__init__.py index cb07088e..4d89dde9 100644 --- a/tailbone/forms/__init__.py +++ b/tailbone/forms/__init__.py @@ -1,9 +1,8 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- +# -*- coding: utf-8 -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2012 Lance Edgar +# Copyright © 2010-2015 Lance Edgar # # This file is part of Rattail. # @@ -21,7 +20,6 @@ # along with Rattail. If not, see . # ################################################################################ - """ Forms """ @@ -30,3 +28,5 @@ from .simpleform import * from .alchemy import * from .fields import * from .renderers import * + +from tailbone.forms import renderers diff --git a/tailbone/forms/renderers/__init__.py b/tailbone/forms/renderers/__init__.py index c37b58fd..77645e73 100644 --- a/tailbone/forms/renderers/__init__.py +++ b/tailbone/forms/renderers/__init__.py @@ -33,4 +33,8 @@ from tailbone.forms.renderers.common import ( from .people import * from .employees import EmployeeFieldRenderer -from .products import * + +from tailbone.forms.renderers.products import GPCFieldRenderer +from tailbone.forms.renderers.products import * + +from tailbone.forms.renderers.stores import StoreFieldRenderer diff --git a/tailbone/forms/renderers/stores.py b/tailbone/forms/renderers/stores.py new file mode 100644 index 00000000..bbe1545c --- /dev/null +++ b/tailbone/forms/renderers/stores.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2015 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 . +# +################################################################################ +""" +Store Field Renderers +""" + +from __future__ import unicode_literals + +from formalchemy.fields import SelectFieldRenderer + + +class StoreFieldRenderer(SelectFieldRenderer): + """ + Renderer for :class:`rattail.db.model.Store` instance fields. + """ + + def render_readonly(self, **kwargs): + store = self.raw_value + if not store: + return '' + return '{0} - {1}'.format(store.id, store.name) From bafa1a0fd7ed5570c4703f2e4849bd89247a08f9 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 15 Jul 2015 23:19:29 -0500 Subject: [PATCH 0269/3860] Tweak how default filter config is handled for batch grid views. Not sure I fully understand what happened but this seemed to fix it.. --- tailbone/views/batch.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tailbone/views/batch.py b/tailbone/views/batch.py index 99f84fab..a45c9523 100644 --- a/tailbone/views/batch.py +++ b/tailbone/views/batch.py @@ -204,13 +204,14 @@ class BatchGrid(BaseGrid): classes should *not* override this, but :meth:`filter_config_extras()` instead. """ + defaults = self.filter_config_extras() config = self.make_filter_config( filter_factory_executed=BooleanSearchFilter, filter_type_executed='is', executed=False, include_filter_executed=True) - config.update(self.filter_config_extras()) - return config + defaults.update(config) + return defaults def sort_map(self): """ From e0cb47d03af60192944d3dc49cc810d16e8b8de7 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 16 Jul 2015 17:11:25 -0500 Subject: [PATCH 0270/3860] Add list of assigned users to role view page. This surely could be better still; at least this is *something*. --- tailbone/templates/roles/read.mako | 37 ++++++++++++++++++++++++++++++ tailbone/views/roles.py | 8 ++++++- 2 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 tailbone/templates/roles/read.mako diff --git a/tailbone/templates/roles/read.mako b/tailbone/templates/roles/read.mako new file mode 100644 index 00000000..c4710de3 --- /dev/null +++ b/tailbone/templates/roles/read.mako @@ -0,0 +1,37 @@ +## -*- coding: utf-8 -*- +<%inherit file="/roles/crud.mako" /> + +${parent.body()} + +

    Users

    + +% if role is guest_role: + +

    The guest role is implied for all users.

    + +% elif role.users: + +

    The following users are assigned to this role:

    +
    +
    + + + + + + + % for i, user in enumerate(role.users, 1): + + + + % endfor + +
    UsernameFull Name
    ${user.username} + ${user.display_name}
    +
    + +% else: + +

    There are no users assigned to this role.

    + +% endif diff --git a/tailbone/views/roles.py b/tailbone/views/roles.py index 926a1658..9e70032c 100644 --- a/tailbone/views/roles.py +++ b/tailbone/views/roles.py @@ -260,6 +260,12 @@ class RoleCrud(CrudView): ]) return fs + def template_kwargs(self, form): + kwargs = super(RoleCrud, self).template_kwargs(form) + kwargs['role'] = form.fieldset.model + kwargs['guest_role'] = guest_role(Session) + return kwargs + def pre_delete(self, model): admin = administrator_role(Session()) guest = guest_role(Session()) @@ -295,7 +301,7 @@ def includeme(config): config.add_route('role.read', '/roles/{uuid}') config.add_view(RoleCrud, attr='read', route_name='role.read', - renderer='/roles/crud.mako', + renderer='/roles/read.mako', permission='roles.read') config.add_route('role.update', '/roles/{uuid}/edit') From d0a977d64b30161698341be67396f08d7f65adfb Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 20 Jul 2015 09:51:08 -0500 Subject: [PATCH 0271/3860] Add products autocomplete view. --- tailbone/views/products.py | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 634b2f90..d9aa3111 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2014 Lance Edgar +# Copyright © 2010-2015 Lance Edgar # # This file is part of Rattail. # @@ -29,6 +29,7 @@ from __future__ import unicode_literals import os import re +from sqlalchemy import orm from sqlalchemy import and_ from sqlalchemy.orm import joinedload, aliased @@ -52,7 +53,7 @@ from rattail.db.api import get_product_by_upc from rattail.db.util import configure_session from rattail.pod import get_image_url, get_image_path -from tailbone.views import SearchableAlchemyGridView, CrudView +from tailbone.views import SearchableAlchemyGridView, CrudView, AutocompleteView from tailbone.views.continuum import VersionView, version_defaults from tailbone.forms import EnumFieldRenderer, DateTimeFieldRenderer from tailbone.db import Session @@ -344,6 +345,27 @@ class ProductVersionView(VersionView): return super(ProductVersionView, self).details() +class ProductsAutocomplete(AutocompleteView): + """ + Autocomplete view for products. + """ + mapped_class = model.Product + fieldname = 'description' + + def query(self, term): + q = Session.query(model.Product).outerjoin(model.Brand) + q = q.filter(or_( + model.Brand.name.ilike('%{0}%'.format(term)), + model.Product.description.ilike('%{0}%'.format(term)))) + if not self.request.has_perm('products.view_deleted'): + q = q.filter(model.Product.deleted == False) + q = q.order_by(model.Brand.name, model.Product.description) + q = q.options(orm.joinedload(model.Product.brand)) + return q + + def display(self, product): + return product.full_description + def products_search(request): """ @@ -470,6 +492,7 @@ class CreateProductsBatch(ProductsGrid): def add_routes(config): config.add_route('products', '/products') + config.add_route('products.autocomplete', '/products/autocomplete') config.add_route('products.search', '/products/search') config.add_route('products.print_labels', '/products/labels') config.add_route('products.create_batch', '/products/batch') @@ -485,6 +508,11 @@ def includeme(config): config.add_view(ProductsGrid, route_name='products', renderer='/products/index.mako', permission='products.list') + + config.add_view(ProductsAutocomplete, route_name='products.autocomplete', + renderer='json', + permission='products.list') + config.add_view(print_labels, route_name='products.print_labels', renderer='json', permission='products.print_labels') config.add_view(CreateProductsBatch, route_name='products.create_batch', From 3732cc30f2e2cd60fa370c2506eb56aa16001daa Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 20 Jul 2015 09:51:29 -0500 Subject: [PATCH 0272/3860] Add `rattail_config` attribute to base `View` class. Just a shortcut but should save a little code noise. --- tailbone/views/core.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tailbone/views/core.py b/tailbone/views/core.py index 80f94f60..3ea681a3 100644 --- a/tailbone/views/core.py +++ b/tailbone/views/core.py @@ -38,6 +38,13 @@ class View(object): def __init__(self, request): self.request = request + @property + def rattail_config(self): + """ + Reference to the effective Rattail config object. + """ + return self.request.rattail_config + def fake_error(request): """ From 50e8637b715a55aba3ac87bbd96958041f4b7d98 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 20 Jul 2015 09:52:24 -0500 Subject: [PATCH 0273/3860] Fix timezone issues with `util.pretty_datetime()` function. Seems we should just calculate the "time ago" value instead of just providing a "then" timestamp and expecting the humanize library to understand exactly what we meant. --- tailbone/util.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tailbone/util.py b/tailbone/util.py index 2dfa399b..ba938edd 100644 --- a/tailbone/util.py +++ b/tailbone/util.py @@ -26,12 +26,14 @@ Utilities from __future__ import unicode_literals +import datetime + import pytz import humanize from webhelpers.html import HTML -from rattail.time import timezone +from rattail.time import timezone, make_utc def pretty_datetime(config, value): @@ -52,11 +54,13 @@ def pretty_datetime(config, value): if not value.tzinfo: value = pytz.utc.localize(value) - # Convert value to local timezone, and make a naive copy. + # Calculate time diff using UTC. + time_ago = datetime.datetime.utcnow() - make_utc(value) + + # Convert value to local timezone. local = timezone(config) value = local.normalize(value.astimezone(local)) - naive_value = value.replace(tzinfo=None) return HTML.tag('span', title=value.strftime('%Y-%m-%d %H:%M:%S %Z%z'), - c=humanize.naturaltime(naive_value)) + c=humanize.naturaltime(time_ago)) From a992a34fdf4b16efd7d5346d49adda2afbaee74b Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 20 Jul 2015 09:57:41 -0500 Subject: [PATCH 0274/3860] Add some custom FormEncode validators. --- tailbone/forms/validators.py | 76 ++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 tailbone/forms/validators.py diff --git a/tailbone/forms/validators.py b/tailbone/forms/validators.py new file mode 100644 index 00000000..eb81833b --- /dev/null +++ b/tailbone/forms/validators.py @@ -0,0 +1,76 @@ +# -*- coding: utf-8 -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2015 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 . +# +################################################################################ +""" +Custom FormEncode Validators +""" + +from __future__ import unicode_literals + +from rattail.db import model + +import formencode +from formencode import validators + +from tailbone.db import Session + + +class ValidCustomer(validators.FancyValidator): + """ + Validator for customer field. + """ + + def _to_python(self, value, state): + if not value: + return None + customer = Session.query(model.Customer).get(value) + if not customer: + raise formencode.Invalid("Customer not found", value, state) + return customer + + +class ValidProduct(validators.FancyValidator): + """ + Validator for product field. + """ + + def _to_python(self, value, state): + if not value: + return None + product = Session.query(model.Product).get(value) + if not product: + raise formencode.Invalid("Product not found", value, state) + return product + + +class ValidUser(validators.FancyValidator): + """ + Validator for product field. + """ + + def to_python(self, value, state): + if not value: + return None + user = Session.query(model.User).get(value) + if not user: + raise formencode.Invalid("User not found.", value, state) + return user From b6192b49f2aa3de97122dd2620ff7d77ff1c86b1 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 20 Jul 2015 10:43:19 -0500 Subject: [PATCH 0275/3860] Tweak form label area width for common forms. This still needs to be overhauled I'm sure. --- tailbone/static/css/forms.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/static/css/forms.css b/tailbone/static/css/forms.css index 5b26f63a..ac83fb79 100644 --- a/tailbone/static/css/forms.css +++ b/tailbone/static/css/forms.css @@ -57,7 +57,7 @@ div.field-wrapper label { color: #000000; display: block; float: left; - width: 160px; + width: 170px; font-weight: bold; margin-top: 2px; white-space: nowrap; From ab23a8067c34c10d764c67b45f08347e9550176f Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 20 Jul 2015 12:47:37 -0500 Subject: [PATCH 0276/3860] Add `DecimalFieldRenderer`. --- tailbone/forms/renderers/__init__.py | 1 + tailbone/forms/renderers/common.py | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/tailbone/forms/renderers/__init__.py b/tailbone/forms/renderers/__init__.py index 77645e73..084933dc 100644 --- a/tailbone/forms/renderers/__init__.py +++ b/tailbone/forms/renderers/__init__.py @@ -27,6 +27,7 @@ FormAlchemy Field Renderers from tailbone.forms.renderers.common import ( AutocompleteFieldRenderer, DateTimeFieldRenderer, + DecimalFieldRenderer, EnumFieldRenderer, YesNoFieldRenderer, ) diff --git a/tailbone/forms/renderers/common.py b/tailbone/forms/renderers/common.py index bf34d9c4..1638ece1 100644 --- a/tailbone/forms/renderers/common.py +++ b/tailbone/forms/renderers/common.py @@ -124,6 +124,29 @@ class EnumFieldRenderer(SelectFieldRenderer): return SelectFieldRenderer.render(self, opts, **kwargs) +class DecimalFieldRenderer(formalchemy.FieldRenderer): + """ + Sort of generic field renderer for decimal values. You must provide the + number of places after the decimal (scale). Note that this in turn relies + on simple string formatting; the renderer does not attempt any mathematics + of its own. + """ + + def __init__(self, scale): + self.scale = scale + + def __call__(self, field): + super(DecimalFieldRenderer, self).__init__(field) + return self + + def render_readonly(self, **kwargs): + value = self.raw_value + if value is None: + return '' + fmt = '{{0:0.{0}f}}'.format(self.scale) + return fmt.format(value) + + class YesNoFieldRenderer(CheckBoxFieldRenderer): def render_readonly(self, **kwargs): From e2131d3500d7a96d64780c21e78189be4fbc97f8 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 20 Jul 2015 17:16:07 -0500 Subject: [PATCH 0277/3860] Update changelog. --- CHANGES.rst | 26 ++++++++++++++++++++++++++ tailbone/_version.py | 2 +- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 5a9d7fe6..cc91e062 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,5 +1,31 @@ .. -*- coding: utf-8 -*- +0.4.14 +------ + +* Make anchor tags with 'button' class render as jQuery UI buttons. + +* Tweak ``app.make_rattail_config()`` to allow caller to define some settings. + +* Add ``display_name`` field to employee CRUD view. + +* Allow batch handler to disable the Execute button. + +* Add ``StoreFieldRenderer`` and ``DecimalFieldRenderer``. + +* Tweak how default filter config is handled for batch grid views. + +* Add list of assigned users to role view page. + +* Add products autocomplete view. + +* Add ``rattail_config`` attribute to base ``View`` class. + +* Fix timezone issues with ``util.pretty_datetime()`` function. + +* Add some custom FormEncode validators. + + 0.4.13 ------ diff --git a/tailbone/_version.py b/tailbone/_version.py index cca1d6c1..6368ded2 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- -__version__ = u'0.4.13' +__version__ = u'0.4.14' From fca1ae55db5ac7925010abd09188e41c10b45acf Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 21 Jul 2015 12:54:39 -0500 Subject: [PATCH 0278/3860] Fix missing import bug. --- tailbone/views/products.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index d9aa3111..99381fb0 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -29,6 +29,7 @@ from __future__ import unicode_literals import os import re +import sqlalchemy as sa from sqlalchemy import orm from sqlalchemy import and_ from sqlalchemy.orm import joinedload, aliased @@ -354,7 +355,7 @@ class ProductsAutocomplete(AutocompleteView): def query(self, term): q = Session.query(model.Product).outerjoin(model.Brand) - q = q.filter(or_( + q = q.filter(sa.or_( model.Brand.name.ilike('%{0}%'.format(term)), model.Product.description.ilike('%{0}%'.format(term)))) if not self.request.has_perm('products.view_deleted'): From cfd5e5ae5082da022792379d31e288d48dface59 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 21 Jul 2015 12:55:25 -0500 Subject: [PATCH 0279/3860] Update changelog. --- CHANGES.rst | 6 ++++++ tailbone/_version.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index cc91e062..22290c15 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,5 +1,11 @@ .. -*- coding: utf-8 -*- +0.4.15 +------ + +* Fix missing import bug. + + 0.4.14 ------ diff --git a/tailbone/_version.py b/tailbone/_version.py index 6368ded2..66f7cf70 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- -__version__ = u'0.4.14' +__version__ = u'0.4.15' From f523146a4b907c345146d878e7c198659ae4ce7b Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 21 Jul 2015 19:19:01 -0500 Subject: [PATCH 0280/3860] Add initial support for email bounce management. --- tailbone/forms/renderers/bouncer.py | 63 ++++++ tailbone/templates/emailbounces/crud.mako | 14 ++ tailbone/templates/emailbounces/index.mako | 6 + tailbone/views/__init__.py | 1 + tailbone/views/bouncer.py | 245 +++++++++++++++++++++ 5 files changed, 329 insertions(+) create mode 100644 tailbone/forms/renderers/bouncer.py create mode 100644 tailbone/templates/emailbounces/crud.mako create mode 100644 tailbone/templates/emailbounces/index.mako create mode 100644 tailbone/views/bouncer.py diff --git a/tailbone/forms/renderers/bouncer.py b/tailbone/forms/renderers/bouncer.py new file mode 100644 index 00000000..e9522668 --- /dev/null +++ b/tailbone/forms/renderers/bouncer.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2015 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 . +# +################################################################################ +""" +Batch Field Renderers +""" + +from __future__ import unicode_literals + +import os +import stat +import random + +from formalchemy.ext import fsblob + + +class BounceMessageFieldRenderer(fsblob.FileFieldRenderer): + """ + Custom file field renderer for email bounce messages. In readonly mode, + shows the filename and size. + """ + + @classmethod + def new(cls, request, handler): + name = 'Configured%s_%s' % (cls.__name__, unicode(random.random())[2:]) + return type(str(name), (cls,), dict(request=request, handler=handler)) + + @property + def storage_path(self): + return self.handler.root_msgdir + + def get_size(self): + size = super(BounceMessageFieldRenderer, self).get_size() + if size: + return size + bounce = self.field.parent.model + path = os.path.join(self.handler.msgpath(bounce)) + if os.path.isfile(path): + return os.stat(path)[stat.ST_SIZE] + return 0 + + def get_url(self, filename): + bounce = self.field.parent.model + return self.request.route_url('emailbounce.download', uuid=bounce.uuid) diff --git a/tailbone/templates/emailbounces/crud.mako b/tailbone/templates/emailbounces/crud.mako new file mode 100644 index 00000000..666c28d9 --- /dev/null +++ b/tailbone/templates/emailbounces/crud.mako @@ -0,0 +1,14 @@ +## -*- coding: utf-8 -*- +<%inherit file="/crud.mako" /> + +<%def name="context_menu_items()"> + <% bounce = form.fieldset.model %> +
  • ${h.link_to("Back to Email Bounces", url('emailbounces'))}
  • + % if not bounce.processed and request.has_perm('emailbounces.process'): +
  • ${h.link_to("Mark this Email Bounce as Processed", url('emailbounce.process', uuid=bounce.uuid))}
  • + % elif bounce.processed and request.has_perm('emailbounces.unprocess'): +
  • ${h.link_to("Mark this Email Bounce as UN-processed", url('emailbounce.unprocess', uuid=bounce.uuid))}
  • + % endif + + +${parent.body()} diff --git a/tailbone/templates/emailbounces/index.mako b/tailbone/templates/emailbounces/index.mako new file mode 100644 index 00000000..01f5beb7 --- /dev/null +++ b/tailbone/templates/emailbounces/index.mako @@ -0,0 +1,6 @@ +## -*- coding: utf-8 -*- +<%inherit file="/grid.mako" /> + +<%def name="title()">Email Bounces + +${parent.body()} diff --git a/tailbone/views/__init__.py b/tailbone/views/__init__.py index 6d0b0c80..fe0bd02c 100644 --- a/tailbone/views/__init__.py +++ b/tailbone/views/__init__.py @@ -55,6 +55,7 @@ def includeme(config): config.include('tailbone.views.auth') config.include('tailbone.views.batches') + config.include('tailbone.views.bouncer') config.include('tailbone.views.brands') config.include('tailbone.views.categories') config.include('tailbone.views.customergroups') diff --git a/tailbone/views/bouncer.py b/tailbone/views/bouncer.py new file mode 100644 index 00000000..1eb743ab --- /dev/null +++ b/tailbone/views/bouncer.py @@ -0,0 +1,245 @@ +# -*- coding: utf-8 -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2015 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 . +# +################################################################################ +""" +Views for Email Bounces +""" + +from __future__ import unicode_literals + +import os +import datetime + +from rattail.db import model +from rattail.bouncer import get_handler + +import formalchemy +from pyramid.response import FileResponse +from pyramid.httpexceptions import HTTPFound, HTTPNotFound +from webhelpers.html import literal + +from tailbone.db import Session +from tailbone.views import SearchableAlchemyGridView, CrudView +from tailbone.forms import renderers +from tailbone.forms.renderers.bouncer import BounceMessageFieldRenderer +from tailbone.grids.search import BooleanSearchFilter + + +class EmailBouncesGrid(SearchableAlchemyGridView): + """ + Main grid view for email bounces. + """ + mapped_class = model.EmailBounce + config_prefix = 'emailbounces' + + def join_map(self): + return { + 'processed_by': lambda q: q.outerjoin(model.User), + } + + def filter_map(self): + + def processed_is(q, v): + if v == 'True': + return q.filter(model.EmailBounce.processed != None) + else: + return q.filter(model.EmailBounce.processed == None) + + def processed_nt(q, v): + if v == 'True': + return q.filter(model.EmailBounce.processed == None) + else: + return q.filter(model.EmailBounce.processed != None) + + return self.make_filter_map( + ilike=['config_key', 'bounce_recipient_address', 'intended_recipient_address'], + processed={'is': processed_is, 'nt': processed_nt}, + processed_by=self.filter_ilike(model.User.username)) + + def filter_config(self): + return self.make_filter_config( + include_filter_config_key=True, + filter_type_config_key='lk', + filter_label_config_key="Source", + filter_factory_processed=BooleanSearchFilter, + filter_type_processed='is', + processed=False, + include_filter_processed=True, + filter_label_bounce_recipient_address="Bounced To", + filter_label_intended_recipient_address="Intended For") + + def sort_config(self): + return self.make_sort_config(sort='bounced', dir='desc') + + def sort_map(self): + return self.make_sort_map( + 'config_key', 'bounced', 'bounce_recipient_address', 'intended_recipient_address', + processed_by=self.sorter(model.User.username)) + + def grid(self): + g = self.make_grid() + g.bounced.set(renderer=renderers.DateTimeFieldRenderer(self.rattail_config)) + g.configure( + include=[ + g.config_key.label("Source"), + g.bounced, + g.bounce_recipient_address.label("Bounced To"), + g.intended_recipient_address.label("Intended For"), + g.processed_by, + ], + readonly=True) + if self.request.has_perm('emailbounces.view'): + g.viewable = True + g.view_route_name = 'emailbounce' + if self.request.has_perm('emailbounces.delete'): + g.deletable = True + g.delete_route_name = 'emailbounce.delete' + return g + + +class LinksFieldRenderer(formalchemy.FieldRenderer): + + def render_readonly(self, **kwargs): + value = self.raw_value + if not value: + return 'n/a' + html = literal('
      ') + for link in value: + html += literal('
    • {0}:  {2}
    • '.format( + link.type, link.url, link.title)) + html += literal('
    ') + return html + + +class EmailBounceCrud(CrudView): + """ + Main CRUD view for email bounces. + """ + mapped_class = model.EmailBounce + home_route = 'emailbounces' + pretty_name = "Email Bounce" + + def get_handler(self, bounce): + return get_handler(self.rattail_config, bounce.config_key) + + def fieldset(self, bounce): + assert isinstance(bounce, model.EmailBounce) + handler = self.get_handler(bounce) + fs = self.make_fieldset(bounce) + fs.bounced.set(renderer=renderers.DateTimeFieldRenderer(self.rattail_config)) + fs.processed.set(renderer=renderers.DateTimeFieldRenderer(self.rattail_config)) + fs.append(formalchemy.Field('message', + value=handler.msgpath(bounce), + renderer=BounceMessageFieldRenderer.new(self.request, handler))) + fs.append(formalchemy.Field('links', + value=list(handler.make_links(Session(), bounce.intended_recipient_address)), + renderer=LinksFieldRenderer)) + fs.configure( + include=[ + fs.config_key.label("Source"), + fs.message, + fs.bounced, + fs.intended_recipient_address.label("Intended For"), + fs.bounce_recipient_address.label("Bounced To"), + fs.links, + fs.processed, + fs.processed_by, + ], + readonly=True) + return fs + + def template_kwargs(self, form): + kwargs = super(EmailBounceCrud, self).template_kwargs(form) + bounce = form.fieldset.model + kwargs['handler'] = self.get_handler(bounce) + return kwargs + + def process(self): + """ + View for marking a bounce as processed. + """ + bounce = self.get_model_from_request() + if not bounce: + return HTTPNotFound() + bounce.processed = datetime.datetime.utcnow() + bounce.processed_by = self.request.user + self.request.session.flash("Email bounce has been marked processed.") + return HTTPFound(location=self.request.route_url('emailbounces')) + + def unprocess(self): + """ + View for marking a bounce as *unprocessed*. + """ + bounce = self.get_model_from_request() + if not bounce: + return HTTPNotFound() + bounce.processed = None + bounce.processed_by = None + self.request.session.flash("Email bounce has been marked UN-processed.") + return HTTPFound(location=self.request.route_url('emailbounces')) + + def download(self): + """ + View for downloading the message file associated with a bounce. + """ + bounce = self.get_model_from_request() + if not bounce: + return HTTPNotFound() + handler = self.get_handler(bounce) + path = handler.msgpath(bounce) + response = FileResponse(path, request=self.request) + response.headers[b'Content-Length'] = str(os.path.getsize(path)) + response.headers[b'Content-Disposition'] = b'attachment; filename="bounce.eml"' + return response + + +def add_routes(config): + config.add_route('emailbounces', '/emailbounces/') + config.add_route('emailbounce', '/emailbounces/{uuid}') + config.add_route('emailbounce.process', '/emailbounces/{uuid}/process') + config.add_route('emailbounce.unprocess', '/emailbounces/{uuid}/unprocess') + config.add_route('emailbounce.delete', '/emailbounces/{uuid}/delete') + config.add_route('emailbounce.download', '/emailbounces/{uuid}/download') + + +def includeme(config): + add_routes(config) + + config.add_view(EmailBouncesGrid, route_name='emailbounces', + renderer='/emailbounces/index.mako', + permission='emailbounces.list') + + config.add_view(EmailBounceCrud, attr='read', route_name='emailbounce', + renderer='/emailbounces/crud.mako', + permission='emailbounces.view') + + config.add_view(EmailBounceCrud, attr='process', route_name='emailbounce.process', + permission='emailbounces.process') + + config.add_view(EmailBounceCrud, attr='unprocess', route_name='emailbounce.unprocess', + permission='emailbounces.unprocess') + + config.add_view(EmailBounceCrud, attr='download', route_name='emailbounce.download', + permission='emailbounces.download') + + config.add_view(EmailBounceCrud, attr='delete', route_name='emailbounce.delete', + permission='emailbounces.delete') From 0ddb5bffd74213ab37b201a7b0960fbf2452172b Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 23 Jul 2015 15:47:17 -0500 Subject: [PATCH 0281/3860] Add plain text of message body to email bounce view. Also tweak some labels. --- tailbone/templates/emailbounces/crud.mako | 37 +++++++++++++++++++++++ tailbone/views/bouncer.py | 10 ++++-- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/tailbone/templates/emailbounces/crud.mako b/tailbone/templates/emailbounces/crud.mako index 666c28d9..4433fbb0 100644 --- a/tailbone/templates/emailbounces/crud.mako +++ b/tailbone/templates/emailbounces/crud.mako @@ -11,4 +11,41 @@ % endif +<%def name="head_tags()"> + ${parent.head_tags()} + + + + ${parent.body()} + +
    +${message}
    +
    diff --git a/tailbone/views/bouncer.py b/tailbone/views/bouncer.py index 1eb743ab..d5a1c684 100644 --- a/tailbone/views/bouncer.py +++ b/tailbone/views/bouncer.py @@ -158,19 +158,25 @@ class EmailBounceCrud(CrudView): fs.config_key.label("Source"), fs.message, fs.bounced, - fs.intended_recipient_address.label("Intended For"), fs.bounce_recipient_address.label("Bounced To"), + fs.intended_recipient_address.label("Intended For"), fs.links, fs.processed, fs.processed_by, ], readonly=True) + if not bounce.processed: + del fs.processed + del fs.processed_by return fs def template_kwargs(self, form): kwargs = super(EmailBounceCrud, self).template_kwargs(form) bounce = form.fieldset.model - kwargs['handler'] = self.get_handler(bounce) + handler = self.get_handler(bounce) + kwargs['handler'] = handler + with open(handler.msgpath(bounce), 'rb') as f: + kwargs['message'] = f.read() return kwargs def process(self): From c42e80f87ae65dbe307d39419fa86cb4c2ee4d33 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 23 Jul 2015 20:05:48 -0500 Subject: [PATCH 0282/3860] Make email "source" filter use a dropdown, in bouncer UI. --- tailbone/grids/search.py | 17 +++++++++++++++++ tailbone/views/bouncer.py | 11 +++++++++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/tailbone/grids/search.py b/tailbone/grids/search.py index 2b0c01df..74ced5ee 100644 --- a/tailbone/grids/search.py +++ b/tailbone/grids/search.py @@ -85,6 +85,23 @@ class BooleanSearchFilter(SearchFilter): ["True", "False"]) +class ChoiceSearchFilter(SearchFilter): + """ + Generic search filter where the user may only select among a specific set + of choices. + """ + + def __init__(self, choices): + self.choices = choices + + def __call__(self, name, label=None, **kwargs): + super(ChoiceSearchFilter, self).__init__(name, label=label, **kwargs) + return self + + def value_control(self): + return tags.select(self.name, self.search.config.get(self.name), self.choices) + + def EnumSearchFilter(enum): options = enum.items() diff --git a/tailbone/views/bouncer.py b/tailbone/views/bouncer.py index d5a1c684..d06ef422 100644 --- a/tailbone/views/bouncer.py +++ b/tailbone/views/bouncer.py @@ -31,6 +31,7 @@ import datetime from rattail.db import model from rattail.bouncer import get_handler +from rattail.bouncer.config import get_profile_keys import formalchemy from pyramid.response import FileResponse @@ -41,7 +42,7 @@ from tailbone.db import Session from tailbone.views import SearchableAlchemyGridView, CrudView from tailbone.forms import renderers from tailbone.forms.renderers.bouncer import BounceMessageFieldRenderer -from tailbone.grids.search import BooleanSearchFilter +from tailbone.grids.search import BooleanSearchFilter, ChoiceSearchFilter class EmailBouncesGrid(SearchableAlchemyGridView): @@ -51,6 +52,10 @@ class EmailBouncesGrid(SearchableAlchemyGridView): mapped_class = model.EmailBounce config_prefix = 'emailbounces' + def __init__(self, request): + super(EmailBouncesGrid, self).__init__(request) + self.handler_options = [('', '(any)')] + sorted(get_profile_keys(self.rattail_config)) + def join_map(self): return { 'processed_by': lambda q: q.outerjoin(model.User), @@ -71,7 +76,8 @@ class EmailBouncesGrid(SearchableAlchemyGridView): return q.filter(model.EmailBounce.processed != None) return self.make_filter_map( - ilike=['config_key', 'bounce_recipient_address', 'intended_recipient_address'], + exact=['config_key'], + ilike=['bounce_recipient_address', 'intended_recipient_address'], processed={'is': processed_is, 'nt': processed_nt}, processed_by=self.filter_ilike(model.User.username)) @@ -80,6 +86,7 @@ class EmailBouncesGrid(SearchableAlchemyGridView): include_filter_config_key=True, filter_type_config_key='lk', filter_label_config_key="Source", + filter_factory_config_key=ChoiceSearchFilter(self.handler_options), filter_factory_processed=BooleanSearchFilter, filter_type_processed='is', processed=False, From eecabac08f3840e6a7251f3e06c7a83cbd72c98f Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 23 Jul 2015 20:10:29 -0500 Subject: [PATCH 0283/3860] Fix filter bug in bouncer. --- tailbone/views/bouncer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/views/bouncer.py b/tailbone/views/bouncer.py index d06ef422..ac48399e 100644 --- a/tailbone/views/bouncer.py +++ b/tailbone/views/bouncer.py @@ -84,7 +84,7 @@ class EmailBouncesGrid(SearchableAlchemyGridView): def filter_config(self): return self.make_filter_config( include_filter_config_key=True, - filter_type_config_key='lk', + filter_type_config_key='is', filter_label_config_key="Source", filter_factory_config_key=ChoiceSearchFilter(self.handler_options), filter_factory_processed=BooleanSearchFilter, From b4f5c36b3bc41bff82ab02f4e64ddbb1f9059c79 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 28 Jul 2015 01:13:53 -0500 Subject: [PATCH 0284/3860] Update changelog. --- CHANGES.rst | 6 ++++++ tailbone/_version.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 22290c15..2445e44d 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,5 +1,11 @@ .. -*- coding: utf-8 -*- +0.4.16 +------ + +* Add initial support for email bounce management. + + 0.4.15 ------ diff --git a/tailbone/_version.py b/tailbone/_version.py index 66f7cf70..b9664eaa 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- -__version__ = u'0.4.15' +__version__ = u'0.4.16' From c45f2d807b070b8816a4ae311b0eb958b5948821 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 28 Jul 2015 20:15:02 -0500 Subject: [PATCH 0285/3860] Upgrade packages when running tox stuff. --- tox.ini | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tox.ini b/tox.ini index 3ad8bb5f..e591333f 100644 --- a/tox.ini +++ b/tox.ini @@ -8,13 +8,13 @@ deps = mock nose commands = - pip install Tailbone + pip install --upgrade Tailbone nosetests {posargs} [testenv:coverage] basepython = python commands = - pip install Tailbone + pip install --upgrade Tailbone nosetests {posargs:--with-coverage --cover-html-dir={envtmpdir}/coverage} [testenv:docs] @@ -22,5 +22,5 @@ basepython = python deps = Sphinx changedir = docs commands = - pip install Tailbone + pip install --upgrade Tailbone sphinx-build -b html -d {envtmpdir}/doctrees -W -T . {envtmpdir}/docs From e348a2f216f81018f98292e705e70406aa79e056 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 28 Jul 2015 20:21:21 -0500 Subject: [PATCH 0286/3860] Tweak package handling for tox some more. --- tox.ini | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tox.ini b/tox.ini index e591333f..9fcbd7c7 100644 --- a/tox.ini +++ b/tox.ini @@ -8,13 +8,13 @@ deps = mock nose commands = - pip install --upgrade Tailbone + pip install --upgrade Tailbone rattail[bouncer] nosetests {posargs} [testenv:coverage] basepython = python commands = - pip install --upgrade Tailbone + pip install --upgrade Tailbone rattail[bouncer] nosetests {posargs:--with-coverage --cover-html-dir={envtmpdir}/coverage} [testenv:docs] @@ -22,5 +22,5 @@ basepython = python deps = Sphinx changedir = docs commands = - pip install --upgrade Tailbone + pip install --upgrade Tailbone rattail[bouncer] sphinx-build -b html -d {envtmpdir}/doctrees -W -T . {envtmpdir}/docs From 46f8430c32992e6f67b994a329b7db843bc8a9ab Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 29 Jul 2015 12:27:28 -0500 Subject: [PATCH 0287/3860] Log warning instead of error when refreshing batch fails. --- tailbone/views/batch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/views/batch.py b/tailbone/views/batch.py index a45c9523..30013ce5 100644 --- a/tailbone/views/batch.py +++ b/tailbone/views/batch.py @@ -551,7 +551,7 @@ class BatchCrud(BaseCrud): self.refresh_data(session, batch, progress=progress) except Exception as error: session.rollback() - log.exception("refreshing data for batch failed: {0}".format(batch)) + log.warning("refreshing data for batch failed: {0}".format(batch), exc_info=True) session.close() progress.session.load() progress.session['error'] = True From d756b7885a0d668206a25b94796305bee22fa2d8 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 29 Jul 2015 12:28:17 -0500 Subject: [PATCH 0288/3860] Update changelog. --- CHANGES.rst | 6 ++++++ tailbone/_version.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 2445e44d..c3c065aa 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,5 +1,11 @@ .. -*- coding: utf-8 -*- +0.4.17 +------ + +* Log warning instead of error when refreshing batch fails. + + 0.4.16 ------ diff --git a/tailbone/_version.py b/tailbone/_version.py index b9664eaa..7bb6aaa4 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- -__version__ = u'0.4.16' +__version__ = u'0.4.17' From d698bef608cd229054f1df6b4a6c31d56965c221 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 8 Aug 2015 13:58:16 -0500 Subject: [PATCH 0289/3860] Don't show flash message when user logs in. That just seems more annoying to me, somehow.. --- tailbone/views/auth.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tailbone/views/auth.py b/tailbone/views/auth.py index cc03122f..a8d103e8 100644 --- a/tailbone/views/auth.py +++ b/tailbone/views/auth.py @@ -36,8 +36,6 @@ import formencode from pyramid_simpleform import Form from ..forms.simpleform import FormRenderer -from rattail.time import localtime - from ..db import Session from rattail.db.auth import authenticate_user, set_user_password @@ -82,8 +80,6 @@ def login(request): form.data['username'], form.data['password']) if user: - request.session.flash("{0} logged in at {1}".format( - user, localtime(request.rattail_config).strftime('%I:%M %p'))) headers = remember(request, user.uuid) # Treat URL from session as referrer, if available. referrer = request.session.pop('next_url', referrer) From 17c6f390c02cd24494fe4c5460e54d662b404f9f Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 8 Aug 2015 15:26:06 -0500 Subject: [PATCH 0290/3860] Remove cached copies of jQuery / jQuery UI files. We just need to use the CDN, or else folks are welcome to roll their own theme etc. for use within their templates. --- tailbone/static/css/jquery.ui.tailbone.css | 14 ++++++++++++++ .../css/smoothness/images/animated-overlay.gif | Bin 1738 -> 0 bytes .../images/ui-bg_flat_0_aaaaaa_40x100.png | Bin 274 -> 0 bytes .../images/ui-bg_flat_75_ffffff_40x100.png | Bin 271 -> 0 bytes .../images/ui-bg_glass_55_fbf9ee_1x400.png | Bin 387 -> 0 bytes .../images/ui-bg_glass_65_ffffff_1x400.png | Bin 272 -> 0 bytes .../images/ui-bg_glass_75_dadada_1x400.png | Bin 375 -> 0 bytes .../images/ui-bg_glass_75_e6e6e6_1x400.png | Bin 368 -> 0 bytes .../images/ui-bg_glass_95_fef1ec_1x400.png | Bin 384 -> 0 bytes .../ui-bg_highlight-soft_75_cccccc_1x100.png | Bin 360 -> 0 bytes .../images/ui-icons_222222_256x240.png | Bin 6781 -> 0 bytes .../images/ui-icons_2e83ff_256x240.png | Bin 4353 -> 0 bytes .../images/ui-icons_454545_256x240.png | Bin 6854 -> 0 bytes .../images/ui-icons_888888_256x240.png | Bin 6897 -> 0 bytes .../images/ui-icons_cd0a0a_256x240.png | Bin 4353 -> 0 bytes .../smoothness/jquery-ui-1.10.0.custom.min.css | 5 ----- tailbone/static/js/lib/jquery-1.9.1.min.js | 5 ----- .../static/js/lib/jquery-ui-1.10.0.custom.min.js | 6 ------ 18 files changed, 14 insertions(+), 16 deletions(-) create mode 100644 tailbone/static/css/jquery.ui.tailbone.css delete mode 100644 tailbone/static/css/smoothness/images/animated-overlay.gif delete mode 100644 tailbone/static/css/smoothness/images/ui-bg_flat_0_aaaaaa_40x100.png delete mode 100644 tailbone/static/css/smoothness/images/ui-bg_flat_75_ffffff_40x100.png delete mode 100644 tailbone/static/css/smoothness/images/ui-bg_glass_55_fbf9ee_1x400.png delete mode 100644 tailbone/static/css/smoothness/images/ui-bg_glass_65_ffffff_1x400.png delete mode 100644 tailbone/static/css/smoothness/images/ui-bg_glass_75_dadada_1x400.png delete mode 100644 tailbone/static/css/smoothness/images/ui-bg_glass_75_e6e6e6_1x400.png delete mode 100644 tailbone/static/css/smoothness/images/ui-bg_glass_95_fef1ec_1x400.png delete mode 100644 tailbone/static/css/smoothness/images/ui-bg_highlight-soft_75_cccccc_1x100.png delete mode 100644 tailbone/static/css/smoothness/images/ui-icons_222222_256x240.png delete mode 100644 tailbone/static/css/smoothness/images/ui-icons_2e83ff_256x240.png delete mode 100644 tailbone/static/css/smoothness/images/ui-icons_454545_256x240.png delete mode 100644 tailbone/static/css/smoothness/images/ui-icons_888888_256x240.png delete mode 100644 tailbone/static/css/smoothness/images/ui-icons_cd0a0a_256x240.png delete mode 100644 tailbone/static/css/smoothness/jquery-ui-1.10.0.custom.min.css delete mode 100644 tailbone/static/js/lib/jquery-1.9.1.min.js delete mode 100644 tailbone/static/js/lib/jquery-ui-1.10.0.custom.min.js diff --git a/tailbone/static/css/jquery.ui.tailbone.css b/tailbone/static/css/jquery.ui.tailbone.css new file mode 100644 index 00000000..02dbae2a --- /dev/null +++ b/tailbone/static/css/jquery.ui.tailbone.css @@ -0,0 +1,14 @@ + +/********************************************************************** + * jquery.ui.tailbone.css + * + * jQuery UI tweaks for Tailbone + **********************************************************************/ + +/* + * This was provided by the 'smoothness' theme CSS which was cached within the + * Tailbone project. Not sure why the CDN theme doesn't do it..? + */ +.ui-menu-item a { + display: block; +} diff --git a/tailbone/static/css/smoothness/images/animated-overlay.gif b/tailbone/static/css/smoothness/images/animated-overlay.gif deleted file mode 100644 index d441f75ebfbdf26a265dfccd670120d25c0a341c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1738 zcmZ|OX;ji_6b5ixNYt8>l?gOuO)6lU%W(mxn(`>1S(XO;u`D+P%xqBvMr|w-Vyr1s z7R|Cn0b8|Hu<=Zmv1mFqh9Fj!NuZfKB2MP$e75`XJ@>=!y!Ux9xR3x;EW!q1^V>X| znVFuRUN`NqJ2)ybXh%e__h!!pv(M|S3+?9F%(K}zyE40MGyhWF5-IDgL&=%2-9`Nk z!1@8uk4t%_{(K~>N;sK&dzJbwJ=$kYTlL=$%#0Pfh>U{%i@~wWbvYsD_K-D`&+u1( z#Ma`>%q<^UhzGvi(hyE`zCD{-=2|zL5>wnB=DE!U?(CZG%q4@lDnCq_%&3DCla#(X zmBhDD+RN$aMWWHm?ig*>1Onn6~r?Ma~N2JKAxN>H%UtRyRqS)6Um!-Tz%-r=& zQmTb^JFIe3W^-kAm`}`2P|niMh>RYyd)S^f(dbrx965?rzbhP|XeP}o&&DSZ4|oYQ z)I{f!SfycYw?3=9W;o-B%U5xs(pP267X~9-7L|4WzaYexC0GtG8wWygm63rF{llCEraxzkc=IxvFQ-y37=_;e5 zJLq^gsSO0Ayz?a>E_?{dmUc+t#qv$)XN8$<<}rQ#)lsiw+pmL&J>~+hgpo>i$m+;l zZIa_ZRIfSeT$~v5d`EBV&*k`apPgjv&B|+d`Q!nyu{L4rs%ZfoF0*Kq8I%ByOcFpL zK=>wzofZo<+0GZLCnWM3oQ^pb(gRSf02;~cEn@LJ>~XB9IkEX{$N#Z`m%>S!U{uPx zloI%bLdo$Adxlh(Uv^yX7s5G&C zLwNRG>~T?G{kzupp8EcyLGPoPf)@&9Wqfw_l&uU-6cexk%5;uQg%wb=0k_733{i#& z1a2p)gV3S2+QG1-K9tZ}E~I<(P0r2aFFY-c{o?TUOz3Xjod#TLE2A_c?*T7t z=1>~%YW450{Qqno4t`}gvLnuMrcu8+#xEBoY%2_+Mb#Z6S38+r*M4O`-+!zl(@m`D zQsi|GA2l3gEy}LFe<#Hv8?$_L#u8E|3-bP$*La*E>B{X!Sy4i6?TKam!49aXCAW4S*P_O^H4^*DpiA40o}Uqw~Eo&veh1`|8i zD2$x+>_b^bXE4N;AW=5>iYak2%!JAh0j1*k1{p#iRCjbB7!cSws~U{1IA@acLII$t z$>X#A+^s6iJ5~DFG!xa?>z{=lxtdi1rzbM-(nqAu3D8h-&64xo6|E!p?pK0xT;qoK z`6%+SpBk+~M?nO}>2mTw!A{yZ6O>Z@kwSd4;8aWU5z!P~tQl?u==^+R`{OmOS}oZh zOXQ3{6kuz?Is^n^L7;9ieB9C+8B{>t+pDrlq4xGDDn#T#3T5$l1g`FTQkU;b-981j zNm{zC`$wn7etklM#qHI4=3m5gwa6DNS{?Z!vSObi_od{4eUo=_S2BKNpkSdiqe(k9WtkeM79;2-%CFbb)aB=&H1?i1}uwFzoZQ(38Kn1zBP ORn*B%u*Wk|4g3!*Rv{Mv diff --git a/tailbone/static/css/smoothness/images/ui-bg_flat_0_aaaaaa_40x100.png b/tailbone/static/css/smoothness/images/ui-bg_flat_0_aaaaaa_40x100.png deleted file mode 100644 index d625e15289fcce827519f4b3324739d02a3d0cf9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 274 zcmeAS@N?(olHy`uVBq!ia0vp^8bF-F$P6UUt$JVyq*&4&eH|GXHuiJ>Nn{1`8H!C8<`)MX5lF!N|bSSl7Tv*T5{q(7?*n!phh{*TBNcz<}*!j5msg-29Zx Yv`X9>%BMW}4%EQl>FVdQ&MBb@093L^T>t<8 diff --git a/tailbone/static/css/smoothness/images/ui-bg_flat_75_ffffff_40x100.png b/tailbone/static/css/smoothness/images/ui-bg_flat_75_ffffff_40x100.png deleted file mode 100644 index 3b389cff011bda0c0f7a93c2720900e8c9e04fe3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 271 zcmeAS@N?(olHy`uVBq!ia0vp^8bF-F$P6UUt$JVyq*&4&eH|GXHuiJ>Nn{1`8HIsBe3jv*T7lM^IZ7dHS+VTxd2EH!H@2TG}y zxJHzuB$lLFB^RXvDF!10Lt|Y7BV7Zt5JLkiQwu9YGhG7{n>-#A4Y~O#nQ4`{ VHH0Nz+W^$S;OXk;vd$@?2>{HbL|Xs= diff --git a/tailbone/static/css/smoothness/images/ui-bg_glass_55_fbf9ee_1x400.png b/tailbone/static/css/smoothness/images/ui-bg_glass_55_fbf9ee_1x400.png deleted file mode 100644 index d4b506bf1ebc0486e424f018fa4e60103dc568c7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 387 zcmeAS@N?(olHy`uVBq!ia0vp^j6gI&fC$~zaujKJSU)dGAg8%w!(ui^3BziR*I}z84KnZk zu3$dcT+u38*8N!e;3H9&!`|^fF8l9$xy+KAL82{D;y&BeDX(VTsr~jY?gNWg;)$NM z21)PAoE)F1i0Ir6oUvh%QoM=D$IB=9ZMOs$CX2nPbltnn^8-sb7qd;aRdP`(kYX@0Ff`URFw!+J3o$gXGPSTWGSD@! mure?>l^k;wMMG|WN@iLmZVkKbo@4?wFnGH9xvX?)FK#IZ0z|dINz)07?EX2^j%GAQj&{WsJ!pguv>`KW*6b-rgDVb@N WxHY7Ap3wzrVDNPHb6Mw<&;$V3q(s&L diff --git a/tailbone/static/css/smoothness/images/ui-bg_glass_75_dadada_1x400.png b/tailbone/static/css/smoothness/images/ui-bg_glass_75_dadada_1x400.png deleted file mode 100644 index 1264f5a2c27c4ae96e1f0a7e10a59635a3f08ab5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 375 zcmeAS@N?(olHy`uVBq!ia0vp^j6gI&fC-!3l}eP^2^y2tgpW7t>QVU z!zOt zI!3j`HKHUXu_VOj=74WAvZrI ZGp!Q0hTV2gGJzTxJYD@<);T3K0RSb*dPV>M diff --git a/tailbone/static/css/smoothness/images/ui-bg_glass_75_e6e6e6_1x400.png b/tailbone/static/css/smoothness/images/ui-bg_glass_75_e6e6e6_1x400.png deleted file mode 100644 index 48887ce5abc68c523597a71031f0353aee9dccd5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 368 zcmeAS@N?(olHy`uVBq!ia0vp^j6gI&fCN7hf*w8d!INoBxwRi;GitEYKCIC9V-A zDTyViR>?)FK#IZ0z|dINz)07?EX2^j%GA=zz*N`3!pgwFbdS6tiiX_$l+3hB+!|aS SF7E|uVDNPHb6Mw<&;$TFF?Z?! diff --git a/tailbone/static/css/smoothness/images/ui-bg_glass_95_fef1ec_1x400.png b/tailbone/static/css/smoothness/images/ui-bg_glass_95_fef1ec_1x400.png deleted file mode 100644 index 8d9d7318428e68c0c86f013dd0178e525037da09..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 384 zcmeAS@N?(olHy`uVBq!ia0vp^j6gI&fC8eS1;{{-AUQbcXq5B8w2)SU0<=PeM`lecf!mJ#|vei-@dS|RF>)Ftim~yHl>Mc zU;1FSYhABg^xWQrCQ<|QIe8al4_M)lnSI6j0_Bobq$Pk4a`Ce4XjKptV~RF4J@n- l41PEl0z(E#LvDUbW?Cg~4V`i){{l5Ic)I$ztaD0e0sw(Yf=2)V diff --git a/tailbone/static/css/smoothness/images/ui-bg_highlight-soft_75_cccccc_1x100.png b/tailbone/static/css/smoothness/images/ui-bg_highlight-soft_75_cccccc_1x100.png deleted file mode 100644 index 04f5318fb783992e54e8567ec7d8a84ec4865d95..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 360 zcmeAS@N?(olHy`uVBq!ia0vp^j6j?szyu^`+!HJTQY`6?zK#qG8~eHcB(j1O6bHFG zF|0c$^AgBWNcITwWnidMV_;}#VPN41qIX6((c#2JpPek&$r@Ir;|4~0c}?;ag8WRNi0dVN-jzT zQVd20hQ_)EM!E)OA%+H4rWRI42D%0oRt5&Al4Gu-Xvob^$xN%ntzoy_lT4rn22WQ% Jmvv4FO#q6Wcc%aV diff --git a/tailbone/static/css/smoothness/images/ui-icons_222222_256x240.png b/tailbone/static/css/smoothness/images/ui-icons_222222_256x240.png deleted file mode 100644 index 0de6293259d866646ee06e63162f42bc56c5bae4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6781 zcmZ{Jbx<76vhFVK9w4|o1P>4_1cE0xB)Es*9&{IX3GNo$6M_U?g1eJtaks^FS>)xM zcVE@{y?gIJ(*h@40J4UfkRg?!XfsQQZ0f6WE%JQ#uzQB%hO=DRGj0a?-UirPU_xmI5 zeu&0=g79MWIwgGpjVCb?TR`C?9Dn1M-kTKS(XW(9)JL+%Q^?@TEgR1O#BNEBWhGls z;%H}#%)#7JqNMw(mII+W`EY*$llHsGS-x6%ccEdtth;r{j@?LlQ%KHQlmB-_iOGvp1uV(WZPEZive#BJgO?O~_6uC?RSTW7_~bjOkd_lh>99?uiuvJZLll67d`WT17H&JoCRy^8g(7VK;ut zj+J->n9~8JE!8IW3D-J9@}(T_f%iKunm3swNxpyA$oah~V9$kZ5i}p05*|9I6I4#F zr)~SG3l0d^oL2Y^&>Xzo1}73Ql9!qLal;jvZ<=JU)0W3`}Du zSHC`}2{O}4mbi0&SFnA@%z$22Bf}ys3j=FWsGqD?fwFk7T2;<)6=#;rd9jprnVsLO zuGt3;Hfj1jvJ`~KWtq_zGQxW1!iz6WPU!49JxBkb9+S7Cb6jmM%|( zIK)N>yu{{M?kh-i%f1HNV}++2orK_Yj88!1`of9@XO_4oUvwV}`g4h@DInh_o*XkK z50;+BwB*;$6~tp;Y3Cl<{8BDRd#+DQS{97Y{SsSghU|FYCldH+bg`Kwi-yfOWYhl% z!)H^)>~Admm!1CU4-uW>IvA?CH1}tafko;^I;`)Jq9ePD)U>|oS?YY!L$c+Y2jHo~ z4<91&ut&5{DHKUMki{N*r)ae#aIok=t5QmR_;#huwj6?Po{+Krb|dp`r|1IIU2 zTJ4&+I>IHYL0z}6ase+3jYeS8RwsZ5#P;VvZ2^w+J4&;|U{Q*YpFhOr9c3E z%so;{{fp&PcabUGaF}q{VtN!hBiF3SaXp!=g;Dlaz~Z0fvu9kyWCvKXw}Z{Pf?MrG zPf?6P>Q&3}L_m+m0;tikgtok%0Y@Yg z7B?x4%Va#TRG3p8&O$o-Ir}KO1ApVUqaCiB>(k!0hq(ze!$AFgQn>|s2G5VhZ+LfV z@3EWE)9VA;`232x?6uiLVoH`~WqGR^oiH}-mUW`QnYy*8TXQ#Vq<(q#e31m#ZR!h_ zl*OU2yCf=o^&1h~NN3v%og%XoCyr|8ujFO+$Q*LG5{HA&n5YJY9W1=ylhY>D`VV)IS)}Nuyq~N>c;zW;c{7vC0lS^d`@@TMmr(q&}6i=ErUxND& ze=mIxC>_=jL}C$A)bY8skW2nl`l(mRtKgPw)G+fw;-LZmAS{MZsPE?#*PEr0@1NDP zwa{($&`w1sUv+n=9GM)Y*<^$kdiphR^tF4xPgxn!#^XgW;uGn#4S+$}o@E;KDug#C z3g(2(VQT5I4ZqS5?~c`p*w~RPAAGzo9y}9*-8jwHnI$mAF*#aCY0qG`LrHM@iOiRM z9|J6gY;i03au;y!@MwXn7+L_UKb24FN_Nx}i#L~EakD>l-gnGe+wA zMp!f-bZSZrg~qlCDu}6u&w&1A#?WQH#eQ;*VFH9t8k$LR;xPv?YbZVCx}SV3#y-^M zkah`4A8dz9M4ai7OswDYkM~1B6@`D07Z@V2+)WXBPAP{4si@xMbFFk$CiMGFc9(db zL4HurM~VsG&cBKMg;~?LiS{Nur+yxyj|_a<_;CGWCO~|hxg;G)AfBEiGKC;KWvhoY zCAB18Y9p?mtu&Dt^K4Z&*nLpzH)w@$Idk%a&KS)}c-N%_etn?w=m;-`U5U?jPPl*5 zStW(fpoZh!PJWkx`X_w$ZIGH#yP%Ec)gqHRvA68pVrM;22dsRTBx%A&`Oz%|t#_&b zw?1o(<&_Sh^^1g%pePo@?YyCunxsK}B+CgsT8mY@tKL^bIc{~oVomeghFanZ-LqdI=5a7OkHXqWc3bMzegZC8xHPdW~^k)DI! z(yy{2&TS-A47DZZ|89E1kDQW0#7%+9l41IRL^~ zCe~-(;}e)bx=;lRrn@Mn5ipaL8*XpdCFdPA)q8aKbTV>k+TV!^EuMI?5m|T^y3@}v zQz0eozo)c61Gu1(6TK$HXX2mSVLV_A={e&Y{-w);f zhV%O|+vjKGZ^ON@!FM^8T)A9GnL^J&c0ATr);|y2W>cp^bp*!3hM*VTUp<5us_xNx z`KzFHYBT=HlD9J3b1H&R1L`3nCc4pO=7P3YWfcrW9VL%x z@&JQIVf7yidA=sq_Yh#)Dc~g;IsB>axg<<|60QExM{Zb+L(Dr5L_^*XRekQj!5w*J z?@vIe>nk@ra7#?TsVE7lB3GuZpWtlb7M1cmEZZlLj)+MKm05pztJ>L1f03O=iHS;} zq-;=Lefi7-@|t2~nLXBH>cQffbCnID%7HImD_1}b)8t{hQ_B-)llWLRUP+I7VR<;h z?I2G9_qG5tPjp?6pe5w+9_^&TLQTLd;cpCQc`U9S-b+07klw#Cozm|Sj%>UPN_J}I zh$d_5G#pEFYmS}Go40RT$Xr<5aO!!M4Df%}66XhY;22tzEUhU$`95eV(yAvaId+fc zaPWwFyeSnJ@BO8vsOa?%&?}*~cq-G~KhU*i=$h(%e2{d$XN+T^-n1hf{9zi+-nmiw`#kJk@2tsx$pAKCIbQHVx@E{jhy{%ek24i~H+l zMKa9&8`jh`Tpmx;{1tM$4iIIi(nTsC$@#pf6ozrbqA9%~(;Itz89Lh?sEw~KKoW|T z;ia)UE_!nzQQvQ0y_X02>n*58wpBUJlYmN93sQ_)d-OWPwr<^4`^yv87$%fvoj|s6 z_rXLt#02%;-Ho9CYfISVd%p$35xCX2_JPTAOKpYT5lXlBJGQ41Ha3v-r3{lg)@zAE{RT#p&JYRY9WdE!j?33E9Q>t`Rw&CF|Xz7~i#tgRH$;V%jon^9b<;0P-jI)mMN0fMr|HiFNy3 zh`fzQ$ve>0?T2Ps?tIvA9`Sd%m*6bLjBSN;{m;5F%X#w-hYT!(0o?dDz8gy|=n{ z@|r(Y5c7#@4kG=f-u37No(N&NL1r!s|8tfa8iHF%v3?rQ%O+^+1_yWJ?>Vgf{4u-@WZ zNk1-!wk)HTZW4s_6257G5t!f?e?{WEE8&w zZ(I^j;$AuW%XVNP_FguaUiom;HJWO$fg&V74m}yC1<~Rn7r#ZCFvLS^H+F$gn=r>6 z{I3bxIEt+YU@BSZ**dpk1w_4 z$7(MGeGGlanG1>fw;Dp@QA33mVd;W=Xg&&uR(G5J$wTE~q7Qr-y)wrhlr4$opBker zj!kB*%B#^CVfBPi!`)*_w1J!Mv|b?$(6R&_3Dw0Xy3V->&v4j?6KVMzJavJTR<7~G z``gXvYqH1_5`fBvPR2|ha{=rPC$|qnK?hTK(q}v_tEJAH02m0}KcvhjtxoqdWBEv4 z)S~z!x|ro@Sx}0#m-2@li8qx@iF*CtD)3wzC*riCwVzRlOVMT9Id0U?(=h0^^EZ#7 z5CtpafT^*2{AXoG{;23(kOlk4S$)ur|`(gQUkVQYvMvS0q z7vorWgJ~&?EM*-c|;5!Ijuv>(wp+&u|_F+u~hq6Q&4?g%rsiJNo)wY zG%liwZ4O)x;FZ+l5Qc1i=porQeAY?oKL6bjHyIJ3u~L9Vrm{L3xQyQ0-euBaclqNK zm+ek@g?U@*-A5IGVZlvi*epQxS!1=JY`Xs+F$0SMJ2&N^T|{d1yRQfb_)WsQUsA%2 z2N@d%uwsiEZk4Vincr&Hl8kf|rPhqiQpI+1Kf26L-!Y3cQzwp$213mjh95PSda^&< zjaIA!12-Q*uA?ET*eVm zZMtlcp}1Z#UFV94?d>u~Y>$*9>dQOGc#S1n_&8^28C~^aH($rQHywZbXf1DZk%rDY z)$Dj@3M68iHO4rSzQI-YtAA1g9j)cJ1vyHo1Vv(@xy=DwRsgfbA!Uy{I;$=HiAQtd zG@SAMAPr>Rr?Y(~XvdYTjAVUhjg@Zv-eO(z(eB{62%m=@fXRkriVEub->DwKv>e>u zs(ITRmYBK#Ej}L_fw$f)y~U>)>{!=H?zHYhheIy#0()=_{7V=lR_+UlJ+`XRFu7)0 ziw-fP@%=pBrAQ6S1%b~E!4qp|k2lQ`t}Q51f9AWaxL!R3qs!T7zd@1oU86tewn)H~ z4|aX8v%BWfqULX^>}RcFDBWqM&OxujUvCs3RQPxzguXL<%0o4>ieOU>l?#dFgH18v zC|#QldqI&Z_1)>KVY8-dcHbAJ^!k3j$LTa81JS|i*W)^etwmZC$x1a(yY|-2E zRhrRWC`CCA*z9$HcEV`7OO$nz7Iqs8k9$<8F*j+?ar>eH7Rk1CDc`G$%GsaYQP|qp z0R(1#*A$#^b{sXCrT0ET7PVUWzC4BfSm!T!u-}QtVO3l?9jEF2og=0Q9(M5)3BQ%3 zdlT{eXHxz3eA& zD8-k&E=;;DOvpvM+0Ur59LsoHXyVjP96z-x>Cs#j%>$Q(^WB~uKuTnqIhLru0O%*# zE3>OlzxAvK>*E@;1Mq@IU<>#-rd4Vc99UyoM4u{0JK@?(GYw5~`6hqj&Wt2wvKa@- z=TndBo+}zK2t$CUDQ;I4@T_>$+G*6l^puPC&pZ>yTmtD`ccZQ;3QaF2ZG)sAc>3aS z0^-Bq&hechT}qb2^yZ;N;1vA4K@gz!9E}=7FM59thhqOiYYbWKUeGbGdS|_KiG^D% z0~X_6^LflE`+{;21q^!$7L)Ar7*D;UaK$Ugqdc$rkOQeZr8uj!jv1QXl26aI3Y!iv-|1JU_eQd*JL53| zn6$n;SLi)$@_1gPJ3!kGO5Xc7h?jv5sIUd{T%7&T!~V?rE|bUkcP7b$whDr>HgH&{ zJuFXn5Hz2$IxB68oV)uo0a94Zuo*cis%qqSFJTMCI8{Ks$)(W9A#r(e8t!H7u^q8> zlU`R^o-Nbe-px~UCZ(%VF|APkpl<&(RJENPN!{~Pq}J_q4I&i-oJ5Sm@`kacljbC>`hl zk9Ww6yHP8dqXJ$VCoyKLE2}5XnB5DhV zc`Shv6s{FV0>!Z}b1{kDc2PhrxRJ%vGF4kD`1&$c8UY?T9u0P|`HTp46jb+?_DN#-_7H>w*L;f!V{f|n^gT~rW z7USw#?QUL zp+^nC6e`1ad>|&j#9Q?nzm#GRP@3Rm;QZo`KCMUaj96p}w?(U2(i{7zzwSMl>9bru z+Fjn0<36!=gv2ho+AFCd;D(&W|HzMSasxVA7=&^5P$mO&d97uK*Vtwa<;mkeVH*Q> zesn4Kylh2BZnkb>72rZNuV3y<{8IF`dcq>E1Zw%FcKIrCzc7rGhkEB+n&+U#VzdhE zMHZ7qm@*06&%H6Y^%{B;nz)5jYc`M)V>Nh!JDAvPmdeb?q)lr-eU*)Y7xHlzaKZO9ts8?R^}eo5|(b(e+590UqFbLUyzqy zK!;yMLP$tLfS;FNOoE@E(+raI4}hbym7R^x{|+#a`oi}Y@cdspXgk|@c$>Rf172G> hTUawFJDS^CYgwCH`M3^QOa4^?C@Z{^uaz?k{y)rQ2%-Q0 diff --git a/tailbone/static/css/smoothness/images/ui-icons_2e83ff_256x240.png b/tailbone/static/css/smoothness/images/ui-icons_2e83ff_256x240.png deleted file mode 100644 index 513a241d44d68c31a0db4af7f0c4d4a3d5596822..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4353 zcmds4_g9lmw|!CwEtG^JO^RT7r6qs@(n1GCng|Gl8kz_}y0koWqzDKiQUugju}}mH zNDCdL3j!)7s7RBd5LyWN(C_|of4Kj^-D}pIbJm(Yvu4&_v-Y`ZZfeNECd38+0Ee-W zo+SW44pU&A1$H=37@yxg3~*2Fi`oDru>`x2>vUKTnwwbZ%f&JNKaI?<7s3v&rWa&s zdI^9+Sa=guEtug)PIDX+h2?BYx|E-*Y!$t%QJ*Mr~_uxHCR zY2c71W@6`evf(nf_&;ZjB$Yj*O%C^ixLz>S1Cp7J8UX<3uCboBRoLehJiFUyEh<+m z;?wbIL)N=c4qvt?eC7DGsK{JELXHB1p?vwaSbmaB`LE7psXeH1=k4SD^I1xgJ;MK@ z(P$44S$#=)wtcUyPf%d00ol6enFqy8Z=@FmQr^do@$3BaT-nvt9LHZ0fs$8L^O%u|1omXL_Hc^mQyOi z+Nzqoa=IGMk_-0a!X7b67x&-xS^=qGS}y;R#gjLHd^Ik$tCG1}G~feP{8o}CyVU=n zkn@`eqs&K0mJ~$6Rs)$|o?^te7q)u70S~mZK8Q!^KkK@!?NRzx*D1d_))}V$bapT5 zcsvVw@d2hZ<^`wiu}!HrX(_)aIEL3ZLVzR_{HP%7hUvrYBa0il5A82!EhU|yl(4n_YM8L6DgpmWH>d`KHl7OG%qJ;R z(|6N(^|!{?ZW8ODrPVDobCebv(d_+LIA^`5HuRWh+nw<~9VC3WSG`vwYmON`@MhLO zLi=ZtB0GAv>ZH(-%4=mXA&MDQq3`_scEq(3Mu;}(6?0AW;yhl)6l{hxW~`SGP?&8V z%9QdH_Qp77 zka@k_bBKO*c-q{5Xqr9htymFwcJimB_(ptN^}@P%(h^+nc1~bIMo)=svP>DAxuxW<%)Dx zZID)~t3hib!Z^G8I`{Df$?>F3Jv!Kb&m3uIO%phchQ6wi)qCf)ThSg)l z|3Ozm>kexNC>)U9V`sC}Df{017V+49;lhsw~@9F);Sbm}p-lFEQwHFB@?RWwKuL z#<8@iYU|?!llSGzb7ur3*mVF`;F_P!H$e&9k;ab;4&Gj}CB$ao{H3Wqo%`-g&d`v0 z{0;{)fz=!ZwT{QxYrHFA?ZHR{bDmc@4qgFr!05b`0xVz-GfrJzLP0)Vr3&lda%V?o z9ATg%&m3HUUhE1jGmaS(lhQYGIod6>X-6I_Xh3>wnA3%MS~cRe`SdxqAi6Q0vu` zlxl-O3H#Qobu!L3XHYtLBd48mdh6bgLa-18v_r>C82|)-eNZ;9r|w?9J?`aeFR`r5 zmb910Gd1fRLTbp><)oX>B{^=x+us4Em0jd#y^|>Op}lusuWA8J78=g{+y#i`trv?z zCbN(IGT96fjT{w@juf~p;&iY&Z%3zVa%Gq5qlydaYH4=Xc1JJ&R|5CGb)@=-bG8Kg z0yD#giLD9s@+(TzYQA2NX+O80!IG8r5kBLD;Hnp+Yqk;3C=EY`&CI^2G~$NmTf+=& zdeKa3?|PPN2C_CREoztLg>4$#aWmJ*A^ByVHA)qfmH@i?IgZoc`sSu4?+g2l+s@kn zm3ar97EAo%X>O|mVBcDDslRA+W%I!GI5v7Adrt9Bj^7hHbZz`|t*<*FPZN%3@!bhl zNhXXO^gphQ8B(F8PYQ`tt4vK?Ty^LQ?|6_}Aq9~UK~TeK9<*zzx#?`aGfaMFnV?on zF?HOjDz=mA`8!YtY=vL*f(d(SZjWz2;2Tz{0yolsl<*IH<}0YtgyvfSSIDaSe2%pU zZ~E|U{3P_?anmJIXquw}do6+AzemPd*OhAuwxsH&uaHHsmQncDpipZETWb@NAbH-FMEzl?v0qM#96^=PNTRh) zr{3|$ox&6NQuq6~D817rTTxIGqiKKou2R{OE?(}_DvSElcQoxBT$R-}_eT&Y-ogF- ztxw`^9(kND&3w1?XK3WBAwPY%rA;@Z@gNzU2yL5`zYN2aW1ifa=Y0cGg^F#Q_!C&=k3#H73AtpI zZ|b(|seSD)>+3xC#?7jZ!lJ9b46ax7nqU5YAOP9Q_;`=xTaB$kTX#ckH|^FwItDW} zohi2$$pHKyTK2mdA7}olowR?kuyPB-1?dj>JVoIXfcA6dZ8SKa zCVtfA_3MWI;fQd65nVK#5p1#3opPVI59XVGwN}hOoFJx2(a-3wvoHw6eNGjUF*QAL z=%UheZoa7-?U46K-ktk8nx4t*-_sWN}-gqxSMMh3wne+FRQw#*w^-9o>llI8scLp)4pn0lLa9Y)!lO5WEk zOMK4WV85f7(%o`VlJs6{czltk41MU?GJa1z*Bs1qcWjg|n7-m35416MoBEGdNtMJs{#Nu`YHP}y`ndPnx zWE+>Q_3f~KAt?r{vO3Dx+%=A)E)`iYri?R22fbe(llX(#TNc_z4L;=9{$1uq2`s|X z(}Bd29BKw&*jONEWxE8Q>H9ThAx{we=7Ni!YBiMStvTEzRH+eo(71m};G^F7f|YN> zOTUouK%pEfrR9MGvLxyHNeMs<@dG|Fm+?4js|)(Jt_Nhb4GT)~F==-@po4KNc|%an zW6rMZc_B!O%B}$BmyGzhnyET_AMk_=dU)a*aKn2i_BXf^-`BK>9z0<+UkIvpahDLF5Od#c?6rNblSuOUSJP%m zsM)vCX)!ss$tl&b)>)qLU7WzJ@I8>OD78|zE4>|`D~x%v(vzx|4A9=`zgZcW_sn$v zp(9P~zf0TK$gZd)e7T2qmc@M&(z>oF_p@h_phw>LF*27@neCaBPy|z6=u71^xL|un z1Fl}H?a%5T6`Y<#4_9qXc;x@R*L#V?5~4E${#*0z)4_}i(7NeN)2y%LxS_Z2UXm$7?)mg) zh8*3ENN0;(RCAZ71a*9=T&HnLfX{(*b+USA>RaBv4GUGtb$Xn-@0U7x_g3iOzE;dD z!>HZg`omkbHfdbq;QrUFi))SU9Gx8K-8<%|3X4(Pz8O~;+ucl=8MPv2WJDCAC<&Mu zlwi|e-nKRBhMG*`$pG{#o*cP1#?6xvj7mlc{IpD&o3HjLS@><(GQ+iwyt*R3fWT@R zLGIF+FRzLBEYHWuTqN!;W|!_Jr*JW&FjS?pAwow_cVS;kDvo6d{Yj+N#_ouo{`)vm zQt%-a&d?KsqGU$CgGn?9#~oUj1U@SlC@w1{zj_hFS>q ztt;Yx#Vj#9(Y=~+l3Rm`wPQW5LXbwo1sXg)=Boh$2Muf42VGS_#aiQsVmnY{+OIjB zWnpW&tSMeMa69fOEQQ~SwBPPSU(4V~dkvmr&_9x1Fil3#y9S^dds-7%ap)5U{bfuU z6EbGFo?e`Z3EUT}@AXo&jP2Sx?1+yC>DvYoT!P%sxdpf%20#IWRg}dj$YQWom^0@T z70+QYvKW_I{d-L9%d*|N!&z`fhdv<4Mey9A-J~cMdrlx>U0001LT^$V*008($1^gxh z{~0w4{8au3FkID86##rvB(a(0@Mlistgo#B(5zC(5&;0%Hgz>r&4Ool-oR2^2h0be z&}9P?jA8xuk}tvhW3vi$3a3w5a2n)d)AcGtaR+I^2fT7R(ps_cce0XxFiA@w*I3_ zYGUEU?0a>E|MvX0e3_9OZTdKzY8@66f5u0yGL{Zj8HjFoi1=37Pa?5QCyO(!HE4T( zzzqfAf5_3s+_K~2(JzQ%FH4HqADwD$4?m-WKz9itz9%t~hMS8SPj_s1rlEow7)!jw zH+s;HKZ>|{vKai@@_CPRwMQY#y?X+f_>>V-zieqy4w9kUT|`WruZV~IQwv$mLb7u{ z-sKfQ8VC_?p*((e7dtmP0Q<8yxZ+j@5}Vr!6FN;=Qw^8+bwpb73arJ=-e66m_c!lF z3hpl6Lb2>0DaB4^v^Dt_i$Biizsa#%;~{3Tf6KrMPo~10KGkR6xaQJxB~7%fjS@ih z{iV(cehI^6-WuylHSPUKyj=b^`IJMK<0ZEwxmP;4XhVl+-2WzUQ!WE=$e{}eeSQ<5 zq>U#PKlsviCBte+0In($srBc?OmSnP62-$>PeJjcD=f>P7@(uLQ~xtH^kE^zbQC*jco+rc;_Jy!`pnl~+TKMcnYQK&5? z8PW@pN7@VAm;t-5co#P@gDb$qP?(1A<3|nTJ8A#rL4K4n>0`bkMKpQv+K_hss>Wt6eM(m<>ipkK_#XRd@)S+ep>bhCq%Wl zZNPwUj!$^isnsG*0K#Jw7?g*7ly39LB5YYO!Gc9dHx`~5G~$S?kAr}0)&zqeU^mQU4(%SsJ>WT39=k7wy@t$mAm z=Z{|e0!}HB^dHZvzMVIxRD+by{WzQ@fkpV2pD%s{5i&|G8{48NXK-51cY(9<}_!cRJujTL_ggPK@|C8)u)HufI}z+Uv!@&NzGRNdFJ?V3t_sh|Q`?zm$N|P*@W{*Jq_NMP(ijoH zh9P#13sa&z#cxI_pFVroT3pwnX_H^W>1X4^|Mmm6aZUyHZVM7yvJmmpB;e`31jvl2 z$Yx@8&}R_BBHa{Zp8C2(6$ep^Lch!Ta;IrQb&8Af?QJu2n9}PVQQf!_5AQ^9Ayg%$ zUW$XO`^Crw6HQ>m5uKklOS)jg6EmqkwS;`MEr?c$Iz>+SQ&vCn@%O#8(zV_@)(&Ku zqV!s3%+l{FUL`nn=>u{fWH^5_?3sp`s)ZHCveIwbv=OV@kWc!8t*5E~Kfe7F+^rOW zR(DK4?`wxQ2M4D2pqGm^O9D+`f*;t|a^G;5O+ed&|VFG=vdY16#tC{=|)9UZ= z=ixO)+uvSSmvF!3Uv5*Oh{o9*by3T~eug9#EOw^nvL8+ASC`@jFIU6oC6;XZ}y6GsILLT z_XkHd=TZ|UTWPsPpHgV)9{0sDJ%)`WWzKh<@ln&N{kDQ_S;9n@$ZN{p`8Iu-2~(WhOFHR<$qu zHsw8~1|0Pck&Ebcu0ZA&c{$=M0&r@zMs8-aDWsT;(ksSAcsl`gr{$H>P5EnOdt!@e za|X+d&WH9(YaiZjdh){alArgvR=uNqyr0{xCj0qTwf{-#VPy*Y_z`5*Q)VsqIO6p$ ztq7#^2p~s%6*hL|c%ihtwR3EsAQEt=N$25FczG%|qwrO^B`|nlUuU|JY*4PCKfz0z zc(S@&Am|kAjpHZMC*#Oys9kU>G&clWP=~Tkr8G^kJnyF(q$|OO1}K9tjLdq3&I)U< z0&`8pT2YF2nDm@I)-nbatgtPdnp|Ch%9v_DG}J8jla_)Ji(6=5q|Zk$Tcn?uRipZ} zS(hCY!@j8>AXK{Klp9`sGr-)sKw?FXKFAB)wg+t~tZ72dJ`Deij@iI4o!s|Kbihk5 zQ{=gAJof9$a2~nq=+d?5v|IgFA3L1&sFxQ}<@w61bS_Y<|{OpN5Nqs(XJY z_oc&~sJD8ae(43Iz^gTMKxTZ7@F;WAshh~Y;M$GM2baZ}p_n8I@l}j-{b0;M=H3f5 zp^?twu$T$;d#MpoGKRJW+LJoM=vJdhe#{`HjZ-d#%YnEPY7c1CVYkgMptXO-D|=pT(M@uEFKP&w<1Q8KycWJ$BVkc3~0iF z({bomWeKRz%pN1)NY30+KJYlo>Q@@At=^%2&9x<_0t>fiY;|wC@(xN5xIa^S3GTgD z$vRc)QLM{FD8yS1=mxzS+c9bO?O^gvxb)+6q!%YztPGDDmB`E!@vEzg%6&&U0+;d~ zlzMZSyhbZd;^LL%}k2ta%yuu&;mo8Z?jDJ#Jn2Y!!{0# zC&%pZSe$F6usWfw0^WfLE?=KiL)=pHQ<#4;b6~N~QXeBBWbz_qT#RS)K)6B@UdT zI?HRHc+erX_{A2y9x@5qMWD@vvAb69LDf@s4U%o>KF%y5Y2Yb?4<(AI&o~1eqG4;e z1kcol1XMo`wm&6rp1f3;@5v)@)E*S)J|p>jwa|@|_X^K+_{q`LC#O7ywA~!mL^oUD zZZ2TI@{DTp9#iHZ&Jfu8aeUf_fPc*1e$nxzYNw^Xr0XEtGLb_5TE=q5Bp}MPy_T<_ z^Cy1jzON)HfvNUJl)%l4%eT;z=^j$IU`QTGXe<;f-!;abzwtB+Q2)3%k!ACb)Z`_@0#4fUr{|YMaD&C+$qHEIcuXr zwRWAls-HBmlBBouxc zp7*H!oKV6AalQmvC|-$?PxL~9Bj0{Zv_h3Xb*zG_IA5oB3+K=pIV8JM{wo!^2CL+x zdJR`0-U*^weBU!rq7=i)DgaflIP1Q(wjb{UTlx4mzOEj;{gV0Qf|9-0-Vpp_=4VHH zwp&)n6az@*@XMI|9&|Ox&wKrmpEsKIC1FL#07)`YSZqZ5u3S5Ge2e%l(8_5TUXhF zNPL~+f&XjLne|mWX}9<~P|A#R%4kt2gpTibJ{~skfO#(no@yNblBJny-fZ?T@7mcx zFvxN4DEctY?>o;%TtfBt0yVp~I0eiMNy|mc{CSBo(G{IUsL_GS&(f`-uCq8Ebo(|Z zIOFJOH>{Hh7(Jorpr3x@CzNUqKiz}N067RwJ2+T9)Gp(^cs^DV)J=O=sA>8~x-W2= zXC33v#_801Zs|n-^;kBMot*9Bdg3 z`WgtAo9M<;BA>}7-d^G~6YB6n?!wQ8@dr~OLwEGux>93=aw$g~rjAJ~P-t>1?_do# z=GIUtnX2@4u@?vu*a@lZr~}&x-Lck+U<;D`Ld1P_1=X>=pa-yE^-m{_7W3_IIg<;3 z=qT*akv?piBwAOxdGUb7MujD&-SJu;slaVyUL-hoJf1&65Ml|A&aQhJjq`D;tfa)0 zC;7XUW-nPpgLb+e0K|LW-z!^+31F8bc5I*me{|?GHmQ!uOldpm95V$pO*{1hg;GRZ ztme;tDfUgB`c*T>wsPe(D-Cr4P__gQ9{8KewWbwJxV|2KoG9o32Y#6fe1!;G%WO z8^qFi?I=oG36)B>%)vWtix<$Xv#Qn&9q$ zB1#O)v|F~A)whKn_V6~svDe~qL5QW26U3N!8N38oW^de&h~;J~tbqAK>pWxNgULxV z((T6XX}@dG$6;&mL^zZBcgclhTdH!R-*R_^tqUbhl-x%VZRfI~y>^^kR_%K$1G#-p z>NYE0oq&2lwZnY4o`TDpz6Y|(+#Y7X0JnqVJP}hnHY$%$;4sFOOqIF2xz8KbR1Op7 zL`3vTMqsYz>yGOuPdSk<8vl|Yu%Vw$CooyS( z=Y`fM#||O{|8N|1>$J*Y!O28x<^4ph4?7#$w~rBVv47{8!gu;Ox@f)2%7K0}O9x@- z`_51?o1wWL=m6@B!rnZ#WlsD0U51BfVwLeT{roiw#tkOAWyQ*brN6>KJQiMDM9@Dx2S>vwiSQI&j!%+w&IYLswb9aVZSj zajcr7_ASNN4e?T6n3L}y@~fYwiAI(QADY_e9&_TaIIj@liF?7bEMr%!wWa((NsBs+ zT;y2mw~gll`G_;W5vho;A#8R1ma_c2JAx}rEQkPKaI8?*BGHBC8|1I-yZ5cZM>$nw zvl{^TbVI>=#@KP5lH}u$Nkkc!@MO;8Vg@@h>f$r+e=WA(l|uSDcdMxtf|?61%_&Zv5+&1Ko7xxUR^Bh;(k+*W?WP_sF%{)= z_Kau@&_@_4W#~|<3J~m5C6r%iQ~U>7Do%_+pGdE;mM}!8&=UQQd}3t!Ojh|S=aOz) z<}ZM&!Rh1unZTgaq(H9qTLSc?dO7PErdl_Do66)AEJxds6PUhR)K+53gjdpNWtZEi zPGS88jvs<1Q`^q3X00a}Ip`ofKjjLrd(jtX%D;ujti4v{UVP}C$aleo&`qblI}c$6 z>9-RUV+*v${e_f1Q8-aS>c>C?|B$0d`wuBaHx>tI>PC1OcYiK%xakROPCX&H948BG z@8#9kaWU-=j8k3<-M_6dQgEHdJ7rygec+?w9t~R7#cscxrb$*>W&kat2IWSH5h31| zPv7CCT%Y#veTYcl6{2Ee7Oa`t)URTS(x-ZJrU|jV0t>|h*7ASW<4Kp9U-*9M)|2R% zU87RBcMmS~qWL)0RP6A`@Xz_@pri((0_<3D#z0*mt8oY@smF91?z}m+63-7f>P>SH zSp`R?Lm!kSoDh421&^ga2L4k4U$jvU<_X|Zu{?)x3vF>X;HvVZB7W@T$#mRWtBnpD zivG=Tf>+1dT87>knXlSv@Lp=)s^$f&Xi&&J)TG+e%jUHSobR`lNmBlGJes%ow`*%& zS)vV<+|T~%{%Z-gHrhN_6lEsJJ^cN}>@(DO1M?a8$8~h0*kokm)#yRtlyqqe{$kTp z1ZU?#Rt!}8ic#MnHY)?q6StSZUfZ8Zob_NU1#{QO>X72Y?BQdXCffn@p4TM}*K6hn zH&=huc~MK|d3^I7KUi(CjpE9~HY<`4u!-!#QpR^sT6xrquklMu&|SPgA)WZDXU-^O zbpXs1PcXE!$MFO}HU-U>aTU1mp!I6>>3Pj}5eOuc;+Yg=t0T6f z5a&5D{y-Lb;Ah@?LBk8A`Ysx;7uPH`sX2CeV(q2~J08|`+*FK~J%?dv6#8ywUg_Sl z^lmxVO@NgAeCP7+*U=cl5?b?6?Xc;Re=Zwcx`)7_ml}C{Z~kt+rph}R8@HZN_yZL5 zGj8v@yB5#(^t!q`q|Q5rYNxFy;0v80e^y0_ySn9Rvn{zFEy&W82wf)5e}NAn3s-_% zI1xV%tWTr9vLTMEcsz&)&LeYQh zvT9obNIX>CA>+hF7xK1WlJdV&m!p$}^T+910m3(;4z%0@D38km2Lx4EqI*`6i-GtT zyS-O}d={WJD$l6Vv?==<$kV#L;H{VUNpoldT*i4{(Um$XEwV z#EJrxsq4%>XkAH}b|1lZ0(l{KuyYy)@TgES17PxBK!%HrXe;V*&J_?xZhh3UPWCui zJ!Dx6QwDpCd*B}}=r{{;w6Je8*~;4^=X{7Fw;k~w<@lre26zFes0999{vm1}DmlNF0#LdYy#P_qC;-5_bT}>m6 J8a2n*{{hD+0U`hZ diff --git a/tailbone/static/css/smoothness/images/ui-icons_888888_256x240.png b/tailbone/static/css/smoothness/images/ui-icons_888888_256x240.png deleted file mode 100644 index 8449bebc24690ee945ba5827b2ae8d6d3fb5bb36..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6897 zcmZ`;cT^M3lTShmNbgDsRVmU1krq1AyP=0(3>^YQ5TaD2gCQs&*Z^q(K{_M|2uhVA zy(mrTL_tCc=kLn9`@XyTXJ_Z_%-emlv%8=9>>~>^0|vS)bN~Q=!SJ@O6#xMI+Xd`{ zDgU;5&qFo;3Q8X>Q!M~6sYLEa9{jHkayK^61*916iU|M!SJ(`7wQQnhcZ!_TMEh*} z;_h!`Y?u{Zjvh*5OH)JaX>KoHbE#z$ZFlVji8BrBd>=|)6}TnODx&*Hw;|6WYZ{~V zNB~H?ig_r?K66{FT`=VXMJPLc=51pQSDlZX%<>4Fx`&Uqnrc&2Ik~)|2DjT8xmPrI zh=0}sDXVC$7{*t&Z566u)J?UE zW{RN{x?6?U2&~d{?4l!`>1;iO<8?%#7+fP^7!nzq4Y|K;!ln}Oq9;NsYZJ1;zK?0w z^ytw%oxcxo+O*KlZc=8NYbd?%(%;Wb;UOgb(Ig(qf_>7`tyX3mX^R2T-TA0X3 zA(gD|#E%Uh;GtNhVLl1lQyRiCael+~m?)UJ3wwck61Y*i$ND;NKA`Zaf=$sH4`AiX z+LZ?G`Sf240G1IWS&ljFS*0e?m;5-yf=;}=azJ(|=uT3sUAw=w-rXOjsN z>)zgG4Cf9)w?ff))QT@%bH=*0g>CD|^!KeZyTdu-kZZqM zX$Lg>mI5__HDfwl&G{^w6Iy?0z(o|yIzSj#U!h_M8E|cQK49yr*GXI3@8<>a=bZK9 zyG+a0HU1>+nM=7)!fWDltLuXc{@SV{px?ZY0O|%pnj_iZ(XjtgLoeMF^j7AwFx76t z>2#kkwRsgAT^oEyEo4XW_O%j{exp!p;LsQiz*R%9YM^}u{T?^=fhXas)*YQzA69(} zRBNc@-ds*K3>5eA1#&X`|k{Bi8$KttNiI|J(~!{i~Szu9SlB zJvK3+;pY|=+!2kceJf82^q%ZmTbQ&2L=z(EK0e{wjepku$-`B#r}y={fioS~{4TP* zwvPWiGoMPlwn1jgBmY#{-tN$9YEyl5^Nad9=RnR|pG_(=o<^e6C2ywQ9_J(vG+cmu zhg8Ewp7D7*$B)OHaiYdeT>lZP{}H;A@1sI?o7>D8G#U$w;6IvadJWEv+87Lv5K_?h*ke&MLht`|R3>RLFdLq`>K#3wfneoIf^GbteCm@-5>$Ig4&mV&SI#{^;5! z9nKrnGppBH+POV8-_ssGh@&K8oZeV01;`?1@=bRcM{)4Z^`HK@L%0i0C?=Fi=zoBs5TZ!J6r?Y@#(8*@LamF$X)xd5|Ev^aF~ zyMS=>K7Vt1ABm2oSV45+Lp!gYe6}dyj5vMwR=^vK#HuQRZkr&oV|+=i&`xcshuUnQ zo?=ps(Z_8r)1yCteQ@LZ#g=74; zmO=ENhA$lv_O9=uhi3yXaqbxJG{SnT269OXc|x4|tzVEKm^f4vrxW?gomxpe?)T-c z-!#lFso_<<^H^xN;y2c&z$qVd`pD{iiRNdYIXL~i7!D-!HMiCugCcB6N?EX_p{tA_ z()}zx-iU(GK3V3T*^_tF_}L7^wcEa4znorLJqZ-^56sSMLnIe+&i9x5qjoqgV1BJh zCBBjK&Fs$ZF(yC`pbf%-hcUS-#LWXS^+L{CpZOI00zYSEvVOghPYa^H?pCg#bvOqqCenW5a9b0hZ{ z%s<=cNsXHdlxR?uJFgS{DtTYIH#*k}UVYZF@T-{{wrxo|JTZ}|ieV~&QYZ0VJqt{i zJN?!_uVmvxo({<^kFA&*gUU-`7~j%f89WBOSdg9%#5#LivhnIud+wEu=MbB>WTo#3 z;$~^pHKBID8@Z+KbP9<_U1H9D5}upw-TjZ(gYyyK)flVnvoRKJEirRJ5ug$=qpe?m zD`$}nY|Z^}*-~o4%~~`=SquuX3(Hg)U*Y_i*3xSqZ?TiyGI6-QmX{p*K{X9~0e+L` z`JhdWFhHPdGy4AcC(qhmfm0a!c~Pj4&zxM*YqoBaki25wrM*?r>M*9X-<7=gR%w!) z$m@>xlveFdI|mtFbv6a}1Gp8wOI;dbdJ?+TXE6?Oj7Gns?o)i~Xtmr&P=X21<^QBa zV}VhXei?qdk@X=9KDSM|k+#n3=IOWw&(2BR<&ifK99= zwp#oS249e>eZJ?}AJ8hmXmuW&pyVU-Bf=k(sE_`8&<|nA;Dyy{?!CkIUfVcd48PoW zX6_KrIy>+(?b<1uR59lViu+={&S&O3H)l5HssK1Ztu)m^<^$wm0mGAxSWaHxb{|7J zpmq?cG_$HQaEFxQp)|eRymp_Ab|k_&s3`JS3T54OlU!%!!YUcpaFTJ-;eWi2gAhwV zC$Ij@U(dFUwW2+;0;ZlbFA`tVrvnCfbz*lRj$*Q7t4eh33sFrNnZC^KcLNeX1vjUW z{VBpe&P=|UwZ%Em#09q_{Y4E_+?|Rg1Hhq*EhKz%GY;ug(%DE}qR^6zr5Vbe@e9vP z&7DvDRJDs-OVw7)Nde>>bK6wP!+sOB5lbazED_7N<~JRl`W`@F4X203;t zg#RQOi#3kPS*q{H{t65kn&t?_8tAxb(7lC!C>(%GHYG|cp#no?_5=98c0vc65PnJY zEdVFC#ZF?%wOV zI%PD%TC6vhF-cIAHamZJRS*kq`;C|%o@&P8>W00@Cb?1=>XPq22G}sL&Bz(mqc9FG zByK5@;RaSQIw+R*SPeP1%Bf0vf02{D<9Ep1I~#o}RS2<7$%{~R@26{elK%S|rh{j;~0OcNe{49_lRsDON( z85kh~J+`M=g z?emj}T|W`o{=k2L_;X?O4W7AD`za&q-Ov@QOM?qX`-0_^cH*k8Z;+^+6^d}Zq~*G^ zUmn9xlW4+tdR)>$TiYSmBHsu)>IEnF=C3lr-R>#mlj)Ht zF+KIMcAFu>VYkAqT=VFcKEG{wSZg=lM+j4+R{Iz>T@7?>2lpG6sOuc5 zP}GE^vzhf@&+JGp^vRX1T2+)Y$fj@sF0Ps`a}3Ou#K-)cC$t9o!t9kL;cZDV7STIj zjnKAt>=PZxLI5Ajlk!myJ+zMW0sB-`en8Ezr=>nvl_2QI>#k7#oJNG|xpn8(@yyx6 zZWYVpV4_qFu<41?`MUYGB#F-dRN3NKtL3ghRAeCHeOVTMq1SoYU#IS*#p&dw3y92( zIx5_F@zHdg=3<=-F#tRb}gI^5mS;3J1?d_F_mPDBFBz;y*rJ4^|e=;TGaqE(ix#(RaM;|lH%mMu!V zRY-3l>TDB&&|q43I%WGjv?P}~L5#~qx@KNtE}RUCm$>&}-n}gYPVI^{jj(Uj)!?*g zcvq~^i^30Th2+2*0bRC@ziQh06FH^fQPw@T)JPd64nG4BVbEbXrWilCk^au+aE`tC zWeefVG~j;8y>Oncgl{Yu`_)a{hg$t-=KOt0PJd{WN8mhtSx5Rm#Zj)br&3 ze+4;x6Ljg(fAF&VO?8bK+LC{x-vl1x2+U}>&;YrPzLhUq()*_5&FWfDPZ@5}YhhLW zfTM2#vE*ZjmWtpG>7h%`3$x=V4a7W`IcRZ^dL6DFymG{SK{t;$CoPxii;^mNZ zDYGPBNgcAvYEriQtB9$ZK9Bk#Q(i{{fFWQGY=Lsyouy@tw`V0}riGxs#yuLjd?yTO z*{QE*kL)i?tvmw>MGlt3dv@Kh-D>x8QUSfr*vtD;9*ObuqCps8kHXp7V~b68IqM0E z6Z;jeLGu_iihzdGkcYob*Y(bk0u4jP<)qfH!qUB!8SopN{{qD)K9_Ef{#WgB6{u8r z@L^>COK8r~tHP(qUbd?gMSx;&tg}XdVI%}cJS5Idp)jZ3&W8zxnxcfuogf12?#7?X zo!GdFJH7WuROr;=`-Q#d_N!wkX>s=uc>UOWQTlQ=>f;^FB(xFQTr=>~NkaCf8^UD{ zXI^43IC)S>I^~b8vgcKzlWZU25>V2+EgeMcwHWwZgKO%*h|mZs*o4#j@!XWQ!VLR8LZzyt;r5i6&C4$;(ney@uf}h zPJVql!rm@Bj(#1aS90a|&gPV}=JwXso1)mPWO(hTk^L(Vr<7O3fKCq( zY^i<>{wbnCY@}&j8rYL=AD=ok8|A+IyNxcqf++{zqztnb+36 zgql?8#&utijk=<%lUmMGoFWdp`M1mt`@RRd46neagL!akdspWR++oKOljlP)t~F^* z7f=O{G%^JjdtRc5+nIV#P>=O?p2HWPZX8d?y(;M(7 z+n*2V4+MJ(;-{jRF0$fc6XKrF%CDmgPBou}Jvu%I9g)`E6+1*ZtC(^WYyUTyBwM#$ z#O!j(gsWJkoRZmzUfJCw&^X2Z+ZRpdZN)vcb%)X1ZxdP&pmZ&l$OAqOl)Miu!fCMh zVob9LDV#L2fu-LN&guqHCevuXpc(c3x+>A(Kh zkX#U-sQgEClQK)fH*iy&=;h>DG$`g)oQ9OnsoS%0pU?i|n5)Uq<%z9{{#9Z1Wo-Rc zl{3{{2``wQxzf$rY_OO9|LvPAh{e=1xmMY~w zZXDPM{%?j(mUh`Ox}X+a4$e9V1T20U^)t$z>yw#|x4>I63wR9n{Tiw1=r*YO1(uP| zn6~(E16!yd^q{!y6OoeU@l!&kh1B6JjfOjIa!e1)k|zNY{Dbx?!eF7f5{D~lROa@>otkx^asLlmytFu{R@^F2Gl@VGM(w43wQcAv-^X&mL z9muTVn20ae=pDZCZ36}hW!Hh^1xxQ2l;=$^1fg=;d@>;)SQG2SSCRxFp~4rr?DZQ^ zw|<7ayCgQ=EMWr6{%hh^?zB>>`gc)k!k)152nPZc2z&H~sOb49SMA;XL_taV%j@ES z6RXBmt8vEkMI^5)j$}$n5g;WtsyzlU|2;(7-bOqKr_@5o3Q|-XRx+4H%yGj@rObm% z_Gz-BsniDG`y!bhOL8Sd>a2pYi9bD~xNh1qK(EI#i?BxbN_+oG%*T*Sc+{BZw&T#t zrw7gR}YKYO--PU8n+1ADbm#WsP1!)C@6AQ|Pk|Hr&f)2=e7yT=R|LI7vDq8aR3@2T1 zZ<=VBD2{I`6l|0MzwI-jQ_BnSFAok19j-BffXMCo^ zbAlRHzVshIU#v5Cv2PW=nPx#nzsk zxanQVS;2Tjv|NJc#dR6Xd<%Uy$_@nlyAG7d}g5{F^&lr1%0l zBotjk+?t2yF5%RA%^Wp$j)oi-fxR~SnRXl5#3=*2sr4_d1NafXn5wfQxn0@yx>Bj) zh*NK=p!8Mdu`m(D|H*>pH=^mtDKo#s%zc3iSM1VhB2ryos1NfmVrhSrwuaOu1o{Lz zL@2rCQ-Z~Vs{&*KB!jj)aA0xS2Dy4)zb*4%S67n8ekmks&4b$IAsBp2mtw}qLCYYQ za{R^*MP_X+!V7{#ZzROBakr8qG1mt9o^5lZ?kFr0|EbMtr>EUR`xcVb}rfs zz5C1mA4!0LaVADM-o6OUcUF$SSHRD5%KEO35m#$jXYl5_10G5D?_)=Z*UR9qiPjr2jfx w{?`oFLEhn!ZlPWPEzcl#F9E{wA$R{tgzeWHC*Dhks=_7ND)w9#X=D* zAT4x|E(oZUpdw9*LMVZd5B=^h_lNrr+`VSaIcKfeGizq;HEW-nW+n#gNI@h30PIGF zx)uNcIZT0d7TDoDVRU}?Fu*;uE@}ag#1h;>uH#`nXl87w*N;U0e;PzHXvpE!bb~BR zE&)&o3r~WIIWzpoY4&3xuzVB;8Y9TWhfrhHP)a?XU7U&J1jFMLJwu#y+=<=+G#lPY zefu;~V_V;o4VSsZ{yA$Xq2v*5e7GOP<${4Okj!+{5CAxKjdZmv!#=MN*jz_z(Yc}# zpN>x(u-=8T`yitTmE+SQ!gB!$Ir0pK(&gKt`AO2{zdDyCX;7oi+sFIovlJzIg#N`~ zFzz6#`jXOYJFTrxfPbn1)k^cogJj>Z)y`vzg&YUpCOs+`ML9yac=09BkY;+YMK*=+ z6l-@_gyA_-Y%CC6{{l}OI6=g}nyJ(lBgmK8tokoD^oXyg7@)B=3DJ>&5 z1TM;uO;bEwl7Z_=EZnR)1IGR|90kx=4dSB^JZV=b-@|_1GSmKx*0k&M_bE5&CC0km z(F#gr=FcYc05;#vtKL<{tcxh8UPo*-=kHbWXS)=P+J>^3)L z%I>b}W+Nv)2r{PkfIyzRIili$vne*cA)u*6Sc`78NRmiao^7WN6_2_bA5BbY1+Il7%65pDlTdbu`cKl%jrOm9pF^U;v zUN83;qF)`JHuE2vW{Y|&S_Gb*{3#)}5#Lt57!!8>@m|Q2n18jE6@;^5`$n|UtI)IN zEL+Z|UXfaoVeTClfHjN(=h|e6J0G`hkp9ft_9KOs@fDnSY%z;CuC*(!Vj+1UrxAy_ z>EN0Kx-4_fQXeuWCsjE9(R6E@e)Ql;`HU}+(v{Pz?41lm3z!eb3ISvFy6Ux55}Jvgo-eXJ9b~F@MDEVaSD^EtV^UcB=-Rj)O9{p>Z8PlF7*+l zzVtHd!1HIi1NZ4gTrN`&UaQe3QwsFk*oWVGKp9>+BeK#g2Xh=O_ zhaHu`YKDed#pCVN-<7cTV8w$u&MO}WuK+nKQs8?H1g$C6ASH%#D@9c*YJ?p#jfjhrZg!ykoPsr^r?* zJbZ94J!LjgSt&_gV0`~H5)G9PX*uhk%41b!TTbf&Caxl09pnLfl@er**krK0uD-Xj zI-IL7@7w~y2n^HH!9klkmbsVvx#^8d5}>u`=9$y=zvblQh6T&2Ko`4QTEQ~ZYV{CuW)dnz-8<>4j`L#Xs49PiWJ%2+uXY?u0x|IG)9OCs;X| zIC9YcxH4u)c`toZP`FxoYU1LmeOGwLgVYL1h_o<*8ouYgcP%wH9qBW}b-Q z58ozCLJuA{T_T01Imols68Zdlq@8qJIHzDsTF+Abu0+j@f7m9#e6Sg=%ve{zwm2EH z9=^A7p0rE#Qwui6JII}W{acg5OsAR#xx{W>mpWpszxal6^%WtHdq_W2#fz&(G(pSY zM)w6lx$975Cw8P+6|KdJ|GiXyEsOsA%r76i$tCO!o#Oi#LP^%kouxb3=_{ejMGjD& zI2DPU=LdR5PN|b}1&I19WMP~|6rnXJ)XLt*%9tcTp0^=Ue;BCmmy;q#&?S2$ky^)7 z@A%?Q5s190`+c00-f82lD5$aFv_E}Uv200)Ap2>RMeXUkJ*^ykmE|_qM-V99!TRAFEw=@-hO zInkr2re(k`r(E=s{5{8oSfy1Z8E5lcKUUZ*=aE$BhrPBBJh>HN5v%Eyh4YvF^i-{b z9G9SHkJXwhH_r>7p6U(0@oL#-!KurKV~ka{R{qv6k?C-g-st0y&X3xm{8K9o*|s}_ z)S#7rcrAq*YT8)E4D`ve`lT?=x@UJ!2h4jPaaTv6owk?!6C&EeMImYpIZM@>;h}?9 zMyKDKFof0Mwf*Cv=e%MjFTWJgTB{8GG7M9Sd2(x>=M6{|EVg#!OJJ2d3b7?6W12E+OB7b4nbWuuX_PpVJ7^l$b)RYa z`L^=GeCe0y#t=5EaQG-ApbHR{Tz0fxVbWj^;d{9x04q@>!*}k_@>Y!<-jLF_EJB?xmWDo4LGaiwKqqko;9y6te%*0qH8Sr z6&of_v9gwZo+nVb&0flK`WJmue9@c%7Urd;BLgrd+)(7iO328KcP6vZ(C_Moq;gdO z9s@h_tGJRgAgzf1c^k6#{_;^Cx5haRwv%wvPb;~^Ox%#J#InNC?RqB4I zTkP1EZVTkPd*B=J!?Y9o8=Q&nYuZE(p0JuN1XVk`iStwZa^G#x+P>F`Cwc#?VZ9{S z?9=GDn4H_>nCejLBuD%%M&y$J9!OV^T&dfY+K$f^!aiB)NmWe-_TK5eSs9r3$aMRm zE#=pLcW+-kyP}f#&H6};8+iH9l1vb?&!;yt zWa+McbY$$Js+$}osN+lJ`W}Zk_#8M_C!>3&zUA%Puu$b($H%GrzNwRUZ-ox-YsS1X zh}!+FH@sDAoyI8!?tjg?xYp>#-pP*Hy<>K&uo%tdlW~=?-OZGlQ7ddpMnoZsl7N{( zairezwvAyo)OeCW2B2RFWbI`0;gdJ zxx2@Fc}=Wmc|K11qTlXfcIj?%3MVrfOI17@B6#$47w)x$!dRBzpF~P^?2gFkzmFp+ z2_I7C2t6?12B0Bmd}1s@_QShfdx$T4F^kVmbgyQdyKp3|9FZ7Wt=pXX1_PJ#K1;ivS}%6Wt1Jr`Z1hd_CNw diff --git a/tailbone/static/css/smoothness/jquery-ui-1.10.0.custom.min.css b/tailbone/static/css/smoothness/jquery-ui-1.10.0.custom.min.css deleted file mode 100644 index 4a8c7096..00000000 --- a/tailbone/static/css/smoothness/jquery-ui-1.10.0.custom.min.css +++ /dev/null @@ -1,5 +0,0 @@ -/*! jQuery UI - v1.10.0 - 2013-02-09 -* http://jqueryui.com -* Includes: jquery.ui.core.css, jquery.ui.resizable.css, jquery.ui.selectable.css, jquery.ui.accordion.css, jquery.ui.autocomplete.css, jquery.ui.button.css, jquery.ui.datepicker.css, jquery.ui.dialog.css, jquery.ui.menu.css, jquery.ui.progressbar.css, jquery.ui.slider.css, jquery.ui.spinner.css, jquery.ui.tabs.css, jquery.ui.tooltip.css -* To view and modify this theme, visit http://jqueryui.com/themeroller/?ffDefault=Verdana%2CArial%2Csans-serif&fwDefault=normal&fsDefault=1.1em&cornerRadius=4px&bgColorHeader=cccccc&bgTextureHeader=highlight_soft&bgImgOpacityHeader=75&borderColorHeader=aaaaaa&fcHeader=222222&iconColorHeader=222222&bgColorContent=ffffff&bgTextureContent=flat&bgImgOpacityContent=75&borderColorContent=aaaaaa&fcContent=222222&iconColorContent=222222&bgColorDefault=e6e6e6&bgTextureDefault=glass&bgImgOpacityDefault=75&borderColorDefault=d3d3d3&fcDefault=555555&iconColorDefault=888888&bgColorHover=dadada&bgTextureHover=glass&bgImgOpacityHover=75&borderColorHover=999999&fcHover=212121&iconColorHover=454545&bgColorActive=ffffff&bgTextureActive=glass&bgImgOpacityActive=65&borderColorActive=aaaaaa&fcActive=212121&iconColorActive=454545&bgColorHighlight=fbf9ee&bgTextureHighlight=glass&bgImgOpacityHighlight=55&borderColorHighlight=fcefa1&fcHighlight=363636&iconColorHighlight=2e83ff&bgColorError=fef1ec&bgTextureError=glass&bgImgOpacityError=95&borderColorError=cd0a0a&fcError=cd0a0a&iconColorError=cd0a0a&bgColorOverlay=aaaaaa&bgTextureOverlay=flat&bgImgOpacityOverlay=0&opacityOverlay=30&bgColorShadow=aaaaaa&bgTextureShadow=flat&bgImgOpacityShadow=0&opacityShadow=30&thicknessShadow=8px&offsetTopShadow=-8px&offsetLeftShadow=-8px&cornerRadiusShadow=8px -* Copyright (c) 2013 jQuery Foundation and other contributors Licensed MIT */.ui-helper-hidden{display:none}.ui-helper-hidden-accessible{border:0;clip:rect(0 0 0 0);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px}.ui-helper-reset{margin:0;padding:0;border:0;outline:0;line-height:1.3;text-decoration:none;font-size:100%;list-style:none}.ui-helper-clearfix:before,.ui-helper-clearfix:after{content:"";display:table}.ui-helper-clearfix:after{clear:both}.ui-helper-clearfix{min-height:0}.ui-helper-zfix{width:100%;height:100%;top:0;left:0;position:absolute;opacity:0;filter:Alpha(Opacity=0)}.ui-front{z-index:100}.ui-state-disabled{cursor:default!important}.ui-icon{display:block;text-indent:-99999px;overflow:hidden;background-repeat:no-repeat}.ui-widget-overlay{position:fixed;top:0;left:0;width:100%;height:100%}.ui-resizable{position:relative}.ui-resizable-handle{position:absolute;font-size:0.1px;display:block}.ui-resizable-disabled .ui-resizable-handle,.ui-resizable-autohide .ui-resizable-handle{display:none}.ui-resizable-n{cursor:n-resize;height:7px;width:100%;top:-5px;left:0}.ui-resizable-s{cursor:s-resize;height:7px;width:100%;bottom:-5px;left:0}.ui-resizable-e{cursor:e-resize;width:7px;right:-5px;top:0;height:100%}.ui-resizable-w{cursor:w-resize;width:7px;left:-5px;top:0;height:100%}.ui-resizable-se{cursor:se-resize;width:12px;height:12px;right:1px;bottom:1px}.ui-resizable-sw{cursor:sw-resize;width:9px;height:9px;left:-5px;bottom:-5px}.ui-resizable-nw{cursor:nw-resize;width:9px;height:9px;left:-5px;top:-5px}.ui-resizable-ne{cursor:ne-resize;width:9px;height:9px;right:-5px;top:-5px}.ui-selectable-helper{position:absolute;z-index:100;border:1px dotted black}.ui-accordion .ui-accordion-header{display:block;cursor:pointer;position:relative;margin-top:2px;padding:.5em .5em .5em .7em;min-height:0}.ui-accordion .ui-accordion-icons{padding-left:2.2em}.ui-accordion .ui-accordion-noicons{padding-left:.7em}.ui-accordion .ui-accordion-icons .ui-accordion-icons{padding-left:2.2em}.ui-accordion .ui-accordion-header .ui-accordion-header-icon{position:absolute;left:.5em;top:50%;margin-top:-8px}.ui-accordion .ui-accordion-content{padding:1em 2.2em;border-top:0;overflow:auto}.ui-autocomplete{position:absolute;top:0;left:0;cursor:default}.ui-button{display:inline-block;position:relative;padding:0;line-height:normal;margin-right:.1em;cursor:pointer;vertical-align:middle;text-align:center;overflow:visible}.ui-button,.ui-button:link,.ui-button:visited,.ui-button:hover,.ui-button:active{text-decoration:none}.ui-button-icon-only{width:2.2em}button.ui-button-icon-only{width:2.4em}.ui-button-icons-only{width:3.4em}button.ui-button-icons-only{width:3.7em}.ui-button .ui-button-text{display:block;line-height:normal}.ui-button-text-only .ui-button-text{padding:.4em 1em}.ui-button-icon-only .ui-button-text,.ui-button-icons-only .ui-button-text{padding:.4em;text-indent:-9999999px}.ui-button-text-icon-primary .ui-button-text,.ui-button-text-icons .ui-button-text{padding:.4em 1em .4em 2.1em}.ui-button-text-icon-secondary .ui-button-text,.ui-button-text-icons .ui-button-text{padding:.4em 2.1em .4em 1em}.ui-button-text-icons .ui-button-text{padding-left:2.1em;padding-right:2.1em}input.ui-button{padding:.4em 1em}.ui-button-icon-only .ui-icon,.ui-button-text-icon-primary .ui-icon,.ui-button-text-icon-secondary .ui-icon,.ui-button-text-icons .ui-icon,.ui-button-icons-only .ui-icon{position:absolute;top:50%;margin-top:-8px}.ui-button-icon-only .ui-icon{left:50%;margin-left:-8px}.ui-button-text-icon-primary .ui-button-icon-primary,.ui-button-text-icons .ui-button-icon-primary,.ui-button-icons-only .ui-button-icon-primary{left:.5em}.ui-button-text-icon-secondary .ui-button-icon-secondary,.ui-button-text-icons .ui-button-icon-secondary,.ui-button-icons-only .ui-button-icon-secondary{right:.5em}.ui-buttonset{margin-right:7px}.ui-buttonset .ui-button{margin-left:0;margin-right:-.3em}input.ui-button::-moz-focus-inner,button.ui-button::-moz-focus-inner{border:0;padding:0}.ui-datepicker{width:17em;padding:.2em .2em 0;display:none}.ui-datepicker .ui-datepicker-header{position:relative;padding:.2em 0}.ui-datepicker .ui-datepicker-prev,.ui-datepicker .ui-datepicker-next{position:absolute;top:2px;width:1.8em;height:1.8em}.ui-datepicker .ui-datepicker-prev-hover,.ui-datepicker .ui-datepicker-next-hover{top:1px}.ui-datepicker .ui-datepicker-prev{left:2px}.ui-datepicker .ui-datepicker-next{right:2px}.ui-datepicker .ui-datepicker-prev-hover{left:1px}.ui-datepicker .ui-datepicker-next-hover{right:1px}.ui-datepicker .ui-datepicker-prev span,.ui-datepicker .ui-datepicker-next span{display:block;position:absolute;left:50%;margin-left:-8px;top:50%;margin-top:-8px}.ui-datepicker .ui-datepicker-title{margin:0 2.3em;line-height:1.8em;text-align:center}.ui-datepicker .ui-datepicker-title select{font-size:1em;margin:1px 0}.ui-datepicker select.ui-datepicker-month-year{width:100%}.ui-datepicker select.ui-datepicker-month,.ui-datepicker select.ui-datepicker-year{width:49%}.ui-datepicker table{width:100%;font-size:.9em;border-collapse:collapse;margin:0 0 .4em}.ui-datepicker th{padding:.7em .3em;text-align:center;font-weight:bold;border:0}.ui-datepicker td{border:0;padding:1px}.ui-datepicker td span,.ui-datepicker td a{display:block;padding:.2em;text-align:right;text-decoration:none}.ui-datepicker .ui-datepicker-buttonpane{background-image:none;margin:.7em 0 0 0;padding:0 .2em;border-left:0;border-right:0;border-bottom:0}.ui-datepicker .ui-datepicker-buttonpane button{float:right;margin:.5em .2em .4em;cursor:pointer;padding:.2em .6em .3em .6em;width:auto;overflow:visible}.ui-datepicker .ui-datepicker-buttonpane button.ui-datepicker-current{float:left}.ui-datepicker.ui-datepicker-multi{width:auto}.ui-datepicker-multi .ui-datepicker-group{float:left}.ui-datepicker-multi .ui-datepicker-group table{width:95%;margin:0 auto .4em}.ui-datepicker-multi-2 .ui-datepicker-group{width:50%}.ui-datepicker-multi-3 .ui-datepicker-group{width:33.3%}.ui-datepicker-multi-4 .ui-datepicker-group{width:25%}.ui-datepicker-multi .ui-datepicker-group-last .ui-datepicker-header,.ui-datepicker-multi .ui-datepicker-group-middle .ui-datepicker-header{border-left-width:0}.ui-datepicker-multi .ui-datepicker-buttonpane{clear:left}.ui-datepicker-row-break{clear:both;width:100%;font-size:0}.ui-datepicker-rtl{direction:rtl}.ui-datepicker-rtl .ui-datepicker-prev{right:2px;left:auto}.ui-datepicker-rtl .ui-datepicker-next{left:2px;right:auto}.ui-datepicker-rtl .ui-datepicker-prev:hover{right:1px;left:auto}.ui-datepicker-rtl .ui-datepicker-next:hover{left:1px;right:auto}.ui-datepicker-rtl .ui-datepicker-buttonpane{clear:right}.ui-datepicker-rtl .ui-datepicker-buttonpane button{float:left}.ui-datepicker-rtl .ui-datepicker-buttonpane button.ui-datepicker-current,.ui-datepicker-rtl .ui-datepicker-group{float:right}.ui-datepicker-rtl .ui-datepicker-group-last .ui-datepicker-header,.ui-datepicker-rtl .ui-datepicker-group-middle .ui-datepicker-header{border-right-width:0;border-left-width:1px}.ui-dialog{position:absolute;top:0;left:0;padding:.2em;outline:0}.ui-dialog .ui-dialog-titlebar{padding:.4em 1em;position:relative}.ui-dialog .ui-dialog-title{float:left;margin:.1em 0;white-space:nowrap;width:90%;overflow:hidden;text-overflow:ellipsis}.ui-dialog .ui-dialog-titlebar-close{position:absolute;right:.3em;top:50%;width:21px;margin:-10px 0 0 0;padding:1px;height:20px}.ui-dialog .ui-dialog-content{position:relative;border:0;padding:.5em 1em;background:none;overflow:auto}.ui-dialog .ui-dialog-buttonpane{text-align:left;border-width:1px 0 0 0;background-image:none;margin-top:.5em;padding:.3em 1em .5em .4em}.ui-dialog .ui-dialog-buttonpane .ui-dialog-buttonset{float:right}.ui-dialog .ui-dialog-buttonpane button{margin:.5em .4em .5em 0;cursor:pointer}.ui-dialog .ui-resizable-se{width:12px;height:12px;right:-5px;bottom:-5px;background-position:16px 16px}.ui-draggable .ui-dialog-titlebar{cursor:move}.ui-menu{list-style:none;padding:2px;margin:0;display:block;outline:none}.ui-menu .ui-menu{margin-top:-3px;position:absolute}.ui-menu .ui-menu-item{margin:0;padding:0;width:100%}.ui-menu .ui-menu-divider{margin:5px -2px 5px -2px;height:0;font-size:0;line-height:0;border-width:1px 0 0 0}.ui-menu .ui-menu-item a{text-decoration:none;display:block;padding:2px .4em;line-height:1.5;min-height:0;font-weight:normal}.ui-menu .ui-menu-item a.ui-state-focus,.ui-menu .ui-menu-item a.ui-state-active{font-weight:normal;margin:-1px}.ui-menu .ui-state-disabled{font-weight:normal;margin:.4em 0 .2em;line-height:1.5}.ui-menu .ui-state-disabled a{cursor:default}.ui-menu-icons{position:relative}.ui-menu-icons .ui-menu-item a{position:relative;padding-left:2em}.ui-menu .ui-icon{position:absolute;top:.2em;left:.2em}.ui-menu .ui-menu-icon{position:static;float:right}.ui-progressbar{height:2em;text-align:left;overflow:hidden}.ui-progressbar .ui-progressbar-value{margin:-1px;height:100%}.ui-progressbar .ui-progressbar-overlay{background:url("images/animated-overlay.gif");height:100%;filter:alpha(opacity=25);opacity:0.25}.ui-progressbar-indeterminate .ui-progressbar-value{background-image:none}.ui-slider{position:relative;text-align:left}.ui-slider .ui-slider-handle{position:absolute;z-index:2;width:1.2em;height:1.2em;cursor:default}.ui-slider .ui-slider-range{position:absolute;z-index:1;font-size:.7em;display:block;border:0;background-position:0 0}.ui-slider.ui-state-disabled .ui-slider-handle,.ui-slider.ui-state-disabled .ui-slider-range{filter:inherit}.ui-slider-horizontal{height:.8em}.ui-slider-horizontal .ui-slider-handle{top:-.3em;margin-left:-.6em}.ui-slider-horizontal .ui-slider-range{top:0;height:100%}.ui-slider-horizontal .ui-slider-range-min{left:0}.ui-slider-horizontal .ui-slider-range-max{right:0}.ui-slider-vertical{width:.8em;height:100px}.ui-slider-vertical .ui-slider-handle{left:-.3em;margin-left:0;margin-bottom:-.6em}.ui-slider-vertical .ui-slider-range{left:0;width:100%}.ui-slider-vertical .ui-slider-range-min{bottom:0}.ui-slider-vertical .ui-slider-range-max{top:0}.ui-spinner{position:relative;display:inline-block;overflow:hidden;padding:0;vertical-align:middle}.ui-spinner-input{border:none;background:none;color:inherit;padding:0;margin:.2em 0;vertical-align:middle;margin-left:.4em;margin-right:22px}.ui-spinner-button{width:16px;height:50%;font-size:.5em;padding:0;margin:0;text-align:center;position:absolute;cursor:default;display:block;overflow:hidden;right:0}.ui-spinner a.ui-spinner-button{border-top:none;border-bottom:none;border-right:none}.ui-spinner .ui-icon{position:absolute;margin-top:-8px;top:50%;left:0}.ui-spinner-up{top:0}.ui-spinner-down{bottom:0}.ui-spinner .ui-icon-triangle-1-s{background-position:-65px -16px}.ui-tabs{position:relative;padding:.2em}.ui-tabs .ui-tabs-nav{margin:0;padding:.2em .2em 0}.ui-tabs .ui-tabs-nav li{list-style:none;float:left;position:relative;top:0;margin:1px .2em 0 0;border-bottom:0;padding:0;white-space:nowrap}.ui-tabs .ui-tabs-nav li a{float:left;padding:.5em 1em;text-decoration:none}.ui-tabs .ui-tabs-nav li.ui-tabs-active{margin-bottom:-1px;padding-bottom:1px}.ui-tabs .ui-tabs-nav li.ui-tabs-active a,.ui-tabs .ui-tabs-nav li.ui-state-disabled a,.ui-tabs .ui-tabs-nav li.ui-tabs-loading a{cursor:text}.ui-tabs .ui-tabs-nav li a,.ui-tabs-collapsible .ui-tabs-nav li.ui-tabs-active a{cursor:pointer}.ui-tabs .ui-tabs-panel{display:block;border-width:0;padding:1em 1.4em;background:none}.ui-tooltip{padding:8px;position:absolute;z-index:9999;max-width:300px;-webkit-box-shadow:0 0 5px #aaa;box-shadow:0 0 5px #aaa}body .ui-tooltip{border-width:2px}.ui-widget{font-family:Verdana,Arial,sans-serif;font-size:1.1em}.ui-widget .ui-widget{font-size:1em}.ui-widget input,.ui-widget select,.ui-widget textarea,.ui-widget button{font-family:Verdana,Arial,sans-serif;font-size:1em}.ui-widget-content{border:1px solid #aaa;background:#fff url(images/ui-bg_flat_75_ffffff_40x100.png) 50% 50% repeat-x;color:#222}.ui-widget-content a{color:#222}.ui-widget-header{border:1px solid #aaa;background:#ccc url(images/ui-bg_highlight-soft_75_cccccc_1x100.png) 50% 50% repeat-x;color:#222;font-weight:bold}.ui-widget-header a{color:#222}.ui-state-default,.ui-widget-content .ui-state-default,.ui-widget-header .ui-state-default{border:1px solid #d3d3d3;background:#e6e6e6 url(images/ui-bg_glass_75_e6e6e6_1x400.png) 50% 50% repeat-x;font-weight:normal;color:#555}.ui-state-default a,.ui-state-default a:link,.ui-state-default a:visited{color:#555;text-decoration:none}.ui-state-hover,.ui-widget-content .ui-state-hover,.ui-widget-header .ui-state-hover,.ui-state-focus,.ui-widget-content .ui-state-focus,.ui-widget-header .ui-state-focus{border:1px solid #999;background:#dadada url(images/ui-bg_glass_75_dadada_1x400.png) 50% 50% repeat-x;font-weight:normal;color:#212121}.ui-state-hover a,.ui-state-hover a:hover,.ui-state-hover a:link,.ui-state-hover a:visited{color:#212121;text-decoration:none}.ui-state-active,.ui-widget-content .ui-state-active,.ui-widget-header .ui-state-active{border:1px solid #aaa;background:#fff url(images/ui-bg_glass_65_ffffff_1x400.png) 50% 50% repeat-x;font-weight:normal;color:#212121}.ui-state-active a,.ui-state-active a:link,.ui-state-active a:visited{color:#212121;text-decoration:none}.ui-state-highlight,.ui-widget-content .ui-state-highlight,.ui-widget-header .ui-state-highlight{border:1px solid #fcefa1;background:#fbf9ee url(images/ui-bg_glass_55_fbf9ee_1x400.png) 50% 50% repeat-x;color:#363636}.ui-state-highlight a,.ui-widget-content .ui-state-highlight a,.ui-widget-header .ui-state-highlight a{color:#363636}.ui-state-error,.ui-widget-content .ui-state-error,.ui-widget-header .ui-state-error{border:1px solid #cd0a0a;background:#fef1ec url(images/ui-bg_glass_95_fef1ec_1x400.png) 50% 50% repeat-x;color:#cd0a0a}.ui-state-error a,.ui-widget-content .ui-state-error a,.ui-widget-header .ui-state-error a{color:#cd0a0a}.ui-state-error-text,.ui-widget-content .ui-state-error-text,.ui-widget-header .ui-state-error-text{color:#cd0a0a}.ui-priority-primary,.ui-widget-content .ui-priority-primary,.ui-widget-header .ui-priority-primary{font-weight:bold}.ui-priority-secondary,.ui-widget-content .ui-priority-secondary,.ui-widget-header .ui-priority-secondary{opacity:.7;filter:Alpha(Opacity=70);font-weight:normal}.ui-state-disabled,.ui-widget-content .ui-state-disabled,.ui-widget-header .ui-state-disabled{opacity:.35;filter:Alpha(Opacity=35);background-image:none}.ui-state-disabled .ui-icon{filter:Alpha(Opacity=35)}.ui-icon{width:16px;height:16px;background-position:16px 16px}.ui-icon,.ui-widget-content .ui-icon{background-image:url(images/ui-icons_222222_256x240.png)}.ui-widget-header .ui-icon{background-image:url(images/ui-icons_222222_256x240.png)}.ui-state-default .ui-icon{background-image:url(images/ui-icons_888888_256x240.png)}.ui-state-hover .ui-icon,.ui-state-focus .ui-icon{background-image:url(images/ui-icons_454545_256x240.png)}.ui-state-active .ui-icon{background-image:url(images/ui-icons_454545_256x240.png)}.ui-state-highlight .ui-icon{background-image:url(images/ui-icons_2e83ff_256x240.png)}.ui-state-error .ui-icon,.ui-state-error-text .ui-icon{background-image:url(images/ui-icons_cd0a0a_256x240.png)}.ui-icon-carat-1-n{background-position:0 0}.ui-icon-carat-1-ne{background-position:-16px 0}.ui-icon-carat-1-e{background-position:-32px 0}.ui-icon-carat-1-se{background-position:-48px 0}.ui-icon-carat-1-s{background-position:-64px 0}.ui-icon-carat-1-sw{background-position:-80px 0}.ui-icon-carat-1-w{background-position:-96px 0}.ui-icon-carat-1-nw{background-position:-112px 0}.ui-icon-carat-2-n-s{background-position:-128px 0}.ui-icon-carat-2-e-w{background-position:-144px 0}.ui-icon-triangle-1-n{background-position:0 -16px}.ui-icon-triangle-1-ne{background-position:-16px -16px}.ui-icon-triangle-1-e{background-position:-32px -16px}.ui-icon-triangle-1-se{background-position:-48px -16px}.ui-icon-triangle-1-s{background-position:-64px -16px}.ui-icon-triangle-1-sw{background-position:-80px -16px}.ui-icon-triangle-1-w{background-position:-96px -16px}.ui-icon-triangle-1-nw{background-position:-112px -16px}.ui-icon-triangle-2-n-s{background-position:-128px -16px}.ui-icon-triangle-2-e-w{background-position:-144px -16px}.ui-icon-arrow-1-n{background-position:0 -32px}.ui-icon-arrow-1-ne{background-position:-16px -32px}.ui-icon-arrow-1-e{background-position:-32px -32px}.ui-icon-arrow-1-se{background-position:-48px -32px}.ui-icon-arrow-1-s{background-position:-64px -32px}.ui-icon-arrow-1-sw{background-position:-80px -32px}.ui-icon-arrow-1-w{background-position:-96px -32px}.ui-icon-arrow-1-nw{background-position:-112px -32px}.ui-icon-arrow-2-n-s{background-position:-128px -32px}.ui-icon-arrow-2-ne-sw{background-position:-144px -32px}.ui-icon-arrow-2-e-w{background-position:-160px -32px}.ui-icon-arrow-2-se-nw{background-position:-176px -32px}.ui-icon-arrowstop-1-n{background-position:-192px -32px}.ui-icon-arrowstop-1-e{background-position:-208px -32px}.ui-icon-arrowstop-1-s{background-position:-224px -32px}.ui-icon-arrowstop-1-w{background-position:-240px -32px}.ui-icon-arrowthick-1-n{background-position:0 -48px}.ui-icon-arrowthick-1-ne{background-position:-16px -48px}.ui-icon-arrowthick-1-e{background-position:-32px -48px}.ui-icon-arrowthick-1-se{background-position:-48px -48px}.ui-icon-arrowthick-1-s{background-position:-64px -48px}.ui-icon-arrowthick-1-sw{background-position:-80px -48px}.ui-icon-arrowthick-1-w{background-position:-96px -48px}.ui-icon-arrowthick-1-nw{background-position:-112px -48px}.ui-icon-arrowthick-2-n-s{background-position:-128px -48px}.ui-icon-arrowthick-2-ne-sw{background-position:-144px -48px}.ui-icon-arrowthick-2-e-w{background-position:-160px -48px}.ui-icon-arrowthick-2-se-nw{background-position:-176px -48px}.ui-icon-arrowthickstop-1-n{background-position:-192px -48px}.ui-icon-arrowthickstop-1-e{background-position:-208px -48px}.ui-icon-arrowthickstop-1-s{background-position:-224px -48px}.ui-icon-arrowthickstop-1-w{background-position:-240px -48px}.ui-icon-arrowreturnthick-1-w{background-position:0 -64px}.ui-icon-arrowreturnthick-1-n{background-position:-16px -64px}.ui-icon-arrowreturnthick-1-e{background-position:-32px -64px}.ui-icon-arrowreturnthick-1-s{background-position:-48px -64px}.ui-icon-arrowreturn-1-w{background-position:-64px -64px}.ui-icon-arrowreturn-1-n{background-position:-80px -64px}.ui-icon-arrowreturn-1-e{background-position:-96px -64px}.ui-icon-arrowreturn-1-s{background-position:-112px -64px}.ui-icon-arrowrefresh-1-w{background-position:-128px -64px}.ui-icon-arrowrefresh-1-n{background-position:-144px -64px}.ui-icon-arrowrefresh-1-e{background-position:-160px -64px}.ui-icon-arrowrefresh-1-s{background-position:-176px -64px}.ui-icon-arrow-4{background-position:0 -80px}.ui-icon-arrow-4-diag{background-position:-16px -80px}.ui-icon-extlink{background-position:-32px -80px}.ui-icon-newwin{background-position:-48px -80px}.ui-icon-refresh{background-position:-64px -80px}.ui-icon-shuffle{background-position:-80px -80px}.ui-icon-transfer-e-w{background-position:-96px -80px}.ui-icon-transferthick-e-w{background-position:-112px -80px}.ui-icon-folder-collapsed{background-position:0 -96px}.ui-icon-folder-open{background-position:-16px -96px}.ui-icon-document{background-position:-32px -96px}.ui-icon-document-b{background-position:-48px -96px}.ui-icon-note{background-position:-64px -96px}.ui-icon-mail-closed{background-position:-80px -96px}.ui-icon-mail-open{background-position:-96px -96px}.ui-icon-suitcase{background-position:-112px -96px}.ui-icon-comment{background-position:-128px -96px}.ui-icon-person{background-position:-144px -96px}.ui-icon-print{background-position:-160px -96px}.ui-icon-trash{background-position:-176px -96px}.ui-icon-locked{background-position:-192px -96px}.ui-icon-unlocked{background-position:-208px -96px}.ui-icon-bookmark{background-position:-224px -96px}.ui-icon-tag{background-position:-240px -96px}.ui-icon-home{background-position:0 -112px}.ui-icon-flag{background-position:-16px -112px}.ui-icon-calendar{background-position:-32px -112px}.ui-icon-cart{background-position:-48px -112px}.ui-icon-pencil{background-position:-64px -112px}.ui-icon-clock{background-position:-80px -112px}.ui-icon-disk{background-position:-96px -112px}.ui-icon-calculator{background-position:-112px -112px}.ui-icon-zoomin{background-position:-128px -112px}.ui-icon-zoomout{background-position:-144px -112px}.ui-icon-search{background-position:-160px -112px}.ui-icon-wrench{background-position:-176px -112px}.ui-icon-gear{background-position:-192px -112px}.ui-icon-heart{background-position:-208px -112px}.ui-icon-star{background-position:-224px -112px}.ui-icon-link{background-position:-240px -112px}.ui-icon-cancel{background-position:0 -128px}.ui-icon-plus{background-position:-16px -128px}.ui-icon-plusthick{background-position:-32px -128px}.ui-icon-minus{background-position:-48px -128px}.ui-icon-minusthick{background-position:-64px -128px}.ui-icon-close{background-position:-80px -128px}.ui-icon-closethick{background-position:-96px -128px}.ui-icon-key{background-position:-112px -128px}.ui-icon-lightbulb{background-position:-128px -128px}.ui-icon-scissors{background-position:-144px -128px}.ui-icon-clipboard{background-position:-160px -128px}.ui-icon-copy{background-position:-176px -128px}.ui-icon-contact{background-position:-192px -128px}.ui-icon-image{background-position:-208px -128px}.ui-icon-video{background-position:-224px -128px}.ui-icon-script{background-position:-240px -128px}.ui-icon-alert{background-position:0 -144px}.ui-icon-info{background-position:-16px -144px}.ui-icon-notice{background-position:-32px -144px}.ui-icon-help{background-position:-48px -144px}.ui-icon-check{background-position:-64px -144px}.ui-icon-bullet{background-position:-80px -144px}.ui-icon-radio-on{background-position:-96px -144px}.ui-icon-radio-off{background-position:-112px -144px}.ui-icon-pin-w{background-position:-128px -144px}.ui-icon-pin-s{background-position:-144px -144px}.ui-icon-play{background-position:0 -160px}.ui-icon-pause{background-position:-16px -160px}.ui-icon-seek-next{background-position:-32px -160px}.ui-icon-seek-prev{background-position:-48px -160px}.ui-icon-seek-end{background-position:-64px -160px}.ui-icon-seek-start{background-position:-80px -160px}.ui-icon-seek-first{background-position:-80px -160px}.ui-icon-stop{background-position:-96px -160px}.ui-icon-eject{background-position:-112px -160px}.ui-icon-volume-off{background-position:-128px -160px}.ui-icon-volume-on{background-position:-144px -160px}.ui-icon-power{background-position:0 -176px}.ui-icon-signal-diag{background-position:-16px -176px}.ui-icon-signal{background-position:-32px -176px}.ui-icon-battery-0{background-position:-48px -176px}.ui-icon-battery-1{background-position:-64px -176px}.ui-icon-battery-2{background-position:-80px -176px}.ui-icon-battery-3{background-position:-96px -176px}.ui-icon-circle-plus{background-position:0 -192px}.ui-icon-circle-minus{background-position:-16px -192px}.ui-icon-circle-close{background-position:-32px -192px}.ui-icon-circle-triangle-e{background-position:-48px -192px}.ui-icon-circle-triangle-s{background-position:-64px -192px}.ui-icon-circle-triangle-w{background-position:-80px -192px}.ui-icon-circle-triangle-n{background-position:-96px -192px}.ui-icon-circle-arrow-e{background-position:-112px -192px}.ui-icon-circle-arrow-s{background-position:-128px -192px}.ui-icon-circle-arrow-w{background-position:-144px -192px}.ui-icon-circle-arrow-n{background-position:-160px -192px}.ui-icon-circle-zoomin{background-position:-176px -192px}.ui-icon-circle-zoomout{background-position:-192px -192px}.ui-icon-circle-check{background-position:-208px -192px}.ui-icon-circlesmall-plus{background-position:0 -208px}.ui-icon-circlesmall-minus{background-position:-16px -208px}.ui-icon-circlesmall-close{background-position:-32px -208px}.ui-icon-squaresmall-plus{background-position:-48px -208px}.ui-icon-squaresmall-minus{background-position:-64px -208px}.ui-icon-squaresmall-close{background-position:-80px -208px}.ui-icon-grip-dotted-vertical{background-position:0 -224px}.ui-icon-grip-dotted-horizontal{background-position:-16px -224px}.ui-icon-grip-solid-vertical{background-position:-32px -224px}.ui-icon-grip-solid-horizontal{background-position:-48px -224px}.ui-icon-gripsmall-diagonal-se{background-position:-64px -224px}.ui-icon-grip-diagonal-se{background-position:-80px -224px}.ui-corner-all,.ui-corner-top,.ui-corner-left,.ui-corner-tl{border-top-left-radius:4px}.ui-corner-all,.ui-corner-top,.ui-corner-right,.ui-corner-tr{border-top-right-radius:4px}.ui-corner-all,.ui-corner-bottom,.ui-corner-left,.ui-corner-bl{border-bottom-left-radius:4px}.ui-corner-all,.ui-corner-bottom,.ui-corner-right,.ui-corner-br{border-bottom-right-radius:4px}.ui-widget-overlay{background:#aaa url(images/ui-bg_flat_0_aaaaaa_40x100.png) 50% 50% repeat-x;opacity:.3;filter:Alpha(Opacity=30)}.ui-widget-shadow{margin:-8px 0 0 -8px;padding:8px;background:#aaa url(images/ui-bg_flat_0_aaaaaa_40x100.png) 50% 50% repeat-x;opacity:.3;filter:Alpha(Opacity=30);border-radius:8px} \ No newline at end of file diff --git a/tailbone/static/js/lib/jquery-1.9.1.min.js b/tailbone/static/js/lib/jquery-1.9.1.min.js deleted file mode 100644 index 006e9531..00000000 --- a/tailbone/static/js/lib/jquery-1.9.1.min.js +++ /dev/null @@ -1,5 +0,0 @@ -/*! jQuery v1.9.1 | (c) 2005, 2012 jQuery Foundation, Inc. | jquery.org/license -//@ sourceMappingURL=jquery.min.map -*/(function(e,t){var n,r,i=typeof t,o=e.document,a=e.location,s=e.jQuery,u=e.$,l={},c=[],p="1.9.1",f=c.concat,d=c.push,h=c.slice,g=c.indexOf,m=l.toString,y=l.hasOwnProperty,v=p.trim,b=function(e,t){return new b.fn.init(e,t,r)},x=/[+-]?(?:\d*\.|)\d+(?:[eE][+-]?\d+|)/.source,w=/\S+/g,T=/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g,N=/^(?:(<[\w\W]+>)[^>]*|#([\w-]*))$/,C=/^<(\w+)\s*\/?>(?:<\/\1>|)$/,k=/^[\],:{}\s]*$/,E=/(?:^|:|,)(?:\s*\[)+/g,S=/\\(?:["\\\/bfnrt]|u[\da-fA-F]{4})/g,A=/"[^"\\\r\n]*"|true|false|null|-?(?:\d+\.|)\d+(?:[eE][+-]?\d+|)/g,j=/^-ms-/,D=/-([\da-z])/gi,L=function(e,t){return t.toUpperCase()},H=function(e){(o.addEventListener||"load"===e.type||"complete"===o.readyState)&&(q(),b.ready())},q=function(){o.addEventListener?(o.removeEventListener("DOMContentLoaded",H,!1),e.removeEventListener("load",H,!1)):(o.detachEvent("onreadystatechange",H),e.detachEvent("onload",H))};b.fn=b.prototype={jquery:p,constructor:b,init:function(e,n,r){var i,a;if(!e)return this;if("string"==typeof e){if(i="<"===e.charAt(0)&&">"===e.charAt(e.length-1)&&e.length>=3?[null,e,null]:N.exec(e),!i||!i[1]&&n)return!n||n.jquery?(n||r).find(e):this.constructor(n).find(e);if(i[1]){if(n=n instanceof b?n[0]:n,b.merge(this,b.parseHTML(i[1],n&&n.nodeType?n.ownerDocument||n:o,!0)),C.test(i[1])&&b.isPlainObject(n))for(i in n)b.isFunction(this[i])?this[i](n[i]):this.attr(i,n[i]);return this}if(a=o.getElementById(i[2]),a&&a.parentNode){if(a.id!==i[2])return r.find(e);this.length=1,this[0]=a}return this.context=o,this.selector=e,this}return e.nodeType?(this.context=this[0]=e,this.length=1,this):b.isFunction(e)?r.ready(e):(e.selector!==t&&(this.selector=e.selector,this.context=e.context),b.makeArray(e,this))},selector:"",length:0,size:function(){return this.length},toArray:function(){return h.call(this)},get:function(e){return null==e?this.toArray():0>e?this[this.length+e]:this[e]},pushStack:function(e){var t=b.merge(this.constructor(),e);return t.prevObject=this,t.context=this.context,t},each:function(e,t){return b.each(this,e,t)},ready:function(e){return b.ready.promise().done(e),this},slice:function(){return this.pushStack(h.apply(this,arguments))},first:function(){return this.eq(0)},last:function(){return this.eq(-1)},eq:function(e){var t=this.length,n=+e+(0>e?t:0);return this.pushStack(n>=0&&t>n?[this[n]]:[])},map:function(e){return this.pushStack(b.map(this,function(t,n){return e.call(t,n,t)}))},end:function(){return this.prevObject||this.constructor(null)},push:d,sort:[].sort,splice:[].splice},b.fn.init.prototype=b.fn,b.extend=b.fn.extend=function(){var e,n,r,i,o,a,s=arguments[0]||{},u=1,l=arguments.length,c=!1;for("boolean"==typeof s&&(c=s,s=arguments[1]||{},u=2),"object"==typeof s||b.isFunction(s)||(s={}),l===u&&(s=this,--u);l>u;u++)if(null!=(o=arguments[u]))for(i in o)e=s[i],r=o[i],s!==r&&(c&&r&&(b.isPlainObject(r)||(n=b.isArray(r)))?(n?(n=!1,a=e&&b.isArray(e)?e:[]):a=e&&b.isPlainObject(e)?e:{},s[i]=b.extend(c,a,r)):r!==t&&(s[i]=r));return s},b.extend({noConflict:function(t){return e.$===b&&(e.$=u),t&&e.jQuery===b&&(e.jQuery=s),b},isReady:!1,readyWait:1,holdReady:function(e){e?b.readyWait++:b.ready(!0)},ready:function(e){if(e===!0?!--b.readyWait:!b.isReady){if(!o.body)return setTimeout(b.ready);b.isReady=!0,e!==!0&&--b.readyWait>0||(n.resolveWith(o,[b]),b.fn.trigger&&b(o).trigger("ready").off("ready"))}},isFunction:function(e){return"function"===b.type(e)},isArray:Array.isArray||function(e){return"array"===b.type(e)},isWindow:function(e){return null!=e&&e==e.window},isNumeric:function(e){return!isNaN(parseFloat(e))&&isFinite(e)},type:function(e){return null==e?e+"":"object"==typeof e||"function"==typeof e?l[m.call(e)]||"object":typeof e},isPlainObject:function(e){if(!e||"object"!==b.type(e)||e.nodeType||b.isWindow(e))return!1;try{if(e.constructor&&!y.call(e,"constructor")&&!y.call(e.constructor.prototype,"isPrototypeOf"))return!1}catch(n){return!1}var r;for(r in e);return r===t||y.call(e,r)},isEmptyObject:function(e){var t;for(t in e)return!1;return!0},error:function(e){throw Error(e)},parseHTML:function(e,t,n){if(!e||"string"!=typeof e)return null;"boolean"==typeof t&&(n=t,t=!1),t=t||o;var r=C.exec(e),i=!n&&[];return r?[t.createElement(r[1])]:(r=b.buildFragment([e],t,i),i&&b(i).remove(),b.merge([],r.childNodes))},parseJSON:function(n){return e.JSON&&e.JSON.parse?e.JSON.parse(n):null===n?n:"string"==typeof n&&(n=b.trim(n),n&&k.test(n.replace(S,"@").replace(A,"]").replace(E,"")))?Function("return "+n)():(b.error("Invalid JSON: "+n),t)},parseXML:function(n){var r,i;if(!n||"string"!=typeof n)return null;try{e.DOMParser?(i=new DOMParser,r=i.parseFromString(n,"text/xml")):(r=new ActiveXObject("Microsoft.XMLDOM"),r.async="false",r.loadXML(n))}catch(o){r=t}return r&&r.documentElement&&!r.getElementsByTagName("parsererror").length||b.error("Invalid XML: "+n),r},noop:function(){},globalEval:function(t){t&&b.trim(t)&&(e.execScript||function(t){e.eval.call(e,t)})(t)},camelCase:function(e){return e.replace(j,"ms-").replace(D,L)},nodeName:function(e,t){return e.nodeName&&e.nodeName.toLowerCase()===t.toLowerCase()},each:function(e,t,n){var r,i=0,o=e.length,a=M(e);if(n){if(a){for(;o>i;i++)if(r=t.apply(e[i],n),r===!1)break}else for(i in e)if(r=t.apply(e[i],n),r===!1)break}else if(a){for(;o>i;i++)if(r=t.call(e[i],i,e[i]),r===!1)break}else for(i in e)if(r=t.call(e[i],i,e[i]),r===!1)break;return e},trim:v&&!v.call("\ufeff\u00a0")?function(e){return null==e?"":v.call(e)}:function(e){return null==e?"":(e+"").replace(T,"")},makeArray:function(e,t){var n=t||[];return null!=e&&(M(Object(e))?b.merge(n,"string"==typeof e?[e]:e):d.call(n,e)),n},inArray:function(e,t,n){var r;if(t){if(g)return g.call(t,e,n);for(r=t.length,n=n?0>n?Math.max(0,r+n):n:0;r>n;n++)if(n in t&&t[n]===e)return n}return-1},merge:function(e,n){var r=n.length,i=e.length,o=0;if("number"==typeof r)for(;r>o;o++)e[i++]=n[o];else while(n[o]!==t)e[i++]=n[o++];return e.length=i,e},grep:function(e,t,n){var r,i=[],o=0,a=e.length;for(n=!!n;a>o;o++)r=!!t(e[o],o),n!==r&&i.push(e[o]);return i},map:function(e,t,n){var r,i=0,o=e.length,a=M(e),s=[];if(a)for(;o>i;i++)r=t(e[i],i,n),null!=r&&(s[s.length]=r);else for(i in e)r=t(e[i],i,n),null!=r&&(s[s.length]=r);return f.apply([],s)},guid:1,proxy:function(e,n){var r,i,o;return"string"==typeof n&&(o=e[n],n=e,e=o),b.isFunction(e)?(r=h.call(arguments,2),i=function(){return e.apply(n||this,r.concat(h.call(arguments)))},i.guid=e.guid=e.guid||b.guid++,i):t},access:function(e,n,r,i,o,a,s){var u=0,l=e.length,c=null==r;if("object"===b.type(r)){o=!0;for(u in r)b.access(e,n,u,r[u],!0,a,s)}else if(i!==t&&(o=!0,b.isFunction(i)||(s=!0),c&&(s?(n.call(e,i),n=null):(c=n,n=function(e,t,n){return c.call(b(e),n)})),n))for(;l>u;u++)n(e[u],r,s?i:i.call(e[u],u,n(e[u],r)));return o?e:c?n.call(e):l?n(e[0],r):a},now:function(){return(new Date).getTime()}}),b.ready.promise=function(t){if(!n)if(n=b.Deferred(),"complete"===o.readyState)setTimeout(b.ready);else if(o.addEventListener)o.addEventListener("DOMContentLoaded",H,!1),e.addEventListener("load",H,!1);else{o.attachEvent("onreadystatechange",H),e.attachEvent("onload",H);var r=!1;try{r=null==e.frameElement&&o.documentElement}catch(i){}r&&r.doScroll&&function a(){if(!b.isReady){try{r.doScroll("left")}catch(e){return setTimeout(a,50)}q(),b.ready()}}()}return n.promise(t)},b.each("Boolean Number String Function Array Date RegExp Object Error".split(" "),function(e,t){l["[object "+t+"]"]=t.toLowerCase()});function M(e){var t=e.length,n=b.type(e);return b.isWindow(e)?!1:1===e.nodeType&&t?!0:"array"===n||"function"!==n&&(0===t||"number"==typeof t&&t>0&&t-1 in e)}r=b(o);var _={};function F(e){var t=_[e]={};return b.each(e.match(w)||[],function(e,n){t[n]=!0}),t}b.Callbacks=function(e){e="string"==typeof e?_[e]||F(e):b.extend({},e);var n,r,i,o,a,s,u=[],l=!e.once&&[],c=function(t){for(r=e.memory&&t,i=!0,a=s||0,s=0,o=u.length,n=!0;u&&o>a;a++)if(u[a].apply(t[0],t[1])===!1&&e.stopOnFalse){r=!1;break}n=!1,u&&(l?l.length&&c(l.shift()):r?u=[]:p.disable())},p={add:function(){if(u){var t=u.length;(function i(t){b.each(t,function(t,n){var r=b.type(n);"function"===r?e.unique&&p.has(n)||u.push(n):n&&n.length&&"string"!==r&&i(n)})})(arguments),n?o=u.length:r&&(s=t,c(r))}return this},remove:function(){return u&&b.each(arguments,function(e,t){var r;while((r=b.inArray(t,u,r))>-1)u.splice(r,1),n&&(o>=r&&o--,a>=r&&a--)}),this},has:function(e){return e?b.inArray(e,u)>-1:!(!u||!u.length)},empty:function(){return u=[],this},disable:function(){return u=l=r=t,this},disabled:function(){return!u},lock:function(){return l=t,r||p.disable(),this},locked:function(){return!l},fireWith:function(e,t){return t=t||[],t=[e,t.slice?t.slice():t],!u||i&&!l||(n?l.push(t):c(t)),this},fire:function(){return p.fireWith(this,arguments),this},fired:function(){return!!i}};return p},b.extend({Deferred:function(e){var t=[["resolve","done",b.Callbacks("once memory"),"resolved"],["reject","fail",b.Callbacks("once memory"),"rejected"],["notify","progress",b.Callbacks("memory")]],n="pending",r={state:function(){return n},always:function(){return i.done(arguments).fail(arguments),this},then:function(){var e=arguments;return b.Deferred(function(n){b.each(t,function(t,o){var a=o[0],s=b.isFunction(e[t])&&e[t];i[o[1]](function(){var e=s&&s.apply(this,arguments);e&&b.isFunction(e.promise)?e.promise().done(n.resolve).fail(n.reject).progress(n.notify):n[a+"With"](this===r?n.promise():this,s?[e]:arguments)})}),e=null}).promise()},promise:function(e){return null!=e?b.extend(e,r):r}},i={};return r.pipe=r.then,b.each(t,function(e,o){var a=o[2],s=o[3];r[o[1]]=a.add,s&&a.add(function(){n=s},t[1^e][2].disable,t[2][2].lock),i[o[0]]=function(){return i[o[0]+"With"](this===i?r:this,arguments),this},i[o[0]+"With"]=a.fireWith}),r.promise(i),e&&e.call(i,i),i},when:function(e){var t=0,n=h.call(arguments),r=n.length,i=1!==r||e&&b.isFunction(e.promise)?r:0,o=1===i?e:b.Deferred(),a=function(e,t,n){return function(r){t[e]=this,n[e]=arguments.length>1?h.call(arguments):r,n===s?o.notifyWith(t,n):--i||o.resolveWith(t,n)}},s,u,l;if(r>1)for(s=Array(r),u=Array(r),l=Array(r);r>t;t++)n[t]&&b.isFunction(n[t].promise)?n[t].promise().done(a(t,l,n)).fail(o.reject).progress(a(t,u,s)):--i;return i||o.resolveWith(l,n),o.promise()}}),b.support=function(){var t,n,r,a,s,u,l,c,p,f,d=o.createElement("div");if(d.setAttribute("className","t"),d.innerHTML="
    a",n=d.getElementsByTagName("*"),r=d.getElementsByTagName("a")[0],!n||!r||!n.length)return{};s=o.createElement("select"),l=s.appendChild(o.createElement("option")),a=d.getElementsByTagName("input")[0],r.style.cssText="top:1px;float:left;opacity:.5",t={getSetAttribute:"t"!==d.className,leadingWhitespace:3===d.firstChild.nodeType,tbody:!d.getElementsByTagName("tbody").length,htmlSerialize:!!d.getElementsByTagName("link").length,style:/top/.test(r.getAttribute("style")),hrefNormalized:"/a"===r.getAttribute("href"),opacity:/^0.5/.test(r.style.opacity),cssFloat:!!r.style.cssFloat,checkOn:!!a.value,optSelected:l.selected,enctype:!!o.createElement("form").enctype,html5Clone:"<:nav>"!==o.createElement("nav").cloneNode(!0).outerHTML,boxModel:"CSS1Compat"===o.compatMode,deleteExpando:!0,noCloneEvent:!0,inlineBlockNeedsLayout:!1,shrinkWrapBlocks:!1,reliableMarginRight:!0,boxSizingReliable:!0,pixelPosition:!1},a.checked=!0,t.noCloneChecked=a.cloneNode(!0).checked,s.disabled=!0,t.optDisabled=!l.disabled;try{delete d.test}catch(h){t.deleteExpando=!1}a=o.createElement("input"),a.setAttribute("value",""),t.input=""===a.getAttribute("value"),a.value="t",a.setAttribute("type","radio"),t.radioValue="t"===a.value,a.setAttribute("checked","t"),a.setAttribute("name","t"),u=o.createDocumentFragment(),u.appendChild(a),t.appendChecked=a.checked,t.checkClone=u.cloneNode(!0).cloneNode(!0).lastChild.checked,d.attachEvent&&(d.attachEvent("onclick",function(){t.noCloneEvent=!1}),d.cloneNode(!0).click());for(f in{submit:!0,change:!0,focusin:!0})d.setAttribute(c="on"+f,"t"),t[f+"Bubbles"]=c in e||d.attributes[c].expando===!1;return d.style.backgroundClip="content-box",d.cloneNode(!0).style.backgroundClip="",t.clearCloneStyle="content-box"===d.style.backgroundClip,b(function(){var n,r,a,s="padding:0;margin:0;border:0;display:block;box-sizing:content-box;-moz-box-sizing:content-box;-webkit-box-sizing:content-box;",u=o.getElementsByTagName("body")[0];u&&(n=o.createElement("div"),n.style.cssText="border:0;width:0;height:0;position:absolute;top:0;left:-9999px;margin-top:1px",u.appendChild(n).appendChild(d),d.innerHTML="
    t
    ",a=d.getElementsByTagName("td"),a[0].style.cssText="padding:0;margin:0;border:0;display:none",p=0===a[0].offsetHeight,a[0].style.display="",a[1].style.display="none",t.reliableHiddenOffsets=p&&0===a[0].offsetHeight,d.innerHTML="",d.style.cssText="box-sizing:border-box;-moz-box-sizing:border-box;-webkit-box-sizing:border-box;padding:1px;border:1px;display:block;width:4px;margin-top:1%;position:absolute;top:1%;",t.boxSizing=4===d.offsetWidth,t.doesNotIncludeMarginInBodyOffset=1!==u.offsetTop,e.getComputedStyle&&(t.pixelPosition="1%"!==(e.getComputedStyle(d,null)||{}).top,t.boxSizingReliable="4px"===(e.getComputedStyle(d,null)||{width:"4px"}).width,r=d.appendChild(o.createElement("div")),r.style.cssText=d.style.cssText=s,r.style.marginRight=r.style.width="0",d.style.width="1px",t.reliableMarginRight=!parseFloat((e.getComputedStyle(r,null)||{}).marginRight)),typeof d.style.zoom!==i&&(d.innerHTML="",d.style.cssText=s+"width:1px;padding:1px;display:inline;zoom:1",t.inlineBlockNeedsLayout=3===d.offsetWidth,d.style.display="block",d.innerHTML="
    ",d.firstChild.style.width="5px",t.shrinkWrapBlocks=3!==d.offsetWidth,t.inlineBlockNeedsLayout&&(u.style.zoom=1)),u.removeChild(n),n=d=a=r=null)}),n=s=u=l=r=a=null,t}();var O=/(?:\{[\s\S]*\}|\[[\s\S]*\])$/,B=/([A-Z])/g;function P(e,n,r,i){if(b.acceptData(e)){var o,a,s=b.expando,u="string"==typeof n,l=e.nodeType,p=l?b.cache:e,f=l?e[s]:e[s]&&s;if(f&&p[f]&&(i||p[f].data)||!u||r!==t)return f||(l?e[s]=f=c.pop()||b.guid++:f=s),p[f]||(p[f]={},l||(p[f].toJSON=b.noop)),("object"==typeof n||"function"==typeof n)&&(i?p[f]=b.extend(p[f],n):p[f].data=b.extend(p[f].data,n)),o=p[f],i||(o.data||(o.data={}),o=o.data),r!==t&&(o[b.camelCase(n)]=r),u?(a=o[n],null==a&&(a=o[b.camelCase(n)])):a=o,a}}function R(e,t,n){if(b.acceptData(e)){var r,i,o,a=e.nodeType,s=a?b.cache:e,u=a?e[b.expando]:b.expando;if(s[u]){if(t&&(o=n?s[u]:s[u].data)){b.isArray(t)?t=t.concat(b.map(t,b.camelCase)):t in o?t=[t]:(t=b.camelCase(t),t=t in o?[t]:t.split(" "));for(r=0,i=t.length;i>r;r++)delete o[t[r]];if(!(n?$:b.isEmptyObject)(o))return}(n||(delete s[u].data,$(s[u])))&&(a?b.cleanData([e],!0):b.support.deleteExpando||s!=s.window?delete s[u]:s[u]=null)}}}b.extend({cache:{},expando:"jQuery"+(p+Math.random()).replace(/\D/g,""),noData:{embed:!0,object:"clsid:D27CDB6E-AE6D-11cf-96B8-444553540000",applet:!0},hasData:function(e){return e=e.nodeType?b.cache[e[b.expando]]:e[b.expando],!!e&&!$(e)},data:function(e,t,n){return P(e,t,n)},removeData:function(e,t){return R(e,t)},_data:function(e,t,n){return P(e,t,n,!0)},_removeData:function(e,t){return R(e,t,!0)},acceptData:function(e){if(e.nodeType&&1!==e.nodeType&&9!==e.nodeType)return!1;var t=e.nodeName&&b.noData[e.nodeName.toLowerCase()];return!t||t!==!0&&e.getAttribute("classid")===t}}),b.fn.extend({data:function(e,n){var r,i,o=this[0],a=0,s=null;if(e===t){if(this.length&&(s=b.data(o),1===o.nodeType&&!b._data(o,"parsedAttrs"))){for(r=o.attributes;r.length>a;a++)i=r[a].name,i.indexOf("data-")||(i=b.camelCase(i.slice(5)),W(o,i,s[i]));b._data(o,"parsedAttrs",!0)}return s}return"object"==typeof e?this.each(function(){b.data(this,e)}):b.access(this,function(n){return n===t?o?W(o,e,b.data(o,e)):null:(this.each(function(){b.data(this,e,n)}),t)},null,n,arguments.length>1,null,!0)},removeData:function(e){return this.each(function(){b.removeData(this,e)})}});function W(e,n,r){if(r===t&&1===e.nodeType){var i="data-"+n.replace(B,"-$1").toLowerCase();if(r=e.getAttribute(i),"string"==typeof r){try{r="true"===r?!0:"false"===r?!1:"null"===r?null:+r+""===r?+r:O.test(r)?b.parseJSON(r):r}catch(o){}b.data(e,n,r)}else r=t}return r}function $(e){var t;for(t in e)if(("data"!==t||!b.isEmptyObject(e[t]))&&"toJSON"!==t)return!1;return!0}b.extend({queue:function(e,n,r){var i;return e?(n=(n||"fx")+"queue",i=b._data(e,n),r&&(!i||b.isArray(r)?i=b._data(e,n,b.makeArray(r)):i.push(r)),i||[]):t},dequeue:function(e,t){t=t||"fx";var n=b.queue(e,t),r=n.length,i=n.shift(),o=b._queueHooks(e,t),a=function(){b.dequeue(e,t)};"inprogress"===i&&(i=n.shift(),r--),o.cur=i,i&&("fx"===t&&n.unshift("inprogress"),delete o.stop,i.call(e,a,o)),!r&&o&&o.empty.fire()},_queueHooks:function(e,t){var n=t+"queueHooks";return b._data(e,n)||b._data(e,n,{empty:b.Callbacks("once memory").add(function(){b._removeData(e,t+"queue"),b._removeData(e,n)})})}}),b.fn.extend({queue:function(e,n){var r=2;return"string"!=typeof e&&(n=e,e="fx",r--),r>arguments.length?b.queue(this[0],e):n===t?this:this.each(function(){var t=b.queue(this,e,n);b._queueHooks(this,e),"fx"===e&&"inprogress"!==t[0]&&b.dequeue(this,e)})},dequeue:function(e){return this.each(function(){b.dequeue(this,e)})},delay:function(e,t){return e=b.fx?b.fx.speeds[e]||e:e,t=t||"fx",this.queue(t,function(t,n){var r=setTimeout(t,e);n.stop=function(){clearTimeout(r)}})},clearQueue:function(e){return this.queue(e||"fx",[])},promise:function(e,n){var r,i=1,o=b.Deferred(),a=this,s=this.length,u=function(){--i||o.resolveWith(a,[a])};"string"!=typeof e&&(n=e,e=t),e=e||"fx";while(s--)r=b._data(a[s],e+"queueHooks"),r&&r.empty&&(i++,r.empty.add(u));return u(),o.promise(n)}});var I,z,X=/[\t\r\n]/g,U=/\r/g,V=/^(?:input|select|textarea|button|object)$/i,Y=/^(?:a|area)$/i,J=/^(?:checked|selected|autofocus|autoplay|async|controls|defer|disabled|hidden|loop|multiple|open|readonly|required|scoped)$/i,G=/^(?:checked|selected)$/i,Q=b.support.getSetAttribute,K=b.support.input;b.fn.extend({attr:function(e,t){return b.access(this,b.attr,e,t,arguments.length>1)},removeAttr:function(e){return this.each(function(){b.removeAttr(this,e)})},prop:function(e,t){return b.access(this,b.prop,e,t,arguments.length>1)},removeProp:function(e){return e=b.propFix[e]||e,this.each(function(){try{this[e]=t,delete this[e]}catch(n){}})},addClass:function(e){var t,n,r,i,o,a=0,s=this.length,u="string"==typeof e&&e;if(b.isFunction(e))return this.each(function(t){b(this).addClass(e.call(this,t,this.className))});if(u)for(t=(e||"").match(w)||[];s>a;a++)if(n=this[a],r=1===n.nodeType&&(n.className?(" "+n.className+" ").replace(X," "):" ")){o=0;while(i=t[o++])0>r.indexOf(" "+i+" ")&&(r+=i+" ");n.className=b.trim(r)}return this},removeClass:function(e){var t,n,r,i,o,a=0,s=this.length,u=0===arguments.length||"string"==typeof e&&e;if(b.isFunction(e))return this.each(function(t){b(this).removeClass(e.call(this,t,this.className))});if(u)for(t=(e||"").match(w)||[];s>a;a++)if(n=this[a],r=1===n.nodeType&&(n.className?(" "+n.className+" ").replace(X," "):"")){o=0;while(i=t[o++])while(r.indexOf(" "+i+" ")>=0)r=r.replace(" "+i+" "," ");n.className=e?b.trim(r):""}return this},toggleClass:function(e,t){var n=typeof e,r="boolean"==typeof t;return b.isFunction(e)?this.each(function(n){b(this).toggleClass(e.call(this,n,this.className,t),t)}):this.each(function(){if("string"===n){var o,a=0,s=b(this),u=t,l=e.match(w)||[];while(o=l[a++])u=r?u:!s.hasClass(o),s[u?"addClass":"removeClass"](o)}else(n===i||"boolean"===n)&&(this.className&&b._data(this,"__className__",this.className),this.className=this.className||e===!1?"":b._data(this,"__className__")||"")})},hasClass:function(e){var t=" "+e+" ",n=0,r=this.length;for(;r>n;n++)if(1===this[n].nodeType&&(" "+this[n].className+" ").replace(X," ").indexOf(t)>=0)return!0;return!1},val:function(e){var n,r,i,o=this[0];{if(arguments.length)return i=b.isFunction(e),this.each(function(n){var o,a=b(this);1===this.nodeType&&(o=i?e.call(this,n,a.val()):e,null==o?o="":"number"==typeof o?o+="":b.isArray(o)&&(o=b.map(o,function(e){return null==e?"":e+""})),r=b.valHooks[this.type]||b.valHooks[this.nodeName.toLowerCase()],r&&"set"in r&&r.set(this,o,"value")!==t||(this.value=o))});if(o)return r=b.valHooks[o.type]||b.valHooks[o.nodeName.toLowerCase()],r&&"get"in r&&(n=r.get(o,"value"))!==t?n:(n=o.value,"string"==typeof n?n.replace(U,""):null==n?"":n)}}}),b.extend({valHooks:{option:{get:function(e){var t=e.attributes.value;return!t||t.specified?e.value:e.text}},select:{get:function(e){var t,n,r=e.options,i=e.selectedIndex,o="select-one"===e.type||0>i,a=o?null:[],s=o?i+1:r.length,u=0>i?s:o?i:0;for(;s>u;u++)if(n=r[u],!(!n.selected&&u!==i||(b.support.optDisabled?n.disabled:null!==n.getAttribute("disabled"))||n.parentNode.disabled&&b.nodeName(n.parentNode,"optgroup"))){if(t=b(n).val(),o)return t;a.push(t)}return a},set:function(e,t){var n=b.makeArray(t);return b(e).find("option").each(function(){this.selected=b.inArray(b(this).val(),n)>=0}),n.length||(e.selectedIndex=-1),n}}},attr:function(e,n,r){var o,a,s,u=e.nodeType;if(e&&3!==u&&8!==u&&2!==u)return typeof e.getAttribute===i?b.prop(e,n,r):(a=1!==u||!b.isXMLDoc(e),a&&(n=n.toLowerCase(),o=b.attrHooks[n]||(J.test(n)?z:I)),r===t?o&&a&&"get"in o&&null!==(s=o.get(e,n))?s:(typeof e.getAttribute!==i&&(s=e.getAttribute(n)),null==s?t:s):null!==r?o&&a&&"set"in o&&(s=o.set(e,r,n))!==t?s:(e.setAttribute(n,r+""),r):(b.removeAttr(e,n),t))},removeAttr:function(e,t){var n,r,i=0,o=t&&t.match(w);if(o&&1===e.nodeType)while(n=o[i++])r=b.propFix[n]||n,J.test(n)?!Q&&G.test(n)?e[b.camelCase("default-"+n)]=e[r]=!1:e[r]=!1:b.attr(e,n,""),e.removeAttribute(Q?n:r)},attrHooks:{type:{set:function(e,t){if(!b.support.radioValue&&"radio"===t&&b.nodeName(e,"input")){var n=e.value;return e.setAttribute("type",t),n&&(e.value=n),t}}}},propFix:{tabindex:"tabIndex",readonly:"readOnly","for":"htmlFor","class":"className",maxlength:"maxLength",cellspacing:"cellSpacing",cellpadding:"cellPadding",rowspan:"rowSpan",colspan:"colSpan",usemap:"useMap",frameborder:"frameBorder",contenteditable:"contentEditable"},prop:function(e,n,r){var i,o,a,s=e.nodeType;if(e&&3!==s&&8!==s&&2!==s)return a=1!==s||!b.isXMLDoc(e),a&&(n=b.propFix[n]||n,o=b.propHooks[n]),r!==t?o&&"set"in o&&(i=o.set(e,r,n))!==t?i:e[n]=r:o&&"get"in o&&null!==(i=o.get(e,n))?i:e[n]},propHooks:{tabIndex:{get:function(e){var n=e.getAttributeNode("tabindex");return n&&n.specified?parseInt(n.value,10):V.test(e.nodeName)||Y.test(e.nodeName)&&e.href?0:t}}}}),z={get:function(e,n){var r=b.prop(e,n),i="boolean"==typeof r&&e.getAttribute(n),o="boolean"==typeof r?K&&Q?null!=i:G.test(n)?e[b.camelCase("default-"+n)]:!!i:e.getAttributeNode(n);return o&&o.value!==!1?n.toLowerCase():t},set:function(e,t,n){return t===!1?b.removeAttr(e,n):K&&Q||!G.test(n)?e.setAttribute(!Q&&b.propFix[n]||n,n):e[b.camelCase("default-"+n)]=e[n]=!0,n}},K&&Q||(b.attrHooks.value={get:function(e,n){var r=e.getAttributeNode(n);return b.nodeName(e,"input")?e.defaultValue:r&&r.specified?r.value:t},set:function(e,n,r){return b.nodeName(e,"input")?(e.defaultValue=n,t):I&&I.set(e,n,r)}}),Q||(I=b.valHooks.button={get:function(e,n){var r=e.getAttributeNode(n);return r&&("id"===n||"name"===n||"coords"===n?""!==r.value:r.specified)?r.value:t},set:function(e,n,r){var i=e.getAttributeNode(r);return i||e.setAttributeNode(i=e.ownerDocument.createAttribute(r)),i.value=n+="","value"===r||n===e.getAttribute(r)?n:t}},b.attrHooks.contenteditable={get:I.get,set:function(e,t,n){I.set(e,""===t?!1:t,n)}},b.each(["width","height"],function(e,n){b.attrHooks[n]=b.extend(b.attrHooks[n],{set:function(e,r){return""===r?(e.setAttribute(n,"auto"),r):t}})})),b.support.hrefNormalized||(b.each(["href","src","width","height"],function(e,n){b.attrHooks[n]=b.extend(b.attrHooks[n],{get:function(e){var r=e.getAttribute(n,2);return null==r?t:r}})}),b.each(["href","src"],function(e,t){b.propHooks[t]={get:function(e){return e.getAttribute(t,4)}}})),b.support.style||(b.attrHooks.style={get:function(e){return e.style.cssText||t},set:function(e,t){return e.style.cssText=t+""}}),b.support.optSelected||(b.propHooks.selected=b.extend(b.propHooks.selected,{get:function(e){var t=e.parentNode;return t&&(t.selectedIndex,t.parentNode&&t.parentNode.selectedIndex),null}})),b.support.enctype||(b.propFix.enctype="encoding"),b.support.checkOn||b.each(["radio","checkbox"],function(){b.valHooks[this]={get:function(e){return null===e.getAttribute("value")?"on":e.value}}}),b.each(["radio","checkbox"],function(){b.valHooks[this]=b.extend(b.valHooks[this],{set:function(e,n){return b.isArray(n)?e.checked=b.inArray(b(e).val(),n)>=0:t}})});var Z=/^(?:input|select|textarea)$/i,et=/^key/,tt=/^(?:mouse|contextmenu)|click/,nt=/^(?:focusinfocus|focusoutblur)$/,rt=/^([^.]*)(?:\.(.+)|)$/;function it(){return!0}function ot(){return!1}b.event={global:{},add:function(e,n,r,o,a){var s,u,l,c,p,f,d,h,g,m,y,v=b._data(e);if(v){r.handler&&(c=r,r=c.handler,a=c.selector),r.guid||(r.guid=b.guid++),(u=v.events)||(u=v.events={}),(f=v.handle)||(f=v.handle=function(e){return typeof b===i||e&&b.event.triggered===e.type?t:b.event.dispatch.apply(f.elem,arguments)},f.elem=e),n=(n||"").match(w)||[""],l=n.length;while(l--)s=rt.exec(n[l])||[],g=y=s[1],m=(s[2]||"").split(".").sort(),p=b.event.special[g]||{},g=(a?p.delegateType:p.bindType)||g,p=b.event.special[g]||{},d=b.extend({type:g,origType:y,data:o,handler:r,guid:r.guid,selector:a,needsContext:a&&b.expr.match.needsContext.test(a),namespace:m.join(".")},c),(h=u[g])||(h=u[g]=[],h.delegateCount=0,p.setup&&p.setup.call(e,o,m,f)!==!1||(e.addEventListener?e.addEventListener(g,f,!1):e.attachEvent&&e.attachEvent("on"+g,f))),p.add&&(p.add.call(e,d),d.handler.guid||(d.handler.guid=r.guid)),a?h.splice(h.delegateCount++,0,d):h.push(d),b.event.global[g]=!0;e=null}},remove:function(e,t,n,r,i){var o,a,s,u,l,c,p,f,d,h,g,m=b.hasData(e)&&b._data(e);if(m&&(c=m.events)){t=(t||"").match(w)||[""],l=t.length;while(l--)if(s=rt.exec(t[l])||[],d=g=s[1],h=(s[2]||"").split(".").sort(),d){p=b.event.special[d]||{},d=(r?p.delegateType:p.bindType)||d,f=c[d]||[],s=s[2]&&RegExp("(^|\\.)"+h.join("\\.(?:.*\\.|)")+"(\\.|$)"),u=o=f.length;while(o--)a=f[o],!i&&g!==a.origType||n&&n.guid!==a.guid||s&&!s.test(a.namespace)||r&&r!==a.selector&&("**"!==r||!a.selector)||(f.splice(o,1),a.selector&&f.delegateCount--,p.remove&&p.remove.call(e,a));u&&!f.length&&(p.teardown&&p.teardown.call(e,h,m.handle)!==!1||b.removeEvent(e,d,m.handle),delete c[d])}else for(d in c)b.event.remove(e,d+t[l],n,r,!0);b.isEmptyObject(c)&&(delete m.handle,b._removeData(e,"events"))}},trigger:function(n,r,i,a){var s,u,l,c,p,f,d,h=[i||o],g=y.call(n,"type")?n.type:n,m=y.call(n,"namespace")?n.namespace.split("."):[];if(l=f=i=i||o,3!==i.nodeType&&8!==i.nodeType&&!nt.test(g+b.event.triggered)&&(g.indexOf(".")>=0&&(m=g.split("."),g=m.shift(),m.sort()),u=0>g.indexOf(":")&&"on"+g,n=n[b.expando]?n:new b.Event(g,"object"==typeof n&&n),n.isTrigger=!0,n.namespace=m.join("."),n.namespace_re=n.namespace?RegExp("(^|\\.)"+m.join("\\.(?:.*\\.|)")+"(\\.|$)"):null,n.result=t,n.target||(n.target=i),r=null==r?[n]:b.makeArray(r,[n]),p=b.event.special[g]||{},a||!p.trigger||p.trigger.apply(i,r)!==!1)){if(!a&&!p.noBubble&&!b.isWindow(i)){for(c=p.delegateType||g,nt.test(c+g)||(l=l.parentNode);l;l=l.parentNode)h.push(l),f=l;f===(i.ownerDocument||o)&&h.push(f.defaultView||f.parentWindow||e)}d=0;while((l=h[d++])&&!n.isPropagationStopped())n.type=d>1?c:p.bindType||g,s=(b._data(l,"events")||{})[n.type]&&b._data(l,"handle"),s&&s.apply(l,r),s=u&&l[u],s&&b.acceptData(l)&&s.apply&&s.apply(l,r)===!1&&n.preventDefault();if(n.type=g,!(a||n.isDefaultPrevented()||p._default&&p._default.apply(i.ownerDocument,r)!==!1||"click"===g&&b.nodeName(i,"a")||!b.acceptData(i)||!u||!i[g]||b.isWindow(i))){f=i[u],f&&(i[u]=null),b.event.triggered=g;try{i[g]()}catch(v){}b.event.triggered=t,f&&(i[u]=f)}return n.result}},dispatch:function(e){e=b.event.fix(e);var n,r,i,o,a,s=[],u=h.call(arguments),l=(b._data(this,"events")||{})[e.type]||[],c=b.event.special[e.type]||{};if(u[0]=e,e.delegateTarget=this,!c.preDispatch||c.preDispatch.call(this,e)!==!1){s=b.event.handlers.call(this,e,l),n=0;while((o=s[n++])&&!e.isPropagationStopped()){e.currentTarget=o.elem,a=0;while((i=o.handlers[a++])&&!e.isImmediatePropagationStopped())(!e.namespace_re||e.namespace_re.test(i.namespace))&&(e.handleObj=i,e.data=i.data,r=((b.event.special[i.origType]||{}).handle||i.handler).apply(o.elem,u),r!==t&&(e.result=r)===!1&&(e.preventDefault(),e.stopPropagation()))}return c.postDispatch&&c.postDispatch.call(this,e),e.result}},handlers:function(e,n){var r,i,o,a,s=[],u=n.delegateCount,l=e.target;if(u&&l.nodeType&&(!e.button||"click"!==e.type))for(;l!=this;l=l.parentNode||this)if(1===l.nodeType&&(l.disabled!==!0||"click"!==e.type)){for(o=[],a=0;u>a;a++)i=n[a],r=i.selector+" ",o[r]===t&&(o[r]=i.needsContext?b(r,this).index(l)>=0:b.find(r,this,null,[l]).length),o[r]&&o.push(i);o.length&&s.push({elem:l,handlers:o})}return n.length>u&&s.push({elem:this,handlers:n.slice(u)}),s},fix:function(e){if(e[b.expando])return e;var t,n,r,i=e.type,a=e,s=this.fixHooks[i];s||(this.fixHooks[i]=s=tt.test(i)?this.mouseHooks:et.test(i)?this.keyHooks:{}),r=s.props?this.props.concat(s.props):this.props,e=new b.Event(a),t=r.length;while(t--)n=r[t],e[n]=a[n];return e.target||(e.target=a.srcElement||o),3===e.target.nodeType&&(e.target=e.target.parentNode),e.metaKey=!!e.metaKey,s.filter?s.filter(e,a):e},props:"altKey bubbles cancelable ctrlKey currentTarget eventPhase metaKey relatedTarget shiftKey target timeStamp view which".split(" "),fixHooks:{},keyHooks:{props:"char charCode key keyCode".split(" "),filter:function(e,t){return null==e.which&&(e.which=null!=t.charCode?t.charCode:t.keyCode),e}},mouseHooks:{props:"button buttons clientX clientY fromElement offsetX offsetY pageX pageY screenX screenY toElement".split(" "),filter:function(e,n){var r,i,a,s=n.button,u=n.fromElement;return null==e.pageX&&null!=n.clientX&&(i=e.target.ownerDocument||o,a=i.documentElement,r=i.body,e.pageX=n.clientX+(a&&a.scrollLeft||r&&r.scrollLeft||0)-(a&&a.clientLeft||r&&r.clientLeft||0),e.pageY=n.clientY+(a&&a.scrollTop||r&&r.scrollTop||0)-(a&&a.clientTop||r&&r.clientTop||0)),!e.relatedTarget&&u&&(e.relatedTarget=u===e.target?n.toElement:u),e.which||s===t||(e.which=1&s?1:2&s?3:4&s?2:0),e}},special:{load:{noBubble:!0},click:{trigger:function(){return b.nodeName(this,"input")&&"checkbox"===this.type&&this.click?(this.click(),!1):t}},focus:{trigger:function(){if(this!==o.activeElement&&this.focus)try{return this.focus(),!1}catch(e){}},delegateType:"focusin"},blur:{trigger:function(){return this===o.activeElement&&this.blur?(this.blur(),!1):t},delegateType:"focusout"},beforeunload:{postDispatch:function(e){e.result!==t&&(e.originalEvent.returnValue=e.result)}}},simulate:function(e,t,n,r){var i=b.extend(new b.Event,n,{type:e,isSimulated:!0,originalEvent:{}});r?b.event.trigger(i,null,t):b.event.dispatch.call(t,i),i.isDefaultPrevented()&&n.preventDefault()}},b.removeEvent=o.removeEventListener?function(e,t,n){e.removeEventListener&&e.removeEventListener(t,n,!1)}:function(e,t,n){var r="on"+t;e.detachEvent&&(typeof e[r]===i&&(e[r]=null),e.detachEvent(r,n))},b.Event=function(e,n){return this instanceof b.Event?(e&&e.type?(this.originalEvent=e,this.type=e.type,this.isDefaultPrevented=e.defaultPrevented||e.returnValue===!1||e.getPreventDefault&&e.getPreventDefault()?it:ot):this.type=e,n&&b.extend(this,n),this.timeStamp=e&&e.timeStamp||b.now(),this[b.expando]=!0,t):new b.Event(e,n)},b.Event.prototype={isDefaultPrevented:ot,isPropagationStopped:ot,isImmediatePropagationStopped:ot,preventDefault:function(){var e=this.originalEvent;this.isDefaultPrevented=it,e&&(e.preventDefault?e.preventDefault():e.returnValue=!1)},stopPropagation:function(){var e=this.originalEvent;this.isPropagationStopped=it,e&&(e.stopPropagation&&e.stopPropagation(),e.cancelBubble=!0)},stopImmediatePropagation:function(){this.isImmediatePropagationStopped=it,this.stopPropagation()}},b.each({mouseenter:"mouseover",mouseleave:"mouseout"},function(e,t){b.event.special[e]={delegateType:t,bindType:t,handle:function(e){var n,r=this,i=e.relatedTarget,o=e.handleObj; -return(!i||i!==r&&!b.contains(r,i))&&(e.type=o.origType,n=o.handler.apply(this,arguments),e.type=t),n}}}),b.support.submitBubbles||(b.event.special.submit={setup:function(){return b.nodeName(this,"form")?!1:(b.event.add(this,"click._submit keypress._submit",function(e){var n=e.target,r=b.nodeName(n,"input")||b.nodeName(n,"button")?n.form:t;r&&!b._data(r,"submitBubbles")&&(b.event.add(r,"submit._submit",function(e){e._submit_bubble=!0}),b._data(r,"submitBubbles",!0))}),t)},postDispatch:function(e){e._submit_bubble&&(delete e._submit_bubble,this.parentNode&&!e.isTrigger&&b.event.simulate("submit",this.parentNode,e,!0))},teardown:function(){return b.nodeName(this,"form")?!1:(b.event.remove(this,"._submit"),t)}}),b.support.changeBubbles||(b.event.special.change={setup:function(){return Z.test(this.nodeName)?(("checkbox"===this.type||"radio"===this.type)&&(b.event.add(this,"propertychange._change",function(e){"checked"===e.originalEvent.propertyName&&(this._just_changed=!0)}),b.event.add(this,"click._change",function(e){this._just_changed&&!e.isTrigger&&(this._just_changed=!1),b.event.simulate("change",this,e,!0)})),!1):(b.event.add(this,"beforeactivate._change",function(e){var t=e.target;Z.test(t.nodeName)&&!b._data(t,"changeBubbles")&&(b.event.add(t,"change._change",function(e){!this.parentNode||e.isSimulated||e.isTrigger||b.event.simulate("change",this.parentNode,e,!0)}),b._data(t,"changeBubbles",!0))}),t)},handle:function(e){var n=e.target;return this!==n||e.isSimulated||e.isTrigger||"radio"!==n.type&&"checkbox"!==n.type?e.handleObj.handler.apply(this,arguments):t},teardown:function(){return b.event.remove(this,"._change"),!Z.test(this.nodeName)}}),b.support.focusinBubbles||b.each({focus:"focusin",blur:"focusout"},function(e,t){var n=0,r=function(e){b.event.simulate(t,e.target,b.event.fix(e),!0)};b.event.special[t]={setup:function(){0===n++&&o.addEventListener(e,r,!0)},teardown:function(){0===--n&&o.removeEventListener(e,r,!0)}}}),b.fn.extend({on:function(e,n,r,i,o){var a,s;if("object"==typeof e){"string"!=typeof n&&(r=r||n,n=t);for(a in e)this.on(a,n,r,e[a],o);return this}if(null==r&&null==i?(i=n,r=n=t):null==i&&("string"==typeof n?(i=r,r=t):(i=r,r=n,n=t)),i===!1)i=ot;else if(!i)return this;return 1===o&&(s=i,i=function(e){return b().off(e),s.apply(this,arguments)},i.guid=s.guid||(s.guid=b.guid++)),this.each(function(){b.event.add(this,e,i,r,n)})},one:function(e,t,n,r){return this.on(e,t,n,r,1)},off:function(e,n,r){var i,o;if(e&&e.preventDefault&&e.handleObj)return i=e.handleObj,b(e.delegateTarget).off(i.namespace?i.origType+"."+i.namespace:i.origType,i.selector,i.handler),this;if("object"==typeof e){for(o in e)this.off(o,n,e[o]);return this}return(n===!1||"function"==typeof n)&&(r=n,n=t),r===!1&&(r=ot),this.each(function(){b.event.remove(this,e,r,n)})},bind:function(e,t,n){return this.on(e,null,t,n)},unbind:function(e,t){return this.off(e,null,t)},delegate:function(e,t,n,r){return this.on(t,e,n,r)},undelegate:function(e,t,n){return 1===arguments.length?this.off(e,"**"):this.off(t,e||"**",n)},trigger:function(e,t){return this.each(function(){b.event.trigger(e,t,this)})},triggerHandler:function(e,n){var r=this[0];return r?b.event.trigger(e,n,r,!0):t}}),function(e,t){var n,r,i,o,a,s,u,l,c,p,f,d,h,g,m,y,v,x="sizzle"+-new Date,w=e.document,T={},N=0,C=0,k=it(),E=it(),S=it(),A=typeof t,j=1<<31,D=[],L=D.pop,H=D.push,q=D.slice,M=D.indexOf||function(e){var t=0,n=this.length;for(;n>t;t++)if(this[t]===e)return t;return-1},_="[\\x20\\t\\r\\n\\f]",F="(?:\\\\.|[\\w-]|[^\\x00-\\xa0])+",O=F.replace("w","w#"),B="([*^$|!~]?=)",P="\\["+_+"*("+F+")"+_+"*(?:"+B+_+"*(?:(['\"])((?:\\\\.|[^\\\\])*?)\\3|("+O+")|)|)"+_+"*\\]",R=":("+F+")(?:\\(((['\"])((?:\\\\.|[^\\\\])*?)\\3|((?:\\\\.|[^\\\\()[\\]]|"+P.replace(3,8)+")*)|.*)\\)|)",W=RegExp("^"+_+"+|((?:^|[^\\\\])(?:\\\\.)*)"+_+"+$","g"),$=RegExp("^"+_+"*,"+_+"*"),I=RegExp("^"+_+"*([\\x20\\t\\r\\n\\f>+~])"+_+"*"),z=RegExp(R),X=RegExp("^"+O+"$"),U={ID:RegExp("^#("+F+")"),CLASS:RegExp("^\\.("+F+")"),NAME:RegExp("^\\[name=['\"]?("+F+")['\"]?\\]"),TAG:RegExp("^("+F.replace("w","w*")+")"),ATTR:RegExp("^"+P),PSEUDO:RegExp("^"+R),CHILD:RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+_+"*(even|odd|(([+-]|)(\\d*)n|)"+_+"*(?:([+-]|)"+_+"*(\\d+)|))"+_+"*\\)|)","i"),needsContext:RegExp("^"+_+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+_+"*((?:-\\d)?\\d*)"+_+"*\\)|)(?=[^-]|$)","i")},V=/[\x20\t\r\n\f]*[+~]/,Y=/^[^{]+\{\s*\[native code/,J=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,G=/^(?:input|select|textarea|button)$/i,Q=/^h\d$/i,K=/'|\\/g,Z=/\=[\x20\t\r\n\f]*([^'"\]]*)[\x20\t\r\n\f]*\]/g,et=/\\([\da-fA-F]{1,6}[\x20\t\r\n\f]?|.)/g,tt=function(e,t){var n="0x"+t-65536;return n!==n?t:0>n?String.fromCharCode(n+65536):String.fromCharCode(55296|n>>10,56320|1023&n)};try{q.call(w.documentElement.childNodes,0)[0].nodeType}catch(nt){q=function(e){var t,n=[];while(t=this[e++])n.push(t);return n}}function rt(e){return Y.test(e+"")}function it(){var e,t=[];return e=function(n,r){return t.push(n+=" ")>i.cacheLength&&delete e[t.shift()],e[n]=r}}function ot(e){return e[x]=!0,e}function at(e){var t=p.createElement("div");try{return e(t)}catch(n){return!1}finally{t=null}}function st(e,t,n,r){var i,o,a,s,u,l,f,g,m,v;if((t?t.ownerDocument||t:w)!==p&&c(t),t=t||p,n=n||[],!e||"string"!=typeof e)return n;if(1!==(s=t.nodeType)&&9!==s)return[];if(!d&&!r){if(i=J.exec(e))if(a=i[1]){if(9===s){if(o=t.getElementById(a),!o||!o.parentNode)return n;if(o.id===a)return n.push(o),n}else if(t.ownerDocument&&(o=t.ownerDocument.getElementById(a))&&y(t,o)&&o.id===a)return n.push(o),n}else{if(i[2])return H.apply(n,q.call(t.getElementsByTagName(e),0)),n;if((a=i[3])&&T.getByClassName&&t.getElementsByClassName)return H.apply(n,q.call(t.getElementsByClassName(a),0)),n}if(T.qsa&&!h.test(e)){if(f=!0,g=x,m=t,v=9===s&&e,1===s&&"object"!==t.nodeName.toLowerCase()){l=ft(e),(f=t.getAttribute("id"))?g=f.replace(K,"\\$&"):t.setAttribute("id",g),g="[id='"+g+"'] ",u=l.length;while(u--)l[u]=g+dt(l[u]);m=V.test(e)&&t.parentNode||t,v=l.join(",")}if(v)try{return H.apply(n,q.call(m.querySelectorAll(v),0)),n}catch(b){}finally{f||t.removeAttribute("id")}}}return wt(e.replace(W,"$1"),t,n,r)}a=st.isXML=function(e){var t=e&&(e.ownerDocument||e).documentElement;return t?"HTML"!==t.nodeName:!1},c=st.setDocument=function(e){var n=e?e.ownerDocument||e:w;return n!==p&&9===n.nodeType&&n.documentElement?(p=n,f=n.documentElement,d=a(n),T.tagNameNoComments=at(function(e){return e.appendChild(n.createComment("")),!e.getElementsByTagName("*").length}),T.attributes=at(function(e){e.innerHTML="";var t=typeof e.lastChild.getAttribute("multiple");return"boolean"!==t&&"string"!==t}),T.getByClassName=at(function(e){return e.innerHTML="",e.getElementsByClassName&&e.getElementsByClassName("e").length?(e.lastChild.className="e",2===e.getElementsByClassName("e").length):!1}),T.getByName=at(function(e){e.id=x+0,e.innerHTML="
    ",f.insertBefore(e,f.firstChild);var t=n.getElementsByName&&n.getElementsByName(x).length===2+n.getElementsByName(x+0).length;return T.getIdNotName=!n.getElementById(x),f.removeChild(e),t}),i.attrHandle=at(function(e){return e.innerHTML="",e.firstChild&&typeof e.firstChild.getAttribute!==A&&"#"===e.firstChild.getAttribute("href")})?{}:{href:function(e){return e.getAttribute("href",2)},type:function(e){return e.getAttribute("type")}},T.getIdNotName?(i.find.ID=function(e,t){if(typeof t.getElementById!==A&&!d){var n=t.getElementById(e);return n&&n.parentNode?[n]:[]}},i.filter.ID=function(e){var t=e.replace(et,tt);return function(e){return e.getAttribute("id")===t}}):(i.find.ID=function(e,n){if(typeof n.getElementById!==A&&!d){var r=n.getElementById(e);return r?r.id===e||typeof r.getAttributeNode!==A&&r.getAttributeNode("id").value===e?[r]:t:[]}},i.filter.ID=function(e){var t=e.replace(et,tt);return function(e){var n=typeof e.getAttributeNode!==A&&e.getAttributeNode("id");return n&&n.value===t}}),i.find.TAG=T.tagNameNoComments?function(e,n){return typeof n.getElementsByTagName!==A?n.getElementsByTagName(e):t}:function(e,t){var n,r=[],i=0,o=t.getElementsByTagName(e);if("*"===e){while(n=o[i++])1===n.nodeType&&r.push(n);return r}return o},i.find.NAME=T.getByName&&function(e,n){return typeof n.getElementsByName!==A?n.getElementsByName(name):t},i.find.CLASS=T.getByClassName&&function(e,n){return typeof n.getElementsByClassName===A||d?t:n.getElementsByClassName(e)},g=[],h=[":focus"],(T.qsa=rt(n.querySelectorAll))&&(at(function(e){e.innerHTML="",e.querySelectorAll("[selected]").length||h.push("\\["+_+"*(?:checked|disabled|ismap|multiple|readonly|selected|value)"),e.querySelectorAll(":checked").length||h.push(":checked")}),at(function(e){e.innerHTML="",e.querySelectorAll("[i^='']").length&&h.push("[*^$]="+_+"*(?:\"\"|'')"),e.querySelectorAll(":enabled").length||h.push(":enabled",":disabled"),e.querySelectorAll("*,:x"),h.push(",.*:")})),(T.matchesSelector=rt(m=f.matchesSelector||f.mozMatchesSelector||f.webkitMatchesSelector||f.oMatchesSelector||f.msMatchesSelector))&&at(function(e){T.disconnectedMatch=m.call(e,"div"),m.call(e,"[s!='']:x"),g.push("!=",R)}),h=RegExp(h.join("|")),g=RegExp(g.join("|")),y=rt(f.contains)||f.compareDocumentPosition?function(e,t){var n=9===e.nodeType?e.documentElement:e,r=t&&t.parentNode;return e===r||!(!r||1!==r.nodeType||!(n.contains?n.contains(r):e.compareDocumentPosition&&16&e.compareDocumentPosition(r)))}:function(e,t){if(t)while(t=t.parentNode)if(t===e)return!0;return!1},v=f.compareDocumentPosition?function(e,t){var r;return e===t?(u=!0,0):(r=t.compareDocumentPosition&&e.compareDocumentPosition&&e.compareDocumentPosition(t))?1&r||e.parentNode&&11===e.parentNode.nodeType?e===n||y(w,e)?-1:t===n||y(w,t)?1:0:4&r?-1:1:e.compareDocumentPosition?-1:1}:function(e,t){var r,i=0,o=e.parentNode,a=t.parentNode,s=[e],l=[t];if(e===t)return u=!0,0;if(!o||!a)return e===n?-1:t===n?1:o?-1:a?1:0;if(o===a)return ut(e,t);r=e;while(r=r.parentNode)s.unshift(r);r=t;while(r=r.parentNode)l.unshift(r);while(s[i]===l[i])i++;return i?ut(s[i],l[i]):s[i]===w?-1:l[i]===w?1:0},u=!1,[0,0].sort(v),T.detectDuplicates=u,p):p},st.matches=function(e,t){return st(e,null,null,t)},st.matchesSelector=function(e,t){if((e.ownerDocument||e)!==p&&c(e),t=t.replace(Z,"='$1']"),!(!T.matchesSelector||d||g&&g.test(t)||h.test(t)))try{var n=m.call(e,t);if(n||T.disconnectedMatch||e.document&&11!==e.document.nodeType)return n}catch(r){}return st(t,p,null,[e]).length>0},st.contains=function(e,t){return(e.ownerDocument||e)!==p&&c(e),y(e,t)},st.attr=function(e,t){var n;return(e.ownerDocument||e)!==p&&c(e),d||(t=t.toLowerCase()),(n=i.attrHandle[t])?n(e):d||T.attributes?e.getAttribute(t):((n=e.getAttributeNode(t))||e.getAttribute(t))&&e[t]===!0?t:n&&n.specified?n.value:null},st.error=function(e){throw Error("Syntax error, unrecognized expression: "+e)},st.uniqueSort=function(e){var t,n=[],r=1,i=0;if(u=!T.detectDuplicates,e.sort(v),u){for(;t=e[r];r++)t===e[r-1]&&(i=n.push(r));while(i--)e.splice(n[i],1)}return e};function ut(e,t){var n=t&&e,r=n&&(~t.sourceIndex||j)-(~e.sourceIndex||j);if(r)return r;if(n)while(n=n.nextSibling)if(n===t)return-1;return e?1:-1}function lt(e){return function(t){var n=t.nodeName.toLowerCase();return"input"===n&&t.type===e}}function ct(e){return function(t){var n=t.nodeName.toLowerCase();return("input"===n||"button"===n)&&t.type===e}}function pt(e){return ot(function(t){return t=+t,ot(function(n,r){var i,o=e([],n.length,t),a=o.length;while(a--)n[i=o[a]]&&(n[i]=!(r[i]=n[i]))})})}o=st.getText=function(e){var t,n="",r=0,i=e.nodeType;if(i){if(1===i||9===i||11===i){if("string"==typeof e.textContent)return e.textContent;for(e=e.firstChild;e;e=e.nextSibling)n+=o(e)}else if(3===i||4===i)return e.nodeValue}else for(;t=e[r];r++)n+=o(t);return n},i=st.selectors={cacheLength:50,createPseudo:ot,match:U,find:{},relative:{">":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(et,tt),e[3]=(e[4]||e[5]||"").replace(et,tt),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||st.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&st.error(e[0]),e},PSEUDO:function(e){var t,n=!e[5]&&e[2];return U.CHILD.test(e[0])?null:(e[4]?e[2]=e[4]:n&&z.test(n)&&(t=ft(n,!0))&&(t=n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){return"*"===e?function(){return!0}:(e=e.replace(et,tt).toLowerCase(),function(t){return t.nodeName&&t.nodeName.toLowerCase()===e})},CLASS:function(e){var t=k[e+" "];return t||(t=RegExp("(^|"+_+")"+e+"("+_+"|$)"))&&k(e,function(e){return t.test(e.className||typeof e.getAttribute!==A&&e.getAttribute("class")||"")})},ATTR:function(e,t,n){return function(r){var i=st.attr(r,e);return null==i?"!="===t:t?(i+="","="===t?i===n:"!="===t?i!==n:"^="===t?n&&0===i.indexOf(n):"*="===t?n&&i.indexOf(n)>-1:"$="===t?n&&i.slice(-n.length)===n:"~="===t?(" "+i+" ").indexOf(n)>-1:"|="===t?i===n||i.slice(0,n.length+1)===n+"-":!1):!0}},CHILD:function(e,t,n,r,i){var o="nth"!==e.slice(0,3),a="last"!==e.slice(-4),s="of-type"===t;return 1===r&&0===i?function(e){return!!e.parentNode}:function(t,n,u){var l,c,p,f,d,h,g=o!==a?"nextSibling":"previousSibling",m=t.parentNode,y=s&&t.nodeName.toLowerCase(),v=!u&&!s;if(m){if(o){while(g){p=t;while(p=p[g])if(s?p.nodeName.toLowerCase()===y:1===p.nodeType)return!1;h=g="only"===e&&!h&&"nextSibling"}return!0}if(h=[a?m.firstChild:m.lastChild],a&&v){c=m[x]||(m[x]={}),l=c[e]||[],d=l[0]===N&&l[1],f=l[0]===N&&l[2],p=d&&m.childNodes[d];while(p=++d&&p&&p[g]||(f=d=0)||h.pop())if(1===p.nodeType&&++f&&p===t){c[e]=[N,d,f];break}}else if(v&&(l=(t[x]||(t[x]={}))[e])&&l[0]===N)f=l[1];else while(p=++d&&p&&p[g]||(f=d=0)||h.pop())if((s?p.nodeName.toLowerCase()===y:1===p.nodeType)&&++f&&(v&&((p[x]||(p[x]={}))[e]=[N,f]),p===t))break;return f-=i,f===r||0===f%r&&f/r>=0}}},PSEUDO:function(e,t){var n,r=i.pseudos[e]||i.setFilters[e.toLowerCase()]||st.error("unsupported pseudo: "+e);return r[x]?r(t):r.length>1?(n=[e,e,"",t],i.setFilters.hasOwnProperty(e.toLowerCase())?ot(function(e,n){var i,o=r(e,t),a=o.length;while(a--)i=M.call(e,o[a]),e[i]=!(n[i]=o[a])}):function(e){return r(e,0,n)}):r}},pseudos:{not:ot(function(e){var t=[],n=[],r=s(e.replace(W,"$1"));return r[x]?ot(function(e,t,n,i){var o,a=r(e,null,i,[]),s=e.length;while(s--)(o=a[s])&&(e[s]=!(t[s]=o))}):function(e,i,o){return t[0]=e,r(t,null,o,n),!n.pop()}}),has:ot(function(e){return function(t){return st(e,t).length>0}}),contains:ot(function(e){return function(t){return(t.textContent||t.innerText||o(t)).indexOf(e)>-1}}),lang:ot(function(e){return X.test(e||"")||st.error("unsupported lang: "+e),e=e.replace(et,tt).toLowerCase(),function(t){var n;do if(n=d?t.getAttribute("xml:lang")||t.getAttribute("lang"):t.lang)return n=n.toLowerCase(),n===e||0===n.indexOf(e+"-");while((t=t.parentNode)&&1===t.nodeType);return!1}}),target:function(t){var n=e.location&&e.location.hash;return n&&n.slice(1)===t.id},root:function(e){return e===f},focus:function(e){return e===p.activeElement&&(!p.hasFocus||p.hasFocus())&&!!(e.type||e.href||~e.tabIndex)},enabled:function(e){return e.disabled===!1},disabled:function(e){return e.disabled===!0},checked:function(e){var t=e.nodeName.toLowerCase();return"input"===t&&!!e.checked||"option"===t&&!!e.selected},selected:function(e){return e.parentNode&&e.parentNode.selectedIndex,e.selected===!0},empty:function(e){for(e=e.firstChild;e;e=e.nextSibling)if(e.nodeName>"@"||3===e.nodeType||4===e.nodeType)return!1;return!0},parent:function(e){return!i.pseudos.empty(e)},header:function(e){return Q.test(e.nodeName)},input:function(e){return G.test(e.nodeName)},button:function(e){var t=e.nodeName.toLowerCase();return"input"===t&&"button"===e.type||"button"===t},text:function(e){var t;return"input"===e.nodeName.toLowerCase()&&"text"===e.type&&(null==(t=e.getAttribute("type"))||t.toLowerCase()===e.type)},first:pt(function(){return[0]}),last:pt(function(e,t){return[t-1]}),eq:pt(function(e,t,n){return[0>n?n+t:n]}),even:pt(function(e,t){var n=0;for(;t>n;n+=2)e.push(n);return e}),odd:pt(function(e,t){var n=1;for(;t>n;n+=2)e.push(n);return e}),lt:pt(function(e,t,n){var r=0>n?n+t:n;for(;--r>=0;)e.push(r);return e}),gt:pt(function(e,t,n){var r=0>n?n+t:n;for(;t>++r;)e.push(r);return e})}};for(n in{radio:!0,checkbox:!0,file:!0,password:!0,image:!0})i.pseudos[n]=lt(n);for(n in{submit:!0,reset:!0})i.pseudos[n]=ct(n);function ft(e,t){var n,r,o,a,s,u,l,c=E[e+" "];if(c)return t?0:c.slice(0);s=e,u=[],l=i.preFilter;while(s){(!n||(r=$.exec(s)))&&(r&&(s=s.slice(r[0].length)||s),u.push(o=[])),n=!1,(r=I.exec(s))&&(n=r.shift(),o.push({value:n,type:r[0].replace(W," ")}),s=s.slice(n.length));for(a in i.filter)!(r=U[a].exec(s))||l[a]&&!(r=l[a](r))||(n=r.shift(),o.push({value:n,type:a,matches:r}),s=s.slice(n.length));if(!n)break}return t?s.length:s?st.error(e):E(e,u).slice(0)}function dt(e){var t=0,n=e.length,r="";for(;n>t;t++)r+=e[t].value;return r}function ht(e,t,n){var i=t.dir,o=n&&"parentNode"===i,a=C++;return t.first?function(t,n,r){while(t=t[i])if(1===t.nodeType||o)return e(t,n,r)}:function(t,n,s){var u,l,c,p=N+" "+a;if(s){while(t=t[i])if((1===t.nodeType||o)&&e(t,n,s))return!0}else while(t=t[i])if(1===t.nodeType||o)if(c=t[x]||(t[x]={}),(l=c[i])&&l[0]===p){if((u=l[1])===!0||u===r)return u===!0}else if(l=c[i]=[p],l[1]=e(t,n,s)||r,l[1]===!0)return!0}}function gt(e){return e.length>1?function(t,n,r){var i=e.length;while(i--)if(!e[i](t,n,r))return!1;return!0}:e[0]}function mt(e,t,n,r,i){var o,a=[],s=0,u=e.length,l=null!=t;for(;u>s;s++)(o=e[s])&&(!n||n(o,r,i))&&(a.push(o),l&&t.push(s));return a}function yt(e,t,n,r,i,o){return r&&!r[x]&&(r=yt(r)),i&&!i[x]&&(i=yt(i,o)),ot(function(o,a,s,u){var l,c,p,f=[],d=[],h=a.length,g=o||xt(t||"*",s.nodeType?[s]:s,[]),m=!e||!o&&t?g:mt(g,f,e,s,u),y=n?i||(o?e:h||r)?[]:a:m;if(n&&n(m,y,s,u),r){l=mt(y,d),r(l,[],s,u),c=l.length;while(c--)(p=l[c])&&(y[d[c]]=!(m[d[c]]=p))}if(o){if(i||e){if(i){l=[],c=y.length;while(c--)(p=y[c])&&l.push(m[c]=p);i(null,y=[],l,u)}c=y.length;while(c--)(p=y[c])&&(l=i?M.call(o,p):f[c])>-1&&(o[l]=!(a[l]=p))}}else y=mt(y===a?y.splice(h,y.length):y),i?i(null,a,y,u):H.apply(a,y)})}function vt(e){var t,n,r,o=e.length,a=i.relative[e[0].type],s=a||i.relative[" "],u=a?1:0,c=ht(function(e){return e===t},s,!0),p=ht(function(e){return M.call(t,e)>-1},s,!0),f=[function(e,n,r){return!a&&(r||n!==l)||((t=n).nodeType?c(e,n,r):p(e,n,r))}];for(;o>u;u++)if(n=i.relative[e[u].type])f=[ht(gt(f),n)];else{if(n=i.filter[e[u].type].apply(null,e[u].matches),n[x]){for(r=++u;o>r;r++)if(i.relative[e[r].type])break;return yt(u>1&>(f),u>1&&dt(e.slice(0,u-1)).replace(W,"$1"),n,r>u&&vt(e.slice(u,r)),o>r&&vt(e=e.slice(r)),o>r&&dt(e))}f.push(n)}return gt(f)}function bt(e,t){var n=0,o=t.length>0,a=e.length>0,s=function(s,u,c,f,d){var h,g,m,y=[],v=0,b="0",x=s&&[],w=null!=d,T=l,C=s||a&&i.find.TAG("*",d&&u.parentNode||u),k=N+=null==T?1:Math.random()||.1;for(w&&(l=u!==p&&u,r=n);null!=(h=C[b]);b++){if(a&&h){g=0;while(m=e[g++])if(m(h,u,c)){f.push(h);break}w&&(N=k,r=++n)}o&&((h=!m&&h)&&v--,s&&x.push(h))}if(v+=b,o&&b!==v){g=0;while(m=t[g++])m(x,y,u,c);if(s){if(v>0)while(b--)x[b]||y[b]||(y[b]=L.call(f));y=mt(y)}H.apply(f,y),w&&!s&&y.length>0&&v+t.length>1&&st.uniqueSort(f)}return w&&(N=k,l=T),x};return o?ot(s):s}s=st.compile=function(e,t){var n,r=[],i=[],o=S[e+" "];if(!o){t||(t=ft(e)),n=t.length;while(n--)o=vt(t[n]),o[x]?r.push(o):i.push(o);o=S(e,bt(i,r))}return o};function xt(e,t,n){var r=0,i=t.length;for(;i>r;r++)st(e,t[r],n);return n}function wt(e,t,n,r){var o,a,u,l,c,p=ft(e);if(!r&&1===p.length){if(a=p[0]=p[0].slice(0),a.length>2&&"ID"===(u=a[0]).type&&9===t.nodeType&&!d&&i.relative[a[1].type]){if(t=i.find.ID(u.matches[0].replace(et,tt),t)[0],!t)return n;e=e.slice(a.shift().value.length)}o=U.needsContext.test(e)?0:a.length;while(o--){if(u=a[o],i.relative[l=u.type])break;if((c=i.find[l])&&(r=c(u.matches[0].replace(et,tt),V.test(a[0].type)&&t.parentNode||t))){if(a.splice(o,1),e=r.length&&dt(a),!e)return H.apply(n,q.call(r,0)),n;break}}}return s(e,p)(r,t,d,n,V.test(e)),n}i.pseudos.nth=i.pseudos.eq;function Tt(){}i.filters=Tt.prototype=i.pseudos,i.setFilters=new Tt,c(),st.attr=b.attr,b.find=st,b.expr=st.selectors,b.expr[":"]=b.expr.pseudos,b.unique=st.uniqueSort,b.text=st.getText,b.isXMLDoc=st.isXML,b.contains=st.contains}(e);var at=/Until$/,st=/^(?:parents|prev(?:Until|All))/,ut=/^.[^:#\[\.,]*$/,lt=b.expr.match.needsContext,ct={children:!0,contents:!0,next:!0,prev:!0};b.fn.extend({find:function(e){var t,n,r,i=this.length;if("string"!=typeof e)return r=this,this.pushStack(b(e).filter(function(){for(t=0;i>t;t++)if(b.contains(r[t],this))return!0}));for(n=[],t=0;i>t;t++)b.find(e,this[t],n);return n=this.pushStack(i>1?b.unique(n):n),n.selector=(this.selector?this.selector+" ":"")+e,n},has:function(e){var t,n=b(e,this),r=n.length;return this.filter(function(){for(t=0;r>t;t++)if(b.contains(this,n[t]))return!0})},not:function(e){return this.pushStack(ft(this,e,!1))},filter:function(e){return this.pushStack(ft(this,e,!0))},is:function(e){return!!e&&("string"==typeof e?lt.test(e)?b(e,this.context).index(this[0])>=0:b.filter(e,this).length>0:this.filter(e).length>0)},closest:function(e,t){var n,r=0,i=this.length,o=[],a=lt.test(e)||"string"!=typeof e?b(e,t||this.context):0;for(;i>r;r++){n=this[r];while(n&&n.ownerDocument&&n!==t&&11!==n.nodeType){if(a?a.index(n)>-1:b.find.matchesSelector(n,e)){o.push(n);break}n=n.parentNode}}return this.pushStack(o.length>1?b.unique(o):o)},index:function(e){return e?"string"==typeof e?b.inArray(this[0],b(e)):b.inArray(e.jquery?e[0]:e,this):this[0]&&this[0].parentNode?this.first().prevAll().length:-1},add:function(e,t){var n="string"==typeof e?b(e,t):b.makeArray(e&&e.nodeType?[e]:e),r=b.merge(this.get(),n);return this.pushStack(b.unique(r))},addBack:function(e){return this.add(null==e?this.prevObject:this.prevObject.filter(e))}}),b.fn.andSelf=b.fn.addBack;function pt(e,t){do e=e[t];while(e&&1!==e.nodeType);return e}b.each({parent:function(e){var t=e.parentNode;return t&&11!==t.nodeType?t:null},parents:function(e){return b.dir(e,"parentNode")},parentsUntil:function(e,t,n){return b.dir(e,"parentNode",n)},next:function(e){return pt(e,"nextSibling")},prev:function(e){return pt(e,"previousSibling")},nextAll:function(e){return b.dir(e,"nextSibling")},prevAll:function(e){return b.dir(e,"previousSibling")},nextUntil:function(e,t,n){return b.dir(e,"nextSibling",n)},prevUntil:function(e,t,n){return b.dir(e,"previousSibling",n)},siblings:function(e){return b.sibling((e.parentNode||{}).firstChild,e)},children:function(e){return b.sibling(e.firstChild)},contents:function(e){return b.nodeName(e,"iframe")?e.contentDocument||e.contentWindow.document:b.merge([],e.childNodes)}},function(e,t){b.fn[e]=function(n,r){var i=b.map(this,t,n);return at.test(e)||(r=n),r&&"string"==typeof r&&(i=b.filter(r,i)),i=this.length>1&&!ct[e]?b.unique(i):i,this.length>1&&st.test(e)&&(i=i.reverse()),this.pushStack(i)}}),b.extend({filter:function(e,t,n){return n&&(e=":not("+e+")"),1===t.length?b.find.matchesSelector(t[0],e)?[t[0]]:[]:b.find.matches(e,t)},dir:function(e,n,r){var i=[],o=e[n];while(o&&9!==o.nodeType&&(r===t||1!==o.nodeType||!b(o).is(r)))1===o.nodeType&&i.push(o),o=o[n];return i},sibling:function(e,t){var n=[];for(;e;e=e.nextSibling)1===e.nodeType&&e!==t&&n.push(e);return n}});function ft(e,t,n){if(t=t||0,b.isFunction(t))return b.grep(e,function(e,r){var i=!!t.call(e,r,e);return i===n});if(t.nodeType)return b.grep(e,function(e){return e===t===n});if("string"==typeof t){var r=b.grep(e,function(e){return 1===e.nodeType});if(ut.test(t))return b.filter(t,r,!n);t=b.filter(t,r)}return b.grep(e,function(e){return b.inArray(e,t)>=0===n})}function dt(e){var t=ht.split("|"),n=e.createDocumentFragment();if(n.createElement)while(t.length)n.createElement(t.pop());return n}var ht="abbr|article|aside|audio|bdi|canvas|data|datalist|details|figcaption|figure|footer|header|hgroup|mark|meter|nav|output|progress|section|summary|time|video",gt=/ jQuery\d+="(?:null|\d+)"/g,mt=RegExp("<(?:"+ht+")[\\s/>]","i"),yt=/^\s+/,vt=/<(?!area|br|col|embed|hr|img|input|link|meta|param)(([\w:]+)[^>]*)\/>/gi,bt=/<([\w:]+)/,xt=/\s*$/g,At={option:[1,""],legend:[1,"
    ","
    "],area:[1,"",""],param:[1,"",""],thead:[1,"","
    "],tr:[2,"","
    "],col:[2,"","
    "],td:[3,"","
    "],_default:b.support.htmlSerialize?[0,"",""]:[1,"X
    ","
    "]},jt=dt(o),Dt=jt.appendChild(o.createElement("div"));At.optgroup=At.option,At.tbody=At.tfoot=At.colgroup=At.caption=At.thead,At.th=At.td,b.fn.extend({text:function(e){return b.access(this,function(e){return e===t?b.text(this):this.empty().append((this[0]&&this[0].ownerDocument||o).createTextNode(e))},null,e,arguments.length)},wrapAll:function(e){if(b.isFunction(e))return this.each(function(t){b(this).wrapAll(e.call(this,t))});if(this[0]){var t=b(e,this[0].ownerDocument).eq(0).clone(!0);this[0].parentNode&&t.insertBefore(this[0]),t.map(function(){var e=this;while(e.firstChild&&1===e.firstChild.nodeType)e=e.firstChild;return e}).append(this)}return this},wrapInner:function(e){return b.isFunction(e)?this.each(function(t){b(this).wrapInner(e.call(this,t))}):this.each(function(){var t=b(this),n=t.contents();n.length?n.wrapAll(e):t.append(e)})},wrap:function(e){var t=b.isFunction(e);return this.each(function(n){b(this).wrapAll(t?e.call(this,n):e)})},unwrap:function(){return this.parent().each(function(){b.nodeName(this,"body")||b(this).replaceWith(this.childNodes)}).end()},append:function(){return this.domManip(arguments,!0,function(e){(1===this.nodeType||11===this.nodeType||9===this.nodeType)&&this.appendChild(e)})},prepend:function(){return this.domManip(arguments,!0,function(e){(1===this.nodeType||11===this.nodeType||9===this.nodeType)&&this.insertBefore(e,this.firstChild)})},before:function(){return this.domManip(arguments,!1,function(e){this.parentNode&&this.parentNode.insertBefore(e,this)})},after:function(){return this.domManip(arguments,!1,function(e){this.parentNode&&this.parentNode.insertBefore(e,this.nextSibling)})},remove:function(e,t){var n,r=0;for(;null!=(n=this[r]);r++)(!e||b.filter(e,[n]).length>0)&&(t||1!==n.nodeType||b.cleanData(Ot(n)),n.parentNode&&(t&&b.contains(n.ownerDocument,n)&&Mt(Ot(n,"script")),n.parentNode.removeChild(n)));return this},empty:function(){var e,t=0;for(;null!=(e=this[t]);t++){1===e.nodeType&&b.cleanData(Ot(e,!1));while(e.firstChild)e.removeChild(e.firstChild);e.options&&b.nodeName(e,"select")&&(e.options.length=0)}return this},clone:function(e,t){return e=null==e?!1:e,t=null==t?e:t,this.map(function(){return b.clone(this,e,t)})},html:function(e){return b.access(this,function(e){var n=this[0]||{},r=0,i=this.length;if(e===t)return 1===n.nodeType?n.innerHTML.replace(gt,""):t;if(!("string"!=typeof e||Tt.test(e)||!b.support.htmlSerialize&&mt.test(e)||!b.support.leadingWhitespace&&yt.test(e)||At[(bt.exec(e)||["",""])[1].toLowerCase()])){e=e.replace(vt,"<$1>");try{for(;i>r;r++)n=this[r]||{},1===n.nodeType&&(b.cleanData(Ot(n,!1)),n.innerHTML=e);n=0}catch(o){}}n&&this.empty().append(e)},null,e,arguments.length)},replaceWith:function(e){var t=b.isFunction(e);return t||"string"==typeof e||(e=b(e).not(this).detach()),this.domManip([e],!0,function(e){var t=this.nextSibling,n=this.parentNode;n&&(b(this).remove(),n.insertBefore(e,t))})},detach:function(e){return this.remove(e,!0)},domManip:function(e,n,r){e=f.apply([],e);var i,o,a,s,u,l,c=0,p=this.length,d=this,h=p-1,g=e[0],m=b.isFunction(g);if(m||!(1>=p||"string"!=typeof g||b.support.checkClone)&&Ct.test(g))return this.each(function(i){var o=d.eq(i);m&&(e[0]=g.call(this,i,n?o.html():t)),o.domManip(e,n,r)});if(p&&(l=b.buildFragment(e,this[0].ownerDocument,!1,this),i=l.firstChild,1===l.childNodes.length&&(l=i),i)){for(n=n&&b.nodeName(i,"tr"),s=b.map(Ot(l,"script"),Ht),a=s.length;p>c;c++)o=l,c!==h&&(o=b.clone(o,!0,!0),a&&b.merge(s,Ot(o,"script"))),r.call(n&&b.nodeName(this[c],"table")?Lt(this[c],"tbody"):this[c],o,c);if(a)for(u=s[s.length-1].ownerDocument,b.map(s,qt),c=0;a>c;c++)o=s[c],kt.test(o.type||"")&&!b._data(o,"globalEval")&&b.contains(u,o)&&(o.src?b.ajax({url:o.src,type:"GET",dataType:"script",async:!1,global:!1,"throws":!0}):b.globalEval((o.text||o.textContent||o.innerHTML||"").replace(St,"")));l=i=null}return this}});function Lt(e,t){return e.getElementsByTagName(t)[0]||e.appendChild(e.ownerDocument.createElement(t))}function Ht(e){var t=e.getAttributeNode("type");return e.type=(t&&t.specified)+"/"+e.type,e}function qt(e){var t=Et.exec(e.type);return t?e.type=t[1]:e.removeAttribute("type"),e}function Mt(e,t){var n,r=0;for(;null!=(n=e[r]);r++)b._data(n,"globalEval",!t||b._data(t[r],"globalEval"))}function _t(e,t){if(1===t.nodeType&&b.hasData(e)){var n,r,i,o=b._data(e),a=b._data(t,o),s=o.events;if(s){delete a.handle,a.events={};for(n in s)for(r=0,i=s[n].length;i>r;r++)b.event.add(t,n,s[n][r])}a.data&&(a.data=b.extend({},a.data))}}function Ft(e,t){var n,r,i;if(1===t.nodeType){if(n=t.nodeName.toLowerCase(),!b.support.noCloneEvent&&t[b.expando]){i=b._data(t);for(r in i.events)b.removeEvent(t,r,i.handle);t.removeAttribute(b.expando)}"script"===n&&t.text!==e.text?(Ht(t).text=e.text,qt(t)):"object"===n?(t.parentNode&&(t.outerHTML=e.outerHTML),b.support.html5Clone&&e.innerHTML&&!b.trim(t.innerHTML)&&(t.innerHTML=e.innerHTML)):"input"===n&&Nt.test(e.type)?(t.defaultChecked=t.checked=e.checked,t.value!==e.value&&(t.value=e.value)):"option"===n?t.defaultSelected=t.selected=e.defaultSelected:("input"===n||"textarea"===n)&&(t.defaultValue=e.defaultValue)}}b.each({appendTo:"append",prependTo:"prepend",insertBefore:"before",insertAfter:"after",replaceAll:"replaceWith"},function(e,t){b.fn[e]=function(e){var n,r=0,i=[],o=b(e),a=o.length-1;for(;a>=r;r++)n=r===a?this:this.clone(!0),b(o[r])[t](n),d.apply(i,n.get());return this.pushStack(i)}});function Ot(e,n){var r,o,a=0,s=typeof e.getElementsByTagName!==i?e.getElementsByTagName(n||"*"):typeof e.querySelectorAll!==i?e.querySelectorAll(n||"*"):t;if(!s)for(s=[],r=e.childNodes||e;null!=(o=r[a]);a++)!n||b.nodeName(o,n)?s.push(o):b.merge(s,Ot(o,n));return n===t||n&&b.nodeName(e,n)?b.merge([e],s):s}function Bt(e){Nt.test(e.type)&&(e.defaultChecked=e.checked)}b.extend({clone:function(e,t,n){var r,i,o,a,s,u=b.contains(e.ownerDocument,e);if(b.support.html5Clone||b.isXMLDoc(e)||!mt.test("<"+e.nodeName+">")?o=e.cloneNode(!0):(Dt.innerHTML=e.outerHTML,Dt.removeChild(o=Dt.firstChild)),!(b.support.noCloneEvent&&b.support.noCloneChecked||1!==e.nodeType&&11!==e.nodeType||b.isXMLDoc(e)))for(r=Ot(o),s=Ot(e),a=0;null!=(i=s[a]);++a)r[a]&&Ft(i,r[a]);if(t)if(n)for(s=s||Ot(e),r=r||Ot(o),a=0;null!=(i=s[a]);a++)_t(i,r[a]);else _t(e,o);return r=Ot(o,"script"),r.length>0&&Mt(r,!u&&Ot(e,"script")),r=s=i=null,o},buildFragment:function(e,t,n,r){var i,o,a,s,u,l,c,p=e.length,f=dt(t),d=[],h=0;for(;p>h;h++)if(o=e[h],o||0===o)if("object"===b.type(o))b.merge(d,o.nodeType?[o]:o);else if(wt.test(o)){s=s||f.appendChild(t.createElement("div")),u=(bt.exec(o)||["",""])[1].toLowerCase(),c=At[u]||At._default,s.innerHTML=c[1]+o.replace(vt,"<$1>")+c[2],i=c[0];while(i--)s=s.lastChild;if(!b.support.leadingWhitespace&&yt.test(o)&&d.push(t.createTextNode(yt.exec(o)[0])),!b.support.tbody){o="table"!==u||xt.test(o)?""!==c[1]||xt.test(o)?0:s:s.firstChild,i=o&&o.childNodes.length;while(i--)b.nodeName(l=o.childNodes[i],"tbody")&&!l.childNodes.length&&o.removeChild(l) -}b.merge(d,s.childNodes),s.textContent="";while(s.firstChild)s.removeChild(s.firstChild);s=f.lastChild}else d.push(t.createTextNode(o));s&&f.removeChild(s),b.support.appendChecked||b.grep(Ot(d,"input"),Bt),h=0;while(o=d[h++])if((!r||-1===b.inArray(o,r))&&(a=b.contains(o.ownerDocument,o),s=Ot(f.appendChild(o),"script"),a&&Mt(s),n)){i=0;while(o=s[i++])kt.test(o.type||"")&&n.push(o)}return s=null,f},cleanData:function(e,t){var n,r,o,a,s=0,u=b.expando,l=b.cache,p=b.support.deleteExpando,f=b.event.special;for(;null!=(n=e[s]);s++)if((t||b.acceptData(n))&&(o=n[u],a=o&&l[o])){if(a.events)for(r in a.events)f[r]?b.event.remove(n,r):b.removeEvent(n,r,a.handle);l[o]&&(delete l[o],p?delete n[u]:typeof n.removeAttribute!==i?n.removeAttribute(u):n[u]=null,c.push(o))}}});var Pt,Rt,Wt,$t=/alpha\([^)]*\)/i,It=/opacity\s*=\s*([^)]*)/,zt=/^(top|right|bottom|left)$/,Xt=/^(none|table(?!-c[ea]).+)/,Ut=/^margin/,Vt=RegExp("^("+x+")(.*)$","i"),Yt=RegExp("^("+x+")(?!px)[a-z%]+$","i"),Jt=RegExp("^([+-])=("+x+")","i"),Gt={BODY:"block"},Qt={position:"absolute",visibility:"hidden",display:"block"},Kt={letterSpacing:0,fontWeight:400},Zt=["Top","Right","Bottom","Left"],en=["Webkit","O","Moz","ms"];function tn(e,t){if(t in e)return t;var n=t.charAt(0).toUpperCase()+t.slice(1),r=t,i=en.length;while(i--)if(t=en[i]+n,t in e)return t;return r}function nn(e,t){return e=t||e,"none"===b.css(e,"display")||!b.contains(e.ownerDocument,e)}function rn(e,t){var n,r,i,o=[],a=0,s=e.length;for(;s>a;a++)r=e[a],r.style&&(o[a]=b._data(r,"olddisplay"),n=r.style.display,t?(o[a]||"none"!==n||(r.style.display=""),""===r.style.display&&nn(r)&&(o[a]=b._data(r,"olddisplay",un(r.nodeName)))):o[a]||(i=nn(r),(n&&"none"!==n||!i)&&b._data(r,"olddisplay",i?n:b.css(r,"display"))));for(a=0;s>a;a++)r=e[a],r.style&&(t&&"none"!==r.style.display&&""!==r.style.display||(r.style.display=t?o[a]||"":"none"));return e}b.fn.extend({css:function(e,n){return b.access(this,function(e,n,r){var i,o,a={},s=0;if(b.isArray(n)){for(o=Rt(e),i=n.length;i>s;s++)a[n[s]]=b.css(e,n[s],!1,o);return a}return r!==t?b.style(e,n,r):b.css(e,n)},e,n,arguments.length>1)},show:function(){return rn(this,!0)},hide:function(){return rn(this)},toggle:function(e){var t="boolean"==typeof e;return this.each(function(){(t?e:nn(this))?b(this).show():b(this).hide()})}}),b.extend({cssHooks:{opacity:{get:function(e,t){if(t){var n=Wt(e,"opacity");return""===n?"1":n}}}},cssNumber:{columnCount:!0,fillOpacity:!0,fontWeight:!0,lineHeight:!0,opacity:!0,orphans:!0,widows:!0,zIndex:!0,zoom:!0},cssProps:{"float":b.support.cssFloat?"cssFloat":"styleFloat"},style:function(e,n,r,i){if(e&&3!==e.nodeType&&8!==e.nodeType&&e.style){var o,a,s,u=b.camelCase(n),l=e.style;if(n=b.cssProps[u]||(b.cssProps[u]=tn(l,u)),s=b.cssHooks[n]||b.cssHooks[u],r===t)return s&&"get"in s&&(o=s.get(e,!1,i))!==t?o:l[n];if(a=typeof r,"string"===a&&(o=Jt.exec(r))&&(r=(o[1]+1)*o[2]+parseFloat(b.css(e,n)),a="number"),!(null==r||"number"===a&&isNaN(r)||("number"!==a||b.cssNumber[u]||(r+="px"),b.support.clearCloneStyle||""!==r||0!==n.indexOf("background")||(l[n]="inherit"),s&&"set"in s&&(r=s.set(e,r,i))===t)))try{l[n]=r}catch(c){}}},css:function(e,n,r,i){var o,a,s,u=b.camelCase(n);return n=b.cssProps[u]||(b.cssProps[u]=tn(e.style,u)),s=b.cssHooks[n]||b.cssHooks[u],s&&"get"in s&&(a=s.get(e,!0,r)),a===t&&(a=Wt(e,n,i)),"normal"===a&&n in Kt&&(a=Kt[n]),""===r||r?(o=parseFloat(a),r===!0||b.isNumeric(o)?o||0:a):a},swap:function(e,t,n,r){var i,o,a={};for(o in t)a[o]=e.style[o],e.style[o]=t[o];i=n.apply(e,r||[]);for(o in t)e.style[o]=a[o];return i}}),e.getComputedStyle?(Rt=function(t){return e.getComputedStyle(t,null)},Wt=function(e,n,r){var i,o,a,s=r||Rt(e),u=s?s.getPropertyValue(n)||s[n]:t,l=e.style;return s&&(""!==u||b.contains(e.ownerDocument,e)||(u=b.style(e,n)),Yt.test(u)&&Ut.test(n)&&(i=l.width,o=l.minWidth,a=l.maxWidth,l.minWidth=l.maxWidth=l.width=u,u=s.width,l.width=i,l.minWidth=o,l.maxWidth=a)),u}):o.documentElement.currentStyle&&(Rt=function(e){return e.currentStyle},Wt=function(e,n,r){var i,o,a,s=r||Rt(e),u=s?s[n]:t,l=e.style;return null==u&&l&&l[n]&&(u=l[n]),Yt.test(u)&&!zt.test(n)&&(i=l.left,o=e.runtimeStyle,a=o&&o.left,a&&(o.left=e.currentStyle.left),l.left="fontSize"===n?"1em":u,u=l.pixelLeft+"px",l.left=i,a&&(o.left=a)),""===u?"auto":u});function on(e,t,n){var r=Vt.exec(t);return r?Math.max(0,r[1]-(n||0))+(r[2]||"px"):t}function an(e,t,n,r,i){var o=n===(r?"border":"content")?4:"width"===t?1:0,a=0;for(;4>o;o+=2)"margin"===n&&(a+=b.css(e,n+Zt[o],!0,i)),r?("content"===n&&(a-=b.css(e,"padding"+Zt[o],!0,i)),"margin"!==n&&(a-=b.css(e,"border"+Zt[o]+"Width",!0,i))):(a+=b.css(e,"padding"+Zt[o],!0,i),"padding"!==n&&(a+=b.css(e,"border"+Zt[o]+"Width",!0,i)));return a}function sn(e,t,n){var r=!0,i="width"===t?e.offsetWidth:e.offsetHeight,o=Rt(e),a=b.support.boxSizing&&"border-box"===b.css(e,"boxSizing",!1,o);if(0>=i||null==i){if(i=Wt(e,t,o),(0>i||null==i)&&(i=e.style[t]),Yt.test(i))return i;r=a&&(b.support.boxSizingReliable||i===e.style[t]),i=parseFloat(i)||0}return i+an(e,t,n||(a?"border":"content"),r,o)+"px"}function un(e){var t=o,n=Gt[e];return n||(n=ln(e,t),"none"!==n&&n||(Pt=(Pt||b("